diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 117ce655..b75408e1 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -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 diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 2b7594cc..ba6da1b8 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dae2a5..82e62bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index 909e4193..1aacf291 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -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 diff --git a/eslint.config.js b/eslint.config.js index 6a2a948c..197dc291 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/main.js b/main.js index 795ca5e8..541ae15f 100644 --- a/main.js +++ b/main.js @@ -578,6 +578,7 @@ function annotateRemoteAttachable(projects) { 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; @@ -616,6 +617,7 @@ function toSidebarPlaceholderSession(ph) { remoteDescriptorSeen: ph.remoteDescriptorSeen, status: ph.status, statusUpdatedAt: ph.statusUpdatedAt, + waitingFor: ph.waitingFor, placeholder: true, }; } diff --git a/public/remote-activity-ui.js b/public/remote-activity-ui.js index 998beec1..d2ddcaed 100644 --- a/public/remote-activity-ui.js +++ b/public/remote-activity-ui.js @@ -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); } @@ -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'); @@ -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; diff --git a/public/session-activity-dom.js b/public/session-activity-dom.js index 1a256f05..c727a514 100644 --- a/public/session-activity-dom.js +++ b/public/session-activity-dom.js @@ -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; diff --git a/public/session-state.js b/public/session-state.js index cc3ce08b..0e61dd9a 100644 --- a/public/session-state.js +++ b/public/session-state.js @@ -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; @@ -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; @@ -57,6 +64,7 @@ function createSessionState(kind) { touch(event); break; case 'attention': + attentionFromDescriptor = false; if (event.active === false) { attention = false; } else { @@ -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; @@ -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': diff --git a/public/sidebar.js b/public/sidebar.js index 3a448323..7d39c731 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -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'); @@ -1381,6 +1381,15 @@ 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'; @@ -1388,7 +1397,8 @@ function buildSessionItem(session) { 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)); @@ -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'); @@ -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); diff --git a/remote-index.js b/remote-index.js index 13b83f95..a1c2da49 100644 --- a/remote-index.js +++ b/remote-index.js @@ -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) @@ -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), @@ -98,15 +120,18 @@ 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; @@ -114,6 +139,7 @@ function createRemoteIndexer(ctx) { log.warn(`[remote:${alias}] refresh failed (${state.failures}x consecutive): ${err.message}; ` + `retrying in ${Math.round(delay / 1000)}s`); } + return errorChanged; } function getRemoteHostState(alias) { @@ -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); @@ -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 { @@ -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); @@ -394,4 +422,4 @@ function createRemoteIndexer(ctx) { }; } -module.exports = { createRemoteIndexer, backoffDelayMs, buildPlaceholderSession, placeholderTitle }; +module.exports = { createRemoteIndexer, backoffDelayMs, buildPlaceholderSession, placeholderTitle, sanitizeWaitingFor }; diff --git a/test/annotate-remote-attachable-local-status.test.js b/test/annotate-remote-attachable-local-status.test.js index b30e949e..3e71dea8 100644 --- a/test/annotate-remote-attachable-local-status.test.js +++ b/test/annotate-remote-attachable-local-status.test.js @@ -111,3 +111,26 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip assert.equal(projects[0].sessions[0].statusUpdatedAt, 999); assert.equal(projects[0].sessions[0].remoteAttachable, true); }); + +test('a remote session carries the descriptor waitingFor, and null when the descriptor has none or is gone', () => { + const annotateRemoteAttachable = makeAnnotate({ + remoteIndexer: { + getRemoteSessions: () => ({ + sessions: [ + { sessionId: 'w', status: 'waiting', statusUpdatedAt: 1, waitingFor: 'permission prompt' }, + { sessionId: 'i', status: 'idle', statusUpdatedAt: 1 }, + ], + at: 1, + error: null, + }), + getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + }, + }); + const projects = [{ + projectPath: '/srv/proj', + remoteAlias: 'p', + sessions: ['w', 'i', 'gone'].map(sessionId => ({ sessionId, remoteAlias: 'p' })), + }]; + annotateRemoteAttachable(projects); + assert.deepEqual(projects[0].sessions.map(s => s.waitingFor), ['permission prompt', null, null]); +}); diff --git a/test/dom-sidebar-local-status.test.js b/test/dom-sidebar-local-status.test.js index 83c96b20..9ec43ed0 100644 --- a/test/dom-sidebar-local-status.test.js +++ b/test/dom-sidebar-local-status.test.js @@ -136,3 +136,17 @@ test('renderProjects wires a real project fixture with a mix of local and remote assert.ok(el.querySelector('.session-status'), 'the local session row must show its status line'); } finally { ctx.destroy(); } }); + +test('a waiting session names what it waits for in the status line', () => { + const ctx = setupSidebarDom(); + try { + const base = { + summary: 'remote session', modified: '2026-09-09T11:59:00.000Z', starred: false, archived: 0, + messageCount: 1, remoteAlias: 'planificator', status: 'waiting', + }; + const waiting = ctx.sidebar.buildSessionItem({ ...base, sessionId: 'w', waitingFor: 'permission prompt' }); + assert.match(waiting.querySelector('.session-status').textContent, /^waiting · permission prompt/); + const busy = ctx.sidebar.buildSessionItem({ ...base, sessionId: 'b', status: 'busy', waitingFor: 'permission prompt' }); + assert.doesNotMatch(busy.querySelector('.session-status').textContent, /permission/); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-sidebar-remote-freshness.test.js b/test/dom-sidebar-remote-freshness.test.js index 1e493d6c..36c9cb5a 100644 --- a/test/dom-sidebar-remote-freshness.test.js +++ b/test/dom-sidebar-remote-freshness.test.js @@ -198,3 +198,58 @@ test('a host genuinely without any live session is distinct from a host with a l assert.match(emptyDot.title, /no live session/i); } finally { ctx.destroy(); } }); + +test('a waiting remote session is orange on a fresh host and not on a host in error', () => { + const ctx = setupSidebarDom(); + try { + const session = { + sessionId: 'wait-1', summary: 'waiting', modified: '2026-09-09T11:59:00.000Z', starred: false, + archived: 0, messageCount: 1, remoteAlias: 'planificator', remoteDescriptorSeen: true, + status: 'waiting', statusUpdatedAt: 1, waitingFor: 'permission prompt', + }; + const project = (remoteHostError) => remoteProject({ + projectPath: '/srv/w', folder: 'planificator::-srv-w', remoteAlias: 'planificator', + remoteHostAt: 1, remoteHostError, sessions: [{ ...session }], + }); + const orange = () => ctx.document.querySelector('.session-item[data-session-id="wait-1"]').classList.contains('needs-attention'); + ctx.sidebar.renderProjects([project(null)], true); + assert.equal(orange(), true); + ctx.sidebar.renderProjects([project('ssh: timed out')], true); + assert.equal(orange(), false); + } finally { ctx.destroy(); } +}); + +test('a rebuilt waiting remote row keeps its attention icon through the morphdom render', () => { + const ctx = setupSidebarDom(); + try { + const project = remoteProject({ + projectPath: '/srv/w', folder: 'planificator::-srv-w', remoteAlias: 'planificator', remoteHostAt: 1, + sessions: [{ + sessionId: 'wait-2', summary: 'waiting', modified: '2026-09-09T11:59:00.000Z', starred: false, + archived: 0, messageCount: 1, remoteAlias: 'planificator', remoteDescriptorSeen: true, status: 'waiting', + }], + }); + ctx.sidebar.renderProjects([project], true); + ctx.sidebar.renderProjects([project], true); + const icon = ctx.document.querySelector('.session-item[data-session-id="wait-2"] .session-icon'); + assert.ok(icon.classList.contains('session-icon--attention')); + } finally { ctx.destroy(); } +}); + +test('an attached waiting remote row is not painted attention by the rebuilt row', () => { + const ctx = setupSidebarDom(); + try { + const project = remoteProject({ + projectPath: '/srv/w', folder: 'planificator::-srv-w', remoteAlias: 'planificator', remoteHostAt: 1, + sessions: [{ + sessionId: 'wait-3', summary: 'waiting', modified: '2026-09-09T11:59:00.000Z', starred: false, + archived: 0, messageCount: 1, remoteAlias: 'planificator', remoteDescriptorSeen: true, status: 'waiting', + }], + }); + ctx.sidebar.renderProjects([project], true); + ctx.window.setRemoteAttached('wait-3', true); + ctx.sidebar.renderProjects([project], true); + const item = ctx.document.querySelector('.session-item[data-session-id="wait-3"]'); + assert.ok(!item.classList.contains('needs-attention')); + } finally { ctx.destroy(); } +}); diff --git a/test/remote-index.test.js b/test/remote-index.test.js index ba6ac7bd..e87b14ff 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -888,3 +888,116 @@ test('refreshNow({force:true}) ignores backoff for every host and resets it on s assert.equal(recovered.nextAttemptAt, 0); } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); + +test('sanitizeWaitingFor keeps a short plain string and drops everything else', () => { + const { sanitizeWaitingFor } = require('../remote-index'); + assert.equal(sanitizeWaitingFor('permission prompt'), 'permission prompt'); + assert.equal(sanitizeWaitingFor(' input needed '), 'input needed'); + assert.equal(sanitizeWaitingFor('x'.repeat(64)), 'x'.repeat(64)); + assert.equal(sanitizeWaitingFor('x'.repeat(65)), null, 'over the length bound'); + for (const bad of [undefined, null, 5, {}, ['a'], '', ' ', 'a\nb', 'a\u001b[31mb', 'a\u007fb']) { + assert.equal(sanitizeWaitingFor(bad), null, JSON.stringify(bad)); + } +}); + +test('the indexer and the placeholder keep a validated waitingFor beside the status', async () => { + const dataDir = tmp('idx-waiting'); + try { + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }], + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + sync: async () => ({ + fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0, + changedFolders: new Set(), + sessions: [ + { pid: 1, sessionId: 'a', cwd: '/srv/a', status: 'waiting', waitingFor: 'permission prompt', descriptorOnly: true }, + { pid: 2, sessionId: 'b', cwd: '/srv/b', status: 'waiting', waitingFor: 'x'.repeat(500), descriptorOnly: true }, + ], + }), + }); + await indexer.refreshNow(); + const byId = new Map(indexer.getRemoteSessions('vps').sessions.map(s => [s.sessionId, s])); + assert.equal(byId.get('a').waitingFor, 'permission prompt'); + assert.equal(byId.get('b').waitingFor, null, 'an oversized value is dropped, not truncated'); + const ph = new Map(indexer.getPlaceholderSessions('vps').map(s => [s.sessionId, s])); + assert.equal(ph.get('a').waitingFor, 'permission prompt'); + assert.equal(ph.get('b').waitingFor, null); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +test('sanitizeWaitingFor rejects bidi and zero-width controls', () => { + const { sanitizeWaitingFor } = require('../remote-index'); + for (const code of [0x200b, 0x200f, 0x202a, 0x202e, 0x2066, 0x2069]) { + assert.equal(sanitizeWaitingFor('a' + String.fromCharCode(code) + 'b'), null, code.toString(16)); + } + assert.equal(sanitizeWaitingFor('caf\u00e9 \u2014 ok'), 'caf\u00e9 \u2014 ok'); +}); + +test('sanitizeWaitingFor rejects C1 controls, soft hyphen, ALM, invisible operators and BOM', () => { + const { sanitizeWaitingFor } = require('../remote-index'); + for (const code of [0x80, 0x9f, 0xad, 0x61c, 0x180e, 0x2028, 0x2029, 0x2060, 0x2064, 0xfeff]) { + assert.equal(sanitizeWaitingFor('a' + String.fromCharCode(code) + 'b'), null, code.toString(16)); + } + assert.equal(sanitizeWaitingFor('a\u00a0b'), 'a\u00a0b', 'a no-break space is plain text'); +}); + +function hostFlipIndexer(notifyCalls, outcome) { + const dataDir = tmp('idx-notify'); + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }], + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + notify: () => { notifyCalls.push(1); }, + timers: fakeTimers(), + sync: async () => { + const o = outcome.next; + if (o instanceof Error) throw o; + return { fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0, changedFolders: new Set(), sessions: [] }; + }, + }); + return { indexer, dataDir }; +} + +test('a host that starts failing, changes its error or recovers notifies once per change, with no file changes', async () => { + const calls = []; + const outcome = { next: null }; + const { indexer, dataDir } = hostFlipIndexer(calls, outcome); + try { + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 0, 'a quiet healthy host does not notify'); + outcome.next = new Error('ssh: timed out'); + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 1, 'the first failure notifies'); + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 1, 'the same error again does not'); + outcome.next = new Error('ssh: refused'); + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 2, 'a different error notifies'); + outcome.next = null; + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 3, 'recovery notifies'); + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 3, 'staying healthy does not'); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +test('refreshHostNow notifies on a host error change too', async () => { + const calls = []; + const outcome = { next: new Error('ssh: timed out') }; + const { indexer, dataDir } = hostFlipIndexer(calls, outcome); + try { + await indexer.refreshHostNow('vps', { force: true }); + assert.equal(calls.length, 1); + await indexer.refreshHostNow('vps', { force: true }); + assert.equal(calls.length, 1); + outcome.next = null; + await indexer.refreshHostNow('vps', { force: true }); + assert.equal(calls.length, 2); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); diff --git a/test/remote-session-adapter.test.js b/test/remote-session-adapter.test.js index 6b8100ee..8b4dee2a 100644 --- a/test/remote-session-adapter.test.js +++ b/test/remote-session-adapter.test.js @@ -76,7 +76,7 @@ function setup(sessionIds = ['s1']) { snapshot: (id) => vm.runInContext(`remoteState(${JSON.stringify(id)}).snapshot()`, ctx), applyRemoteDescriptor: (session) => call('applyRemoteDescriptor', session), setRemoteAttached: (id, attached) => call('setRemoteAttached', id, attached), - seedRemoteActivity: (session) => call('seedRemoteActivity', session), + seedRemoteActivity: (session, hostError) => call('seedRemoteActivity', session, hostError === undefined ? null : hostError), scheduled, pending: () => scheduled.filter(h => !h.cleared), destroy: () => window.close(), @@ -275,7 +275,7 @@ function setupWithClock(sessionIds = ['s1']) { now: () => clock, emit: (payload) => onRemoteActivityCb(payload), snapshot: (id) => vm.runInContext(`remoteState(${JSON.stringify(id)}).snapshot()`, ctx), - seedRemoteActivity: (session) => call('seedRemoteActivity', session), + seedRemoteActivity: (session, hostError) => call('seedRemoteActivity', session, hostError === undefined ? null : hostError), advance(ms) { clock += ms; for (const t of timers) { @@ -398,3 +398,77 @@ test('(6) a rebuild inside the short window re-arms only for what is left of it, assert.equal(t.snapshot('s1').busy, false, 'busy decays at t=3000 — the short window, not the full 20s'); t.destroy(); }); + +const waitingSession = (over) => ({ + sessionId: 's1', remoteAlias: 'planificator', remoteDescriptorSeen: true, + status: 'waiting', statusUpdatedAt: 1, waitingFor: 'permission prompt', ...over, +}); + +test('an unattached remote session whose descriptor says waiting shows attention on its row', () => { + const t = setup(['s1']); + t.seedRemoteActivity(waitingSession()); + assert.equal(t.snapshot('s1').attention, true); + assert.ok(t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); + +test('the remote attention clears when the descriptor goes busy, idle or vanishes', () => { + for (const next of [{ status: 'busy' }, { status: 'idle' }, { status: null, remoteDescriptorSeen: false }]) { + const t = setup(['s1']); + t.seedRemoteActivity(waitingSession()); + t.seedRemoteActivity(waitingSession(next)); + assert.equal(t.snapshot('s1').attention, false, JSON.stringify(next)); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.destroy(); + } +}); + +test('an attached remote row is not painted attention by the descriptor', () => { + const t = setup(['s1']); + t.setRemoteAttached('s1', true); + t.seedRemoteActivity(waitingSession()); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); + +test('a decayed busy edge does not erase a waiting attention', () => { + const t = setup(['s1']); + t.emit({ sessionId: 's1', at: Date.now() }); + t.seedRemoteActivity(waitingSession()); + t.pending()[0].fn(); + assert.equal(t.snapshot('s1').attention, true); + t.destroy(); +}); + +test('a waiting descriptor while attached paints nothing', () => { + const t = setup(['s1']); + t.setRemoteAttached('s1', true); + t.applyRemoteDescriptor(waitingSession()); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); + +test('attach then detach with no new descriptor does not show the stale attention; a later waiting descriptor lights it again', () => { + const t = setup(['s1']); + t.seedRemoteActivity(waitingSession()); + t.setRemoteAttached('s1', true); + t.applyRemoteDescriptor(waitingSession()); + t.setRemoteAttached('s1', false); + assert.equal(t.snapshot('s1').attention, false); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.seedRemoteActivity(waitingSession()); + assert.ok(t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); + +test('a host in error releases the attention its last descriptors raised, and a fresh host restores it', () => { + const t = setup(['s1']); + t.seedRemoteActivity(waitingSession()); + assert.ok(t.item('s1').classList.contains('needs-attention')); + t.seedRemoteActivity(waitingSession(), 'ssh: connection refused'); + assert.equal(t.snapshot('s1').attention, false); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.seedRemoteActivity(waitingSession(), null); + assert.ok(t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); diff --git a/test/session-state.test.js b/test/session-state.test.js index f0219bc6..e330195f 100644 --- a/test/session-state.test.js +++ b/test/session-state.test.js @@ -256,3 +256,93 @@ test('renderSessionIcon: slot class, glyph and title move together with the rung assert.deepEqual(agentsBusy, { classes: ['has-busy-agents'], slotClasses: ['session-icon--agents-busy'], glyph: '◆', title: 'Subagents running' }); assert.notDeepEqual(busy, agentsBusy); }); + +// --------------------------------------------------------------------------- +// descriptor-owned attention (issue #394) — see .ai/contexts/session-state.md +// --------------------------------------------------------------------------- + +test('descriptorStatus waiting with attention:true lights attention and clears busy and unread', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'busy', active: true }); + s.apply({ type: 'descriptorStatus', status: 'waiting', at: 1, attention: true }); + const snap = s.snapshot(); + assert.equal(snap.attention, true); + assert.equal(snap.busy, false); + assert.equal(renderSessionIcon(snap).slotClasses[0], 'session-icon--attention'); +}); + +test('descriptorStatus waiting without attention:true never lights attention (local callers)', () => { + const s = createSessionState('local-pty'); + s.apply({ type: 'descriptorStatus', status: 'waiting', at: 1 }); + assert.equal(s.snapshot().attention, false); +}); + +test('descriptor-owned attention clears when the status leaves waiting, or is null', () => { + for (const next of ['busy', 'idle', null]) { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'descriptorStatus', status: next, attention: true }); + assert.equal(s.snapshot().attention, false, `status ${next} clears attention`); + } +}); + +test('descriptor-owned attention clears when liveness turns dead', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'liveness', value: 'dead' }); + assert.equal(s.snapshot().attention, false); +}); + +test('descriptor-owned attention survives busy edges and transcript touches', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'transcriptTouched', at: 5 }); + s.apply({ type: 'busy', active: true }); + s.apply({ type: 'busy', active: false, armReady: false }); + assert.equal(s.snapshot().attention, true); +}); + +test('a descriptor leaving waiting does not clear attention raised by an explicit attention event', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'attention', active: true }); + s.apply({ type: 'descriptorStatus', status: 'idle', attention: true }); + assert.equal(s.snapshot().attention, true); +}); + +test('an explicit attention:false clears descriptor-owned attention', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'attention', active: false }); + assert.equal(s.snapshot().attention, false); +}); + +test('an explicit attention event takes over from descriptor-owned attention', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'attention', active: true }); + s.apply({ type: 'descriptorStatus', status: 'idle', attention: true }); + assert.equal(s.snapshot().attention, true); +}); + +test('a waiting descriptor does not light attention on a session known dead', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'liveness', value: 'dead' }); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + assert.equal(s.snapshot().attention, false); +}); + +test('descriptorStatus attention:false and releaseDescriptorAttention release only descriptor-owned attention', () => { + for (const release of [ + (s) => s.apply({ type: 'descriptorStatus', status: 'waiting', attention: false }), + (s) => s.apply({ type: 'releaseDescriptorAttention' }), + ]) { + const owned = createSessionState('remote-ssh'); + owned.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + release(owned); + assert.equal(owned.snapshot().attention, false); + const explicit = createSessionState('remote-ssh'); + explicit.apply({ type: 'attention', active: true }); + release(explicit); + assert.equal(explicit.snapshot().attention, true); + } +});