Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -781,12 +781,13 @@ created the `.jsonl`; a manual host refresh did not help.
rel path, not its own, because `readSubagentMeta()` in the transcript's
row is what actually needs re-deriving.

### Remote hosts — capability tiers (issue #218, first slice)
### Remote hosts — capability tiers (issue #218, first and second slice)

`remote-host-profile.js` is a pure function: `computeHostProfile({ at, error, descriptors })` returns
`{ tier, tiers, missing }`, the highest of `observe < liveness < inject < attach < launch` that is
available plus, for every tier above it, the reason it is not. The indexer's `getRemoteHostProfile(alias)`
feeds it the last cycle's own data (`at`, `error`, live descriptors), so there is no probe and no extra ssh.
feeds it the last cycle's own data (`at`, `error`, live descriptors) and the probe result (`tools`), and the profile
also returns `blocked` (why nothing could be read, or null) and the normalised `tools`.

- `none`: never synced, or the last cycle failed. A failed `find ~/.claude/projects` fails the whole
cycle, so an unreadable projects directory and an unreachable host are not told apart; the ssh error is the reason.
Expand All @@ -801,8 +802,31 @@ feeds it the last cycle's own data (`at`, `error`, live descriptors), so there i
Stop is never blocked, it runs its own ssh. The renderer only shows the strings: the host dot's tooltip (which states
the last error from the first failure), the row and badge titles.
- The new-session button was already disabled for every remote host; it is unchanged.
- Not done: the probe for what the descriptors cannot tell (multiplexer installed but no session in it, `inotifywait`),
the inject affordance (issue #219), the launch tier.
- The new-session button was already disabled for every remote host; its title now carries the launch tier's reason.
- Not done: the launch tier itself (issue #222), multiplexers other than tmux (`parseTmuxField` is the only recogniser).

#### The probe and the gates (second slice)

- `PROBE_COMMAND` (remote-transport.js) is its own ssh command, pinned exactly in `test/remote-transport-probe.test.js`;
`LIST_COMMAND` is not widened. It prints `tmux=0|1` and `inotifywait=0|1`; `parseProbe` accepts exactly those two
lines and `probeTools` throws on a timeout (15 s), a cap overrun (1 KiB), a non-zero exit or any other output. It goes
through the same `run()`, so the same ssh binary, `BatchMode` and `ConnectTimeout` apply and no new spawn site exists.
- The indexer probes at the end of `refreshHost`, after a successful sync only, at most every `PROBE_INTERVAL_MS` (6 h);
a failed probe is retried after `PROBE_RETRY_MS` (30 min), keeps the previous answer, and never fails the cycle. A
forced reconnect re-arms it without forgetting the answer. A removed alias drops its answer. A transport without
`probeTools` is skipped.
- `tools.<name>` is `true`, `false` or `null` (unknown). Only `false` withholds anything. `tmux === false` makes the attach tier
unavailable even when a descriptor names a pane, because the attach adapter runs a bare `tmux` over the same
non-interactive ssh, so a failing `command -v tmux` there is a failing attach. `tmux === true` makes attach available on
an idle host. Liveness is unchanged: it needs a descriptor, which the probe cannot tell.
- `attachBlockReason(profile, failures)` returns the observe reason from the third failure on (the fallback for a host
whose ssh keeps failing, which wins), else the tmux reason when tmux is known missing, else null. The session carries it in
`remoteAttachBlocked`; the renderer already opens the transcript and puts it in the row and badge titles.
- `sendBlockReason(profile)` is the inject tier's reason on a host that was read (`blocked` null), else null: a failing host
does not disable Send, which runs its own ssh. The tier is host-level, "some live session reports a socket", so a
session without its own socket on a host where another one has one is still offered Send and fails at the click.
`session.remoteSendBlocked` disables the button with the reason in its title.
- Stop has no gate (`test/annotate-remote-tier-gates.test.js` and `test/dom-sidebar-remote-tier-gates.test.js` pin it).

## Remote hosts — sending a prompt (issue #219)

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
- A session's **Touched** tab, next to Changes in the terminal header, lists the files its file tools (Edit, Write, MultiEdit, NotebookEdit) touched, its subagents' included, with what is on disk now (present, gone, unreadable) and the tools and agents behind each. It works outside any git repository. It is not the complete set of files the session changed: files changed through Bash commands or scripts are not listed, and the tab says so. Local sessions only. (#309)
- With Debug mode on, the activity trace now records how hard each terminal is being drawn: once a second per session, how many writes reached it, how large they were and how often its glyph atlas was rebuilt, to tell a legitimately busy terminal from a runaway one. (#175)
### Changed
- Switchboard now checks once per host, at the first successful refresh and then every six hours (every 30 minutes while one is missing), whether `tmux` and `inotifywait` are installed. A host with `tmux` and no session running no longer shows attach as missing; a host without `tmux` no longer offers to attach to a session and opens its transcript, saying why in the tooltip; and the host's tooltip says when live updates are off because `inotifywait` is missing. On a remote host, the new-session button's tooltip now gives the reason, and Send a prompt… is disabled, with the reason, while no live session on the host reports a messaging socket. Stop is never disabled. (#218)
- A trigger that gave up waiting for a session now says, in its result file's `reason`, when the session was blocked on a dialog such as a permission prompt or a question: for a single trigger, a chain's first wait, and a chain step whose turn never finished. Without a dialog the result is as before. (#379)

## v0.0.87 — 2026-10-02
Expand Down
31 changes: 26 additions & 5 deletions docs/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ next use; one installed after nothing was found is seen after a restart.
`~/.claude/sessions/<pid>.json` descriptors.
- For live updates: `inotifywait` (inotify-tools). Without it, only the periodic
pull runs, and a warning is logged.
- For attach: sessions started inside tmux.
- For attach: `tmux` installed, and sessions started inside it.
- For the [Changes view](changes-view.md): `git`.

## What is copied
Expand Down Expand Up @@ -122,10 +122,31 @@ The project header carries a dot for the host's state, with a tooltip:

Hovering the dot also lists the host's capability: the highest of observe,
liveness, inject, attach and launch that its last refresh could confirm, and for
each one above it why it is missing (for example no live session names a tmux
pane). A tier that needs a live session reads as missing on an idle host. After
three failed refreshes in a row, a row that would attach opens its transcript,
with the reason in its tooltip; **Stop** is never disabled, it runs its own ssh.
each one above it why it is missing (for example no live session reports a
messaging socket). Liveness and inject need a live session to read, so they read
as missing on an idle host.

Once per host, at its first successful refresh and then every six hours (and on
**Reconnect**), one extra ssh asks whether `tmux` and `inotifywait` are
installed and nothing else; while one of them is missing it asks again every 30 minutes, so an install is noticed. A host with `tmux` and no session offers attach; a
host without it does not, even when a descriptor names a pane, and the dot says
so. A host without `inotifywait` says that only the periodic pull runs. When the
probe fails (timeout after 15 seconds, refused, unreadable answer), the host
stays as it was and the probe is tried again 30 minutes later: an unknown answer
is never shown as missing.

What the tier gates:

- **Attach.** A row attaches only when the host has `tmux` (as far as the probe
knows) and the descriptor names a pane. Otherwise it opens its transcript, with
the reason in its tooltip. After three failed refreshes in a row it also opens
its transcript, whatever the probe said.
- **New session** stays disabled on a remote host; its tooltip gives the launch
tier's reason: starting a session from here is not implemented.
- **Send a prompt…** is disabled, with the inject reason in its tooltip, while no
live session on the host reports a messaging socket. A host whose refresh
failed does not disable it: it runs its own ssh.
- **Stop** is never disabled, it runs its own ssh.

A failing host is retried with a doubling delay, up to 30 minutes, and never
dropped; one success resets it. **Reconnect** on the header retries at once and
Expand Down
3 changes: 2 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const { handleTerminalInput } = require('./terminal-input');
const { createTriggerContext } = require('./trigger-context');
const { createTmuxAttachAdapter } = require('./remote-attach');
const { createRemoteStopAdapter } = require('./remote-stop');
const { attachBlockReason } = require('./remote-host-profile');
const { attachBlockReason, sendBlockReason } = require('./remote-host-profile');
const { createRemoteSendAdapter, handleSendRequest } = require('./remote-send');
const { createGitChangesRunner, localGitEnv } = require('./git-changes-runner');
const { runToExit } = require('./run-to-exit');
Expand Down Expand Up @@ -594,6 +594,7 @@ function annotateRemoteAttachable(projects) {
const supportsAttach = !!(descriptor && remoteAttachAdapter.supports(descriptor));
session.remoteAttachable = supportsAttach && !hostBlocked;
session.remoteAttachBlocked = supportsAttach ? hostBlocked : null;
session.remoteSendBlocked = sendBlockReason(info.profile);
session.status = descriptor ? (descriptor.status || null) : null;
session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null;
session.waitingFor = descriptor ? (descriptor.waitingFor || null) : null;
Expand Down
16 changes: 14 additions & 2 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,11 @@ function formatNextAttemptIn(epochMs) {
// session right now. See .ai/contexts/session-cache.md.
function remoteHostTierLines(profile) {
if (!profile || typeof profile.tier !== 'string' || !Array.isArray(profile.missing)) return '';
const watch = profile.tools && profile.tools.inotifywait === false
? '\nlive updates: inotifywait is not installed: only the periodic pull runs' : '';
return '\nCapability: ' + profile.tier
+ profile.missing.map(m => '\n' + m.tier + ' unavailable: ' + m.reason).join('');
+ profile.missing.map(m => '\n' + m.tier + ' unavailable: ' + m.reason).join('')
+ watch;
}

function remoteHostState(project) {
Expand Down Expand Up @@ -888,7 +891,11 @@ function renderProjects(projects, resort) {
newBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="6" y1="2" x2="6" y2="10"/><line x1="2" y1="6" x2="10" y2="6"/></svg>';
if (project.remoteAlias) {
newBtn.disabled = true;
newBtn.title = 'Read-only mirror of ' + project.remoteAlias + ' — new sessions must be started on that host';
const launch = project.remoteHostProfile && Array.isArray(project.remoteHostProfile.tiers)
&& project.remoteHostProfile.tiers.find(t => t.tier === 'launch');
newBtn.title = launch && launch.reason
? 'New session unavailable on ' + project.remoteAlias + ': ' + launch.reason
: 'Read-only mirror of ' + project.remoteAlias + ' — new sessions must be started on that host';
} else {
newBtn.title = 'New session';
}
Expand Down Expand Up @@ -1290,6 +1297,7 @@ function rebindSidebarEvents(projects) {
if (sendBtn) {
sendBtn.onclick = (e) => {
e.stopPropagation();
if (session.remoteSendBlocked) return;
showSendPromptDialog(session);
};
}
Expand Down Expand Up @@ -1515,6 +1523,10 @@ function buildSessionItem(session) {
sendBtn.className = 'session-send-btn';
sendBtn.title = 'Send a prompt…';
sendBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/></svg>';
if (session.remoteSendBlocked) {
sendBtn.disabled = true;
sendBtn.title = 'Send unavailable: ' + session.remoteSendBlocked;
}

const archiveBtn = document.createElement('button');
archiveBtn.className = 'session-archive-btn';
Expand Down
8 changes: 8 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,14 @@ body { display: flex; flex-direction: column; }
background: rgba(62,207,90,0.1);
}

.session-send-btn:disabled,
.session-send-btn:disabled:hover {
color: #5a7a6a;
background: none;
opacity: 0.4;
cursor: not-allowed;
}

.session-launch-config-btn {
color: #5a7a6a;
outline: none;
Expand Down
43 changes: 36 additions & 7 deletions remote-host-profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { parseTmuxField, isValidPid } = require('./remote-attach');

const ATTACH_BLOCK_AFTER_FAILURES = 3;
const TIERS = ['observe', 'liveness', 'inject', 'attach', 'launch'];
const TMUX_MISSING_REASON = 'tmux is not installed on this host (command -v tmux found nothing)';

function hasPosixSocketPath(descriptor) {
const p = descriptor && descriptor.messagingSocketPath;
Expand All @@ -15,18 +16,31 @@ function namesTmuxPane(descriptor) {
return !!(descriptor && parseTmuxField(descriptor.tmux) && isValidPid(descriptor.pid));
}

function toolFlag(value) {
return value === true || value === false ? value : null;
}

function normalizeTools(tools) {
const t = tools && typeof tools === 'object' ? tools : {};
return { tmux: toolFlag(t.tmux), inotifywait: toolFlag(t.inotifywait) };
}

/**
* Highest capability tier of a host, from what its last refresh cycle knew.
* input: { at: epoch ms of the last successful cycle | null,
* error: last cycle's error message | null,
* descriptors: live session descriptors of that cycle }
* descriptors: live session descriptors of that cycle,
* tools: { tmux, inotifywait } each true | false | null (unknown), from the probe }
* -> { tier: 'none' | a TIERS name,
* blocked: why nothing could be read, or null,
* tools: the normalised probe result,
* tiers: [{ tier, available, reason }],
* missing: [{ tier, reason }] for every tier above `tier` }
*/
function computeHostProfile(input) {
const { at, error } = input || {};
const descriptors = Array.isArray(input && input.descriptors) ? input.descriptors.filter(d => d && typeof d === 'object') : [];
const tools = normalizeTools(input && input.tools);

let blocked = null;
if (error) blocked = `last refresh of this host failed: ${error}`;
Expand All @@ -41,8 +55,9 @@ function computeHostProfile(input) {
: 'no live session descriptor under ~/.claude/sessions on this host (one appears while a session runs)';
reasons.inject = descriptors.some(hasPosixSocketPath) ? null
: 'no live session reports a messagingSocketPath on a POSIX path';
reasons.attach = descriptors.some(namesTmuxPane) ? null
: 'no live session names a tmux pane in its descriptor (start it inside tmux)';
if (tools.tmux === false) reasons.attach = TMUX_MISSING_REASON;
else if (tools.tmux === true || descriptors.some(namesTmuxPane)) reasons.attach = null;
else reasons.attach = 'no live session names a tmux pane in its descriptor (start it inside tmux)';
reasons.launch = 'new sessions cannot be started from here; they must be started on the host';
}

Expand All @@ -51,15 +66,29 @@ function computeHostProfile(input) {
tiers.forEach((t, i) => { if (t.available) top = i; });
return {
tier: top === -1 ? 'none' : TIERS[top],
blocked,
tools,
tiers,
missing: tiers.slice(top + 1).map(t => ({ tier: t.tier, reason: t.reason })),
};
}

function attachBlockReason(profile, consecutiveFailures) {
if (!(consecutiveFailures >= ATTACH_BLOCK_AFTER_FAILURES)) return null;
const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === 'observe');
function tierReason(profile, tier) {
const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === tier);
return hit && hit.reason ? hit.reason : null;
}

module.exports = { computeHostProfile, attachBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS };
function attachBlockReason(profile, consecutiveFailures) {
if (consecutiveFailures >= ATTACH_BLOCK_AFTER_FAILURES) return tierReason(profile, 'observe');
if (profile && profile.tools && profile.tools.tmux === false) return TMUX_MISSING_REASON;
return null;
}

function sendBlockReason(profile) {
if (!profile || profile.blocked) return null;
return tierReason(profile, 'inject');
}

module.exports = {
computeHostProfile, attachBlockReason, sendBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS, TMUX_MISSING_REASON,
};
Loading
Loading