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
15 changes: 11 additions & 4 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,17 @@ Facts established by measurement, not by documentation:
- Sampling 295 times at 2 s over 10 min with 2–3 subagents writing, `status`
stayed `busy` throughout, with no false dip. The parent is `busy` while any
delegated agent runs (`delegatedActive` in the CLI's own status computation).
- **Unverified reservation**: no permission dialog occurred during that
measurement, so the `waiting` branch was never observed empirically. We treat
`waiting` as "not idle" on the strength of the name alone. If that reading is
wrong, the only consequence is a missed early rescan — the tick still fires.
- **`waiting` (read from the CLI bundle, 2.1.286; still not observed live)**:
the status computation returns `waiting` whenever a blocking dialog is open,
ahead of `busy`; the prompt at rest is `idle`, never `waiting`. A
`waitingFor` string is written beside it (`permission prompt`,
`input needed`, `dialog open`, `goal proposal`, `worker request`,
`sandbox request`), and `statusUpdatedAt` is rewritten on each status write.
No permission dialog occurred during the 295-sample measurement above, so no
live descriptor was captured during a dialog. A remote row lights the
attention state from it (see session-state.md, "Descriptor-owned attention");
the remote index keeps `waitingFor` only as a trimmed string of at most 64
characters without control characters, and drops anything else.

The `busy` glyph in the terminal title was considered instead and rejected: it
conflates idle, waiting and shell. The state file distinguishes them, which is
Expand Down
49 changes: 49 additions & 0 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,55 @@ per remote session id in `remoteSessionStates` (a `Map`, pruned in
`projectLocalPtyState`, see "The local-pty adapter" below), just fed from the
remote-ssh adapter's own snapshot instead.

### Descriptor-owned attention (issue #394)

A CLI that has a blocking dialog open (permission prompt, question,
elicitation) writes `status: "waiting"` to its descriptor — see
cli-session-state.md. OSC 9 does not reach Switchboard from a tmux pane on a
host, so the descriptor is the only signal an unattached remote row has.

`applyRemoteDescriptor` passes `attention: true` on its `descriptorStatus`
event. Only the remote adapter does: local callers send the same event without
it and never get attention from the descriptor. The reducer's rule:

- `waiting` with `attention: true` and liveness not dead sets attention,
clears busy / waitingForInput / responseReady like an `attention` event, and
records that the descriptor owns it.
- Any other status (`busy`, `idle`, `shell`, none), a `liveness: dead` event,
or an `attention` event releases it — but only when the descriptor owns it. An
attention raised by an explicit `attention` event is never cleared by a
descriptor, and that event takes ownership over a descriptor-set one.
- Busy edges and transcript touches never clear it (attention is orthogonal to
busy and outranks it), so a busy decay cannot erase an open dialog's state.
The status leaving `waiting` is the one thing that clears it.
- Descriptor absence is a null status on the session object, which releases it;
an unattached row whose descriptor disappears loses the attention at the next
refresh. `applyRemoteStopped` also clears it.

Two more releases keep it from freezing:

- The attached true to false handoff releases descriptor-owned attention. The
reducer keeps following the descriptor while a row is attached, so without
this a dialog answered in the PTY would repaint a stale orange on detach; the
next descriptor re-asserts it if the session still waits.
- A host in error keeps its last descriptors (freshness contract), so
`renderProjects` passes the project's `remoteHostError` and the descriptor
event then carries `attention: false`, which releases descriptor-owned
attention for the whole outage. A fresh host passes `attention: true`.
The indexer notifies the renderer when a host's last error changes (first
failure, a different error, recovery), not only when files changed, so the
gate applies without an unrelated render.

The sidebar render replaces row classes from the rebuilt row (morphdom takes the
new element's classes), so `buildSessionItem` reads the unattached remote
state's attention for the row class and the icon slot, as it does for
`agentsBusy`; seeding alone would be wiped at the next render.

Attached rows are untouched: `projectRemoteState` still refuses to paint them,
and the local-pty path owns their attention. The sidebar's status line appends
`waitingFor` while the status is `waiting` (`waiting · permission prompt · 3m
ago`). Latency is the refresh cycle, not the dialog.

### A parent's busy decay shortens while a subagent is running (issue #284)

A Task-tool invocation typically appends to the parent's own top-level
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ What changes for you in each release of Switchboard. How to write an entry: [doc

## Unreleased

### New
- A remote session that is not open in a tab and waits on a dialog on its host, such as a permission prompt or a question, shows the orange attention state, and its status line says what it waits for. It appears and clears with the next refresh of the host. (#394)

## v0.0.86 — 2026-10-01

### New
Expand Down
7 changes: 7 additions & 0 deletions docs/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ written, until 20 seconds of silence (3 seconds while a subagent is busy), and
never "response ready". A session that has a live descriptor but no transcript
yet (before its first prompt) is listed under its directory's name.

A session that is not open in a tab and is waiting on a dialog on the host
(a permission prompt or a question) shows the orange attention state, and its
status line says what it waits for. The state comes from the session's
descriptor, so it appears with the next refresh (about 15 seconds with the host
watch, the pull interval without it) and clears the same way once the dialog is
answered. A session open in a tab keeps the terminal's own signals.

### Opening a session

- A **live session in tmux** — its descriptor names a tmux pane and its pid is a
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ const rendererCrossFileGlobals = {
setIsAlive: 'readonly',
isSessionAlive: 'readonly',
paintSessionIcon: 'readonly',
paintSessionIconFromSnapshot: 'readonly',
// public/remote-activity-ui.js (remote-ssh adapter, see .ai/contexts/session-state.md)
setRemoteAttached: 'readonly',
applyRemoteStopped: 'readonly',
Expand Down
2 changes: 2 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
}

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

Check warning on line 69 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 69 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, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand Down Expand Up @@ -472,8 +472,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 475 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 475 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 476 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 } = require('./read-session-file');

Expand Down Expand Up @@ -578,6 +578,7 @@
session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor));
session.status = descriptor ? (descriptor.status || null) : null;
session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null;
session.waitingFor = descriptor ? (descriptor.waitingFor || null) : null;
session.remoteActiveAt = remoteActivityTracker.activeAt(session.remoteAlias, session.sessionId);
// listed descriptor = live process (ALIVE filter) — see .ai/contexts/session-state.md
session.remoteDescriptorSeen = !!descriptor;
Expand Down Expand Up @@ -616,6 +617,7 @@
remoteDescriptorSeen: ph.remoteDescriptorSeen,
status: ph.status,
statusUpdatedAt: ph.statusUpdatedAt,
waitingFor: ph.waitingFor,
placeholder: true,
};
}
Expand Down Expand Up @@ -2371,7 +2373,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 2376 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
9 changes: 5 additions & 4 deletions public/remote-activity-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,12 @@ function onRemoteActivityEvent(payload) {
}

// descriptor ports; absence stays 'unknown', never 'dead' — see session-state.md ports table
function applyRemoteDescriptor(session) {
function applyRemoteDescriptor(session, hostError) {
if (!session || !session.remoteAlias) return;
const state = remoteState(session.sessionId);
if (session.remoteDescriptorSeen) state.apply({ type: 'liveness', value: 'alive' });
if (session.status !== undefined) {
state.apply({ type: 'descriptorStatus', status: session.status, at: session.statusUpdatedAt });
state.apply({ type: 'descriptorStatus', status: session.status, at: session.statusUpdatedAt, attention: !hostError });
}
projectRemoteState(session.sessionId);
}
Expand All @@ -180,6 +180,7 @@ function setRemoteAttached(sessionId, attached) {
clearRemoteActivityTimer(sessionId);
remoteSeedFloors.set(sessionId, Date.now());
state.apply({ type: 'busy', active: false, armReady: false });
state.apply({ type: 'releaseDescriptorAttention' });
setActivity(sessionId, false, 'remote-attach-handoff', { armReady: false });
// Drops the shadow local-pty entry setActivity() just touched above — see .ai/contexts/session-state.md ("The local-pty adapter")
purgeActivityFor(sessionId, 'remote-detach');
Expand All @@ -202,9 +203,9 @@ function applyRemoteStopped(sessionId) {
purgeActivityFor(sessionId, 'remote-stop');
}

function seedRemoteActivity(session) {
function seedRemoteActivity(session, hostError) {
if (!session || !session.remoteAlias) return;
applyRemoteDescriptor(session);
applyRemoteDescriptor(session, hostError);

if (!Number.isFinite(session.remoteActiveAt)) return;
const sessionId = session.sessionId;
Expand Down
6 changes: 6 additions & 0 deletions public/session-activity-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ function snapshotForLocal(sessionId, session) {
return state.snapshot();
}

// Paints an icon slot from a snapshot held by another adapter — see .ai/contexts/session-state.md ("Descriptor-owned attention")
function paintSessionIconFromSnapshot(el, snapshot) {
if (!el) return;
writeIconSlot(el, renderSessionIcon(snapshot));
}

// Paints a local-pty row's icon slot — see .ai/contexts/session-state.md
function paintSessionIcon(el, sessionId, session) {
if (!el) return;
Expand Down
22 changes: 22 additions & 0 deletions public/session-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function createSessionState(kind) {
let busy = false;
let waitingForInput = false;
let attention = false;
let attentionFromDescriptor = false;
let responseReady = false;
let agentsBusy = false;
let lastActivityAt = null;
Expand All @@ -37,6 +38,12 @@ function createSessionState(kind) {
responseReady = false;
}

function releaseDescriptorAttention() {
if (!attentionFromDescriptor) return;
attention = false;
attentionFromDescriptor = false;
}

function touch(event) {
if (event && event.at !== undefined) lastActivityAt = event.at;
if (event && event.source !== undefined) lastActivitySource = event.source;
Expand All @@ -57,6 +64,7 @@ function createSessionState(kind) {
touch(event);
break;
case 'attention':
attentionFromDescriptor = false;
if (event.active === false) {
attention = false;
} else {
Expand All @@ -65,11 +73,15 @@ function createSessionState(kind) {
}
touch(event);
break;
case 'releaseDescriptorAttention':
releaseDescriptorAttention();
break;
case 'clearUnread':
responseReady = false;
break;
case 'liveness':
liveness = event.value === 'alive' || event.value === 'dead' ? event.value : 'unknown';
if (liveness === 'dead') releaseDescriptorAttention();
break;
case 'attached':
attached = !!event.value;
Expand All @@ -79,6 +91,16 @@ function createSessionState(kind) {
break;
case 'descriptorStatus':
liveness = event.status === 'alive' || event.status === 'dead' ? event.status : liveness;
if (event.attention === false) releaseDescriptorAttention();
if (event.attention === true) {
if (event.status === 'waiting' && liveness !== 'dead') {
clearExclusive();
attention = true;
attentionFromDescriptor = true;
} else {
releaseDescriptorAttention();
}
}
touch({ at: event.at, source: 'descriptor' });
break;
case 'subagentSpawned':
Expand Down
18 changes: 15 additions & 3 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ function renderProjects(projects, resort) {
pendingSubagentRest.clear();
// see .ai/contexts/session-cache.md ("Remote hosts — busy spinner (issue #242)")
for (const project of projects) {
for (const session of project.sessions) seedRemoteActivity(session);
for (const session of project.sessions) seedRemoteActivity(session, project.remoteHostError);
}
const newSidebar = document.createElement('div');

Expand Down Expand Up @@ -1381,14 +1381,24 @@ function rebindSidebarEvents(projects) {
}
}

// see .ai/contexts/session-state.md ("Descriptor-owned attention")
function remoteAttentionSnapshot(sessionId) {
if (typeof remoteSessionStates === 'undefined') return null;
const state = remoteSessionStates.get(sessionId);
if (!state) return null;
const snapshot = state.snapshot();
return !snapshot.attached && snapshot.attention ? snapshot : null;
}

function buildSessionItem(session) {
const item = document.createElement('div');
item.className = 'session-item js-stateful';
item.id = 'si-' + session.sessionId;
if (session.type === 'terminal') item.classList.add('is-terminal');
if (session.archived) item.classList.add('archived-item');
if (activePtyIds.has(session.sessionId)) item.classList.add('has-running-pty');
setNeedsAttention(item, attentionSessions.has(session.sessionId));
const remoteAttention = remoteAttentionSnapshot(session.sessionId);
setNeedsAttention(item, attentionSessions.has(session.sessionId) || !!remoteAttention);
setResponseReady(item, responseReadySessions.has(session.sessionId));
setCliBusy(item, !!sessionBusyState.get(session.sessionId));
setHasBusyAgents(item, parentHasActiveSubagent(session.sessionId));
Expand Down Expand Up @@ -1416,6 +1426,7 @@ function buildSessionItem(session) {
const icon = document.createElement('span');
icon.className = 'session-icon' + (activePtyIds.has(session.sessionId) ? ' running' : '');
paintSessionIcon(icon, session.sessionId, session);
if (remoteAttention) paintSessionIconFromSnapshot(icon, remoteAttention);

// Info block
const info = document.createElement('div');
Expand Down Expand Up @@ -1443,7 +1454,8 @@ function buildSessionItem(session) {
statusEl.className = 'session-status';
if (session.status) {
const age = formatStatusAge(session.statusUpdatedAt);
statusEl.textContent = session.status + (age ? ' · ' + age : '');
const why = session.status === 'waiting' && session.waitingFor ? ' · ' + session.waitingFor : '';
statusEl.textContent = session.status + why + (age ? ' · ' + age : '');
}
metaEl.append(timeEl, shortIdEl, statusEl);

Expand Down
40 changes: 34 additions & 6 deletions remote-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ function placeholderTitle(cwd) {
return parts[parts.length - 1] || cwd;
}

const MAX_WAITING_FOR_LENGTH = 64;

// see .ai/contexts/cli-session-state.md ("waiting")
function sanitizeWaitingFor(value) {
if (typeof value !== 'string') return null;
const text = value.trim();
if (!text || text.length > MAX_WAITING_FOR_LENGTH) return null;
for (let k = 0; k < text.length; k++) {
const code = text.charCodeAt(k);
if (code < 32 || code === 127) return null;
if (code >= 0x200b && code <= 0x200f) return null;
if (code >= 0x202a && code <= 0x202e) return null;
if (code >= 0x2066 && code <= 0x2069) return null;
if (code >= 0x80 && code <= 0x9f) return null;
if (code === 0xad || code === 0x61c || code === 0x180e || code === 0xfeff) return null;
if (code === 0x2028 || code === 0x2029) return null;
if (code >= 0x2060 && code <= 0x2064) return null;
}
return text;
}

// see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions")
function buildPlaceholderSession(alias, descriptor) {
const id = (typeof descriptor.sessionId === 'string' && descriptor.sessionId)
Expand All @@ -45,6 +66,7 @@ function buildPlaceholderSession(alias, descriptor) {
remoteDescriptorSeen: true,
status: descriptor.status || null,
statusUpdatedAt: descriptor.statusUpdatedAt || null,
waitingFor: sanitizeWaitingFor(descriptor.waitingFor),
modified: descriptor.statusUpdatedAt || descriptor.startedAt || null,
messageCount: 0,
summary: placeholderTitle(descriptor.cwd),
Expand Down Expand Up @@ -98,22 +120,26 @@ function createRemoteIndexer(ctx) {
if (state.failures > 0) {
log.info(`[remote:${alias}] refresh recovered after ${state.failures} consecutive failure(s)`);
}
const errorChanged = state.lastError !== null;
state.failures = 0;
state.lastError = null;
state.nextAttemptAt = 0;
return errorChanged;
}

function onHostFailure(alias, err, intervalMs) {
const state = backoffState(alias);
const prevDelay = backoffDelayMs(state.failures, intervalMs);
state.failures += 1;
const errorChanged = state.lastError !== err.message;
state.lastError = err.message;
const delay = backoffDelayMs(state.failures, intervalMs);
state.nextAttemptAt = now() + delay;
if (delay !== prevDelay) {
log.warn(`[remote:${alias}] refresh failed (${state.failures}x consecutive): ${err.message}; ` +
`retrying in ${Math.round(delay / 1000)}s`);
}
return errorChanged;
}

function getRemoteHostState(alias) {
Expand Down Expand Up @@ -179,7 +205,9 @@ function createRemoteIndexer(ctx) {
log,
});

remoteSessions.set(host.alias, Array.isArray(result.sessions) ? result.sessions : []);
remoteSessions.set(host.alias, Array.isArray(result.sessions)
? result.sessions.map(s => (s && 'waitingFor' in s ? { ...s, waitingFor: sanitizeWaitingFor(s.waitingFor) } : s))
: []);

const folderPrefix = host.alias;
const toScan = new Set(result.changedFolders);
Expand Down Expand Up @@ -252,13 +280,13 @@ function createRemoteIndexer(ctx) {
}
try {
if (await refreshHost(host)) changed = true;
onHostSuccess(host.alias);
if (onHostSuccess(host.alias)) changed = true;
remoteSessionsAt.set(host.alias, now());
} catch (err) {
// A failed cycle keeps the last known descriptors — see
// .ai/contexts/session-cache.md ("Remote hosts — freshness contract").
errors.push({ alias: host.alias, error: err.message });
onHostFailure(host.alias, err, intervalMs);
if (onHostFailure(host.alias, err, intervalMs)) changed = true;
}
}
} finally {
Expand Down Expand Up @@ -288,12 +316,12 @@ function createRemoteIndexer(ctx) {
let error = null;
try {
changed = await refreshHost(host);
onHostSuccess(alias);
if (onHostSuccess(alias)) changed = true;
remoteSessionsAt.set(alias, now());
} catch (err) {
// A failed cycle keeps the last known descriptors — see
// .ai/contexts/session-cache.md ("Remote hosts — freshness contract").
onHostFailure(alias, err, intervalMs);
if (onHostFailure(alias, err, intervalMs)) changed = true;
error = err.message;
} finally {
hostInFlight.delete(alias);
Expand Down Expand Up @@ -394,4 +422,4 @@ function createRemoteIndexer(ctx) {
};
}

module.exports = { createRemoteIndexer, backoffDelayMs, buildPlaceholderSession, placeholderTitle };
module.exports = { createRemoteIndexer, backoffDelayMs, buildPlaceholderSession, placeholderTitle, sanitizeWaitingFor };
Loading
Loading