From 49eec0334abfe3736316e915f753b1ae7f2d1eda Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 13 Sep 2026 13:04:30 +0200 Subject: [PATCH 1/3] (session-state): subagent activity feeds the same running set and the same rung for every source A remote or local-transcript subagent event now threads its agentId into activeSubagentsByParent through one write path, so the child row gets the same running dot, slot and TTL as a local PTY subagent. Closes #285. On a remote parent, a busy touch that coincides with a fresh subagent spawn (3 s window, either order) no longer outranks agentsBusy for the 20 s decay: the Task-tool invocation writes the parent's own transcript too, which put the remote row on the animated busy rung while the local row sat on the static agents-busy rung. A busy touch outside the window still wins. Closes #284. --- .ai/contexts/session-state.md | 26 +++ .ai/contexts/subagent-observability.md | 36 ++++ eslint.config.js | 2 + public/local-transcript-adapter.js | 5 + public/remote-activity-ui.js | 33 +++- public/sidebar.js | 30 +-- ...om-sidebar-subagent-running-parity.test.js | 180 ++++++++++++++++++ test/remote-session-adapter.test.js | 52 +++++ 8 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 test/dom-sidebar-subagent-running-parity.test.js diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 0b2628ae..876c0900 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -189,6 +189,32 @@ 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. +### A coincident parent-file touch must not outrank a fresh subagent spawn (issue #284) + +A Task-tool invocation typically appends to the parent's own top-level +transcript (recording the tool_use/tool_result around the spawn) at almost +the same moment it appends to the subagent's own file. On the remote-ssh +adapter this used to mean a plain `busy` edge could win the icon rung over +`agentsBusy` for its full 20s decay, even though the top-level agent was +really just idle waiting on the subagent — visibly different from a local-pty +row, which reflects the OSC-driven busy edge instantly and clears it just as +fast. Fixed with a **3s coincidence window** (`SUBAGENT_BUSY_COINCIDENCE_MS`, +`public/remote-activity-ui.js`) keyed off the moment `agentsBusy` transitions +false→true (`remoteSubagentSpawnAt`), not every subsequent subagent touch: a +plain busy touch inside that window of a fresh spawn is recorded +(`transcriptTouched`) but not applied as `busy`, and a spawn arriving shortly +after an already-live busy touch retroactively demotes it (cancels its decay +timer, applies `busy:false`). A busy touch outside the window — the top-level +agent genuinely still producing output — is unaffected and wins the rung +normally, tinted violet by the existing `.has-busy-agents .session-icon--busy::before` +rule. The window is deliberately keyed off the *spawn edge*, not a +continuously-refreshed "last subagent touch" timestamp: a chatty subagent +writes roughly once a second (`remote-activity.js`'s `DEFAULT_IPC_MIN_MS` +throttle), which would otherwise keep re-arming the window for the entire +run and permanently suppress a genuinely concurrent busy parent. `PRIORITY` +in `session-state.js` is unchanged — this is a renderer-side demotion of +which events reach `apply()`, not a reordering of the ladder. + ### Row ownership: attached vs unattached (issue #273) An attached remote row (a tab open on it) is owned by the local-pty path — diff --git a/.ai/contexts/subagent-observability.md b/.ai/contexts/subagent-observability.md index 976eeadf..e2b5e35e 100644 --- a/.ai/contexts/subagent-observability.md +++ b/.ai/contexts/subagent-observability.md @@ -93,6 +93,42 @@ 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. +## The child row's own `.running` dot, for every source (issue #285) + +`activeSubagentsByParent` (above) used to be fed only by the local +`subagent-spawned`/`subagent-completed` IPC pair, so a remote or +local-transcript subagent's own row never got `.running` — not even after a +full rebuild — even though both sources' `kind:'subagent'` payload already +carries `agentId` (`remote-activity.js`, `local-transcript-activity.js`). +`noteSubagentActivity(parentSessionId, agentId)` (`public/sidebar.js`) is now +the single write path into that map: the local IPC handler, `onRemoteActivityEvent`'s +subagent branch (`remote-activity-ui.js`) and `onLocalTranscriptSubagentActivity` +(`local-transcript-adapter.js`) all call it, so all three share the same 60s +TTL/decay convention `isSubagentActive`/`pruneStaleSubagents` already +implement — no second, independently-tuned decay was added. + +A suspected second cause (`reflectSubagentRunningState`'s DOM lookup, +`subagentDomId(parent, agentId)` vs. the row's own id) turned out not to +reproduce: every subagent row's `sessionId` is built by +`subagentSessionId(parent, agentId)` = `'sub:'+parent+':'+agentId` +(`read-session-file.js`), for local, remote-mirrored and legacy-layout rows +alike, so `'si-' + session.sessionId` and `subagentDomId(parent, agentId)` +are byte-identical by construction. `test/dom-sidebar-subagent-running.test.js` +already pinned this for the local IPC path with no rebuild; the actual gap +was purely the missing feed, not the lookup. + +One landmine found while wiring this: `reflectSubagentRunningState` also +repaints the **parent's** icon slot via `paintSessionIcon`, which paints from +the local-pty adapter's snapshot (`snapshotForLocal` auto-vivifies a +`localPtyState` entry). Calling it for a remote/local-transcript parent would +have overwritten the icon the remote-ssh/local-transcript adapter's own +`projectRemoteState`/`projectLocalTranscriptState` had just painted, with an +empty local-pty snapshot. `reflectSubagentRunningState` now skips that +repaint when `remoteSessionStates`/`localTranscriptStates` already holds an +entry for the parent — the calling adapter has already repainted its own +slot by that point (it creates the entry before calling +`noteSubagentActivity`). + ## Attribution across sources (issue #247) `.has-busy-agents` used to light only for a parent reachable from diff --git a/eslint.config.js b/eslint.config.js index 2da2def3..52d399cb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -186,6 +186,8 @@ const rendererCrossFileGlobals = { // public/sidebar.js, consumed by session-activity-dom.js's snapshotForLocal // (see .ai/contexts/session-state.md, "The icon slot (step 3b)") parentHasActiveSubagent: 'readonly', + // public/sidebar.js — see .ai/contexts/subagent-observability.md + noteSubagentActivity: 'readonly', // Third-party renderer libs loaded as