diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 18a736b1..718056b6 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -244,19 +244,29 @@ the exact remote command, and the mutation proofs are in cleared and reset inside `stop()`, so a queued trailing event from before the stop can never reach `s.onEvent` afterwards. -### Remote hosts — busy spinner (issue #242) - -Remote transcript-write activity feeds the same `setActivity(sessionId, active, via)` -dispatcher in `session-activity.js` that local PTY output uses — `remote-activity-ui.js` -calls `setActivity(sessionId, true, 'remote-watch')` on each `remote-activity` IPC event -and arms a 20 s decay timer (one per session, reset on each event) that calls -`setActivity(sessionId, false, 'remote-decay')` when it fires, and `seedRemoteActivity(session)` -(called from `renderProjects`, before any row is built) applies the same call from -`session.remoteActiveAt` on first paint so a row rendered inside the decay window starts -busy without waiting for the next event; the visual is the shared `.cli-busy` braille -spinner, not a separate indicator. - -The decay call passes `setActivity(sessionId, false, 'remote-decay', { armReady: false })`, +### Remote hosts — busy spinner (issue #242, moved onto the remote-ssh adapter in #246 step 3) + +**Moved.** The mechanics below (decay timer, `armReady: false`, the F7 purge skip) are +unchanged, but the entry point is now `public/remote-activity-ui.js`'s persistent +`remote-ssh` `createSessionState()` adapter, not a bare `setActivity()` call — see +`.ai/contexts/session-state.md` ("The remote-ssh adapter") for the full wiring +(watch channel, descriptor, attach/detach) and why `setActivity()`/the Maps are +still fed in parallel. + +Remote transcript-write activity feeds the adapter, which in turn still feeds the same +`setActivity(sessionId, active, via)` dispatcher in `session-activity.js` that local PTY +output uses (kept for two readers not yet migrated — sidebar's initial paint and the grid +busy dot) — `remote-activity-ui.js` calls `setActivity(sessionId, true, 'remote-watch')` on +each `remote-activity` IPC event and arms a 20 s decay timer (one per session, reset on each +event) that calls `setActivity(sessionId, false, 'remote-decay', { armReady: false })` when +it fires, and `seedRemoteActivity(session)` (called from `renderProjects`, before any row is +built) applies the same call from `session.remoteActiveAt` on first paint so a row rendered +inside the decay window starts busy without waiting for the next event; the visual is the +shared `.cli-busy` braille spinner, not a separate indicator — the DOM write itself now goes +through `session-activity-dom.js`'s `applyStateClasses(sessionId, snapshot)`, projecting the +adapter's own snapshot rather than being computed inline. + +The decay call passes `armReady: false`, not the bare two-argument form local PTY callers use. 20 s of transcript silence means "stopped writing", not "the response is ready" — a remote adapter has no PTY to ask whether a turn actually ended, so a long tool call or a parent delegating to subagents @@ -267,7 +277,8 @@ unviewed remote row as `.response-ready` on a plain inference. `armReady: false` you haven't looked." Separately, `app.js`'s `updateRunningIndicators` PTY-set purge skips rows carrying `dataset.remoteAlias` (F7) — a remote row's busy state is owned by this decay timer, not by local PTY presence, so it must not be cleared just because some unrelated -local PTY started or stopped. +local PTY started or stopped. That same per-row loop is also where `updateRunningIndicators` +feeds the adapter's `attached` port (`setRemoteAttached(id, running)`). ### Remote hosts file-level rescan (issue #216, first half) diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 78ac61e1..85b2a6af 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -6,15 +6,63 @@ what actually shipped, not the whole plan. ## Migration status -- **Steps 1-2: done.** `public/session-activity.js` split into a state part +- **Steps 1-3: done.** `public/session-activity.js` split into a state part (itself) and a DOM part (`public/session-activity-dom.js`); `public/session-state.js` - introduced and wired behind `applyActivityClasses` for **local-pty only**. -- **Steps 3-5: pending.** `remote-activity-ui.js`/`remote-activity.js` still write - `sessionBusyState` directly instead of going through a `remote-ssh` adapter; there - is no `local-transcript` adapter; subagent attribution is not routed through + introduced and wired behind `applyActivityClasses` for local-pty, and behind + a persistent `remote-ssh` adapter (`public/remote-activity-ui.js`) for + remote sessions — see "The remote-ssh adapter" below. +- **Steps 3b/4/5: pending.** The unified icon-slot markup (dot + age + spinner + in one element) is a separate PR (3b). There is no `local-transcript` + adapter (step 4). Subagent attribution is not routed through `session-state.js` (`agentsBusy` exists in the model but nothing local-pty feeds it yet — sidebar.js's `has-busy-agents` is still computed by - `parentHasActiveSubagent()`, independent of the domain module). + `parentHasActiveSubagent()`, independent of the domain module) (step 5). + +### The remote-ssh adapter (step 3) + +`public/remote-activity-ui.js` keeps one persistent `createSessionState('remote-ssh')` +per remote session id in `remoteSessionStates` (a `Map`, pruned in +`pruneRemoteActivityTimers()` alongside the decay timers, called after every +`refreshSidebar()`). It is fed by: + +- **The watch channel** (`onRemoteActivityEvent`, `main.js`'s `remote-activity` + IPC): `transcriptTouched` + `busy: true`; the 20s decay timer then applies + `busy: false, armReady: false` — a remote row must never reach + `.response-ready`, it has no PTY to confirm a turn actually ended. Tested in + `test/remote-session-adapter.test.js` and mutation-proven (see below). +- **The descriptor** (`applyRemoteDescriptor`, called from `seedRemoteActivity` + at every render for every remote session): `descriptorStatus(status, at)` + from `session.status`/`session.statusUpdatedAt`, and `liveness: 'alive'` + when `session.remoteDescriptorSeen` is true. `main.js`'s + `annotateRemoteAttachable` sets `remoteDescriptorSeen = !!descriptor` — the + descriptor list (`remoteIndexer.getRemoteSessions`) is already ALIVE-marker + filtered (#262), so a match means a live process. Absence is left + `'unknown'`, never asserted `'dead'` — a poll miss or host backoff is not + proof the process exited. +- **Attach/detach of the remote tab** (`setRemoteAttached(sessionId, attached)`, + called from `app.js`'s `updateRunningIndicators` for rows carrying + `dataset.remoteAlias`): there is no dedicated open/close IPC event for a + remote attach, so this reuses the same per-row `activePtyIds` transition + `has-running-pty` already reads. + +**The adapter never writes DOM itself.** Every event ends in +`projectRemoteState(sessionId)`, which calls `session-activity-dom.js`'s new +`applyStateClasses(sessionId, snapshot)` — the same two-class output +(`cli-busy`/`response-ready`) `applyActivityClasses` produces for local-pty, +but computed from the adapter's own snapshot instead of the local-pty Maps. + +**`setActivity()`/the Maps in `session-activity.js` are still fed for remote +ids in parallel** (`markRemoteBusy`/`decayRemoteBusy` call both). Two readers +were not migrated onto the adapter in this step, so removing the dual-feed +would regress them: +- `sidebar.js`'s `buildSessionItem` reads `sessionBusyState`/ + `responseReadySessions`/`attentionSessions` directly at initial paint. +- `app.js`'s grid-card busy dot (`updateRunningIndicators`'s `gridCards` + loop) reads `sessionBusyState` directly. + +Both are driven by the same `active`/`armReady` inputs as the adapter, so the +two projections never disagree in practice; the dual-feed is a known, +temporary duplication, not a race. ## Shape @@ -114,12 +162,13 @@ is a later step, not part of this migration. | event | local-pty | local-transcript | remote-ssh | |---|---|---|---| -| `busy` / `attention` (OSC 0 / 9) | yes | never | only while attached | -| `transcriptTouched(at)` | yes | yes (only signal) | yes (watch channel) | -| `descriptorStatus(status, at)` | yes | no (no live CLI) | yes (`main.js:539`) | +| `busy` / `attention` (OSC 0 / 9) | yes | never | wired via the watch channel (transcript writes), not OSC — OSC-while-attached is not wired | +| `transcriptTouched(at)` | yes | yes (only signal) | yes — `onRemoteActivityEvent`/`markRemoteBusy` | +| `descriptorStatus(status, at)` / `liveness` | yes | no (no live CLI) | yes (`main.js:539` → `applyRemoteDescriptor`) | +| `attached` | reserved, unused | reserved, unused | yes — `setRemoteAttached`, driven by the per-row `activePtyIds` transition | | `subagentSpawned` / `subagentCompleted` | yes | no | no today | An adapter without a PTY must never claim `waitingForInput` or `responseReady` -— it has no way to tell "thinking" from "done, unseen". It should only feed -`busy: unknown` (not modeled as a tri-state yet — reserved for step 3/4) plus -`lastActivityAt`. +from a completion signal it cannot verify — that is why the remote-ssh +`busy: false` transition always passes `armReady: false` (see "The remote-ssh +adapter" above), not a tri-state `busy: unknown`. diff --git a/eslint.config.js b/eslint.config.js index 9e166f53..3b37eb45 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -156,10 +156,13 @@ const rendererCrossFileGlobals = { // public/session-activity-dom.js — the only file allowed to write // .cli-busy/.needs-attention/.response-ready/.has-busy-agents. applyActivityClassesToElement: 'readonly', + applyStateClasses: 'readonly', setNeedsAttention: 'readonly', setResponseReady: 'readonly', setCliBusy: 'readonly', setHasBusyAgents: 'readonly', + // public/remote-activity-ui.js (remote-ssh adapter, see .ai/contexts/session-state.md) + setRemoteAttached: 'readonly', // Third-party renderer libs loaded as