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
23 changes: 23 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,29 @@ 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-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.

- `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.
- `liveness`: at least one live descriptor. `inject`: a live descriptor with a `messagingSocketPath` that is a POSIX
absolute path. `attach`: a live descriptor naming a tmux pane with a valid pid (the adapter's own test).
The tiers are independent requirements: the reported tier is the highest available one, not the highest contiguous one.
- `launch` is never available: starting a session from here is not implemented.
- A tier that needs a live session reads as missing on an idle host; that is "nothing to read it from", not "unsupported".
- `annotateRemoteAttachable` (main.js) puts the profile on the project (`remoteHostProfile`). After 3 consecutive failed
cycles (`attachBlockReason`), it sets `remoteAttachable: false` plus `remoteAttachBlocked` (the last error) on the
session: a single transient poll failure blocks nothing, and the descriptors of the last good cycle are kept.
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.

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

`remote-send.js` writes one prompt to a live, unattached remote session through
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ What changes for you in each release of Switchboard. How to write an entry: [doc

## Unreleased

### New
- A remote host's project header now shows what the host supports: hover its status dot to see the highest capability reached (observe, liveness, inject, attach) and, for each one above it, why it is missing. (#218)

### Changed
- After three failed refreshes of a remote host in a row, a row that would have attached opens its transcript and says why in its tooltip, instead of failing when clicked. Stop is never disabled: it runs its own ssh. (#218)
### Fixed
- A step of a trigger chain that follows `/compact` now waits for the CLI to be back at its prompt before it is written, and a step whose Enter did not start a turn is retried once and then reported as "not confirmed submitted" in the log and the result instead of "sent". (#407)
- Stopping a terminal twice in quick succession, or resizing it while it is being stopped, no longer closes the Windows pseudo console twice, which could kill the whole app with no error. (#405)
Expand Down
7 changes: 7 additions & 0 deletions docs/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ The project header carries a dot for the host's state, with a tooltip:
| red | *host unreachable: &lt;error&gt;*, when it was last confirmed, and when the next attempt is |
| amber, pulsing | a reconnect in progress |

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.

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
restarts the watch connection. The sidebar's global refresh retries every host.
Expand Down
15 changes: 11 additions & 4 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand All @@ -82,6 +82,7 @@
const { createTriggerContext } = require('./trigger-context');
const { createTmuxAttachAdapter } = require('./remote-attach');
const { createRemoteStopAdapter } = require('./remote-stop');
const { attachBlockReason } = 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 @@ -478,8 +479,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 482 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 482 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 483 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles, readSubagentMeta } = require('./read-session-file');

Expand Down Expand Up @@ -569,8 +570,9 @@
function hostInfo(alias) {
if (!hostInfoByAlias.has(alias)) {
const { sessions, at, error } = remoteIndexer.getRemoteSessions(alias);
const { nextAttemptAt } = remoteIndexer.getRemoteHostState(alias);
hostInfoByAlias.set(alias, { at, error, nextAttemptAt, byId: new Map(sessions.map(d => [d.sessionId, d])) });
const { nextAttemptAt, consecutiveFailures } = remoteIndexer.getRemoteHostState(alias);
const profile = remoteIndexer.getRemoteHostProfile(alias);
hostInfoByAlias.set(alias, { at, error, nextAttemptAt, consecutiveFailures, profile, byId: new Map(sessions.map(d => [d.sessionId, d])) });
}
return hostInfoByAlias.get(alias);
}
Expand All @@ -580,11 +582,16 @@
project.remoteHostAt = info.at;
project.remoteHostError = info.error;
project.remoteHostNextAttemptAt = info.nextAttemptAt || null;
project.remoteHostProfile = info.profile;
}
for (const session of project.sessions) {
if (session.remoteAlias) {
const descriptor = hostInfo(session.remoteAlias).byId.get(session.sessionId);
session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor));
const info = hostInfo(session.remoteAlias);
const descriptor = info.byId.get(session.sessionId);
const hostBlocked = attachBlockReason(info.profile, info.consecutiveFailures);
const supportsAttach = !!(descriptor && remoteAttachAdapter.supports(descriptor));
session.remoteAttachable = supportsAttach && !hostBlocked;
session.remoteAttachBlocked = supportsAttach ? hostBlocked : null;
session.status = descriptor ? (descriptor.status || null) : null;
session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null;
session.waitingFor = descriptor ? (descriptor.waitingFor || null) : null;
Expand Down Expand Up @@ -2446,7 +2453,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2456 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
20 changes: 18 additions & 2 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,19 @@ function formatNextAttemptIn(epochMs) {
// sync cycle failed (host unreachable, reason visible), the host has never
// been read yet, or it was read successfully and genuinely has no live
// session right now. See .ai/contexts/session-cache.md.
function remoteHostTierLines(profile) {
if (!profile || typeof profile.tier !== 'string' || !Array.isArray(profile.missing)) return '';
return '\nCapability: ' + profile.tier
+ profile.missing.map(m => '\n' + m.tier + ' unavailable: ' + m.reason).join('');
}

function remoteHostState(project) {
const state = remoteHostStatus(project);
state.detail += remoteHostTierLines(project.remoteHostProfile);
return state;
}

function remoteHostStatus(project) {
if (project.remoteHostError) {
const age = formatStatusAge(project.remoteHostAt);
const nextIn = formatNextAttemptIn(project.remoteHostNextAttemptAt);
Expand Down Expand Up @@ -1233,7 +1245,9 @@ function rebindSidebarEvents(projects) {

// see .ai/contexts/session-cache.md ("Remote SSH hosts")
if (session.remoteAlias && !session.remoteAttachable) {
item.title = 'Not currently attachable — opening the transcript instead';
item.title = session.remoteAttachBlocked
? 'Attach unavailable: ' + session.remoteAttachBlocked + ' — opening the transcript instead'
: 'Not currently attachable — opening the transcript instead';
}

item.onclick = () => {
Expand Down Expand Up @@ -1472,7 +1486,9 @@ function buildSessionItem(session) {
badge.className = 'remote-badge';
badge.title = session.remoteAttachable
? 'Live session on ' + session.remoteAlias + ' — click to attach'
: 'Session on ' + session.remoteAlias + ' — no live process, click to read its transcript';
: session.remoteAttachBlocked
? 'Session on ' + session.remoteAlias + ' — attach unavailable: ' + session.remoteAttachBlocked + ', click to read its transcript'
: 'Session on ' + session.remoteAlias + ' — no live process, click to read its transcript';
badge.textContent = session.remoteAlias;
summaryEl.prepend(badge);
}
Expand Down
65 changes: 65 additions & 0 deletions remote-host-profile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// see .ai/contexts/session-cache.md ("Remote hosts — capability tiers")
'use strict';

const { parseTmuxField, isValidPid } = require('./remote-attach');

const ATTACH_BLOCK_AFTER_FAILURES = 3;
const TIERS = ['observe', 'liveness', 'inject', 'attach', 'launch'];

function hasPosixSocketPath(descriptor) {
const p = descriptor && descriptor.messagingSocketPath;
return typeof p === 'string' && p.startsWith('/') && !p.includes('\0') && !p.includes('\\');
}

function namesTmuxPane(descriptor) {
return !!(descriptor && parseTmuxField(descriptor.tmux) && isValidPid(descriptor.pid));
}

/**
* 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 }
* -> { tier: 'none' | a TIERS name,
* 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') : [];

let blocked = null;
if (error) blocked = `last refresh of this host failed: ${error}`;
else if (!Number.isFinite(at)) blocked = 'not yet synced with this host';

const reasons = {};
if (blocked) {
for (const tier of TIERS) reasons[tier] = blocked;
} else {
reasons.observe = null;
reasons.liveness = descriptors.length > 0 ? null
: '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)';
reasons.launch = 'new sessions cannot be started from here; they must be started on the host';
}

const tiers = TIERS.map(tier => ({ tier, available: reasons[tier] === null, reason: reasons[tier] }));
let top = -1;
tiers.forEach((t, i) => { if (t.available) top = i; });
return {
tier: top === -1 ? 'none' : TIERS[top],
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');
return hit && hit.reason ? hit.reason : null;
}

module.exports = { computeHostProfile, attachBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS };
8 changes: 8 additions & 0 deletions remote-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
} = require('./remote-hosts');
const { syncMirror } = require('./remote-mirror');
const { encodeProjectPath } = require('./encode-project-path');
const { computeHostProfile } = require('./remote-host-profile');

const NOOP_LOG = { info() {}, warn() {}, error() {} };

Expand Down Expand Up @@ -374,6 +375,12 @@ function createRemoteIndexer(ctx) {
};
}

// see .ai/contexts/session-cache.md ("Remote hosts — capability tiers")
function getRemoteHostProfile(alias) {
const { sessions, at, error } = getRemoteSessions(alias);
return computeHostProfile({ at, error, descriptors: sessions });
}

// see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions")
function getPlaceholderSessions(alias) {
const list = remoteSessions.get(alias) || [];
Expand Down Expand Up @@ -414,6 +421,7 @@ function createRemoteIndexer(ctx) {
start, stop, dispose, restart, refreshNow, refreshHostNow,
isRunning: () => timer !== null,
getRemoteSessions,
getRemoteHostProfile,
getPlaceholderSessions,
getAllPlaceholderSessions,
findSessionAlias,
Expand Down
69 changes: 68 additions & 1 deletion test/annotate-remote-attachable-local-status.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');

const { computeHostProfile, attachBlockReason } = require('../remote-host-profile');

const root = path.join(__dirname, '..');

function extractAnnotateRemoteAttachableSource() {
Expand All @@ -36,16 +38,19 @@ function makeAnnotate(mocks) {
const source = extractAnnotateRemoteAttachableSource();
const factory = new Function(
'remoteIndexer', 'remoteAttachAdapter', 'remoteActivityTracker', 'cliSessionState',
'attachBlockReason',
source + '\nreturn annotateRemoteAttachable;'
);
return factory(
mocks.remoteIndexer || {
getRemoteSessions: () => ({ sessions: [], at: null, error: null }),
getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }),
getRemoteHostProfile: () => computeHostProfile({ at: null, error: null, descriptors: [] }),
},
mocks.remoteAttachAdapter || { supports: () => false },
mocks.remoteActivityTracker || { activeAt: () => null },
mocks.cliSessionState || { getStatus: () => undefined }
mocks.cliSessionState || { getStatus: () => undefined },
attachBlockReason
);
}

Expand Down Expand Up @@ -94,6 +99,7 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip
error: null,
}),
getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }),
getRemoteHostProfile: () => computeHostProfile({ at: 111, error: null, descriptors: [{ pid: 4, sessionId: 'remote-1' }] }),
},
remoteAttachAdapter: { supports: () => true },
cliSessionState: { getStatus: () => { throw new Error('must not be called for a remote session'); } },
Expand All @@ -112,6 +118,66 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip
assert.equal(projects[0].sessions[0].remoteAttachable, true);
});

function failedHostIndexer(error, failures = 3) {
return {
getRemoteSessions: () => ({ sessions: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }], at: 111, error }),
getRemoteHostState: () => ({ consecutiveFailures: failures, lastError: error, nextAttemptAt: 0 }),
getRemoteHostProfile: () => computeHostProfile({ at: 111, error, descriptors: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }] }),
};
}

function remoteProjects() {
return [{
projectPath: '/srv/proj',
remoteAlias: 'planificator',
sessions: [{ sessionId: 'remote-1', remoteAlias: 'planificator' }],
}];
}

test('a session on a host that failed three refreshes in a row is not attachable and carries the reason', () => {
const annotate = makeAnnotate({
remoteIndexer: failedHostIndexer('connect timed out'),
remoteAttachAdapter: { supports: () => true },
});
const projects = remoteProjects();
annotate(projects);
const session = projects[0].sessions[0];
assert.equal(session.remoteAttachable, false);
assert.match(session.remoteAttachBlocked, /connect timed out/);
assert.equal(session.remoteStopBlocked, undefined, 'stop runs its own ssh and is never blocked by a poll failure');
assert.equal(projects[0].remoteHostProfile.tier, 'none');
});

test('a single failed refresh does not block attach, while the project profile still states the error', () => {
const annotate = makeAnnotate({
remoteIndexer: failedHostIndexer('connect timed out', 1),
remoteAttachAdapter: { supports: () => true },
});
const projects = remoteProjects();
annotate(projects);
const session = projects[0].sessions[0];
assert.equal(session.remoteAttachable, true);
assert.equal(session.remoteAttachBlocked, null);
assert.match(projects[0].remoteHostProfile.missing[0].reason, /connect timed out/);
});

test('a session on a healthy host carries no blocking reason and the project carries the host profile', () => {
const annotate = makeAnnotate({
remoteIndexer: {
getRemoteSessions: () => ({ sessions: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }], at: 111, error: null }),
getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }),
getRemoteHostProfile: () => computeHostProfile({ at: 111, error: null, descriptors: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }] }),
},
remoteAttachAdapter: { supports: () => true },
});
const projects = remoteProjects();
annotate(projects);
const session = projects[0].sessions[0];
assert.equal(session.remoteAttachable, true);
assert.equal(session.remoteAttachBlocked, null);
assert.equal(projects[0].remoteHostProfile.tier, 'attach');
});

test('a remote session carries the descriptor waitingFor, and null when the descriptor has none or is gone', () => {
const annotateRemoteAttachable = makeAnnotate({
remoteIndexer: {
Expand All @@ -124,6 +190,7 @@ test('a remote session carries the descriptor waitingFor, and null when the desc
error: null,
}),
getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }),
getRemoteHostProfile: () => computeHostProfile({ at: 1, error: null, descriptors: [] }),
},
});
const projects = [{
Expand Down
Loading
Loading