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
39 changes: 25 additions & 14 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
73 changes: 61 additions & 12 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`.
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script>
morphdom: 'readonly',
Expand Down
2 changes: 2 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}

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

Check warning on line 73 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 73 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 } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath, resolveAllowedMemoryPath: _resolveAllowedMemoryPath, isKnownProjectRoot: _isKnownProjectRoot } = require('./ipc-path-validator');
Expand Down Expand Up @@ -453,8 +453,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 456 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 456 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 457 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 } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');

Expand Down Expand Up @@ -555,6 +555,8 @@
session.status = descriptor ? (descriptor.status || null) : null;
session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || 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;
} else {
// Same descriptor vocabulary, read from the local ~/.claude/sessions/<pid>.json
// instead of a remote host's mirror -- see .ai/contexts/cli-session-state.md
Expand Down Expand Up @@ -2167,7 +2169,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 2172 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
1 change: 1 addition & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,7 @@ function updateRunningIndicators() {
// drop the live-subagent state now instead of waiting for the TTL.
clearActiveSubagentsFor(id);
}
if (item.dataset.remoteAlias) setRemoteAttached(id, running);
const dot = item.querySelector('.session-status-dot');
if (dot) dot.classList.toggle('running', running);
if (window.ATRACE) window.atrace('class.toggle', id, { el: item.id || null, cls: 'has-running-pty', on: running, dot: !!dot, fn: 'updateRunningIndicators' });
Expand Down
67 changes: 62 additions & 5 deletions public/remote-activity-ui.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
// See .ai/contexts/session-cache.md ("Remote hosts — busy spinner (issue #242)").
// See .ai/contexts/session-cache.md ("Remote hosts — busy spinner (issue #242)")
// and .ai/contexts/session-state.md (migration step 3: the remote-ssh adapter).

const PIP_DECAY_MS = 20000;
const remoteActivityDecayTimers = new Map();

// remote-ssh adapter: one persistent state per remote session id — see .ai/contexts/session-state.md
const remoteSessionStates = new Map();

function remoteState(sessionId) {
let state = remoteSessionStates.get(sessionId);
if (!state) {
state = createSessionState('remote-ssh');
remoteSessionStates.set(sessionId, state);
}
return state;
}

function projectRemoteState(sessionId) {
applyStateClasses(sessionId, remoteState(sessionId).snapshot());
}

function clearRemoteActivityTimer(sessionId) {
const t = remoteActivityDecayTimers.get(sessionId);
if (t) {
Expand All @@ -11,35 +28,75 @@ function clearRemoteActivityTimer(sessionId) {
}
}

// Maps still fed in parallel for sidebar initial paint and grid dot — see session-state.md "migration status"
function markRemoteBusy(sessionId, via, at) {
const state = remoteState(sessionId);
state.apply({ type: 'transcriptTouched', at: at || Date.now(), source: via });
state.apply({ type: 'busy', active: true });
setActivity(sessionId, true, via);
projectRemoteState(sessionId);
}

// silence is "stopped writing", not "response ready" — see .ai/contexts/session-cache.md ("Remote hosts — busy spinner")
function decayRemoteBusy(sessionId) {
const state = remoteState(sessionId);
state.apply({ type: 'busy', active: false, armReady: false });
setActivity(sessionId, false, 'remote-decay', { armReady: false });
projectRemoteState(sessionId);
}

function armRemoteDecayTimer(sessionId, ms) {
remoteActivityDecayTimers.set(sessionId, setTimeout(() => {
remoteActivityDecayTimers.delete(sessionId);
// silence is "stopped writing", not "response ready" — see .ai/contexts/session-cache.md ("Remote hosts — busy spinner")
setActivity(sessionId, false, 'remote-decay', { armReady: false });
decayRemoteBusy(sessionId);
}, ms));
}

function pruneRemoteActivityTimers() {
for (const sessionId of remoteActivityDecayTimers.keys()) {
if (!sessionItemEl(sessionId)) clearRemoteActivityTimer(sessionId);
}
for (const sessionId of remoteSessionStates.keys()) {
if (!sessionItemEl(sessionId)) remoteSessionStates.delete(sessionId);
}
}

function onRemoteActivityEvent(payload) {
const sessionId = payload && payload.sessionId;
if (typeof sessionId !== 'string' || !sessionId) return;
setActivity(sessionId, true, 'remote-watch');
markRemoteBusy(sessionId, 'remote-watch', payload.at);
clearRemoteActivityTimer(sessionId);
armRemoteDecayTimer(sessionId, PIP_DECAY_MS);
}

// descriptor ports; absence stays 'unknown', never 'dead' — see session-state.md ports table
function applyRemoteDescriptor(session) {
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 });
}
projectRemoteState(session.sessionId);
}

// attached = a PTY/ssh attach exists for this row (activePtyIds signal from app.js)
function setRemoteAttached(sessionId, attached) {
if (!attached && !remoteSessionStates.has(sessionId)) return; // nothing recorded yet, nothing to clear
const state = remoteState(sessionId);
state.apply({ type: 'attached', value: attached });
projectRemoteState(sessionId);
}

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

if (!Number.isFinite(session.remoteActiveAt)) return;
const sessionId = session.sessionId;
const remaining = session.remoteActiveAt + PIP_DECAY_MS - Date.now();
if (remaining <= 0) return;
setActivity(sessionId, true, 'remote-seed');
markRemoteBusy(sessionId, 'remote-seed', session.remoteActiveAt);
if (remoteActivityDecayTimers.has(sessionId)) return;
armRemoteDecayTimer(sessionId, remaining);
}
Expand Down
12 changes: 12 additions & 0 deletions public/session-activity-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,15 @@ function applyActivityClassesToElement(item, sessionId) {
function applyActivityClasses(sessionId) {
applyActivityClassesToElement(sessionItemEl(sessionId), sessionId);
}

// Snapshot-driven projection for adapter-owned state — see .ai/contexts/session-state.md
function applyStateClasses(sessionId, snapshot) {
const item = sessionItemEl(sessionId);
if (!item) return;
const classes = renderSessionIcon(snapshot).classes;
const ready = classes.includes('response-ready');
const busy = classes.includes('cli-busy');
setResponseReady(item, ready);
setCliBusy(item, busy);
if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'response-ready': ready, 'cli-busy': busy, fn: 'applyStateClasses', kind: snapshot && snapshot.kind });
}
Loading
Loading