pantheon-opencode 1.5.0-beta.9 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +14 -63
  2. package/CHANGELOG.md +304 -0
  3. package/README.md +14 -27
  4. package/README.pt-BR.md +5 -6
  5. package/commands/pantheon.md +1 -35
  6. package/package.json +1 -2
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +2 -5
  9. package/scripts/doctor.mjs +32 -3
  10. package/scripts/install/plugin.mjs +9 -12
  11. package/scripts/mcp_persistence_server.py +1 -1
  12. package/scripts/sync-tui.mjs +8 -11
  13. package/src/agents/apollo.md +1 -0
  14. package/src/agents/gaia.md +1 -0
  15. package/src/agents/zeus.md +9 -52
  16. package/src/instructions/memory-protocol.instructions.md +6 -3
  17. package/src/instructions/zeus-anti-stall.instructions.md +2 -4
  18. package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
  19. package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
  20. package/src/mcp/mcp_persistence_server.py +1 -1
  21. package/src/mcp/toon_codec.py +2 -4
  22. package/src/pantheon/background-job-board.ts +4 -4
  23. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  24. package/src/pantheon/cost-command.ts +1 -2
  25. package/src/pantheon/delegation-enforce.ts +59 -14
  26. package/src/pantheon/goal-loop.ts +1 -1
  27. package/src/pantheon/goal-store.ts +1 -1
  28. package/src/pantheon/idle-continuation.ts +4 -6
  29. package/src/pantheon/model-command.ts +1 -2
  30. package/src/pantheon/native-task-status.ts +2 -2
  31. package/src/pantheon/presets.d.mts +0 -20
  32. package/src/pantheon/presets.mjs +0 -64
  33. package/src/pantheon/session-guard.ts +2 -2
  34. package/src/pantheon/session-id.ts +30 -0
  35. package/src/pantheon/task-result-guard.ts +2 -3
  36. package/src/pantheon/todo-enforcer.ts +3 -3
  37. package/src/pantheon/tool-context.ts +23 -0
  38. package/src/pantheon/v2-bridge.ts +1 -4
  39. package/src/plugin-v2.ts +8 -21
  40. package/src/plugin.ts +61 -245
  41. package/src/plugins/pantheon-hooks.ts +1 -1
  42. package/src/plugins/tui/dist/tui.d.ts +212 -81
  43. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  44. package/src/plugins/tui/dist/tui.js +475 -254
  45. package/src/plugins/tui/package-lock.json +2 -2
  46. package/src/plugins/tui/package.json +2 -2
  47. package/src/plugins/tui/src/index.tsx +667 -269
  48. package/src/routing.yml +0 -16
  49. package/src/skills/context-compression/SKILL.md +1 -1
  50. package/scripts/generate-preset-docs.mjs +0 -300
  51. package/scripts/generate-prompts.sh +0 -222
  52. package/scripts/hash_verify.py +0 -192
  53. package/scripts/install/component.mjs +0 -24
  54. package/scripts/install/migrate-config.mjs +0 -302
  55. package/scripts/test-context-compression.sh +0 -169
  56. package/src/pantheon/auto-wake.ts +0 -85
  57. package/src/pantheon/chat-reminders.ts +0 -9
  58. package/src/pantheon/comment-checker.ts +0 -48
  59. package/src/pantheon/compaction-assert.ts +0 -160
  60. package/src/pantheon/compaction-handoff.ts +0 -304
  61. package/src/pantheon/cost-tracker.ts +0 -108
  62. package/src/pantheon/delegate-manager.ts +0 -766
  63. package/src/pantheon/delegation-classifier.ts +0 -307
  64. package/src/pantheon/delegation-finalize.ts +0 -406
  65. package/src/pantheon/delegation-notify.ts +0 -212
  66. package/src/pantheon/delegation-toast.ts +0 -73
  67. package/src/pantheon/delegation.ts +0 -1337
  68. package/src/pantheon/dispatch-guard.ts +0 -109
  69. package/src/pantheon/native-probe.ts +0 -234
  70. package/src/pantheon/permission-eval.ts +0 -284
  71. package/src/pantheon/preemptive-compact.ts +0 -76
  72. package/src/pantheon/provider-capabilities.ts +0 -221
  73. package/src/pantheon/provider-stream-tracker.ts +0 -182
  74. package/src/pantheon/retry-policy.ts +0 -212
  75. package/src/pantheon/step-cap.ts +0 -110
  76. package/src/pantheon/token-opt.ts +0 -689
  77. package/src/pantheon/toon-codec.ts +0 -481
  78. package/src/pantheon/v2-events.ts +0 -148
  79. package/src/pantheon/v2-hooks.ts +0 -287
  80. package/src/pantheon/v2-tool-definitions.ts +0 -331
  81. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
  82. package/src/plugins/tui/dist/tui.tsx +0 -2853
@@ -5,9 +5,10 @@
5
5
  /**
6
6
  * pantheon-tui — Pantheon TUI plugin for opencode.
7
7
  *
8
- * Single self-contained entry file: `tsdown && cp src/index.tsx dist/tui.tsx`
9
- * keeps the no-build load path working (dist/tui.tsx is raw source, transpiled
10
- * on the fly by opencode). All feature code lives in this one file.
8
+ * Bundled entry: `tsdown` emits `dist/tui.js`, and the package `exports`
9
+ * (`./tui` → ./dist/tui.js, `./server` → ./dist/server.js) is the ONLY load
10
+ * path — no raw TSX copy is shipped. Because the bundle inlines relative
11
+ * imports, this entry may be split into modules without breaking the loader.
11
12
  *
12
13
  * Slots:
13
14
  * - sidebar_content (order 900) — Pantheon sidebar (header/version/
@@ -47,7 +48,7 @@
47
48
 
48
49
  import { Buffer } from 'node:buffer'
49
50
  import type { Dirent } from 'node:fs'
50
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
51
+ import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
51
52
  import { homedir } from 'node:os'
52
53
  import { dirname, join } from 'node:path'
53
54
  import { fileURLToPath } from 'node:url'
@@ -90,9 +91,9 @@ const PRESET_REFRESH_MS = 30_000
90
91
  /* ─── Helpers ───────────────────────────────────────────── */
91
92
 
92
93
  /* ─── Active Preset Detection ──────────────────────────────
93
- * Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the TUI
94
- * plugin is self-contained (dist/tui.tsx is a raw copy of this file with no
95
- * relative imports), so the resolution logic is mirrored here rather than
94
+ * Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the
95
+ * copied plugin ships only dist/ + package.json, so it cannot reach
96
+ * src/pantheon at runtime; the resolution logic is mirrored here rather than
96
97
  * importing presets.mjs.
97
98
  *
98
99
  * Priority: env PANTHEON_MODEL_PRESET > first existing candidate file > none.
@@ -155,7 +156,7 @@ async function resolvePresetForTui(
155
156
  *
156
157
  * Try order:
157
158
  * 0a. pantheon-tui/package.json (installed: dist/tui.js → 2 níveis)
158
- * 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.tsx → 4 níveis)
159
+ * 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.js → 4 níveis)
159
160
  * 1. api.client.file.read package.json
160
161
  * 2. git describe --tags
161
162
  * 3. opencode --version
@@ -163,7 +164,7 @@ async function resolvePresetForTui(
163
164
  * 5. null */
164
165
 
165
166
  // Build-time embedded version (tsdown `define` in tsdown.config.ts). Injected
166
- // only into the bundled dist/tui.js; the raw dist/tui.tsx copy falls through.
167
+ // into the bundled dist/tui.js — the only artifact the loader consumes.
167
168
  declare const __PANTHEON_VERSION__: string | undefined
168
169
 
169
170
  async function detectVersion(api: TuiPluginApi): Promise<string | null> {
@@ -175,7 +176,7 @@ async function detectVersion(api: TuiPluginApi): Promise<string | null> {
175
176
  if (pkg.version) return pkg.version
176
177
  } catch {}
177
178
 
178
- // Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.tsx → package.json)
179
+ // Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.js → package.json)
179
180
  try {
180
181
  const pkgUrl = new URL('../../../../package.json', import.meta.url)
181
182
  const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
@@ -885,36 +886,38 @@ async function setupUsageBar(api: TuiPluginApi) {
885
886
  * CHILDREN + MD (delegations-sidebar pattern, feat/compaction-134):
886
887
  *
887
888
  * 1. PRIMARY — SDK child sessions via `api.client.session.children`:
888
- * every pantheon_delegate creates a child session with
889
- * parentID = caller (src/pantheon/delegation.ts session.create), so
889
+ * every delegated child session is created with
890
+ * parentID = caller (native `task()` child session), so
890
891
  * the children of the current session ARE the delegation list — the
891
892
  * same mechanism the opencode-delegations-sidebar reference uses
892
893
  * (`client.session.children` + `session.parentID === current`).
893
894
  * Live status comes from `api.state.session.status(childID)`
894
895
  * (busy/retry → running; idle → terminal). This does NOT depend on
895
896
  * tool-part events, which runtime 1.18.13 may not deliver for
896
- * pantheon_delegate (the "(0) even while delegating" symptom).
897
+ * native `task()` (the "(0) even while delegating" symptom).
897
898
  *
898
899
  * 1b. NATIVE task() CHILDREN — `session.children` returns ALL children,
899
900
  * and the native `task()` tool ALSO spawns a child session with
900
- * parentID = caller. A child WITHOUT a board report is a native
901
+ * parentID = caller. A child WITHOUT a delegate report is a native
901
902
  * task() child: childrenToDelegationEntries tags it source
902
- * 'children-only' with the `[native]` row tag (distinct info color),
903
- * so native task() work is visible in the panel alongside board
904
- * delegates ([pantheon:apo-1]) — never filtered out, never duplicated
905
- * with a board row for the same child (a child WITH a report keeps the
903
+ * 'children-only' (distinct info color),
904
+ * so native task() work is visible in the panel alongside delegate
905
+ * children — never filtered out, never duplicated
906
+ * with a report row for the same child (a child WITH a report keeps the
906
907
  * md entry, source 'md').
907
908
  *
908
- * 2. ENRICHMENT + HISTORY — markdown reports the job board persists under
909
- * `.pantheon/delegations/<sessionID>/<alias>.md` (written by
910
- * src/pantheon/delegation-finalize.ts at terminal state). Each child
909
+ * 2. ENRICHMENT — markdown reports written under
910
+ * `.pantheon/delegations/<sessionID>/<alias>.md` (legacy delegate output;
911
+ * the delegate layer was removed, existing reports stay readable). Each
912
+ * child
911
913
  * is matched to its report by the `Task ID` header (== child session
912
- * id == board task id), which supplies the alias (apo-N), agent,
914
+ * id == task id), which supplies the alias (apo-N), agent,
913
915
  * description and terminal duration/timedOut. No report → the child
914
916
  * itself still renders (title as description, derived state). The md
915
- * channel is ALSO read from EVERY session (readAllDelegationEntries) —
916
- * the panel shows the full delegation history even without a focused
917
- * session.
917
+ * channel is read from EVERY session (readAllDelegationEntries) as an
918
+ * enrichment source, but the ACTIVE-SESSION FILTER
919
+ * (filterDelegationsToSession) drops every non-focused report — the panel
920
+ * lists only the focused session.
918
921
  *
919
922
  * 3. REFRESH — session.created/updated/deleted/status events and
920
923
  * message.part.updated/removed (via liveStore.version bumps) re-fetch
@@ -925,12 +928,12 @@ async function setupUsageBar(api: TuiPluginApi) {
925
928
  * "panel: children=N(pantheon=M native=K) md=N events=N"
926
929
  * to `.pantheon/logs/hooks.log` (silence-by-default, createPantheonLogger
927
930
  * policy) so the (0) symptom is diagnosable from disk: children>0 means
928
- * the source works, pantheon/native splits board vs task() children.
931
+ * the source works, pantheon/native splits report vs task() children.
929
932
  * A children fetch
930
933
  * failure logs "panel: error <msg>" (previously silenced), and a missing
931
934
  * current session (null/placeholder sessionID) logs "panel: children=0
932
- * md=N ... (no sessionID — history shown)" — the md HISTORY renders
933
- * instead of erroring.
935
+ * md=N ... (no sessionID — inactive panel)" — the panel stays EMPTY
936
+ * instead of erroring (no cross-session history).
934
937
  *
935
938
  * 5. SESSIONID GUARD (placeholder regression) — the runtime may hand the
936
939
  * sidebar the UNSUBSTITUTED template literal "{sessionID}" as its
@@ -940,8 +943,8 @@ async function setupUsageBar(api: TuiPluginApi) {
940
943
  * panel into "(0)". resolveCurrentSessionID (slot prop → api.state →
941
944
  * route.current) never yields a placeholder (isValidSessionId rejects
942
945
  * "{sessionID}" and "%7BsessionID%7D" via the startsWith("ses")
943
- * contract); a null resolution SKIPS the children fetch entirely and
944
- * renders the md history — zero errors.
946
+ * contract); a null resolution SKIPS the children fetch entirely and the
947
+ * panel renders EMPTY — zero rows, zero errors.
945
948
  *
946
949
  * The live tool-part lifecycle helpers below remain (tested, exported) but
947
950
  * are no longer the source of truth: they feed a version signal that merely
@@ -954,7 +957,7 @@ export type DelegationEntry = {
954
957
  alias: string
955
958
  /** Parent session the job was launched from (dir name under .pantheon/delegations). */
956
959
  sessionID: string
957
- /** Child session id (= board task id, from the `Task ID` header). The
960
+ /** Child session id (= task id, from the `Task ID` header). The
958
961
  * children channel always sets it from the child session itself; the md
959
962
  * channel parses it so child↔md matching works by taskID. */
960
963
  taskID?: string
@@ -977,9 +980,15 @@ export type DelegationEntry = {
977
980
  description: string
978
981
  /** True while the panel is waiting for pantheon_delegation_read. */
979
982
  read?: boolean
983
+ // NOTE: the nested/tree view is BACKLOG. `session.children` returns direct
984
+ // children only, so no real grandchild rows exist yet; the parentTaskID /
985
+ // childCount fields + tree-connector helpers were removed as dead code.
986
+ // Reintroduce them once nested rows are actually produced.
980
987
  /** Internal provenance used to keep a finalized md report authoritative.
981
- * 'children-only' = a native task() child session with NO board report
982
- * (rendered with the distinct `[native]` tag); 'md' = board report wins. */
988
+ * 'children-only' = a native task() child session with NO delegate report
989
+ * (a native child row); 'md' = the child has a matching report;
990
+ * 'child' = the plain children channel; 'live' = the optimistic tool-event
991
+ * channel. */
983
992
  source?: 'child' | 'live' | 'md' | 'children-only'
984
993
  }
985
994
 
@@ -1155,29 +1164,39 @@ export async function readDelegationEntries(dir: string): Promise<DelegationEntr
1155
1164
 
1156
1165
  /** Read delegation reports from EVERY session under
1157
1166
  * `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
1158
- * HISTORY channel. Unlike the children/live channels it does NOT depend on a
1159
- * resolved sessionID: with no focused session (null/placeholder), the panel
1160
- * still shows the reports from all past sessions (running first, Finalized
1161
- * desc — the sort applied by readDelegationEntries). Fail-open: a
1162
- * missing/unreadable directory yields []. */
1167
+ * ENRICHMENT source. The read is deliberately unfiltered (each entry carries
1168
+ * its directory's sessionID, so a taskID match can enrich any channel);
1169
+ * callers MUST scope the merged result with {@link filterDelegationsToSession}
1170
+ * before rendering — only the active session's reports are shown. Entries are
1171
+ * running first, Finalized desc (the sort applied by readDelegationEntries).
1172
+ * Fail-open: a missing/unreadable directory yields []. */
1163
1173
  export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
1164
1174
  return readDelegationEntries(join(root, '.pantheon', 'delegations'))
1165
1175
  }
1166
1176
 
1167
- /** Resolve the directory where the job board writes delegation md reports.
1168
- * The board writes `.pantheon/delegations` RELATIVE to the server cwd,
1169
- * which the TUI exposes as `TuiState.path.directory`. `project` does NOT
1170
- * exist on `TuiState.path` (the old `state?.project ?? state?.worktree`
1171
- * resolution was always undefined for the first term) and `worktree` is
1172
- * `/` when there is no git (e.g. the sandbox test project) — a root of
1173
- * `''` or `'/'` must fall back to `process.cwd()`. */
1174
- export function resolveDelegationsDir(
1177
+ /** Resolve the PROJECT ROOT used by every pantheon file channel. `directory`
1178
+ * wins over `worktree` (the old `resolveDelegationsDir` already did this);
1179
+ * an absent/empty root or `/` (no git — e.g. the sandbox test project) falls
1180
+ * back to cwd. Standardised here so the delegations md and the panel logger
1181
+ * all read the SAME root (audit finding: the channels
1182
+ * resolved the root independently). Pure — no I/O. */
1183
+ export function resolvePantheonRoot(
1175
1184
  state: { directory?: string; worktree?: string } | undefined,
1176
1185
  cwd = process.cwd(),
1177
1186
  ): string {
1178
1187
  const root = state?.directory ?? state?.worktree ?? ''
1179
- if (root === '' || root === '/') return join(cwd, '.pantheon', 'delegations')
1180
- return join(root, '.pantheon', 'delegations')
1188
+ if (root === '' || root === '/') return cwd
1189
+ return root
1190
+ }
1191
+
1192
+ /** Resolve the directory where delegation md reports are written.
1193
+ * The finalizer writes `.pantheon/delegations` RELATIVE to the server cwd,
1194
+ * which the TUI exposes as `TuiState.path.directory`. */
1195
+ export function resolveDelegationsDir(
1196
+ state: { directory?: string; worktree?: string } | undefined,
1197
+ cwd = process.cwd(),
1198
+ ): string {
1199
+ return join(resolvePantheonRoot(state, cwd), '.pantheon', 'delegations')
1181
1200
  }
1182
1201
 
1183
1202
  /** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
@@ -1208,29 +1227,49 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
1208
1227
  }
1209
1228
 
1210
1229
  /** Split the panel list: active jobs (running/retry, stale-marked) first,
1211
- * then the most recent terminal reports, then the archived tail (paginated
1212
- * in the View). Pure — so the history-only panel (no sessionID) is testable
1230
+ * then the most recent terminal reports; the remaining tail is collapsed
1231
+ * by the View into a single "… +N more" line. Pure — so the history-only panel (no sessionID) is testable
1213
1232
  * without the TUI runtime. */
1214
1233
  export function splitDelegationList(
1215
1234
  all: readonly DelegationEntry[],
1216
1235
  maxRecent = 8,
1217
1236
  now = Date.now(),
1218
1237
  staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1219
- ): { active: DelegationEntry[]; recent: DelegationEntry[]; archived: DelegationEntry[] } {
1238
+ ): { active: DelegationEntry[]; recent: DelegationEntry[] } {
1239
+ // Active = running OR retry OR the display-only stale-running. A stale row
1240
+ // is still a live job (backend state unchanged), so it must never fall into
1241
+ // the terminal tail — pushing a running delegation out hid the row
1242
+ // the user launched.
1243
+ const isActiveState = (st: DelegationEntry['state']): boolean =>
1244
+ st === 'running' || st === 'retry' || st === 'stale-running'
1220
1245
  const active = all
1221
- .filter((d) => d.state === 'running' || d.state === 'retry')
1246
+ .filter((d) => isActiveState(d.state))
1222
1247
  .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1223
- const terminal = all.filter((d) => d.state !== 'running' && d.state !== 'retry')
1248
+ .sort(compareDelegationEntries)
1249
+ const terminal = all
1250
+ .filter((d) => !isActiveState(d.state))
1251
+ .filter((d) => {
1252
+ // A missing/invalid finalized timestamp is not evidence that a report is
1253
+ // old. Keep it visible rather than accidentally hiding a terminal job.
1254
+ if (d.updatedAt === null || !Number.isFinite(d.updatedAt)) return true
1255
+ const age = now - d.updatedAt
1256
+ if (age < 0) return true
1257
+ const retention =
1258
+ delegationRowStatus(d.state) === 'failed'
1259
+ ? DELEGATION_FAILED_RETENTION_MS
1260
+ : DELEGATION_DONE_RETENTION_MS
1261
+ return age < retention
1262
+ })
1263
+ .sort(compareDelegationEntries)
1224
1264
  return {
1225
1265
  active,
1226
1266
  recent: terminal.slice(0, maxRecent),
1227
- archived: terminal.slice(maxRecent),
1228
1267
  }
1229
1268
  }
1230
1269
 
1231
- /** The list the panel actually renders (kept for the header count and
1232
- * existing tests): active jobs first, then the most recent terminal
1233
- * reports (capped) — the archived tail is rendered separately. Pure. */
1270
+ /** Live-first window used by {@link ceilingDelegationList} (kept for the
1271
+ * ceiling helper and existing tests): active jobs first, then the most
1272
+ * recent terminal reports (capped). Pure. */
1234
1273
  export function visibleDelegationList(
1235
1274
  all: readonly DelegationEntry[],
1236
1275
  maxTerminal = 8,
@@ -1249,10 +1288,41 @@ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
1249
1288
  * display-only `stale-running` state. */
1250
1289
  export const IDLE_SILENCE_MS = 60 * 1000
1251
1290
 
1291
+ /** Visual-only terminal retention windows. Reports remain on disk; these
1292
+ * constants only control which rows enter the TUI window. */
1293
+ export const DELEGATION_DONE_RETENTION_MS = 2 * 60 * 1000
1294
+ export const DELEGATION_FAILED_RETENTION_MS = 10 * 60 * 1000
1295
+
1296
+ /** Grace window for an ABSENT child status. `api.state.session.status()` only
1297
+ * carries the sessions that are currently ALIVE; every finished child of a
1298
+ * long-lived session is missing from that map. A child with no status is
1299
+ * therefore treated as terminal (done) — treating it as running made every
1300
+ * historical child of the session show up as "active" (the "197 children,
1301
+ * all active" regression). The one exception is a freshly-spawned child that
1302
+ * has not been registered by the status API yet: within this window of its
1303
+ * last activity (`time.updated`, falling back to `time.created`) it still
1304
+ * reads as running. */
1305
+ export const DELEGATION_CHILD_STATUS_GRACE_MS = 60 * 1000
1306
+
1307
+ /** Recency window for the children channel. `session.children` returns EVERY
1308
+ * child the focused session ever spawned, so a long session accumulates
1309
+ * hundreds of historical rows that would inflate the panel. A child whose
1310
+ * last activity (its own time, or its matched report's Finalized timestamp)
1311
+ * is older than this window is dropped BEFORE it can enter the list. 24h
1312
+ * keeps the sidebar scoped to current work while still covering long
1313
+ * delegations. A child with no timestamp at all is kept (fail-open). */
1314
+ export const DELEGATION_CHILDREN_RECENCY_MS = 24 * 60 * 60 * 1000
1315
+
1316
+ /** Alias-less NATIVE task() live entries never receive a report alias (the
1317
+ * task tool output carries none), so the 30s alias-less prune in
1318
+ * mergeChildDelegationSources must not apply to them — 5 minutes covers a
1319
+ * slow child listing while still bounding the live map. */
1320
+ export const NATIVE_LIVE_ALIASLESS_TTL_MS = 5 * 60 * 1000
1321
+
1252
1322
  /**
1253
1323
  * Mark a running entry as `stale-running` if it has been running longer than
1254
1324
  * the threshold AND has no recent activity (no `updatedAt` change in the last
1255
- * `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
1325
+ * `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the persisted state is unchanged.
1256
1326
  *
1257
1327
  * A `stale-running` entry renders with a warning indicator but the underlying
1258
1328
  * delegation is still treated as running by the backend.
@@ -1270,7 +1340,7 @@ export function markStaleIfRunning(
1270
1340
  return { ...entry, state: 'stale-running' as const }
1271
1341
  }
1272
1342
 
1273
- /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
1343
+ /** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
1274
1344
  * second for running jobs. */
1275
1345
  export function fmtElapsed(ms: number): string {
1276
1346
  const total = Math.max(0, Math.floor(ms / 1000))
@@ -1278,16 +1348,17 @@ export function fmtElapsed(ms: number): string {
1278
1348
  const hours = Math.floor((total % 86_400) / 3_600)
1279
1349
  const minutes = Math.floor((total % 3_600) / 60)
1280
1350
  const seconds = total % 60
1281
- if (days > 0) return `${days}d ${hours}h`
1282
- if (hours > 0) return `${hours}h ${minutes}m`
1283
- if (minutes > 0) return `${minutes}m ${seconds}s`
1351
+ if (days > 0) return `${days}d`
1352
+ if (hours > 0) return `${hours}h`
1353
+ if (minutes > 0) return `${minutes}m`
1284
1354
  return `${seconds}s`
1285
1355
  }
1286
1356
 
1287
- /** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
1288
- * → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
1357
+ /** Elapsed label for one entry: an ACTIVE entry (running/retry/stale-running)
1358
+ * ticks `now - startedAt`; a terminal one is fixed at
1359
+ * `updatedAt - startedAt` (em dash when no finalized timestamp). */
1289
1360
  export function delegationElapsed(entry: DelegationEntry, now: number): string {
1290
- if (entry.state === 'running' || entry.state === 'stale-running')
1361
+ if (entry.state === 'running' || entry.state === 'retry' || entry.state === 'stale-running')
1291
1362
  return fmtElapsed(now - entry.startedAt)
1292
1363
  return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
1293
1364
  }
@@ -1336,12 +1407,47 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
1336
1407
 
1337
1408
  const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
1338
1409
 
1339
- /** Return a deterministic spinner frame. The View ticks this every 140ms. */
1340
- export function delegationSpinnerFrame(now: number): string {
1341
- const index = Math.floor(Math.max(0, now) / 140) % DELEGATION_SPINNER_FRAMES.length
1410
+ /** Return the spinner glyph for the given frame counter. The frame counter
1411
+ * is incremented every ~80ms by a dedicated animation timer, decoupled from
1412
+ * the data poll cycle. Pure — no side effects. */
1413
+ export function delegationSpinnerFrame(frame: number): string {
1414
+ const index = Math.abs(Math.floor(frame)) % DELEGATION_SPINNER_FRAMES.length
1342
1415
  return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
1343
1416
  }
1344
1417
 
1418
+ /** Every state the row knows how to draw: the delegation lifecycle states
1419
+ * derived from the children status + md reports (`completed`, `error`,
1420
+ * `cancelled`, `startup_failed`, `startup_unknown`) plus the TUI-only
1421
+ * states (`retry`, `stale-running`). Fase 1 deliberately omits speculative
1422
+ * blocked/paused/scheduled/skipped. There is no `pending` display state: a
1423
+ * pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
1424
+ export type DelegationDisplayState = DelegationEntry['state']
1425
+
1426
+ /** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
1427
+ * Kept separate + pure so the color channel is testable without booting the
1428
+ * renderer. Every display state resolves to one of the three status colors;
1429
+ * the row paints its whole content with it (see {@link DelegationRow}). */
1430
+ export type DelegationStateTone = 'warning' | 'error' | 'success'
1431
+
1432
+ /** Status → color mapping for the whole-row tone. Red = failure, green =
1433
+ * terminal, yellow = in flight. The glyph remains a redundant channel so the
1434
+ * state stays legible in monochrome. Display-only; no behavior change. */
1435
+ export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
1436
+ switch (state) {
1437
+ case 'running':
1438
+ case 'retry':
1439
+ case 'startup_unknown':
1440
+ case 'stale-running':
1441
+ return 'warning'
1442
+ case 'error':
1443
+ case 'startup_failed':
1444
+ return 'error'
1445
+ case 'completed':
1446
+ case 'cancelled':
1447
+ return 'success'
1448
+ }
1449
+ }
1450
+
1345
1451
  /* ─── Live tool activity per child session (beta.6) ────────────────────────
1346
1452
  * The delegation panel's activity line ("↳ bash npm test") — what the agent
1347
1453
  * is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
@@ -1360,10 +1466,6 @@ const latestToolActivity = new Map<string, ToolActivity>()
1360
1466
  const MAX_TOOL_ACTIVITY_ENTRIES = 200
1361
1467
  const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
1362
1468
 
1363
- /** Archived-section pagination state — module-level so it survives sidebar
1364
- * remounts (route changes) without leaking into the component tree. */
1365
- const archivedUiState = { page: 0 }
1366
-
1367
1469
  /** First usable summary string from common tool input fields. */
1368
1470
  function summarizeToolInput(input: Record<string, unknown> | undefined): string {
1369
1471
  if (!input) return ''
@@ -1446,13 +1548,29 @@ export function mergeChildDelegationSources(
1446
1548
  })
1447
1549
 
1448
1550
  for (const liveEntry of live) {
1551
+ // A native task() live part never carries a report alias or taskID, so it
1552
+ // is exempt from the 30s alias-less prune and lives on its own longer TTL.
1553
+ const aliaslessTTL = liveEntry.tool === 'task' ? NATIVE_LIVE_ALIASLESS_TTL_MS : 30_000
1449
1554
  if (
1450
1555
  liveEntry.alias === null &&
1451
1556
  liveEntry.taskID === null &&
1452
- Date.now() - liveEntry.startedAt > 30_000
1557
+ Date.now() - liveEntry.startedAt > aliaslessTTL
1453
1558
  )
1454
1559
  continue
1455
1560
  const incoming = toDelegationEntry(liveEntry)
1561
+ // A native task() live part never carries a report alias — it is a
1562
+ // children-only row by definition whether it lands on a new row or
1563
+ // upgrades an existing
1564
+ // children-only one. pantheon_delegate pending parts keep source 'live'.
1565
+ const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
1566
+ if (isNativeLive) {
1567
+ // Native task() rows carry no report alias — keep the per-call
1568
+ // `live-<callID>` identity from toDelegationEntry (one row per native
1569
+ // call) with the children-only provenance; the row identity still falls
1570
+ // back to the agent via delegationRowIdentity. A taskID-known native
1571
+ // still merges into its child row through the byTask lookup below.
1572
+ incoming.source = 'children-only'
1573
+ }
1456
1574
  const index =
1457
1575
  (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
1458
1576
  (incoming.alias !== ''
@@ -1500,7 +1618,10 @@ export function mergeChildDelegationSources(
1500
1618
  startedAt: Math.min(existing.startedAt, incoming.startedAt),
1501
1619
  updatedAt: incoming.updatedAt,
1502
1620
  read: incoming.read,
1503
- source: 'live',
1621
+ // A native task() live upgrade must not flip a children-only row to
1622
+ // 'live' (that would drop the children-only provenance); pantheon_delegate
1623
+ // upgrades still take source 'live'.
1624
+ source: isNativeLive ? existing.source : 'live',
1504
1625
  }
1505
1626
  }
1506
1627
 
@@ -1513,7 +1634,7 @@ export function mergeChildDelegationSources(
1513
1634
  * (api.event.on) delivers `message.part.updated` for every part change;
1514
1635
  * we only care about tool parts whose `tool` is pantheon_delegate (job
1515
1636
  * launch), the native `task` subagent tool (same parentID === caller
1516
- * mechanism — its rows render `[native]`), or pantheon_delegation_read
1637
+ * mechanism — its rows render `nat:`), or pantheon_delegation_read
1517
1638
  * (blocking until terminal → closes the entry). Shape-adapted from the
1518
1639
  * SDK v2 ToolPart but duck-typed so the pure helpers are testable without
1519
1640
  * the SDK. */
@@ -1583,7 +1704,7 @@ const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
1583
1704
  /** Extract the tool name + args from a `message.part.updated` part and
1584
1705
  * reduce it to what the panel needs. Returns null for anything that is
1585
1706
  * not a pantheon delegation tool part, the native `task` subagent tool
1586
- * (same parentID === caller mechanism — its children render `[native]`),
1707
+ * (same parentID === caller mechanism — its children render `nat:`),
1587
1708
  * or is missing its callID. */
1588
1709
  export function parseDelegationToolPart(
1589
1710
  part: DelegationToolPart,
@@ -1644,8 +1765,8 @@ export function parseDelegationToolPart(
1644
1765
  : ''
1645
1766
  // The alias/taskID only exist once the delegate tool COMPLETES (they are
1646
1767
  // returned in its output); a running/pending part has neither. Native
1647
- // `task` parts never carry a board alias — the children channel supplies
1648
- // the child session id, so the row renders `[native]`.
1768
+ // `task` parts never carry a report alias — the children channel supplies
1769
+ // the child session id and the row's identity.
1649
1770
  let alias: string | null = null
1650
1771
  let taskID: string | null = null
1651
1772
  if (status === 'completed') {
@@ -1718,8 +1839,8 @@ export function reduceDelegationToolPart(
1718
1839
  // Delegate tool (pantheon_delegate AND native task): pending/running →
1719
1840
  // job launched (running). A COMPLETED delegate tool only means the job was
1720
1841
  // registered — it stays running and we pick up alias + taskID from the
1721
- // output. Native `task` parts never yield a board alias, so their rows
1722
- // stay `[native]` until the children channel resolves them.
1842
+ // output. Native `task` parts never yield a report alias, so their rows
1843
+ // stay identity-less until the children channel resolves them.
1723
1844
  const existing = map.get(parsed.callID)
1724
1845
  if (parsed.status === 'error') {
1725
1846
  if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
@@ -1804,7 +1925,7 @@ export function removeDelegationEntry(
1804
1925
  * SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
1805
1926
  * child session with parentID = caller — the same mechanism as
1806
1927
  * pantheon_delegate — so its parts feed the live-map as the native signal
1807
- * (rows render `[native]` via the children channel). Pure w.r.t. I/O — used
1928
+ * (rows come from the children channel). Pure w.r.t. I/O — used
1808
1929
  * by the mount re-scan to re-seed the live map after compaction/attach. */
1809
1930
  export function collectDelegationToolParts(
1810
1931
  messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
@@ -1902,6 +2023,30 @@ export function mergeDelegationSources(
1902
2023
  return all
1903
2024
  }
1904
2025
 
2026
+ /** Scope a fully-merged display list to the ACTIVE session only.
2027
+ *
2028
+ * The panel is session-scoped: rows from other sessions (md history under
2029
+ * `.pantheon/delegations/<other-session>/`) and rows with no
2030
+ * attributable session (empty sessionID) are DROPPED. The previous
2031
+ * cross-session behavior rendered those rows and clicking them led to
2032
+ * "Session not found" — there is no cross-session channel anymore.
2033
+ *
2034
+ * The md channel still feeds the merge UNFILTERED so it can ENRICH an
2035
+ * active-session row with state/alias/agent (dedup by taskID), but it can
2036
+ * never introduce a row for another session: this filter is applied
2037
+ * ONCE, after every merge (children + live + md).
2038
+ *
2039
+ * Returns [] when no active session resolves (null/placeholder) — there is no
2040
+ * scope to show. Native children always carry the active session id (stamped
2041
+ * by `childrenToDelegationEntries`), so they survive the filter. Pure. */
2042
+ export function filterDelegationsToSession(
2043
+ entries: readonly DelegationEntry[],
2044
+ activeSessionID: string | null | undefined,
2045
+ ): DelegationEntry[] {
2046
+ if (!isValidSessionId(activeSessionID)) return []
2047
+ return entries.filter((entry) => entry.sessionID === activeSessionID)
2048
+ }
2049
+
1905
2050
  /* ─── Children channel (primary source, delegations-sidebar pattern) ────
1906
2051
  * `api.client.session.children` returns the child sessions of the current
1907
2052
  * session — every pantheon_delegate spawns one (parentID = caller). The
@@ -1910,7 +2055,7 @@ export function mergeDelegationSources(
1910
2055
 
1911
2056
  /* ─── Current-session resolution + path guard (placeholder regression) ──
1912
2057
  * The sidebar forwarded `props.session_id` verbatim into
1913
- * `session.children({ path: { id } })`. When the TUI runtime renders the
2058
+ * `session.children({ sessionID })`. When the TUI runtime renders the
1914
2059
  * sidebar without a focused session it leaves the route template
1915
2060
  * UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
1916
2061
  * rejected every poll (SchemaError: Expected a string starting with "ses",
@@ -1974,33 +2119,38 @@ export function resolveCurrentSessionID(sources: TuiSessionSources): string | nu
1974
2119
  }
1975
2120
 
1976
2121
  /** THE single choke point for every `session.children` / session-API path.
1977
- * Returns `{ path: { id } }` ONLY for a server-valid session id; returns
1978
- * null for anything else (placeholder, empty, foreign id) so the caller
1979
- * skips the call entirely instead of sending an unsubstituted placeholder
1980
- * (the "%7BsessionID%7D" regression). Every session-API call site MUST go
1981
- * through this function (enforced by the source-scan test in
1982
- * tests/pantheon/tui-delegations.test.ts). */
1983
- export function safeSessionPath(id: unknown): { path: { id: string } } | null {
2122
+ * Returns the v2 SDK parameter shape `{ sessionID }` ONLY for a
2123
+ * server-valid session id; returns null for anything else (placeholder,
2124
+ * empty, foreign id) so the caller skips the call entirely instead of
2125
+ * sending an unsubstituted placeholder (the "%7BsessionID%7D" regression).
2126
+ * The TUI client is `@opencode-ai/sdk/v2`, whose session methods take a
2127
+ * FLAT parameter object (`{ sessionID }`), NOT the v1 `{ path: { id } }`
2128
+ * envelope — passing the v1 shape left the v2 `{sessionID}` URL template
2129
+ * unsubstituted (`/session/%7BsessionID%7D/children`). Every session-API
2130
+ * call site MUST go through this function (enforced by the source-scan
2131
+ * test in tests/pantheon/tui-delegations.test.ts). */
2132
+ export function safeSessionPath(id: unknown): { sessionID: string } | null {
1984
2133
  if (!isValidSessionId(id)) return null
1985
- return { path: { id } }
2134
+ return { sessionID: id }
1986
2135
  }
1987
2136
 
1988
- /** Build the `session.children` path ONLY from a validated session id.
2137
+ /** Build the `session.children` parameters ONLY from a validated session id.
1989
2138
  * Delegates to {@link safeSessionPath} — the single choke point. Returns
1990
- * null for null/invalid ids so the caller skips the fetch instead of
1991
- * sending an unsubstituted placeholder (the "%7BsessionID%7D" regression). */
1992
- export function buildChildrenPath(id: string | null | undefined): { path: { id: string } } | null {
2139
+ * the v2 SDK shape `{ sessionID }`; null for null/invalid ids so the caller
2140
+ * skips the fetch instead of sending an unsubstituted placeholder (the
2141
+ * "%7BsessionID%7D" regression). */
2142
+ export function buildChildrenPath(id: string | null | undefined): { sessionID: string } | null {
1993
2143
  return safeSessionPath(id)
1994
2144
  }
1995
2145
 
1996
2146
  /** Duck-typed subset of a child Session (+ its live status type). */
1997
2147
  export type ChildDelegationLike = {
1998
- /** Child session id (= board task id). */
2148
+ /** Child session id (= task id). */
1999
2149
  id: string
2000
2150
  /** Session title — the delegate's description or prompt prefix. */
2001
2151
  title?: string
2002
2152
  /** Agent name when the child session carries one (duck-typed; native
2003
- * task() children may expose it, board reports always do). */
2153
+ * task() children may expose it, md reports always do). */
2004
2154
  agent?: string
2005
2155
  /** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
2006
2156
  * or undefined when the status API is unavailable. */
@@ -2011,29 +2161,232 @@ export type ChildDelegationLike = {
2011
2161
  }
2012
2162
  }
2013
2163
 
2014
- /** Map a child status type to a display state. busy/retry → running
2015
- * (the child is actively working), idle → completed, unknown → running
2016
- * (fail-open: a freshly-seen child is assumed active; the 1s poll + md
2017
- * correct it as soon as terminal data exists). */
2018
- export function childStatusToState(status: string | undefined): 'running' | 'completed' | 'retry' {
2019
- if (status === 'idle') return 'completed'
2164
+ /** Map a child status type to a display state.
2165
+ *
2166
+ * Only `busy`/`retry` are EXPLICIT live states. `idle` is terminal and so is
2167
+ * any other/unknown status — a status the panel does not recognise is not
2168
+ * evidence of activity. An ABSENT status is the historical case: the status
2169
+ * map (`api.state.session.status`) contains only currently-alive sessions, so
2170
+ * every finished child of a long session is missing from it. Defaulting that
2171
+ * to running made the whole session history render as "active" (the
2172
+ * "197 children, all active" regression), so an absent status is terminal
2173
+ * (done) UNLESS the child was active within `graceMs` — a just-spawned child
2174
+ * that the status API has not registered yet must still render as running.
2175
+ *
2176
+ * @param status the `api.state.session.status(id)?.type`, or undefined
2177
+ * @param time the child session time fields (`created`/`updated`)
2178
+ * @param now current wall-clock ms (injectable for tests)
2179
+ * @param graceMs recency window for the absent-status running assumption
2180
+ * Pure — no I/O. */
2181
+ export function childStatusToState(
2182
+ status: string | undefined,
2183
+ time?: { created?: number; updated?: number },
2184
+ now = Date.now(),
2185
+ graceMs = DELEGATION_CHILD_STATUS_GRACE_MS,
2186
+ ): 'running' | 'completed' | 'retry' {
2020
2187
  if (status === 'retry') return 'retry'
2021
- return 'running' // busy or unknown
2188
+ if (status === 'busy') {
2189
+ // A stale "busy" in the status map means the child is likely dead, not
2190
+ // running. The opencode status map may retain entries for sessions that
2191
+ // have already terminated. Cross-check against the child's own activity
2192
+ // timestamps: if the last activity is older than the stale-running
2193
+ // threshold, treat the child as completed rather than propagating the
2194
+ // stale busy status as "running" (the "213 children, all active" bug).
2195
+ if (time) {
2196
+ const lastActivity = Math.max(time.updated ?? 0, time.created ?? 0)
2197
+ if (lastActivity > 0 && now - lastActivity > STALE_RUNNING_THRESHOLD_MS) return 'completed'
2198
+ }
2199
+ return 'running'
2200
+ }
2201
+ if (status !== undefined) return 'completed' // idle + any other explicit terminal
2202
+ // Absent status: running only while the child's last activity is fresh.
2203
+ const lastActivity = Math.max(time?.updated ?? 0, time?.created ?? 0)
2204
+ if (lastActivity > 0 && now - lastActivity < graceMs) return 'running'
2205
+ return 'completed'
2206
+ }
2207
+
2208
+ /** Status-only row model: ONE glyph + ONE identity per row.
2209
+ *
2210
+ * A row is
2211
+ * `{glyph} {alias:7} {elapsed:>5}` plus a muted description line. The short
2212
+ * alias (`apo-1`) is the single identity; rows without a report alias show
2213
+ * the agent instead (never both). */
2214
+ export type DelegationRowStatus = 'active' | 'done' | 'failed' | 'retry'
2215
+
2216
+ /** Map every FSM/display state to one of the 4 row kinds. reconciled never
2217
+ * reaches the panel (the md parser drops it) but reads as done;
2218
+ * cancelled reads as done; startup_failed reads as failed while
2219
+ * startup_unknown (no error known) reads as retry. Pure. */
2220
+ export function delegationRowStatus(state: DelegationDisplayState): DelegationRowStatus {
2221
+ switch (state) {
2222
+ case 'running':
2223
+ case 'stale-running':
2224
+ return 'active'
2225
+ case 'retry':
2226
+ case 'startup_unknown':
2227
+ return 'retry'
2228
+ case 'completed':
2229
+ case 'cancelled':
2230
+ return 'done'
2231
+ case 'error':
2232
+ case 'startup_failed':
2233
+ return 'failed'
2234
+ }
2235
+ }
2236
+
2237
+ /** The only 4 glyphs a row may show: animated active, done, failed, retry.
2238
+ * Shape is redundant with color (never the only signal). Pure. */
2239
+ export const DELEGATION_ROW_GLYPHS: Readonly<Record<DelegationRowStatus, string>> = {
2240
+ active: '⠋', // base frame of the animated spinner
2241
+ done: '✓',
2242
+ failed: '✕',
2243
+ retry: '⟳',
2244
+ }
2245
+
2246
+ export function delegationRowGlyph(status: DelegationRowStatus): string {
2247
+ return DELEGATION_ROW_GLYPHS[status]
2248
+ }
2249
+
2250
+ /** Row marker (`<glyph> `) — the single state channel. `active` animates
2251
+ * through the spinner for the given frame counter (incremented every ~80ms);
2252
+ * every other kind is static. Pure. */
2253
+ export function delegationRowMarker(state: DelegationDisplayState, frame = 0): string {
2254
+ const status = delegationRowStatus(state)
2255
+ const glyph = status === 'active' ? delegationSpinnerFrame(frame) : delegationRowGlyph(status)
2256
+ return `${glyph} `
2257
+ }
2258
+
2259
+ /** ONE identity per row: the short report alias (`apo-1`); a row without a
2260
+ * report alias (native task() child `native-task` / `native-<id>`, or an
2261
+ * alias-less native live row `live-<callID>` kept as children-only) shows
2262
+ * the agent instead — never alias + agent stacked. Pure. */
2263
+ export function delegationRowIdentity(entry: DelegationEntry): string {
2264
+ if (entry.agent === '') return entry.alias
2265
+ if (entry.alias === 'native-task' || entry.alias.startsWith('native-')) return entry.agent
2266
+ if (entry.alias.startsWith('live-') && entry.source === 'children-only') return entry.agent
2267
+ return entry.alias
2268
+ }
2269
+
2270
+ /** Fixed alias cell, pad-right + hard-truncate to `width` (default 7) so
2271
+ * identities line up across rows. Pure. */
2272
+ export const DELEGATION_ALIAS_WIDTH = 7
2273
+
2274
+ export function formatDelegationAlias(identity: string, width = DELEGATION_ALIAS_WIDTH): string {
2275
+ if (identity.length >= width) return identity.slice(0, width)
2276
+ return `${identity}${' '.repeat(width - identity.length)}`
2277
+ }
2278
+
2279
+ /** Max description width on the row detail line — the old 180-char slice
2280
+ * wrapped the sidebar; 44 keeps one readable line. */
2281
+ export const DELEGATION_DESCRIPTION_MAX = 44
2282
+
2283
+ /** Lazily-built grapheme segmenter. `Intl.Segmenter` keeps ZWJ emoji families
2284
+ * (and skin-tone/variation sequences) intact; `Array.from` (code points) is
2285
+ * the fallback for runtimes without it. */
2286
+ let graphemeSegmenter: Intl.Segmenter | undefined
2287
+
2288
+ /** Split `text` into display graphemes so truncation never cuts a multi-unit
2289
+ * emoji in half (surrogate pair → mojibake). */
2290
+ function splitGraphemes(text: string): string[] {
2291
+ if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
2292
+ graphemeSegmenter ??= new Intl.Segmenter('pt', { granularity: 'grapheme' })
2293
+ return Array.from(graphemeSegmenter.segment(text), (s) => s.segment)
2294
+ }
2295
+ return Array.from(text)
2296
+ }
2297
+
2298
+ /** Truncate a description to `max` graphemes appending `…` when cut. Exact
2299
+ * `max`-length text is left untouched. Grapheme-granular, so an emoji made of
2300
+ * several code points is never split into mojibake at the boundary. Pure. */
2301
+ export function truncateDelegationDescription(
2302
+ text: string,
2303
+ max = DELEGATION_DESCRIPTION_MAX,
2304
+ ): string {
2305
+ const graphemes = splitGraphemes(text)
2306
+ if (graphemes.length <= max) return text
2307
+ return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
2308
+ }
2309
+
2310
+ /** Fixed elapsed-time box: right-aligned to `width` (default 5) so elapsed
2311
+ * values line up across rows, e.g. ` 12s`. A label that already fills
2312
+ * the box is returned untouched. Pure. */
2313
+ export const DELEGATION_ELAPSED_WIDTH = 5
2314
+
2315
+ export function formatDelegationElapsed(
2316
+ entry: DelegationEntry,
2317
+ now: number,
2318
+ width = DELEGATION_ELAPSED_WIDTH,
2319
+ ): string {
2320
+ const label = delegationElapsed(entry, now)
2321
+ if (label.length >= width) return label
2322
+ return `${' '.repeat(width - label.length)}${label}`
2323
+ }
2324
+
2325
+ /** Colored left half of a row line: `<marker><alias:7> ` — the state glyph
2326
+ * plus the single identity, padded so the muted elapsed column aligns. The
2327
+ * row renders this colored lead separately from the muted elapsed tail. Pure. */
2328
+ export function formatDelegationRowLead(entry: DelegationEntry, marker: string): string {
2329
+ return `${marker}${formatDelegationAlias(delegationRowIdentity(entry))} `
2330
+ }
2331
+
2332
+ // Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
2333
+ // as dead code: `session.children` returns direct children only, so no row is
2334
+ // ever actually nested and the connectors were never populated in production.
2335
+ // BACKLOG: produce real grandchild rows first, then reintroduce them.
2336
+
2337
+ /** Visible-row ceiling for the panel: the remainder collapses into a single
2338
+ * "… +N more" line. Live rows render first, so a running job is never hidden
2339
+ * by the cap. */
2340
+ export const DELEGATION_VISIBLE_CEILING = 8
2341
+
2342
+ /** Header summary: `(N active · M done)` plus `· K failed` only when K > 0.
2343
+ * Active counts running/retry AND display-only stale-running (a stale row is
2344
+ * still a live job — it must never read as done); failed counts
2345
+ * error/startup_failed; cancelled reads as done. Pure. */
2346
+ export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
2347
+ let active = 0
2348
+ let done = 0
2349
+ let failed = 0
2350
+ for (const e of entries) {
2351
+ switch (delegationRowStatus(e.state)) {
2352
+ case 'active':
2353
+ case 'retry':
2354
+ active++
2355
+ break
2356
+ case 'failed':
2357
+ failed++
2358
+ break
2359
+ case 'done':
2360
+ done++
2361
+ break
2362
+ }
2363
+ }
2364
+ const tail = failed > 0 ? ` · ${failed} failed` : ''
2365
+ return `(${active} active · ${done} done${tail})`
2022
2366
  }
2023
2367
 
2024
- /** Row tag for a delegation entry: `[native]` for native task() children
2025
- * (source 'children-only' — no board report), `[pantheon:<alias>]` for board
2026
- * rows ([pantheon:apo-1]) — the same tags the manager prints in
2027
- * pantheon_delegation_list, so panel rows and CLI list lines match. The
2028
- * panel renders the tag with a distinct style so native task() children are
2029
- * visually separable from pantheon_delegate jobs. */
2030
- export function delegationTag(entry: DelegationEntry): string {
2031
- return entry.source === 'children-only' ? '[native]' : `[pantheon:${entry.alias}]`
2368
+ /** Cap the panel: live rows first, then most-recent retained terminal rows, at
2369
+ * most `maxVisible` total. Hidden counts describe the retained render list,
2370
+ * not expired history. */
2371
+ export function ceilingDelegationList(
2372
+ all: readonly DelegationEntry[],
2373
+ maxVisible = DELEGATION_VISIBLE_CEILING,
2374
+ now = Date.now(),
2375
+ ): { visible: DelegationEntry[]; hidden: number; hiddenActive: number; hiddenTerminal: number } {
2376
+ const { active, recent } = splitDelegationList(all, all.length, now)
2377
+ const visibleActive = active.slice(0, maxVisible)
2378
+ const visible = [
2379
+ ...visibleActive,
2380
+ ...recent.slice(0, Math.max(0, maxVisible - visibleActive.length)),
2381
+ ]
2382
+ const hiddenActive = Math.max(0, active.length - visibleActive.length)
2383
+ const hiddenTerminal = Math.max(0, recent.length - (visible.length - visibleActive.length))
2384
+ return { visible, hidden: hiddenActive + hiddenTerminal, hiddenActive, hiddenTerminal }
2032
2385
  }
2033
2386
 
2034
2387
  /** Split a display list into native task() rows vs pantheon_delegate rows.
2035
- * Native = source 'children-only' (no board report); everything else counts
2036
- * as pantheon. Pure — powers the header breakdown + the hooks.log line. */
2388
+ * Native = source 'children-only' (no delegate report); everything else counts
2389
+ * as pantheon. Pure — powers the hooks.log line. */
2037
2390
  export function countDelegationSources(entries: readonly DelegationEntry[]): {
2038
2391
  native: number
2039
2392
  pantheon: number
@@ -2045,7 +2398,7 @@ export function countDelegationSources(entries: readonly DelegationEntry[]): {
2045
2398
  }
2046
2399
 
2047
2400
  /** Diagnostic hooks.log line for a panel re-fetch, with the children
2048
- * breakdown (pantheon = children WITH a board report, native = children
2401
+ * breakdown (pantheon = children WITH a delegate report, native = children
2049
2402
  * WITHOUT one). Pure — the View logs the returned string verbatim. */
2050
2403
  export function formatPanelLogLine(
2051
2404
  children: number,
@@ -2065,19 +2418,44 @@ export function formatPanelLogLine(
2065
2418
  * itself (fallback 'agent'), state derived from its status, startedAt from
2066
2419
  * time.created. A report-less child is a NATIVE task() child (every
2067
2420
  * child of the current session — pantheon_delegate OR the native `task()`
2068
- * tool — carries parentID = caller), so it gets source 'children-only',
2069
- * the internal alias 'native-task' and the `[native]` tag instead of a
2070
- * board alias. The 'task nativa' description fallback keeps the row
2071
- * non-empty when the child carries no title.
2072
- * Terminal md state wins over the derived state; a running md defers to
2421
+ * tool — carries parentID = caller), so it gets source 'children-only', a
2422
+ * per-child alias 'native-<last4 of the child id>' (one identity per native
2423
+ * row) instead of a report alias. The 'task nativa' description fallback
2424
+ * keeps the row non-empty when the child carries no title.
2425
+ * A FRESH terminal md state wins over the derived state (a stale md report
2426
+ * from a previous incarnation never flips a live child — see
2427
+ * mdTerminalCoversChild); a running md defers to
2073
2428
  * the child's live status. A running child is NEVER archived — it always
2074
2429
  * lands in the active split (splitDelegationList active = running/retry).
2075
- * Sorted running-first (compareDelegationEntries).
2430
+ * `parentSessionID` (the focused session id) is stamped on report-less
2431
+ * children so the row carries its origin session; omit it and they stay
2432
+ * unscoped ('').
2433
+ * Children outside {@link DELEGATION_CHILDREN_RECENCY_MS} are dropped before
2434
+ * they can enter the list (the per-session child list grows for the whole
2435
+ * session lifetime); the survivors are sorted running-first, most recent
2436
+ * first (compareDelegationEntries), so a ceiling hides the OLDEST rows.
2076
2437
  * Pure — no I/O. */
2438
+ /** Temporal guard for the children channel (same recycled-alias class as the
2439
+ * md merge): an md terminal report matched by taskID may still belong to
2440
+ * a PREVIOUS job incarnation. Accept it only when its Finalized timestamp
2441
+ * covers the child's latest activity, when either side has no timestamp to
2442
+ * compare, or when both startedAt agree on a single job incarnation.
2443
+ * Pure — no I/O. */
2444
+ function mdTerminalCoversChild(md: DelegationEntry, child: ChildDelegationLike): boolean {
2445
+ const childUpdated = child.time?.updated
2446
+ if (md.updatedAt !== null && childUpdated !== undefined) {
2447
+ if (md.updatedAt >= childUpdated) return true
2448
+ // Stale Finalized — same incarnation only if both startedAt agree.
2449
+ return md.startedAt === child.time?.created
2450
+ }
2451
+ return true
2452
+ }
2453
+
2077
2454
  export function childrenToDelegationEntries(
2078
2455
  children: readonly ChildDelegationLike[] | undefined,
2079
2456
  md: readonly DelegationEntry[],
2080
2457
  now = Date.now(),
2458
+ parentSessionID = '',
2081
2459
  ): DelegationEntry[] {
2082
2460
  const byTaskID = new Map<string, DelegationEntry>()
2083
2461
  for (const m of md) {
@@ -2089,13 +2467,29 @@ export function childrenToDelegationEntries(
2089
2467
  if (child.id === '' || seen.has(child.id)) continue
2090
2468
  seen.add(child.id)
2091
2469
  const mdEntry = byTaskID.get(child.id)
2470
+ // Recency window (source-level): the children API returns EVERY child the
2471
+ // session ever spawned, so without this the whole session history enters
2472
+ // the list (the "197 children" panel). Effective recency = the latest of
2473
+ // the child's own activity and its matched report's Finalized timestamp;
2474
+ // a child with NO timestamp at all is kept (fail-open — it cannot be
2475
+ // judged). Oldest rows are the ones dropped.
2476
+ const lastActivity = Math.max(
2477
+ child.time?.updated ?? 0,
2478
+ child.time?.created ?? 0,
2479
+ mdEntry?.updatedAt ?? 0,
2480
+ )
2481
+ if (lastActivity > 0 && now - lastActivity > DELEGATION_CHILDREN_RECENCY_MS) continue
2092
2482
  const state =
2093
- mdEntry !== undefined && mdEntry.state !== 'running'
2483
+ mdEntry !== undefined && mdEntry.state !== 'running' && mdTerminalCoversChild(mdEntry, child)
2094
2484
  ? mdEntry.state
2095
- : childStatusToState(child.status)
2485
+ : childStatusToState(child.status, child.time, now)
2096
2486
  out.push({
2097
- alias: mdEntry?.alias ?? 'native-task',
2098
- sessionID: mdEntry?.sessionID ?? '',
2487
+ alias: mdEntry?.alias ?? `native-${child.id.slice(-4)}`,
2488
+ // A report-less child IS a child of the focused session
2489
+ // (session.children is session-scoped), so the caller-passed
2490
+ // parentSessionID is stamped on it as its origin session; an md
2491
+ // match always carries its own session (the report dir name).
2492
+ sessionID: mdEntry?.sessionID ?? parentSessionID,
2099
2493
  taskID: child.id,
2100
2494
  agent: mdEntry?.agent ?? child.agent ?? 'agent',
2101
2495
  state,
@@ -2123,17 +2517,46 @@ export function childrenToDelegationEntries(
2123
2517
  * server-valid session id ("ses...") ever reaches the router, so an
2124
2518
  * unsubstituted "{sessionID}" placeholder can never be routed. */
2125
2519
  export function navigateToDelegationSession(
2126
- route: { navigate?: (name: string, params?: Record<string, unknown>) => void } | undefined,
2520
+ route:
2521
+ | {
2522
+ navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
2523
+ }
2524
+ | undefined,
2127
2525
  taskID: string | undefined,
2128
2526
  ): boolean {
2129
2527
  if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
2130
- route.navigate('session', { sessionID: taskID })
2131
- return true
2528
+ try {
2529
+ const navigation = route.navigate('session', { sessionID: taskID })
2530
+ if (navigation !== undefined) {
2531
+ // A removed session can reject route.navigate for historical rows. That
2532
+ // rejection is expected and intentionally absorbed so it cannot break
2533
+ // the TUI; the no-op avoids logging sensitive session details.
2534
+ void Promise.resolve(navigation).catch(() => undefined)
2535
+ }
2536
+ return true
2537
+ } catch {
2538
+ // Synchronous router failures are also fail-open for an inert historical row.
2539
+ return false
2540
+ }
2541
+ }
2542
+
2543
+ /** Build the mouse handler used by each delegation row. */
2544
+ export function createDelegationRowOpenHandler(
2545
+ route:
2546
+ | {
2547
+ navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
2548
+ }
2549
+ | undefined,
2550
+ taskID: string | undefined,
2551
+ ): () => void {
2552
+ return () => {
2553
+ navigateToDelegationSession(route, taskID)
2554
+ }
2132
2555
  }
2133
2556
 
2134
2557
  /* ─── Silence-by-default panel logger ──────────────────────
2135
- * Mirrors src/pantheon/logger.ts createPantheonLogger (the TUI plugin is
2136
- * self-contained — dist/tui.tsx is a raw copy with no relative imports):
2558
+ * Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
2559
+ * ships only dist/ + package.json, so it cannot import from src/pantheon):
2137
2560
  * every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
2138
2561
  * echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
2139
2562
  * renders plugin console output directly into the TUI — the pollution bug).
@@ -2175,8 +2598,19 @@ function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui
2175
2598
  `${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
2176
2599
  'utf8',
2177
2600
  )
2178
- } catch {
2179
- /* best-effort file log — never break the panel over logging */
2601
+ } catch (err) {
2602
+ /* best-effort file log — never break the panel over logging, but do
2603
+ * NOT swallow the failure silently: a missing symbol here (e.g.
2604
+ * `appendFile` used without an import) previously threw an invisible
2605
+ * ReferenceError and the log silently stopped writing. Gated by the
2606
+ * echo flag to avoid the TUI-pollution bug described above. */
2607
+ if (echo) {
2608
+ try {
2609
+ process.stderr.write(`[${module}] file-log failed: ${formatArg(err)}\n`)
2610
+ } catch {
2611
+ /* stderr unavailable — never break the panel */
2612
+ }
2613
+ }
2180
2614
  }
2181
2615
  })()
2182
2616
  if (echo) {
@@ -2229,53 +2663,32 @@ function DelegationRow(props: {
2229
2663
  api: TuiPluginApi
2230
2664
  job: DelegationEntry
2231
2665
  now: number
2232
- animationNow: number
2666
+ frame: number
2233
2667
  }) {
2234
2668
  const theme = () => props.api.theme.current
2235
2669
 
2236
- const color = createMemo(() => {
2670
+ // Color is a status-wide channel: the WHOLE row (lead, elapsed, description,
2671
+ // live activity) carries the state tone, so red=failed / green=done /
2672
+ // yellow=in-flight reads at a glance. The glyph stays as a redundant,
2673
+ // monochrome-legible signal — never the only one.
2674
+ const tone = createMemo(() => {
2237
2675
  const t = theme()
2238
- switch (props.job.state) {
2239
- case 'running':
2676
+ switch (delegationStateTone(props.job.state)) {
2677
+ case 'warning':
2240
2678
  return t.warning
2241
- case 'retry':
2242
- return t.warning
2243
- case 'stale-running':
2244
- return t.error // warning color for stale delegations
2245
- case 'completed':
2246
- return t.success
2247
2679
  case 'error':
2248
2680
  return t.error
2681
+ case 'success':
2682
+ return t.success
2249
2683
  default:
2250
- return t.textMuted // cancelled
2251
- }
2252
- })
2253
-
2254
- const marker = createMemo(() => {
2255
- if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
2256
- if (props.job.state === 'retry') return '\u27f3 ' // ⟳ retrying
2257
- if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
2258
- switch (props.job.state) {
2259
- case 'completed':
2260
- return '\u2713 '
2261
- case 'error':
2262
- return '\u2715 '
2263
- default:
2264
- return '\u25cb '
2684
+ return t.textMuted // defensive: unknown runtime state
2265
2685
  }
2266
2686
  })
2267
2687
 
2268
- // The row tag: `[native]` for native task() children (no board report),
2269
- // `[pantheon:apo-1]`-style alias for pantheon_delegate board rows. Rendered with
2270
- // the info color so task() children are visually distinct from delegates.
2271
- const tagColor = createMemo(() => (props.job.source === 'children-only' ? theme().info : color()))
2272
-
2273
- const detail = createMemo(() => {
2274
- const elapsed = delegationElapsed(props.job, props.now)
2275
- const description = props.job.description !== '' ? ` \u2014 ${props.job.description}` : ''
2276
- const line = `${elapsed}${description}`
2277
- return line.length > 180 ? `${line.slice(0, 177)}\u2026` : line
2278
- })
2688
+ const marker = createMemo(() => delegationRowMarker(props.job.state, props.frame))
2689
+ const lead = createMemo(() => formatDelegationRowLead(props.job, marker()))
2690
+ const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
2691
+ const description = createMemo(() => truncateDelegationDescription(props.job.description))
2279
2692
 
2280
2693
  // beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
2281
2694
  // fed by message.part.updated activity tracking. Null while idle/done.
@@ -2286,25 +2699,26 @@ function DelegationRow(props: {
2286
2699
  // Click a row → navigate to the child session (route API guarded in the
2287
2700
  // helper). Enter-key binding is left out (per-row keymap wiring is not
2288
2701
  // worth the complexity in this sidebar); mouse is enabled in tui.json.
2289
- const open = () => {
2290
- navigateToDelegationSession(props.api.route, props.job.taskID)
2291
- }
2702
+ const open = createDelegationRowOpenHandler(props.api.route, props.job.taskID)
2292
2703
 
2293
2704
  return (
2294
2705
  <box onMouseDown={open}>
2706
+ {/* Line 1: toned lead (glyph + identity) + toned elapsed tail. */}
2295
2707
  <box flexDirection="row">
2296
- <text fg={color()}>{marker()}</text>
2297
- <text fg={tagColor()}>{delegationTag(props.job)}</text>
2298
- <text
2299
- fg={color()}
2300
- >{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
2708
+ <text>
2709
+ <span fg={tone()}>{lead()}</span>
2710
+ <span fg={tone()}>{elapsed()}</span>
2711
+ </text>
2301
2712
  </box>
2302
- <text fg={theme().textMuted}>{detail()}</text>
2713
+ {/* Line 2: the description, indented under the identity and toned. */}
2714
+ <Show when={description() !== ''}>
2715
+ <text fg={tone()}>{` ${description()}`}</text>
2716
+ </Show>
2303
2717
  <Show when={activity()}>
2304
2718
  {(a) => (
2305
2719
  <text
2306
- fg={theme().textMuted}
2307
- >{` \u21b3 ${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`}</text>
2720
+ fg={tone()}
2721
+ >{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2308
2722
  )}
2309
2723
  </Show>
2310
2724
  </box>
@@ -2413,14 +2827,16 @@ function View(props: {
2413
2827
  // (.pantheon/delegations) enriches each child with alias/agent/description
2414
2828
  // and terminal duration via the `Task ID` match. Live tool-call events are
2415
2829
  // merged optimistically so the row appears before those channels catch up.
2416
- const delegationsDir = createMemo(() => resolveDelegationsDir((props.api.state as any).path))
2830
+ // Consistent project root (directory ?? worktree, cwd fallback) — the md
2831
+ // history and the panel logger both read the SAME root (audit finding: the
2832
+ // channels resolved it independently).
2833
+ const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
2417
2834
  // Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
2418
- // panelLogDir(<root>/.pantheon/delegations) = <root>: the old code passed
2419
- // dirname(delegationsDir()) (= <root>/.pantheon), which createTuiLogger
2420
- // re-joined with `.pantheon/logs/hooks.log` → a nested
2421
- // <root>/.pantheon/.pantheon/logs/hooks.log that never reached the real
2422
- // hooks.log (the empty nested dirs observed in production).
2423
- const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
2835
+ const panelLog = createTuiLogger(projectRoot())
2836
+ // ONLY the active session renders: the final list is filtered by
2837
+ // filterDelegationsToSession after every merge (children + live + md).
2838
+ // Live-first ordering keeps running rows on top; the ceiling
2839
+ // collapses the rest into "… +N more".
2424
2840
  // The rendered list is childDelegations (children enriched with md);
2425
2841
  // there is no separate md-only signal — the md channel only feeds
2426
2842
  // childrenToDelegationEntries below.
@@ -2429,9 +2845,9 @@ function View(props: {
2429
2845
  // (oh-my-opencode-slim pattern): re-fetch children + re-read the md every
2430
2846
  // second so the panel updates even when NO event ever fires.
2431
2847
  const [now, setNow] = createSignal(Date.now())
2432
- // Separate fast ticker for the spinner. The elapsed label intentionally
2433
- // remains on the 1s ticker to avoid unnecessary work.
2434
- const [animationNow, setAnimationNow] = createSignal(Date.now())
2848
+ // Frame counter for the spinner animation — incremented every ~80ms by a
2849
+ // dedicated fast timer, completely decoupled from the data poll cycle.
2850
+ const [frame, setFrame] = createSignal(0)
2435
2851
  // Cumulative count of event-triggered refreshes — logged with every fetch
2436
2852
  // ("panel: children=N(pantheon=M native=K) md=N events=N") so the (0)
2437
2853
  // symptom is diagnosable: children>0 means the source works; events=0
@@ -2445,15 +2861,17 @@ function View(props: {
2445
2861
  if (delegationsInflight !== null) return delegationsInflight
2446
2862
  delegationsInflight = (async () => {
2447
2863
  try {
2448
- const state = props.api.state as any
2864
+ // Typed (no `as any`): TuiState.session.status is a v2 sync accessor
2865
+ // taking the session id directly — same shape as the client call.
2866
+ const state = props.api.state
2449
2867
  // 0. md reports from EVERY session under .pantheon/delegations — the
2450
- // HISTORY channel. Read BEFORE the sessionID guard so the panel shows
2451
- // past reports (running first, Finalized desc) even when no session
2452
- // is focused (null/placeholder sessionID): the "(0)" symptom becomes
2453
- // the full historical list. Fail-open: a missing dir yields [].
2868
+ // ENRICHMENT channel. Read unfiltered (taskID match supplies alias/
2869
+ // agent/description/terminal state); the active-session filter after
2870
+ // the merge keeps only the focused session's reports. Fail-open: a
2871
+ // missing dir yields [].
2454
2872
  let md: DelegationEntry[] = []
2455
2873
  try {
2456
- md = await readAllDelegationEntries(panelLogDir(delegationsDir()))
2874
+ md = await readAllDelegationEntries(projectRoot())
2457
2875
  } catch {
2458
2876
  md = [] // fail-open: never crash the sidebar on a read error
2459
2877
  }
@@ -2462,24 +2880,32 @@ function View(props: {
2462
2880
  // slot prop; forwarding it to session.children made the server reject
2463
2881
  // every poll (~1 err/s: "Expected a string starting with \"ses\", got
2464
2882
  // \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
2465
- // resolution SKIPS the fetch entirely: the md history is shown alone
2466
- // (zero children, zero errors).
2883
+ // resolution SKIPS the fetch entirely: the active-session filter yields
2884
+ // an EMPTY panel (zero children, zero rows, zero errors).
2467
2885
  const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
2468
2886
  if (sessionID === null) {
2469
- setChildDelegations(md)
2887
+ // No focused session → no scope → empty list. The md read still ran
2888
+ // (enrichment source) but with no session there is nothing to scope
2889
+ // to: there is no cross-session panel.
2890
+ setChildDelegations([])
2470
2891
  panelLog.info(
2471
- `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
2892
+ `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — inactive panel)`,
2472
2893
  )
2473
2894
  return
2474
2895
  }
2475
2896
  // 2. children — PRIMARY source (SDK: api.client.session.children).
2897
+ // v2 SDK: `children` takes a FLAT `{ sessionID }` parameter object.
2898
+ // The v1 `{ path: { id } }` envelope is NOT understood by the v2
2899
+ // client — it left the URL template unsubstituted and the server
2900
+ // rejected every poll ("%7BsessionID%7D" regression).
2476
2901
  let children: ChildDelegationLike[] = []
2477
2902
  try {
2478
2903
  // buildChildrenPath re-validates: with sessionID !== null it is
2479
2904
  // never null, so no placeholder can reach the wire.
2480
- const result = await (props.api.client as any)?.session?.children?.(
2481
- buildChildrenPath(sessionID),
2482
- )
2905
+ const childrenPath = buildChildrenPath(sessionID)
2906
+ const result = childrenPath
2907
+ ? await props.api.client.session.children(childrenPath)
2908
+ : undefined
2483
2909
  const data = (result?.data ?? result) as unknown
2484
2910
  children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
2485
2911
  } catch (err) {
@@ -2493,13 +2919,16 @@ function View(props: {
2493
2919
  // path — "%7BsessionID%7D").
2494
2920
  if (!isValidSessionId(childID)) return undefined
2495
2921
  try {
2496
- return state?.session?.status?.(childID)?.type as string | undefined
2922
+ return state.session.status(childID)?.type
2497
2923
  } catch {
2498
2924
  return undefined // status API unavailable — child state fallback
2499
2925
  }
2500
2926
  }
2501
2927
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2502
- const childEntries = childrenToDelegationEntries(withStatus, md, now())
2928
+ // Report-less rows ARE children of the focused session
2929
+ // (session.children is session-scoped), so the resolved id is stamped on
2930
+ // the row as its origin session.
2931
+ const childEntries = childrenToDelegationEntries(withStatus, md, now(), sessionID)
2503
2932
  for (const [k, v] of props.liveStore.map)
2504
2933
  if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
2505
2934
  props.liveStore.map.delete(k)
@@ -2509,7 +2938,16 @@ function View(props: {
2509
2938
  const liveEntries = [...props.liveStore.map.values()].filter(
2510
2939
  (entry) => entry.sessionID === sessionID,
2511
2940
  )
2512
- setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
2941
+ // The event channel is optimistic; merge it over the children/md rows.
2942
+ // Then the active-session filter — applied ONCE after the merge — keeps
2943
+ // ONLY rows whose sessionID === the focused session, so md entries from
2944
+ // other sessions (and orphans with no sessionID) can never render.
2945
+ setChildDelegations(
2946
+ filterDelegationsToSession(
2947
+ mergeChildDelegationSources(childEntries, liveEntries),
2948
+ sessionID,
2949
+ ),
2950
+ )
2513
2951
  // 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
2514
2952
  const counts = countDelegationSources(childEntries)
2515
2953
  panelLog.info(
@@ -2527,23 +2965,16 @@ function View(props: {
2527
2965
  })()
2528
2966
  return delegationsInflight
2529
2967
  }
2530
- // beta.6: active (running/retry) first, then the 8 most recent terminal
2531
- // reports, then the archived tail (paginated). Pagination state lives at
2532
- // module level so sidebar remounts keep the user's page.
2533
- const delegationSplit = createMemo(() => splitDelegationList(childDelegations()))
2534
- const visibleDelegations = createMemo(() => [
2535
- ...delegationSplit().active,
2536
- ...delegationSplit().recent,
2537
- ])
2538
- // Header breakdown: native task() rows vs pantheon_delegate rows, so
2539
- // Sessions(3) with 3 native children reads "Delegations (3 native + 0 pantheon)".
2540
- const delegationCounts = createMemo(() => countDelegationSources(visibleDelegations()))
2541
- const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2542
- const clampArchivedPage = (page: number, pages: number) => {
2543
- const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
2544
- archivedUiState.page = clamped
2545
- setArchivedPage(clamped)
2546
- }
2968
+ // Live-first ceiling: at most DELEGATION_VISIBLE_CEILING rows render; the
2969
+ // remainder collapses into a single "… +N more" line. Both the ceiling and
2970
+ // the header operate on the ALREADY-FILTERED active-session list
2971
+ // (childDelegations), so other-session / hidden rows never inflate counts.
2972
+ const delegationCeiling = createMemo(() =>
2973
+ ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
2974
+ )
2975
+ // Header counts the same retained rows shown below, so hidden/expired md
2976
+ // history cannot inflate the summary.
2977
+ const delegationHeader = createMemo(() => formatDelegationHeader(delegationCeiling().visible))
2547
2978
 
2548
2979
  // ── Event subscriptions for live session/delegation updates ──
2549
2980
  onMount(() => {
@@ -2591,7 +3022,9 @@ function View(props: {
2591
3022
  void refreshDelegations()
2592
3023
  }, 1_000)
2593
3024
  cleanup.push(() => clearInterval(poll))
2594
- const animation = setInterval(() => setAnimationNow(Date.now()), 140)
3025
+ // Fast spinner animation: 80ms frame counter, decoupled from data poll.
3026
+ // Only the spinner glyph depends on this; elapsed + data use the 1s timer.
3027
+ const animation = setInterval(() => setFrame((f) => f + 1), 80)
2595
3028
  cleanup.push(() => clearInterval(animation))
2596
3029
 
2597
3030
  // Compaction recovery: `message.part.removed` clears live entries while
@@ -2688,13 +3121,13 @@ function View(props: {
2688
3121
  <text fg={theme().text} attributes={1}>
2689
3122
  {`${showDelegations() ? '▼' : '▶'} Delegations`}
2690
3123
  </text>
2691
- <text
2692
- fg={theme().textMuted}
2693
- >{` (${String(delegationCounts().native)} native + ${String(delegationCounts().pantheon)} pantheon)`}</text>
3124
+ <text fg={theme().textMuted}>
3125
+ {delegationCeiling().visible.length > 0 ? ` ${delegationHeader()}` : ' — idle'}
3126
+ </text>
2694
3127
  </box>
2695
3128
  <Show when={showDelegations()}>
2696
3129
  <Show
2697
- when={visibleDelegations().length > 0}
3130
+ when={delegationCeiling().visible.length > 0}
2698
3131
  fallback={
2699
3132
  <box marginLeft={1}>
2700
3133
  <text fg={theme().textMuted}>No delegations</text>
@@ -2702,50 +3135,15 @@ function View(props: {
2702
3135
  }
2703
3136
  >
2704
3137
  <box marginLeft={1} flexDirection="column">
2705
- <For each={visibleDelegations()}>
2706
- {(job) => (
2707
- <DelegationRow
2708
- api={props.api}
2709
- job={job}
2710
- now={now()}
2711
- animationNow={animationNow()}
2712
- />
2713
- )}
3138
+ <For each={delegationCeiling().visible}>
3139
+ {(job) => <DelegationRow api={props.api} job={job} now={now()} frame={frame()} />}
2714
3140
  </For>
2715
- <Show when={delegationSplit().archived.length > 0}>
2716
- {(() => {
2717
- const archived = delegationSplit().archived
2718
- const pageSize = 8
2719
- const pages = Math.ceil(archived.length / pageSize)
2720
- const page = Math.min(archivedPage(), pages - 1)
2721
- const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
2722
- return (
2723
- <box flexDirection="column">
2724
- <box flexDirection="row" marginLeft={0}>
2725
- <text
2726
- fg={theme().textMuted}
2727
- onMouseDown={() => clampArchivedPage(page - 1, pages)}
2728
- >{`\u2039 `}</text>
2729
- <text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
2730
- <text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
2731
- <text
2732
- fg={theme().textMuted}
2733
- onMouseDown={() => clampArchivedPage(page + 1, pages)}
2734
- >{` \u203a`}</text>
2735
- </box>
2736
- <For each={slice}>
2737
- {(job) => (
2738
- <DelegationRow
2739
- api={props.api}
2740
- job={job}
2741
- now={now()}
2742
- animationNow={animationNow()}
2743
- />
2744
- )}
2745
- </For>
2746
- </box>
2747
- )
2748
- })()}
3141
+ <Show when={delegationCeiling().hidden > 0}>
3142
+ <text fg={theme().textMuted}>
3143
+ {delegationCeiling().hiddenActive > 0
3144
+ ? `… +${delegationCeiling().hiddenActive} active`
3145
+ : `… +${delegationCeiling().hiddenTerminal} more`}
3146
+ </text>
2749
3147
  </Show>
2750
3148
  </box>
2751
3149
  </Show>