diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index d84d1178..c66a5d87 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -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 (`/.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`. diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 2ad5f698..6ecc0430 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -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) @@ -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 diff --git a/.ai/contexts/subagent-observability.md b/.ai/contexts/subagent-observability.md index 68fb0cae..976eeadf 100644 --- a/.ai/contexts/subagent-observability.md +++ b/.ai/contexts/subagent-observability.md @@ -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-.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 `/subagents/agent-.jsonl` +or the legacy `/agent-.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-.jsonl` is never deleted, so **every directory rescan diff --git a/eslint.config.js b/eslint.config.js index 9ce66ef2..b9c1e5ae 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/local-transcript-activity.js b/local-transcript-activity.js index 7c9975ab..ed2c5234 100644 --- a/local-transcript-activity.js +++ b/local-transcript-activity.js @@ -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; @@ -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 }; diff --git a/public/local-transcript-adapter.js b/public/local-transcript-adapter.js index fc2030c5..d23c0c47 100644 --- a/public/local-transcript-adapter.js +++ b/public/local-transcript-adapter.js @@ -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(); @@ -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); @@ -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; @@ -65,10 +95,23 @@ 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); } @@ -76,6 +119,9 @@ 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); } diff --git a/public/remote-activity-ui.js b/public/remote-activity-ui.js index e3a7c271..42e557a8 100644 --- a/public/remote-activity-ui.js +++ b/public/remote-activity-ui.js @@ -4,6 +4,9 @@ const PIP_DECAY_MS = 20000; const remoteActivityDecayTimers = new Map(); +// separate decay for subagent attribution (agentsBusy) — see .ai/contexts/subagent-observability.md +const remoteAgentsDecayTimers = new Map(); + // remote-ssh adapter: one persistent state per remote session id — see .ai/contexts/session-state.md const remoteSessionStates = new Map(); @@ -28,6 +31,14 @@ function clearRemoteActivityTimer(sessionId) { } } +function clearRemoteAgentsTimer(sessionId) { + const t = remoteAgentsDecayTimers.get(sessionId); + if (t) { + clearTimeout(t); + remoteAgentsDecayTimers.delete(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); @@ -52,16 +63,48 @@ function armRemoteDecayTimer(sessionId, ms) { }, ms)); } +// silence means the subagent stopped, not finished — see .ai/contexts/subagent-observability.md +function decayRemoteAgentsBusy(sessionId) { + const state = remoteState(sessionId); + state.apply({ type: 'subagentCompleted', stillActive: false }); + projectRemoteState(sessionId); +} + +function armRemoteAgentsDecayTimer(sessionId) { + clearRemoteAgentsTimer(sessionId); + remoteAgentsDecayTimers.set(sessionId, setTimeout(() => { + remoteAgentsDecayTimers.delete(sessionId); + decayRemoteAgentsBusy(sessionId); + }, PIP_DECAY_MS)); +} + +// attributes a subagent write to its parent's own state — see .ai/contexts/subagent-observability.md +function markRemoteSubagentBusy(sessionId) { + const state = remoteState(sessionId); + state.apply({ type: 'subagentSpawned' }); + projectRemoteState(sessionId); + armRemoteAgentsDecayTimer(sessionId); +} + function pruneRemoteActivityTimers() { for (const sessionId of remoteActivityDecayTimers.keys()) { if (!sessionItemEl(sessionId)) clearRemoteActivityTimer(sessionId); } + for (const sessionId of remoteAgentsDecayTimers.keys()) { + if (!sessionItemEl(sessionId)) clearRemoteAgentsTimer(sessionId); + } for (const sessionId of remoteSessionStates.keys()) { if (!sessionItemEl(sessionId)) remoteSessionStates.delete(sessionId); } } function onRemoteActivityEvent(payload) { + if (payload && payload.kind === 'subagent') { + const parentSessionId = payload.parentSessionId; + if (typeof parentSessionId !== 'string' || !parentSessionId) return; + markRemoteSubagentBusy(parentSessionId); + return; + } const sessionId = payload && payload.sessionId; if (typeof sessionId !== 'string' || !sessionId) return; markRemoteBusy(sessionId, 'remote-watch', payload.at); diff --git a/public/session-activity-dom.js b/public/session-activity-dom.js index 262e42f5..a55f9764 100644 --- a/public/session-activity-dom.js +++ b/public/session-activity-dom.js @@ -46,17 +46,19 @@ function applyActivityClasses(sessionId) { applyActivityClassesToElement(sessionItemEl(sessionId), sessionId); } -// Snapshot-driven projection for adapter-owned state — see .ai/contexts/session-state.md +// Snapshot-driven projection for adapter-owned state; has-busy-agents read off the snapshot — see .ai/contexts/session-state.md function applyStateClasses(sessionId, snapshot) { const item = sessionItemEl(sessionId); if (!item) return; const icon = renderSessionIcon(snapshot); const ready = icon.classes.includes('response-ready'); const busy = icon.classes.includes('cli-busy'); + const agentsBusy = !!(snapshot && snapshot.agentsBusy); setResponseReady(item, ready); setCliBusy(item, busy); + setHasBusyAgents(item, agentsBusy); writeIconSlot(item.querySelector('.session-icon'), icon); - if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'response-ready': ready, 'cli-busy': busy, fn: 'applyStateClasses', kind: snapshot && snapshot.kind }); + if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'response-ready': ready, 'cli-busy': busy, 'has-busy-agents': agentsBusy, fn: 'applyStateClasses', kind: snapshot && snapshot.kind }); } // One icon slot per row, written here and nowhere else — see .ai/contexts/session-state.md diff --git a/public/sidebar.js b/public/sidebar.js index 7cc98b33..c942df6c 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -150,9 +150,19 @@ function isSubagentActive(parentSessionId, agentId) { return !!map && map.has(agentId); } +// remote-ssh/local-transcript parents have no PTY to feed activeSubagentsByParent — see .ai/contexts/subagent-observability.md function parentHasActiveSubagent(parentSessionId) { const map = activeSubagentsByParent.get(parentSessionId); - return !!map && map.size > 0; + if (map && map.size > 0) return true; + if (typeof remoteSessionStates !== 'undefined') { + const remote = remoteSessionStates.get(parentSessionId); + if (remote && remote.snapshot().agentsBusy) return true; + } + if (typeof localTranscriptStates !== 'undefined') { + const local = localTranscriptStates.get(parentSessionId); + if (local && local.snapshot().agentsBusy) return true; + } + return false; } function pruneStaleSubagents() { diff --git a/remote-activity.js b/remote-activity.js index df3789db..a63fdefb 100644 --- a/remote-activity.js +++ b/remote-activity.js @@ -1,6 +1,8 @@ // see .ai/contexts/session-cache.md ("Remote hosts — busy spinner (issue #242)") '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_DECAY_MS = 20000; const DEFAULT_IPC_MIN_MS = 1000; @@ -34,15 +36,29 @@ function createRemoteActivityTracker(opts = {}) { function record(alias, rel) { const sessionId = sessionIdFromRel(rel); - if (!sessionId) return null; + if (sessionId) { + const t = now(); + const k = key(alias, sessionId); + seenAt.set(k, t); + prune(t); + const lastIpc = ipcAt.has(k) ? ipcAt.get(k) : -Infinity; + if (t - lastIpc < ipcMinMs) return null; + ipcAt.set(k, t); + return { alias, sessionId, at: t }; + } + + // subagent leg fallback (issue #247) — see .ai/contexts/subagent-observability.md + const parts = (typeof rel === 'string' ? rel : '').split('/'); + const attribution = subagentParentFromParts(parts); + if (!attribution) return null; + const { parentSessionId, agentId } = attribution; const t = now(); - const k = key(alias, sessionId); - seenAt.set(k, t); + const k = key(alias, 'sub:' + parentSessionId); // distinct namespace: never collides with a UUID sessionId key prune(t); const lastIpc = ipcAt.has(k) ? ipcAt.get(k) : -Infinity; if (t - lastIpc < ipcMinMs) return null; ipcAt.set(k, t); - return { alias, sessionId, at: t }; + return { alias, parentSessionId, agentId, at: t, kind: 'subagent' }; } function activeAt(alias, sessionId) { diff --git a/subagent-attribution.js b/subagent-attribution.js new file mode 100644 index 00000000..b51d2a4b --- /dev/null +++ b/subagent-attribution.js @@ -0,0 +1,27 @@ +// Pure transcript-path -> parent attribution — see .ai/contexts/subagent-observability.md +'use strict'; + +// basename must be "agent-.jsonl" — see read-session-file.js:enumerateSessionFiles +function agentIdFromBasename(basename) { + if (typeof basename !== 'string' || !basename.endsWith('.jsonl')) return null; + const base = basename.slice(0, -'.jsonl'.length); + const m = base.match(/^agent-(.+)$/); + return m ? m[1] : null; +} + +// parts: [folder, ...rest] — both real layouts, null otherwise; see .ai/contexts/subagent-observability.md +function subagentParentFromParts(parts) { + if (!Array.isArray(parts) || parts.length < 3) return null; + const rest = parts.slice(1); + const agentId = agentIdFromBasename(rest[rest.length - 1]); + if (!agentId) return null; + if (rest.length === 3 && rest[1] === 'subagents') { + return { parentSessionId: rest[0], agentId }; + } + if (rest.length === 2) { + return { parentSessionId: rest[0], agentId }; + } + return null; +} + +module.exports = { subagentParentFromParts, agentIdFromBasename }; diff --git a/test/dom-sidebar-remote-subagent-attribution.test.js b/test/dom-sidebar-remote-subagent-attribution.test.js new file mode 100644 index 00000000..1109047c --- /dev/null +++ b/test/dom-sidebar-remote-subagent-attribution.test.js @@ -0,0 +1,109 @@ +// Issue #247 — has-busy-agents for remote parents and local parents without +// a PTY, and parity of the remote subagent label with local. See +// .ai/contexts/subagent-observability.md ("Attribution across sources"). + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +function projectWithRemoteSubagent() { + return makeSampleProject({ + remoteAlias: 'vps', + sessions: [ + { + sessionId: 'r-top-1', + remoteAlias: 'vps', + name: 'remote main session', + summary: 'remote top level', + modified: '2026-05-22T10:00:00.000Z', + starred: false, + archived: 0, + messageCount: 1, + }, + { + sessionId: 'sub:r-top-1:agent-1', + parentSessionId: 'r-top-1', + agentId: 'agent-1', + remoteAlias: 'vps', + subagentType: 'explore', + description: 'explore subagent', + modified: '2026-05-22T09:59:00.000Z', + messageCount: 1, + }, + ], + }); +} + +test('a remote subagent row shows its agent type label, exactly like a local one', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([projectWithRemoteSubagent()], true); + const row = ctx.document.getElementById('si-sub:r-top-1:agent-1'); + assert.ok(row, 'the remote subagent row must render'); + const pill = row.querySelector('.sidebar-subagent-type'); + assert.equal(pill.textContent, 'explore', + 'buildSubagentItem reads session.subagentType regardless of remoteAlias — no fork exists for remote rows'); + assert.notEqual(pill.textContent, 'sub', 'must not fall back to the generic label when a real type is known'); + } finally { + ctx.destroy(); + } +}); + +test('a remote parent gets has-busy-agents from a subagent write, and it survives a full re-render', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([projectWithRemoteSubagent()], true); + + // Drive remote-activity-ui.js's event handler directly — a top-level + // function declared in a classic