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
10 changes: 10 additions & 0 deletions .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ This file is the **canonical inventory** of the IPC surface. When you add a new

`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event`, `session-transcript-activity`

`session-transcript-activity` and (not listed above; see
`.ai/contexts/session-cache.md`, "Remote hosts — busy spinner") `remote-activity`
each carry one of two payload shapes on the same channel — a subagent write is
never a second event (issue #247): `{ sessionId, at }` for a top-level
transcript, or `{ parentSessionId, agentId, at, kind: 'subagent' }` (plus
`alias` for `remote-activity`) when the write is a subagent leg attributed to
its parent. See `.ai/contexts/subagent-observability.md` ("Attribution across
sources") for the attribution and `.ai/contexts/session-state.md` for how each
adapter applies it.

- **`indexing-progress`**: `{coldStart, current, total, sessionsSoFar, done, error?}`. Fired only from `populateCacheViaWorker()` when the `initial_scan_complete` marker was absent at call time (a genuine first launch, a post-migration reset, or the resume of an interrupted first scan) — never on a routine warm-start rebuild. Throttled to ~4 events/s; the first event and the final `done:true` always pass. `done:true` with `error` means the scan failed and the renderer shows the failure in the banner instead of hiding it. Drives the renderer's dismissible first-run banner (`public/app.js`'s `updateIndexingBanner`); see `.ai/contexts/session-cache.md`.
- **`session-transcript-activity`**: `{sessionId, at}`. Fired from inside the raw `fs.watch(PROJECTS_DIR, ...)` callback in `startProjectsWatcher()` — deliberately **not** routed through the debounced `flushChanges()` / `notifyRendererProjectsChanged()` path that also lives there, so it reaches the renderer well before the 500ms debounce plus the cache refresh it triggers. Only fires for a top-level session transcript (`<folder>/<sessionId>.jsonl`, two path segments — a subagent leg is out of scope, see `.ai/contexts/session-state.md` ports table) whose sessionId has no live PTY in `activeSessions` (`sessionHasPty()`, main.js — the OSC busy/attention path already owns a PTY-backed row). Coalesced to at most one emission per second per session by `local-transcript-activity.js`'s `createLocalTranscriptTracker()`. Feeds `public/local-transcript-adapter.js` (`window.api.onSessionTranscriptActivity`) — the `local-transcript` adapter in `.ai/contexts/session-state.md`.

Expand Down
18 changes: 13 additions & 5 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,18 @@ what actually shipped, not the whole plan.
gives a session launched outside Switchboard (no PTY in this app) a busy
signal from transcript growth — see "The local-transcript adapter (step 4)"
below.
- **Step 5: pending.** 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` row class is still computed by
`parentHasActiveSubagent()`, independent of the domain module).
- **Step 5: done** (issue #247). A subagent transcript write for a **remote**
or **local-transcript** parent (no PTY in this app) is attributed to that
parent and applied through its own adapter's `subagentSpawned`/
`subagentCompleted` — see `.ai/contexts/subagent-observability.md`
("Attribution across sources") for the full wiring. A **local-pty** parent
is untouched: it keeps going through the IPC path
(`session-transitions.js:detectSubagentTransitions()`), never double-fed.
`sidebar.js`'s `parentHasActiveSubagent()` now also consults the remote-ssh
and local-transcript adapters' own snapshots (`remoteSessionStates` /
`localTranscriptStates`) so `has-busy-agents` survives a full
`renderProjects()` re-render for those two kinds, the same way it already
did for local-pty via `activeSubagentsByParent`.

### The remote-ssh adapter (step 3)

Expand Down Expand Up @@ -365,7 +373,7 @@ the others assert on the dot/slot element itself, only on row classes and
| `transcriptTouched(at)` | yes | yes (only signal) — `onLocalTranscriptActivity` | yes — `onRemoteActivityEvent`/`markRemoteBusy` |
| `descriptorStatus(status, at)` / `liveness` | yes | yes — `seedLocalTranscriptDescriptor`, from `sessionMap`'s `status`/`statusUpdatedAt` (see "The local-transcript adapter" above for why this widens the issue's original "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 |
| `subagentSpawned` / `subagentCompleted` | yes — via `detectSubagentTransitions()` IPC | yes (issue #247) — `onLocalTranscriptSubagentActivity`, gated on the parent having no PTY | yes (issue #247) — `onRemoteActivityEvent({kind:'subagent'})`, attributed by `subagentParentFromParts()` |

An adapter without a PTY must never claim `waitingForInput` or `responseReady`
from a completion signal it cannot verify — that is why the remote-ssh and
Expand Down
109 changes: 109 additions & 0 deletions .ai/contexts/subagent-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,115 @@ This is the **#1 fork-specific feature** (upstream PR #47 still pending). It per
prune until PR #137. Keep cross-file names distinct;
`test/dom-grid-sidebar-prune-collision.test.js` pins the pair.

## Attribution across sources (issue #247)

`.has-busy-agents` used to light only for a parent reachable from
`activeSessions` — the IPC pair `subagent-spawned`/`subagent-completed`
emitted by `detectSubagentTransitions()` (see above), which only scans
sessions with a PTY in this app. Two parents never lit: a **remote** parent
(the watch channel hears every `agent-<id>.jsonl` write, but
`remote-activity.js`'s `sessionIdFromRel` required a UUID basename and
dropped the subagent leg) and a **local** parent launched outside Switchboard
(no PTY, so `detectSubagentTransitions()` never scans it).

**`subagent-attribution.js`** (repo root, pure, `require()`-able from both
main-process trackers and `node:test`) is the single function this now goes
through: `subagentParentFromParts(parts)` takes a path already split into
segments (`[folder, ...rest]` — the same shape a `fs.watch` filename or a
remote mirror's rel path is split into) and returns
`{ parentSessionId, agentId }` for either real subagent layout
(`enumerateSessionFiles()`'s preferred `<parent>/subagents/agent-<id>.jsonl`
or the legacy `<parent>/agent-<id>.jsonl`), `null` for a top-level transcript
or anything else. Tested in `test/subagent-attribution.test.js` against both
layouts and the top-level/garbage-input null cases, with a mutation proof
(swapping which path segment is reported as the parent turns two tests red).

Each source's own main-process tracker calls it as a **fallback**, after its
existing top-level check fails:

- **`local-transcript-activity.js`**: `record(parts)` tries
`sessionIdFromWatchParts(parts)` first (top-level, unchanged); on `null`,
tries `subagentParentFromParts(parts)`. A resolved attribution is dropped
if `hasPty(parentSessionId)` — the parent already has a PTY in this app, so
`detectSubagentTransitions()` owns it and must not be double-fed. Otherwise
it's coalesced (same `ipcMinMs` throttle, keyed `'sub:' + parentSessionId`
— a namespace no UUID sessionId can collide with) and returned as
`{ parentSessionId, agentId, at, kind: 'subagent' }` on the same
`session-transcript-activity` channel the top-level signal already uses —
one channel, discriminated by `kind`, not a second IPC event.
- **`remote-activity.js`**: `record(alias, rel)` tries `sessionIdFromRel(rel)`
first (unchanged); on `null`, splits `rel` on `/` and tries
`subagentParentFromParts`. No `hasPty` gate here — a remote session has no
local PTY concept the same way. Coalesced independently of the top-level
signal (again `'sub:' + parentSessionId` inside the existing `alias`-keyed
map) and returned as `{ alias, parentSessionId, agentId, at, kind:
'subagent' }` on the existing `remote-activity` channel.

On the renderer side, each adapter's `on*ActivityEvent` branches on
`payload.kind === 'subagent'` and applies `subagentSpawned` to the **parent's
own persistent state** (`localTranscriptState(parentSessionId)` /
`remoteState(parentSessionId)` — the same map the parent's own transcript
signal would use if it had one), then arms a **separate** 20 s decay timer
(`localTranscriptAgentsDecayTimers` / `remoteAgentsDecayTimers` — distinct
maps from the busy-decay timers, so one signal's timer never clobbers the
other's window) that applies `subagentCompleted({stillActive:false})`. Silence
means the subagent stopped writing, not that it finished — same posture as
the busy decay's `armReady:false` (`.ai/contexts/session-state.md`).
`localTranscriptPtyTakeover()` clears both timers, not just the busy one.

`session-activity-dom.js`'s `applyStateClasses(sessionId, snapshot)` — the
shared DOM projection for both adapters — now also writes the `has-busy-agents`
row class, read straight off `snapshot.agentsBusy` rather than off the
winning icon rung (`renderSessionIcon` only reports one rung, and `agentsBusy`
never wins the priority race while `busy` is active — see
`.ai/contexts/session-state.md`, "The slot's CSS keys on its own rung class
alone"). The icon slot itself follows `renderSessionIcon`'s normal priority
resolution, so a parent with no higher-priority rung active shows
`session-icon--agents-busy` exactly like a local-pty parent does.

**Surviving a full re-render.** `buildSessionItem`'s `setHasBusyAgents(item,
parentHasActiveSubagent(session.sessionId))` call fires on every
`renderProjects()`, local and remote rows alike. For local-pty,
`parentHasActiveSubagent()` reads the persistent `activeSubagentsByParent`
map, so the tint survives. For remote/local-transcript there was no such
persistent map to read — a periodic re-render (remote hosts refresh on a
15 s-coalesced watch channel or a ≥60 s poll, well inside the 20 s decay
window) would otherwise wipe `has-busy-agents` moments after the live event
set it. Fixed by having `parentHasActiveSubagent()` (`public/sidebar.js`)
also consult the adapters' own snapshots directly
(`remoteSessionStates.get(id).snapshot().agentsBusy` /
`localTranscriptStates.get(id).snapshot().agentsBusy`, guarded by
`typeof ... !== 'undefined'` for harnesses that don't load those adapter
files) — reading the source of truth instead of adding a third duplicate
map. Both new identifiers are declared in `eslint.config.js`'s
`rendererCrossFileGlobals`. Pinned by
`test/dom-sidebar-remote-subagent-attribution.test.js` (re-render survival for
both kinds) and the acceptance test in `test/remote-session-adapter.test.js`
(live event → `has-busy-agents` + `session-icon--agents-busy` within the
coalescing window, decay clears both).

**Remote subagent label — verified, not forked.** JB's complaint (issue #246
thread) was that a remote subagent row shows the generic "SUB" fallback
instead of its real agent type. `buildSubagentItem()` (`public/sidebar.js`)
reads `session.subagentType` unconditionally — it never branches on
`session.remoteAlias` — and `subagentType` is populated identically for local
and mirrored rows by the same reader (`readSubagentMeta()` in
`read-session-file.js`, called from both `readSessionFile()` and
`readSessionDisplayHeader()`, local file or mirror path alike) and carried
through `session_cache.subagentType` into `buildProjectsFromCache()`'s
`subagentType: row.subagentType || null` (`session-cache.js`). No fork exists
to add or remove. Pinned by
`test/dom-sidebar-remote-subagent-attribution.test.js`'s first test (a
`remoteAlias`-carrying subagent session renders its real type through the
same `buildSubagentItem()` a local one uses). The residual "SUB" a user can
still see transiently is a **mirror-sync latency**, not a rendering gap: a
subagent's `.meta.json` sidecar can mirror on a later cycle than its
transcript (transcripts are sorted ahead of sidecars under the per-cycle
budget — `.ai/contexts/session-cache.md`, "Remote hosts — meta.json
sidecars"), and the type backfills once the sidecar arrives and the
transcript is re-reported dirty. Not fixed here — it is a remote-mirror
scheduling question, out of scope for this issue.

## Not resurrecting finished subagents

A subagent's `agent-<id>.jsonl` is never deleted, so **every directory rescan
Expand Down
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,12 @@ const rendererCrossFileGlobals = {
paintSessionIcon: 'readonly',
// public/remote-activity-ui.js (remote-ssh adapter, see .ai/contexts/session-state.md)
setRemoteAttached: 'readonly',
// read by sidebar.js's parentHasActiveSubagent() — see .ai/contexts/subagent-observability.md
remoteSessionStates: 'readonly',
// public/local-transcript-adapter.js (local-transcript adapter, see .ai/contexts/session-state.md)
localTranscriptPtyTakeover: 'readonly',
pruneLocalTranscriptTimers: 'readonly',
localTranscriptStates: 'readonly',
// public/sidebar.js, consumed by session-activity-dom.js's snapshotForLocal
// (see .ai/contexts/session-state.md, "The icon slot (step 3b)")
parentHasActiveSubagent: 'readonly',
Expand Down
25 changes: 20 additions & 5 deletions local-transcript-activity.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// see .ai/contexts/session-state.md
'use strict';

const { subagentParentFromParts } = require('./subagent-attribution');

const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DEFAULT_IPC_MIN_MS = 1000;

Expand All @@ -23,13 +25,26 @@ function createLocalTranscriptTracker(opts = {}) {

function record(parts) {
const sessionId = sessionIdFromWatchParts(parts);
if (!sessionId) return null;
if (hasPty(sessionId)) return null;
if (sessionId) {
if (hasPty(sessionId)) return null;
const t = now();
const last = ipcAt.has(sessionId) ? ipcAt.get(sessionId) : -Infinity;
if (t - last < ipcMinMs) return null;
ipcAt.set(sessionId, t);
return { sessionId, at: t };
}

// subagent leg fallback (issue #247) — see .ai/contexts/subagent-observability.md
const attribution = subagentParentFromParts(parts);
if (!attribution) return null;
const { parentSessionId, agentId } = attribution;
if (hasPty(parentSessionId)) return null;
const key = 'sub:' + parentSessionId; // distinct namespace: never collides with a UUID sessionId key
const t = now();
const last = ipcAt.has(sessionId) ? ipcAt.get(sessionId) : -Infinity;
const last = ipcAt.has(key) ? ipcAt.get(key) : -Infinity;
if (t - last < ipcMinMs) return null;
ipcAt.set(sessionId, t);
return { sessionId, at: t };
ipcAt.set(key, t);
return { parentSessionId, agentId, at: t, kind: 'subagent' };
}

return { record };
Expand Down
46 changes: 46 additions & 0 deletions public/local-transcript-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
const LOCAL_TRANSCRIPT_DECAY_MS = 20000;
const localTranscriptDecayTimers = new Map();

// separate decay for subagent attribution (agentsBusy) — see .ai/contexts/subagent-observability.md
const localTranscriptAgentsDecayTimers = new Map();

// one persistent state per session id, same shape as remoteSessionStates — see .ai/contexts/session-state.md
const localTranscriptStates = new Map();

Expand All @@ -28,6 +31,14 @@ function clearLocalTranscriptTimer(sessionId) {
}
}

function clearLocalTranscriptAgentsTimer(sessionId) {
const t = localTranscriptAgentsDecayTimers.get(sessionId);
if (t) {
clearTimeout(t);
localTranscriptAgentsDecayTimers.delete(sessionId);
}
}

// descriptorStatus/liveness from session.status — see .ai/contexts/session-state.md
function seedLocalTranscriptDescriptor(sessionId) {
const session = typeof sessionMap !== 'undefined' && sessionMap.get(sessionId);
Expand All @@ -52,8 +63,27 @@ function armLocalTranscriptDecayTimer(sessionId) {
}, LOCAL_TRANSCRIPT_DECAY_MS));
}

// silence means the subagent stopped, not finished — see .ai/contexts/subagent-observability.md
function decayLocalTranscriptAgentsBusy(sessionId) {
const state = localTranscriptState(sessionId);
state.apply({ type: 'subagentCompleted', stillActive: false });
projectLocalTranscriptState(sessionId);
}

function armLocalTranscriptAgentsDecayTimer(sessionId) {
clearLocalTranscriptAgentsTimer(sessionId);
localTranscriptAgentsDecayTimers.set(sessionId, setTimeout(() => {
localTranscriptAgentsDecayTimers.delete(sessionId);
decayLocalTranscriptAgentsBusy(sessionId);
}, LOCAL_TRANSCRIPT_DECAY_MS));
}

// never claims waitingForInput/attention — see .ai/contexts/session-state.md
function onLocalTranscriptActivity(payload) {
if (payload && payload.kind === 'subagent') {
onLocalTranscriptSubagentActivity(payload);
return;
}
const sessionId = payload && payload.sessionId;
if (typeof sessionId !== 'string' || !sessionId) return;
if (activePtyIds.has(sessionId)) return;
Expand All @@ -65,17 +95,33 @@ function onLocalTranscriptActivity(payload) {
armLocalTranscriptDecayTimer(sessionId);
}

// attributes a subagent write to its parent's own state — see .ai/contexts/subagent-observability.md
function onLocalTranscriptSubagentActivity(payload) {
const parentSessionId = payload && payload.parentSessionId;
if (typeof parentSessionId !== 'string' || !parentSessionId) return;
if (activePtyIds.has(parentSessionId)) return;
const state = localTranscriptState(parentSessionId);
state.apply({ type: 'subagentSpawned' });
seedLocalTranscriptDescriptor(parentSessionId);
projectLocalTranscriptState(parentSessionId);
armLocalTranscriptAgentsDecayTimer(parentSessionId);
}

// called once a row gains a PTY; the local-pty path takes over from here — see .ai/contexts/session-state.md
function localTranscriptPtyTakeover(sessionId) {
if (!localTranscriptStates.has(sessionId)) return;
clearLocalTranscriptTimer(sessionId);
clearLocalTranscriptAgentsTimer(sessionId);
localTranscriptStates.delete(sessionId);
}

function pruneLocalTranscriptTimers() {
for (const sessionId of localTranscriptDecayTimers.keys()) {
if (!sessionItemEl(sessionId)) clearLocalTranscriptTimer(sessionId);
}
for (const sessionId of localTranscriptAgentsDecayTimers.keys()) {
if (!sessionItemEl(sessionId)) clearLocalTranscriptAgentsTimer(sessionId);
}
for (const sessionId of localTranscriptStates.keys()) {
if (!sessionItemEl(sessionId)) localTranscriptStates.delete(sessionId);
}
Expand Down
Loading
Loading