pantheon-opencode 1.5.0-beta.9 → 1.5.1

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 (84) hide show
  1. package/AGENTS.md +14 -63
  2. package/CHANGELOG.md +326 -0
  3. package/README.md +77 -29
  4. package/README.pt-BR.md +5 -6
  5. package/commands/pantheon.md +1 -35
  6. package/package.json +2 -3
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +3 -5
  9. package/scripts/doctor.mjs +260 -5
  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/mnemosyne.md +11 -1
  16. package/src/agents/zeus.md +10 -52
  17. package/src/instructions/memory-protocol.instructions.md +6 -3
  18. package/src/instructions/zeus-anti-stall.instructions.md +2 -4
  19. package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
  20. package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
  21. package/src/mcp/mcp_persistence_server.py +1 -1
  22. package/src/mcp/memory_mcp_server.py +81 -27
  23. package/src/pantheon/background-job-board.ts +4 -4
  24. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  25. package/src/pantheon/cost-command.ts +1 -2
  26. package/src/pantheon/delegation-enforce.ts +59 -14
  27. package/src/pantheon/goal-loop.ts +1 -1
  28. package/src/pantheon/goal-store.ts +1 -1
  29. package/src/pantheon/idle-continuation.ts +4 -6
  30. package/src/pantheon/model-command.ts +1 -2
  31. package/src/pantheon/native-task-status.ts +2 -2
  32. package/src/pantheon/presets.d.mts +0 -20
  33. package/src/pantheon/presets.mjs +0 -64
  34. package/src/pantheon/session-guard.ts +2 -2
  35. package/src/pantheon/session-id.ts +30 -0
  36. package/src/pantheon/task-result-guard.ts +2 -3
  37. package/src/pantheon/todo-enforcer.ts +3 -3
  38. package/src/pantheon/tool-context.ts +23 -0
  39. package/src/pantheon/v2-bridge.ts +1 -4
  40. package/src/plugin-v2.ts +8 -21
  41. package/src/plugin.ts +61 -245
  42. package/src/plugins/pantheon-hooks.ts +2 -2
  43. package/src/plugins/tui/dist/tui.d.ts +229 -103
  44. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  45. package/src/plugins/tui/dist/tui.js +495 -333
  46. package/src/plugins/tui/package-lock.json +5 -2
  47. package/src/plugins/tui/package.json +5 -2
  48. package/src/plugins/tui/src/index.tsx +710 -409
  49. package/src/routing.yml +0 -16
  50. package/src/skills/context-compression/SKILL.md +1 -1
  51. package/scripts/generate-preset-docs.mjs +0 -300
  52. package/scripts/generate-prompts.sh +0 -222
  53. package/scripts/hash_verify.py +0 -192
  54. package/scripts/install/component.mjs +0 -24
  55. package/scripts/install/migrate-config.mjs +0 -302
  56. package/scripts/test-context-compression.sh +0 -169
  57. package/src/mcp/toon_codec.py +0 -491
  58. package/src/pantheon/auto-wake.ts +0 -85
  59. package/src/pantheon/chat-reminders.ts +0 -9
  60. package/src/pantheon/comment-checker.ts +0 -48
  61. package/src/pantheon/compaction-assert.ts +0 -160
  62. package/src/pantheon/compaction-handoff.ts +0 -304
  63. package/src/pantheon/cost-tracker.ts +0 -108
  64. package/src/pantheon/delegate-manager.ts +0 -766
  65. package/src/pantheon/delegation-classifier.ts +0 -307
  66. package/src/pantheon/delegation-finalize.ts +0 -406
  67. package/src/pantheon/delegation-notify.ts +0 -212
  68. package/src/pantheon/delegation-toast.ts +0 -73
  69. package/src/pantheon/delegation.ts +0 -1337
  70. package/src/pantheon/dispatch-guard.ts +0 -109
  71. package/src/pantheon/native-probe.ts +0 -234
  72. package/src/pantheon/permission-eval.ts +0 -284
  73. package/src/pantheon/preemptive-compact.ts +0 -76
  74. package/src/pantheon/provider-capabilities.ts +0 -221
  75. package/src/pantheon/provider-stream-tracker.ts +0 -182
  76. package/src/pantheon/retry-policy.ts +0 -212
  77. package/src/pantheon/step-cap.ts +0 -110
  78. package/src/pantheon/token-opt.ts +0 -689
  79. package/src/pantheon/toon-codec.ts +0 -481
  80. package/src/pantheon/v2-events.ts +0 -148
  81. package/src/pantheon/v2-hooks.ts +0 -287
  82. package/src/pantheon/v2-tool-definitions.ts +0 -331
  83. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
  84. 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
@@ -975,11 +978,15 @@ export type DelegationEntry = {
975
978
  updatedAt: number | null
976
979
  timedOut: boolean
977
980
  description: string
978
- /** True while the panel is waiting for pantheon_delegation_read. */
979
- read?: boolean
981
+ // NOTE: the nested/tree view is BACKLOG. `session.children` returns direct
982
+ // children only, so no real grandchild rows exist yet; the parentTaskID /
983
+ // childCount fields + tree-connector helpers were removed as dead code.
984
+ // Reintroduce them once nested rows are actually produced.
980
985
  /** 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. */
986
+ * 'children-only' = a native task() child session with NO delegate report
987
+ * (a native child row); 'md' = the child has a matching report;
988
+ * 'child' = the plain children channel; 'live' = the optimistic tool-event
989
+ * channel. */
983
990
  source?: 'child' | 'live' | 'md' | 'children-only'
984
991
  }
985
992
 
@@ -1155,29 +1162,39 @@ export async function readDelegationEntries(dir: string): Promise<DelegationEntr
1155
1162
 
1156
1163
  /** Read delegation reports from EVERY session under
1157
1164
  * `<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 []. */
1165
+ * ENRICHMENT source. The read is deliberately unfiltered (each entry carries
1166
+ * its directory's sessionID, so a taskID match can enrich any channel);
1167
+ * callers MUST scope the merged result with {@link filterDelegationsToSession}
1168
+ * before rendering — only the active session's reports are shown. Entries are
1169
+ * running first, Finalized desc (the sort applied by readDelegationEntries).
1170
+ * Fail-open: a missing/unreadable directory yields []. */
1163
1171
  export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
1164
1172
  return readDelegationEntries(join(root, '.pantheon', 'delegations'))
1165
1173
  }
1166
1174
 
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(
1175
+ /** Resolve the PROJECT ROOT used by every pantheon file channel. `directory`
1176
+ * wins over `worktree` (the old `resolveDelegationsDir` already did this);
1177
+ * an absent/empty root or `/` (no git — e.g. the sandbox test project) falls
1178
+ * back to cwd. Standardised here so the delegations md and the panel logger
1179
+ * all read the SAME root (audit finding: the channels
1180
+ * resolved the root independently). Pure — no I/O. */
1181
+ export function resolvePantheonRoot(
1175
1182
  state: { directory?: string; worktree?: string } | undefined,
1176
1183
  cwd = process.cwd(),
1177
1184
  ): string {
1178
1185
  const root = state?.directory ?? state?.worktree ?? ''
1179
- if (root === '' || root === '/') return join(cwd, '.pantheon', 'delegations')
1180
- return join(root, '.pantheon', 'delegations')
1186
+ if (root === '' || root === '/') return cwd
1187
+ return root
1188
+ }
1189
+
1190
+ /** Resolve the directory where delegation md reports are written.
1191
+ * The finalizer writes `.pantheon/delegations` RELATIVE to the server cwd,
1192
+ * which the TUI exposes as `TuiState.path.directory`. */
1193
+ export function resolveDelegationsDir(
1194
+ state: { directory?: string; worktree?: string } | undefined,
1195
+ cwd = process.cwd(),
1196
+ ): string {
1197
+ return join(resolvePantheonRoot(state, cwd), '.pantheon', 'delegations')
1181
1198
  }
1182
1199
 
1183
1200
  /** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
@@ -1208,29 +1225,49 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
1208
1225
  }
1209
1226
 
1210
1227
  /** 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
1228
+ * then the most recent terminal reports; the remaining tail is collapsed
1229
+ * by the View into a single "… +N more" line. Pure — so the history-only panel (no sessionID) is testable
1213
1230
  * without the TUI runtime. */
1214
1231
  export function splitDelegationList(
1215
1232
  all: readonly DelegationEntry[],
1216
1233
  maxRecent = 8,
1217
1234
  now = Date.now(),
1218
1235
  staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1219
- ): { active: DelegationEntry[]; recent: DelegationEntry[]; archived: DelegationEntry[] } {
1236
+ ): { active: DelegationEntry[]; recent: DelegationEntry[] } {
1237
+ // Active = running OR retry OR the display-only stale-running. A stale row
1238
+ // is still a live job (backend state unchanged), so it must never fall into
1239
+ // the terminal tail — pushing a running delegation out hid the row
1240
+ // the user launched.
1241
+ const isActiveState = (st: DelegationEntry['state']): boolean =>
1242
+ st === 'running' || st === 'retry' || st === 'stale-running'
1220
1243
  const active = all
1221
- .filter((d) => d.state === 'running' || d.state === 'retry')
1244
+ .filter((d) => isActiveState(d.state))
1222
1245
  .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1223
- const terminal = all.filter((d) => d.state !== 'running' && d.state !== 'retry')
1246
+ .sort(compareDelegationEntries)
1247
+ const terminal = all
1248
+ .filter((d) => !isActiveState(d.state))
1249
+ .filter((d) => {
1250
+ // A missing/invalid finalized timestamp is not evidence that a report is
1251
+ // old. Keep it visible rather than accidentally hiding a terminal job.
1252
+ if (d.updatedAt === null || !Number.isFinite(d.updatedAt)) return true
1253
+ const age = now - d.updatedAt
1254
+ if (age < 0) return true
1255
+ const retention =
1256
+ delegationRowStatus(d.state) === 'failed'
1257
+ ? DELEGATION_FAILED_RETENTION_MS
1258
+ : DELEGATION_DONE_RETENTION_MS
1259
+ return age < retention
1260
+ })
1261
+ .sort(compareDelegationEntries)
1224
1262
  return {
1225
1263
  active,
1226
1264
  recent: terminal.slice(0, maxRecent),
1227
- archived: terminal.slice(maxRecent),
1228
1265
  }
1229
1266
  }
1230
1267
 
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. */
1268
+ /** Live-first window used by {@link ceilingDelegationList} (kept for the
1269
+ * ceiling helper and existing tests): active jobs first, then the most
1270
+ * recent terminal reports (capped). Pure. */
1234
1271
  export function visibleDelegationList(
1235
1272
  all: readonly DelegationEntry[],
1236
1273
  maxTerminal = 8,
@@ -1249,10 +1286,41 @@ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
1249
1286
  * display-only `stale-running` state. */
1250
1287
  export const IDLE_SILENCE_MS = 60 * 1000
1251
1288
 
1289
+ /** Visual-only terminal retention windows. Reports remain on disk; these
1290
+ * constants only control which rows enter the TUI window. */
1291
+ export const DELEGATION_DONE_RETENTION_MS = 2 * 60 * 1000
1292
+ export const DELEGATION_FAILED_RETENTION_MS = 10 * 60 * 1000
1293
+
1294
+ /** Grace window for an ABSENT child status. `api.state.session.status()` only
1295
+ * carries the sessions that are currently ALIVE; every finished child of a
1296
+ * long-lived session is missing from that map. A child with no status is
1297
+ * therefore treated as terminal (done) — treating it as running made every
1298
+ * historical child of the session show up as "active" (the "197 children,
1299
+ * all active" regression). The one exception is a freshly-spawned child that
1300
+ * has not been registered by the status API yet: within this window of its
1301
+ * last activity (`time.updated`, falling back to `time.created`) it still
1302
+ * reads as running. */
1303
+ export const DELEGATION_CHILD_STATUS_GRACE_MS = 60 * 1000
1304
+
1305
+ /** Recency window for the children channel. `session.children` returns EVERY
1306
+ * child the focused session ever spawned, so a long session accumulates
1307
+ * hundreds of historical rows that would inflate the panel. A child whose
1308
+ * last activity (its own time, or its matched report's Finalized timestamp)
1309
+ * is older than this window is dropped BEFORE it can enter the list. 24h
1310
+ * keeps the sidebar scoped to current work while still covering long
1311
+ * delegations. A child with no timestamp at all is kept (fail-open). */
1312
+ export const DELEGATION_CHILDREN_RECENCY_MS = 24 * 60 * 60 * 1000
1313
+
1314
+ /** Alias-less NATIVE task() live entries never receive a report alias (the
1315
+ * task tool output carries none), so the 30s alias-less prune in
1316
+ * mergeChildDelegationSources must not apply to them — 5 minutes covers a
1317
+ * slow child listing while still bounding the live map. */
1318
+ export const NATIVE_LIVE_ALIASLESS_TTL_MS = 5 * 60 * 1000
1319
+
1252
1320
  /**
1253
1321
  * Mark a running entry as `stale-running` if it has been running longer than
1254
1322
  * 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.
1323
+ * `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the persisted state is unchanged.
1256
1324
  *
1257
1325
  * A `stale-running` entry renders with a warning indicator but the underlying
1258
1326
  * delegation is still treated as running by the backend.
@@ -1270,7 +1338,7 @@ export function markStaleIfRunning(
1270
1338
  return { ...entry, state: 'stale-running' as const }
1271
1339
  }
1272
1340
 
1273
- /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
1341
+ /** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
1274
1342
  * second for running jobs. */
1275
1343
  export function fmtElapsed(ms: number): string {
1276
1344
  const total = Math.max(0, Math.floor(ms / 1000))
@@ -1278,29 +1346,24 @@ export function fmtElapsed(ms: number): string {
1278
1346
  const hours = Math.floor((total % 86_400) / 3_600)
1279
1347
  const minutes = Math.floor((total % 3_600) / 60)
1280
1348
  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`
1349
+ if (days > 0) return `${days}d`
1350
+ if (hours > 0) return `${hours}h`
1351
+ if (minutes > 0) return `${minutes}m`
1284
1352
  return `${seconds}s`
1285
1353
  }
1286
1354
 
1287
- /** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
1288
- * → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
1355
+ /** Elapsed label for one entry: an ACTIVE entry (running/retry/stale-running)
1356
+ * ticks `now - startedAt`; a terminal one is fixed at
1357
+ * `updatedAt - startedAt` (em dash when no finalized timestamp). */
1289
1358
  export function delegationElapsed(entry: DelegationEntry, now: number): string {
1290
- if (entry.state === 'running' || entry.state === 'stale-running')
1359
+ if (entry.state === 'running' || entry.state === 'retry' || entry.state === 'stale-running')
1291
1360
  return fmtElapsed(now - entry.startedAt)
1292
1361
  return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
1293
1362
  }
1294
1363
 
1295
1364
  /** The activity labels shown by the animated row. Keeping this pure makes the
1296
1365
  * state machine testable without booting OpenCode's renderer. */
1297
- export type DelegationActivity =
1298
- | 'delegating'
1299
- | 'working'
1300
- | 'reading'
1301
- | 'completed'
1302
- | 'error'
1303
- | 'cancelled'
1366
+ export type DelegationActivity = 'delegating' | 'working' | 'completed' | 'error' | 'cancelled'
1304
1367
 
1305
1368
  export function delegationActivity(entry: DelegationEntry): DelegationActivity {
1306
1369
  if (entry.state === 'completed') return 'completed'
@@ -1311,7 +1374,6 @@ export function delegationActivity(entry: DelegationEntry): DelegationActivity {
1311
1374
  )
1312
1375
  return 'error'
1313
1376
  if (entry.state === 'cancelled') return 'cancelled'
1314
- if (entry.read) return 'reading'
1315
1377
  if (entry.alias.startsWith('live-') && entry.taskID === undefined) return 'delegating'
1316
1378
  return 'working'
1317
1379
  }
@@ -1323,8 +1385,6 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
1323
1385
  return 'DELEGATING'
1324
1386
  case 'working':
1325
1387
  return 'WORKING'
1326
- case 'reading':
1327
- return 'READING RESULT'
1328
1388
  case 'completed':
1329
1389
  return entry.timedOut ? 'DONE (TIMED OUT)' : 'DONE'
1330
1390
  case 'error':
@@ -1336,12 +1396,47 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
1336
1396
 
1337
1397
  const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
1338
1398
 
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
1399
+ /** Return the spinner glyph for the given frame counter. The frame counter
1400
+ * is incremented every ~80ms by a dedicated animation timer, decoupled from
1401
+ * the data poll cycle. Pure — no side effects. */
1402
+ export function delegationSpinnerFrame(frame: number): string {
1403
+ const index = Math.abs(Math.floor(frame)) % DELEGATION_SPINNER_FRAMES.length
1342
1404
  return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
1343
1405
  }
1344
1406
 
1407
+ /** Every state the row knows how to draw: the delegation lifecycle states
1408
+ * derived from the children status + md reports (`completed`, `error`,
1409
+ * `cancelled`, `startup_failed`, `startup_unknown`) plus the TUI-only
1410
+ * states (`retry`, `stale-running`). Fase 1 deliberately omits speculative
1411
+ * blocked/paused/scheduled/skipped. There is no `pending` display state: a
1412
+ * pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
1413
+ export type DelegationDisplayState = DelegationEntry['state']
1414
+
1415
+ /** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
1416
+ * Kept separate + pure so the color channel is testable without booting the
1417
+ * renderer. Every display state resolves to one of the three status colors;
1418
+ * the row paints its whole content with it (see {@link DelegationRow}). */
1419
+ export type DelegationStateTone = 'warning' | 'error' | 'success'
1420
+
1421
+ /** Status → color mapping for the whole-row tone. Red = failure, green =
1422
+ * terminal, yellow = in flight. The glyph remains a redundant channel so the
1423
+ * state stays legible in monochrome. Display-only; no behavior change. */
1424
+ export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
1425
+ switch (state) {
1426
+ case 'running':
1427
+ case 'retry':
1428
+ case 'startup_unknown':
1429
+ case 'stale-running':
1430
+ return 'warning'
1431
+ case 'error':
1432
+ case 'startup_failed':
1433
+ return 'error'
1434
+ case 'completed':
1435
+ case 'cancelled':
1436
+ return 'success'
1437
+ }
1438
+ }
1439
+
1345
1440
  /* ─── Live tool activity per child session (beta.6) ────────────────────────
1346
1441
  * The delegation panel's activity line ("↳ bash npm test") — what the agent
1347
1442
  * is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
@@ -1360,10 +1455,6 @@ const latestToolActivity = new Map<string, ToolActivity>()
1360
1455
  const MAX_TOOL_ACTIVITY_ENTRIES = 200
1361
1456
  const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
1362
1457
 
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
1458
  /** First usable summary string from common tool input fields. */
1368
1459
  function summarizeToolInput(input: Record<string, unknown> | undefined): string {
1369
1460
  if (!input) return ''
@@ -1446,13 +1537,28 @@ export function mergeChildDelegationSources(
1446
1537
  })
1447
1538
 
1448
1539
  for (const liveEntry of live) {
1540
+ // A native task() live part never carries a report alias or taskID, so it
1541
+ // is exempt from the 30s alias-less prune and lives on its own longer TTL.
1542
+ const aliaslessTTL = liveEntry.tool === 'task' ? NATIVE_LIVE_ALIASLESS_TTL_MS : 30_000
1449
1543
  if (
1450
1544
  liveEntry.alias === null &&
1451
1545
  liveEntry.taskID === null &&
1452
- Date.now() - liveEntry.startedAt > 30_000
1546
+ Date.now() - liveEntry.startedAt > aliaslessTTL
1453
1547
  )
1454
1548
  continue
1455
1549
  const incoming = toDelegationEntry(liveEntry)
1550
+ // A native task() live part never carries a report alias — it is a
1551
+ // children-only row by definition whether it lands on a new row or
1552
+ // upgrades an existing children-only one.
1553
+ const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
1554
+ if (isNativeLive) {
1555
+ // Native task() rows carry no report alias — keep the per-call
1556
+ // `live-<callID>` identity from toDelegationEntry (one row per native
1557
+ // call) with the children-only provenance; the row identity still falls
1558
+ // back to the agent via delegationRowIdentity. A taskID-known native
1559
+ // still merges into its child row through the byTask lookup below.
1560
+ incoming.source = 'children-only'
1561
+ }
1456
1562
  const index =
1457
1563
  (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
1458
1564
  (incoming.alias !== ''
@@ -1471,12 +1577,12 @@ export function mergeChildDelegationSources(
1471
1577
  if (existing === undefined) continue
1472
1578
  // Any terminal state is authoritative — a child session that went idle
1473
1579
  // correctly derives 'completed' via childStatusToState before the MD
1474
- // report is written. The live tool-part channel stays 'running' until
1475
- // pantheon_delegation_read fires, so allowing it to overwrite a
1476
- // terminal state would flip completed → running (the bug this fixes).
1580
+ // report is written. The live tool-part channel stays 'running' for the
1581
+ // whole call, so allowing it to overwrite a terminal state would flip
1582
+ // completed → running (the bug this fixes).
1477
1583
  //
1478
1584
  // When the existing entry IS terminal, we still absorb live metadata
1479
- // (alias, agent, read) but preserve the terminal state.
1585
+ // (alias, agent) but preserve the terminal state.
1480
1586
  if (existing.state !== 'running') {
1481
1587
  if (liveEntry.alias !== null && existing.alias !== incoming.alias) {
1482
1588
  existing.alias = incoming.alias
@@ -1484,9 +1590,6 @@ export function mergeChildDelegationSources(
1484
1590
  if (incoming.agent !== 'agent' && existing.agent !== incoming.agent) {
1485
1591
  existing.agent = incoming.agent
1486
1592
  }
1487
- if (incoming.read && !existing.read) {
1488
- existing.read = true
1489
- }
1490
1593
  continue
1491
1594
  }
1492
1595
  result[index] = {
@@ -1499,8 +1602,9 @@ export function mergeChildDelegationSources(
1499
1602
  state: incoming.state,
1500
1603
  startedAt: Math.min(existing.startedAt, incoming.startedAt),
1501
1604
  updatedAt: incoming.updatedAt,
1502
- read: incoming.read,
1503
- source: 'live',
1605
+ // A native task() live upgrade must not flip a children-only row to
1606
+ // 'live' (that would drop the children-only provenance).
1607
+ source: isNativeLive ? existing.source : 'live',
1504
1608
  }
1505
1609
  }
1506
1610
 
@@ -1509,12 +1613,11 @@ export function mergeChildDelegationSources(
1509
1613
  }
1510
1614
 
1511
1615
  /* ─── Live tool-call lifecycle (agent-sidebar pattern) ─────
1512
- * Source of truth for jobs born while opencode is open. The TUI event bus
1616
+ * Refresh trigger for jobs born while opencode is open. The TUI event bus
1513
1617
  * (api.event.on) delivers `message.part.updated` for every part change;
1514
- * we only care about tool parts whose `tool` is pantheon_delegate (job
1515
- * launch), the native `task` subagent tool (same parentID === caller
1516
- * mechanism — its rows render `[native]`), or pantheon_delegation_read
1517
- * (blocking until terminal → closes the entry). Shape-adapted from the
1618
+ * we only care about tool parts whose `tool` is the native `task` subagent
1619
+ * tool (parentID === caller mechanism — its rows render `nat:`); the panel
1620
+ * rows themselves come from the children channel. Shape-adapted from the
1518
1621
  * SDK v2 ToolPart but duck-typed so the pure helpers are testable without
1519
1622
  * the SDK. */
1520
1623
 
@@ -1534,27 +1637,25 @@ export type DelegationToolPart = {
1534
1637
  }
1535
1638
  }
1536
1639
 
1537
- /** One live delegation tracked in-memory, keyed by the delegate callID. */
1640
+ /** One live delegation tracked in-memory, keyed by the task callID. */
1538
1641
  export type LiveDelegationEntry = {
1539
- /** Tool call id of the pantheon_delegate part (stable across events). */
1642
+ /** Tool call id of the task part (stable across events). */
1540
1643
  callID: string
1541
1644
  /** Part id (for message.part.removed cleanup). */
1542
1645
  partID: string
1543
1646
  /** Parent session the delegation was launched from. */
1544
1647
  sessionID: string
1545
- tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
1546
- /** Agent name (from the delegate input args). */
1648
+ tool: 'task'
1649
+ /** Agent name (from the task input args). */
1547
1650
  agent: string
1548
1651
  description: string
1549
- /** Known after the delegate tool completes (parsed from its output). */
1652
+ /** Known once the task completes and its output carries a marker. */
1550
1653
  alias: string | null
1551
- /** Child session id (parsed from the delegate output). */
1654
+ /** Child session id (parsed from the task output, when present). */
1552
1655
  taskID: string | null
1553
1656
  state: 'running' | 'completed' | 'error' | 'cancelled'
1554
1657
  startedAt: number
1555
1658
  updatedAt: number | null
1556
- /** True once a pantheon_delegation_read for this job has been observed. */
1557
- read: boolean
1558
1659
  }
1559
1660
 
1560
1661
  /** Result of parsing one tool part into lifecycle-relevant fields. */
@@ -1562,9 +1663,9 @@ export type ParsedDelegationToolPart = {
1562
1663
  callID: string
1563
1664
  partID: string
1564
1665
  sessionID: string
1565
- tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
1566
- /** null for read parts (no agent arg — the id targets an existing job). */
1567
- agent: string | null
1666
+ tool: 'task'
1667
+ /** Agent name from the task input args ('agent' fallback when absent). */
1668
+ agent: string
1568
1669
  description: string
1569
1670
  status: 'pending' | 'running' | 'completed' | 'error'
1570
1671
  alias: string | null
@@ -1573,29 +1674,21 @@ export type ParsedDelegationToolPart = {
1573
1674
  endAt: number | null
1574
1675
  }
1575
1676
 
1576
- /** Alias in the delegate output: "Delegated to apollo: [apo-1] (task …)". */
1677
+ /** Alias in the task output: "Delegated to apollo: [apo-1] (task …)". */
1577
1678
  const DELEGATE_ALIAS_PATTERN = /\[([a-z]{2,8}-\d+)\]/i
1578
- /** Child task id in the delegate output: "(task ses_child_9)". */
1679
+ /** Child task id in the task output: "(task ses_child_9)". */
1579
1680
  const DELEGATE_TASKID_PATTERN = /\(task\s+([a-z0-9_]+)\)/i
1580
- /** Plain alias, as passed to pantheon_delegation_read input.id: "apo-1". */
1581
- const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
1582
1681
 
1583
1682
  /** Extract the tool name + args from a `message.part.updated` part and
1584
1683
  * reduce it to what the panel needs. Returns null for anything that is
1585
- * not a pantheon delegation tool part, the native `task` subagent tool
1586
- * (same parentID === caller mechanism — its children render `[native]`),
1587
- * or is missing its callID. */
1684
+ * not the native `task` subagent tool (parentID === caller mechanism —
1685
+ * its children render `nat:`), or is missing its callID. */
1588
1686
  export function parseDelegationToolPart(
1589
1687
  part: DelegationToolPart,
1590
1688
  now = Date.now(),
1591
1689
  ): ParsedDelegationToolPart | null {
1592
1690
  if (part.type !== 'tool') return null
1593
- if (
1594
- part.tool !== 'pantheon_delegate' &&
1595
- part.tool !== 'pantheon_delegation_read' &&
1596
- part.tool !== 'task'
1597
- )
1598
- return null
1691
+ if (part.tool !== 'task') return null
1599
1692
  const callID = part.callID
1600
1693
  if (callID === undefined || callID === '') return null
1601
1694
  const sessionID = part.sessionID ?? ''
@@ -1607,29 +1700,6 @@ export function parseDelegationToolPart(
1607
1700
  const startedAt = state.time?.start ?? now
1608
1701
  const endAt = state.time?.end ?? null
1609
1702
 
1610
- if (part.tool === 'pantheon_delegation_read') {
1611
- const target = typeof input.id === 'string' ? input.id : null
1612
- let alias: string | null = null
1613
- let taskID: string | null = null
1614
- if (target !== null) {
1615
- if (READ_ALIAS_PATTERN.test(target)) alias = target
1616
- else if (target.startsWith('ses_')) taskID = target // raw child session id
1617
- }
1618
- return {
1619
- callID,
1620
- partID: part.id ?? '',
1621
- sessionID,
1622
- tool: 'pantheon_delegation_read',
1623
- agent: null,
1624
- description: '',
1625
- status,
1626
- alias,
1627
- taskID,
1628
- startedAt,
1629
- endAt,
1630
- }
1631
- }
1632
-
1633
1703
  const agent =
1634
1704
  typeof input.agent === 'string'
1635
1705
  ? input.agent
@@ -1642,10 +1712,10 @@ export function parseDelegationToolPart(
1642
1712
  : typeof input.prompt === 'string'
1643
1713
  ? (input.prompt as string).slice(0, 120)
1644
1714
  : ''
1645
- // The alias/taskID only exist once the delegate tool COMPLETES (they are
1646
- // 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]`.
1715
+ // The alias/taskID only exist once the task COMPLETES (they are
1716
+ // returned in its output); a running/pending part has neither. A native
1717
+ // `task` part never carries a report alias — the children channel supplies
1718
+ // the child session id and the row's identity.
1649
1719
  let alias: string | null = null
1650
1720
  let taskID: string | null = null
1651
1721
  if (status === 'completed') {
@@ -1657,7 +1727,7 @@ export function parseDelegationToolPart(
1657
1727
  callID,
1658
1728
  partID: part.id ?? '',
1659
1729
  sessionID,
1660
- tool: part.tool === 'task' ? 'task' : 'pantheon_delegate',
1730
+ tool: 'task',
1661
1731
  agent,
1662
1732
  description,
1663
1733
  status,
@@ -1668,20 +1738,6 @@ export function parseDelegationToolPart(
1668
1738
  }
1669
1739
  }
1670
1740
 
1671
- /** Find a live entry by alias or taskID (read parts resolve by id). */
1672
- function findLiveByTarget(
1673
- map: Map<string, LiveDelegationEntry>,
1674
- alias: string | null,
1675
- taskID: string | null,
1676
- ): LiveDelegationEntry | undefined {
1677
- if (alias === null && taskID === null) return undefined
1678
- for (const entry of map.values()) {
1679
- if (alias !== null && entry.alias === alias) return entry
1680
- if (taskID !== null && entry.taskID === taskID) return entry
1681
- }
1682
- return undefined
1683
- }
1684
-
1685
1741
  /** Apply one tool part to the live map. Returns true when the map changed.
1686
1742
  * Pure w.r.t. I/O — only mutates `map`. */
1687
1743
  export function reduceDelegationToolPart(
@@ -1692,34 +1748,11 @@ export function reduceDelegationToolPart(
1692
1748
  const parsed = parseDelegationToolPart(part, now)
1693
1749
  if (parsed === null) return false
1694
1750
 
1695
- // Read tool: never creates a row — it resolves a delegation by id. It
1696
- // marks the target `read` and (because it blocks until terminal) closes
1697
- // the entry on completed/error with its end timestamp.
1698
- if (parsed.tool === 'pantheon_delegation_read') {
1699
- const target = findLiveByTarget(map, parsed.alias, parsed.taskID)
1700
- if (target === undefined) return false
1701
- let changed = false
1702
- if (!target.read) {
1703
- target.read = true
1704
- changed = true
1705
- }
1706
- if (parsed.status === 'completed' && target.state === 'running') {
1707
- target.state = 'completed'
1708
- target.updatedAt = parsed.endAt ?? now
1709
- changed = true
1710
- } else if (parsed.status === 'error' && target.state === 'running') {
1711
- target.state = 'error'
1712
- target.updatedAt = parsed.endAt ?? now
1713
- changed = true
1714
- }
1715
- return changed
1716
- }
1717
-
1718
- // Delegate tool (pantheon_delegate AND native task): pending/running →
1719
- // job launched (running). A COMPLETED delegate tool only means the job was
1720
- // 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.
1751
+ // Task tool: pending/running → job launched (running). A COMPLETED task
1752
+ // only means the call returned — the child session keeps running, and we
1753
+ // pick up alias + taskID from the output when present. Native `task` parts
1754
+ // never yield a report alias, so their rows stay identity-less until the
1755
+ // children channel resolves them.
1723
1756
  const existing = map.get(parsed.callID)
1724
1757
  if (parsed.status === 'error') {
1725
1758
  if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
@@ -1729,14 +1762,13 @@ export function reduceDelegationToolPart(
1729
1762
  partID: parsed.partID,
1730
1763
  sessionID: parsed.sessionID,
1731
1764
  tool: parsed.tool,
1732
- agent: parsed.agent ?? 'agent',
1765
+ agent: parsed.agent,
1733
1766
  description: parsed.description,
1734
1767
  alias: existing?.alias ?? null,
1735
1768
  taskID: existing?.taskID ?? null,
1736
1769
  state: 'error',
1737
1770
  startedAt: existing?.startedAt ?? parsed.startedAt,
1738
1771
  updatedAt: parsed.endAt ?? now,
1739
- read: existing?.read ?? false,
1740
1772
  })
1741
1773
  return true
1742
1774
  }
@@ -1746,14 +1778,13 @@ export function reduceDelegationToolPart(
1746
1778
  partID: parsed.partID,
1747
1779
  sessionID: parsed.sessionID,
1748
1780
  tool: parsed.tool,
1749
- agent: parsed.agent ?? 'agent',
1781
+ agent: parsed.agent,
1750
1782
  description: parsed.description,
1751
1783
  alias: parsed.alias,
1752
1784
  taskID: parsed.taskID,
1753
1785
  state: 'running',
1754
1786
  startedAt: parsed.startedAt,
1755
1787
  updatedAt: null,
1756
- read: false,
1757
1788
  })
1758
1789
  return true
1759
1790
  }
@@ -1798,14 +1829,14 @@ export function removeDelegationEntry(
1798
1829
  return false
1799
1830
  }
1800
1831
 
1801
- /** Collect pantheon delegation + native task tool parts from a session's messages.
1832
+ /** Collect native task tool parts from a session's messages.
1802
1833
  * Messages may carry their parts inline (duck-typed `msg.parts`); when
1803
1834
  * they don't, the optional `getParts(messageID)` callback is used (the TUI
1804
1835
  * SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
1805
- * child session with parentID = caller — the same mechanism as
1806
- * 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
1808
- * by the mount re-scan to re-seed the live map after compaction/attach. */
1836
+ * child session with parentID = caller, so its parts feed the live-map as
1837
+ * the refresh signal (rows come from the children channel). Pure w.r.t.
1838
+ * I/O — used by the mount re-scan to re-seed the live map after
1839
+ * compaction/attach. */
1809
1840
  export function collectDelegationToolParts(
1810
1841
  messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
1811
1842
  getParts?: (messageID: string) => readonly unknown[] | undefined,
@@ -1821,12 +1852,7 @@ export function collectDelegationToolParts(
1821
1852
  if (!parts) continue
1822
1853
  for (const raw of parts) {
1823
1854
  const part = raw as DelegationToolPart
1824
- if (
1825
- part?.type === 'tool' &&
1826
- (part.tool === 'pantheon_delegate' ||
1827
- part.tool === 'pantheon_delegation_read' ||
1828
- part.tool === 'task')
1829
- ) {
1855
+ if (part?.type === 'tool' && part.tool === 'task') {
1830
1856
  out.push(part)
1831
1857
  }
1832
1858
  }
@@ -1851,7 +1877,7 @@ export function seedLiveDelegationMap(
1851
1877
  }
1852
1878
 
1853
1879
  /** Convert a live entry into the shared display shape. Alias falls back to
1854
- * a `live-<callID>` prefix while the delegate tool has not completed yet. */
1880
+ * a `live-<callID>` prefix while the task call has not completed yet. */
1855
1881
  export function toDelegationEntry(live: LiveDelegationEntry): DelegationEntry {
1856
1882
  return {
1857
1883
  alias: live.alias ?? `live-${live.callID.slice(0, 8)}`,
@@ -1863,7 +1889,6 @@ export function toDelegationEntry(live: LiveDelegationEntry): DelegationEntry {
1863
1889
  updatedAt: live.updatedAt,
1864
1890
  timedOut: false,
1865
1891
  description: live.description,
1866
- read: live.read,
1867
1892
  source: 'live',
1868
1893
  }
1869
1894
  }
@@ -1902,15 +1927,39 @@ export function mergeDelegationSources(
1902
1927
  return all
1903
1928
  }
1904
1929
 
1930
+ /** Scope a fully-merged display list to the ACTIVE session only.
1931
+ *
1932
+ * The panel is session-scoped: rows from other sessions (md history under
1933
+ * `.pantheon/delegations/<other-session>/`) and rows with no
1934
+ * attributable session (empty sessionID) are DROPPED. The previous
1935
+ * cross-session behavior rendered those rows and clicking them led to
1936
+ * "Session not found" — there is no cross-session channel anymore.
1937
+ *
1938
+ * The md channel still feeds the merge UNFILTERED so it can ENRICH an
1939
+ * active-session row with state/alias/agent (dedup by taskID), but it can
1940
+ * never introduce a row for another session: this filter is applied
1941
+ * ONCE, after every merge (children + live + md).
1942
+ *
1943
+ * Returns [] when no active session resolves (null/placeholder) — there is no
1944
+ * scope to show. Native children always carry the active session id (stamped
1945
+ * by `childrenToDelegationEntries`), so they survive the filter. Pure. */
1946
+ export function filterDelegationsToSession(
1947
+ entries: readonly DelegationEntry[],
1948
+ activeSessionID: string | null | undefined,
1949
+ ): DelegationEntry[] {
1950
+ if (!isValidSessionId(activeSessionID)) return []
1951
+ return entries.filter((entry) => entry.sessionID === activeSessionID)
1952
+ }
1953
+
1905
1954
  /* ─── Children channel (primary source, delegations-sidebar pattern) ────
1906
1955
  * `api.client.session.children` returns the child sessions of the current
1907
- * session — every pantheon_delegate spawns one (parentID = caller). The
1956
+ * session — every native task() call spawns one (parentID = caller). The
1908
1957
  * pure helpers here turn those children + the md reports into the display
1909
1958
  * list, and navigate to a child session on click. */
1910
1959
 
1911
1960
  /* ─── Current-session resolution + path guard (placeholder regression) ──
1912
1961
  * The sidebar forwarded `props.session_id` verbatim into
1913
- * `session.children({ path: { id } })`. When the TUI runtime renders the
1962
+ * `session.children({ sessionID })`. When the TUI runtime renders the
1914
1963
  * sidebar without a focused session it leaves the route template
1915
1964
  * UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
1916
1965
  * rejected every poll (SchemaError: Expected a string starting with "ses",
@@ -1974,33 +2023,38 @@ export function resolveCurrentSessionID(sources: TuiSessionSources): string | nu
1974
2023
  }
1975
2024
 
1976
2025
  /** 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 {
2026
+ * Returns the v2 SDK parameter shape `{ sessionID }` ONLY for a
2027
+ * server-valid session id; returns null for anything else (placeholder,
2028
+ * empty, foreign id) so the caller skips the call entirely instead of
2029
+ * sending an unsubstituted placeholder (the "%7BsessionID%7D" regression).
2030
+ * The TUI client is `@opencode-ai/sdk/v2`, whose session methods take a
2031
+ * FLAT parameter object (`{ sessionID }`), NOT the v1 `{ path: { id } }`
2032
+ * envelope — passing the v1 shape left the v2 `{sessionID}` URL template
2033
+ * unsubstituted (`/session/%7BsessionID%7D/children`). Every session-API
2034
+ * call site MUST go through this function (enforced by the source-scan
2035
+ * test in tests/pantheon/tui-delegations.test.ts). */
2036
+ export function safeSessionPath(id: unknown): { sessionID: string } | null {
1984
2037
  if (!isValidSessionId(id)) return null
1985
- return { path: { id } }
2038
+ return { sessionID: id }
1986
2039
  }
1987
2040
 
1988
- /** Build the `session.children` path ONLY from a validated session id.
2041
+ /** Build the `session.children` parameters ONLY from a validated session id.
1989
2042
  * 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 {
2043
+ * the v2 SDK shape `{ sessionID }`; null for null/invalid ids so the caller
2044
+ * skips the fetch instead of sending an unsubstituted placeholder (the
2045
+ * "%7BsessionID%7D" regression). */
2046
+ export function buildChildrenPath(id: string | null | undefined): { sessionID: string } | null {
1993
2047
  return safeSessionPath(id)
1994
2048
  }
1995
2049
 
1996
2050
  /** Duck-typed subset of a child Session (+ its live status type). */
1997
2051
  export type ChildDelegationLike = {
1998
- /** Child session id (= board task id). */
2052
+ /** Child session id (= task id). */
1999
2053
  id: string
2000
2054
  /** Session title — the delegate's description or prompt prefix. */
2001
2055
  title?: string
2002
2056
  /** Agent name when the child session carries one (duck-typed; native
2003
- * task() children may expose it, board reports always do). */
2057
+ * task() children may expose it, md reports always do). */
2004
2058
  agent?: string
2005
2059
  /** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
2006
2060
  * or undefined when the status API is unavailable. */
@@ -2011,29 +2065,232 @@ export type ChildDelegationLike = {
2011
2065
  }
2012
2066
  }
2013
2067
 
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'
2068
+ /** Map a child status type to a display state.
2069
+ *
2070
+ * Only `busy`/`retry` are EXPLICIT live states. `idle` is terminal and so is
2071
+ * any other/unknown status — a status the panel does not recognise is not
2072
+ * evidence of activity. An ABSENT status is the historical case: the status
2073
+ * map (`api.state.session.status`) contains only currently-alive sessions, so
2074
+ * every finished child of a long session is missing from it. Defaulting that
2075
+ * to running made the whole session history render as "active" (the
2076
+ * "197 children, all active" regression), so an absent status is terminal
2077
+ * (done) UNLESS the child was active within `graceMs` — a just-spawned child
2078
+ * that the status API has not registered yet must still render as running.
2079
+ *
2080
+ * @param status the `api.state.session.status(id)?.type`, or undefined
2081
+ * @param time the child session time fields (`created`/`updated`)
2082
+ * @param now current wall-clock ms (injectable for tests)
2083
+ * @param graceMs recency window for the absent-status running assumption
2084
+ * Pure — no I/O. */
2085
+ export function childStatusToState(
2086
+ status: string | undefined,
2087
+ time?: { created?: number; updated?: number },
2088
+ now = Date.now(),
2089
+ graceMs = DELEGATION_CHILD_STATUS_GRACE_MS,
2090
+ ): 'running' | 'completed' | 'retry' {
2020
2091
  if (status === 'retry') return 'retry'
2021
- return 'running' // busy or unknown
2092
+ if (status === 'busy') {
2093
+ // A stale "busy" in the status map means the child is likely dead, not
2094
+ // running. The opencode status map may retain entries for sessions that
2095
+ // have already terminated. Cross-check against the child's own activity
2096
+ // timestamps: if the last activity is older than the stale-running
2097
+ // threshold, treat the child as completed rather than propagating the
2098
+ // stale busy status as "running" (the "213 children, all active" bug).
2099
+ if (time) {
2100
+ const lastActivity = Math.max(time.updated ?? 0, time.created ?? 0)
2101
+ if (lastActivity > 0 && now - lastActivity > STALE_RUNNING_THRESHOLD_MS) return 'completed'
2102
+ }
2103
+ return 'running'
2104
+ }
2105
+ if (status !== undefined) return 'completed' // idle + any other explicit terminal
2106
+ // Absent status: running only while the child's last activity is fresh.
2107
+ const lastActivity = Math.max(time?.updated ?? 0, time?.created ?? 0)
2108
+ if (lastActivity > 0 && now - lastActivity < graceMs) return 'running'
2109
+ return 'completed'
2110
+ }
2111
+
2112
+ /** Status-only row model: ONE glyph + ONE identity per row.
2113
+ *
2114
+ * A row is
2115
+ * `{glyph} {alias:7} {elapsed:>5}` plus a muted description line. The short
2116
+ * alias (`apo-1`) is the single identity; rows without a report alias show
2117
+ * the agent instead (never both). */
2118
+ export type DelegationRowStatus = 'active' | 'done' | 'failed' | 'retry'
2119
+
2120
+ /** Map every FSM/display state to one of the 4 row kinds. reconciled never
2121
+ * reaches the panel (the md parser drops it) but reads as done;
2122
+ * cancelled reads as done; startup_failed reads as failed while
2123
+ * startup_unknown (no error known) reads as retry. Pure. */
2124
+ export function delegationRowStatus(state: DelegationDisplayState): DelegationRowStatus {
2125
+ switch (state) {
2126
+ case 'running':
2127
+ case 'stale-running':
2128
+ return 'active'
2129
+ case 'retry':
2130
+ case 'startup_unknown':
2131
+ return 'retry'
2132
+ case 'completed':
2133
+ case 'cancelled':
2134
+ return 'done'
2135
+ case 'error':
2136
+ case 'startup_failed':
2137
+ return 'failed'
2138
+ }
2139
+ }
2140
+
2141
+ /** The only 4 glyphs a row may show: animated active, done, failed, retry.
2142
+ * Shape is redundant with color (never the only signal). Pure. */
2143
+ export const DELEGATION_ROW_GLYPHS: Readonly<Record<DelegationRowStatus, string>> = {
2144
+ active: '⠋', // base frame of the animated spinner
2145
+ done: '✓',
2146
+ failed: '✕',
2147
+ retry: '⟳',
2148
+ }
2149
+
2150
+ export function delegationRowGlyph(status: DelegationRowStatus): string {
2151
+ return DELEGATION_ROW_GLYPHS[status]
2022
2152
  }
2023
2153
 
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}]`
2154
+ /** Row marker (`<glyph> `) — the single state channel. `active` animates
2155
+ * through the spinner for the given frame counter (incremented every ~80ms);
2156
+ * every other kind is static. Pure. */
2157
+ export function delegationRowMarker(state: DelegationDisplayState, frame = 0): string {
2158
+ const status = delegationRowStatus(state)
2159
+ const glyph = status === 'active' ? delegationSpinnerFrame(frame) : delegationRowGlyph(status)
2160
+ return `${glyph} `
2032
2161
  }
2033
2162
 
2034
- /** 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. */
2163
+ /** ONE identity per row: the short report alias (`apo-1`); a row without a
2164
+ * report alias (native task() child `native-task` / `native-<id>`, or an
2165
+ * alias-less native live row `live-<callID>` kept as children-only) shows
2166
+ * the agent instead — never alias + agent stacked. Pure. */
2167
+ export function delegationRowIdentity(entry: DelegationEntry): string {
2168
+ if (entry.agent === '') return entry.alias
2169
+ if (entry.alias === 'native-task' || entry.alias.startsWith('native-')) return entry.agent
2170
+ if (entry.alias.startsWith('live-') && entry.source === 'children-only') return entry.agent
2171
+ return entry.alias
2172
+ }
2173
+
2174
+ /** Fixed alias cell, pad-right + hard-truncate to `width` (default 7) so
2175
+ * identities line up across rows. Pure. */
2176
+ export const DELEGATION_ALIAS_WIDTH = 7
2177
+
2178
+ export function formatDelegationAlias(identity: string, width = DELEGATION_ALIAS_WIDTH): string {
2179
+ if (identity.length >= width) return identity.slice(0, width)
2180
+ return `${identity}${' '.repeat(width - identity.length)}`
2181
+ }
2182
+
2183
+ /** Max description width on the row detail line — the old 180-char slice
2184
+ * wrapped the sidebar; 44 keeps one readable line. */
2185
+ export const DELEGATION_DESCRIPTION_MAX = 44
2186
+
2187
+ /** Lazily-built grapheme segmenter. `Intl.Segmenter` keeps ZWJ emoji families
2188
+ * (and skin-tone/variation sequences) intact; `Array.from` (code points) is
2189
+ * the fallback for runtimes without it. */
2190
+ let graphemeSegmenter: Intl.Segmenter | undefined
2191
+
2192
+ /** Split `text` into display graphemes so truncation never cuts a multi-unit
2193
+ * emoji in half (surrogate pair → mojibake). */
2194
+ function splitGraphemes(text: string): string[] {
2195
+ if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
2196
+ graphemeSegmenter ??= new Intl.Segmenter('pt', { granularity: 'grapheme' })
2197
+ return Array.from(graphemeSegmenter.segment(text), (s) => s.segment)
2198
+ }
2199
+ return Array.from(text)
2200
+ }
2201
+
2202
+ /** Truncate a description to `max` graphemes appending `…` when cut. Exact
2203
+ * `max`-length text is left untouched. Grapheme-granular, so an emoji made of
2204
+ * several code points is never split into mojibake at the boundary. Pure. */
2205
+ export function truncateDelegationDescription(
2206
+ text: string,
2207
+ max = DELEGATION_DESCRIPTION_MAX,
2208
+ ): string {
2209
+ const graphemes = splitGraphemes(text)
2210
+ if (graphemes.length <= max) return text
2211
+ return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
2212
+ }
2213
+
2214
+ /** Fixed elapsed-time box: right-aligned to `width` (default 5) so elapsed
2215
+ * values line up across rows, e.g. ` 12s`. A label that already fills
2216
+ * the box is returned untouched. Pure. */
2217
+ export const DELEGATION_ELAPSED_WIDTH = 5
2218
+
2219
+ export function formatDelegationElapsed(
2220
+ entry: DelegationEntry,
2221
+ now: number,
2222
+ width = DELEGATION_ELAPSED_WIDTH,
2223
+ ): string {
2224
+ const label = delegationElapsed(entry, now)
2225
+ if (label.length >= width) return label
2226
+ return `${' '.repeat(width - label.length)}${label}`
2227
+ }
2228
+
2229
+ /** Colored left half of a row line: `<marker><alias:7> ` — the state glyph
2230
+ * plus the single identity, padded so the muted elapsed column aligns. The
2231
+ * row renders this colored lead separately from the muted elapsed tail. Pure. */
2232
+ export function formatDelegationRowLead(entry: DelegationEntry, marker: string): string {
2233
+ return `${marker}${formatDelegationAlias(delegationRowIdentity(entry))} `
2234
+ }
2235
+
2236
+ // Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
2237
+ // as dead code: `session.children` returns direct children only, so no row is
2238
+ // ever actually nested and the connectors were never populated in production.
2239
+ // BACKLOG: produce real grandchild rows first, then reintroduce them.
2240
+
2241
+ /** Visible-row ceiling for the panel: the remainder collapses into a single
2242
+ * "… +N more" line. Live rows render first, so a running job is never hidden
2243
+ * by the cap. */
2244
+ export const DELEGATION_VISIBLE_CEILING = 8
2245
+
2246
+ /** Header summary: `(N active · M done)` plus `· K failed` only when K > 0.
2247
+ * Active counts running/retry AND display-only stale-running (a stale row is
2248
+ * still a live job — it must never read as done); failed counts
2249
+ * error/startup_failed; cancelled reads as done. Pure. */
2250
+ export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
2251
+ let active = 0
2252
+ let done = 0
2253
+ let failed = 0
2254
+ for (const e of entries) {
2255
+ switch (delegationRowStatus(e.state)) {
2256
+ case 'active':
2257
+ case 'retry':
2258
+ active++
2259
+ break
2260
+ case 'failed':
2261
+ failed++
2262
+ break
2263
+ case 'done':
2264
+ done++
2265
+ break
2266
+ }
2267
+ }
2268
+ const tail = failed > 0 ? ` · ${failed} failed` : ''
2269
+ return `(${active} active · ${done} done${tail})`
2270
+ }
2271
+
2272
+ /** Cap the panel: live rows first, then most-recent retained terminal rows, at
2273
+ * most `maxVisible` total. Hidden counts describe the retained render list,
2274
+ * not expired history. */
2275
+ export function ceilingDelegationList(
2276
+ all: readonly DelegationEntry[],
2277
+ maxVisible = DELEGATION_VISIBLE_CEILING,
2278
+ now = Date.now(),
2279
+ ): { visible: DelegationEntry[]; hidden: number; hiddenActive: number; hiddenTerminal: number } {
2280
+ const { active, recent } = splitDelegationList(all, all.length, now)
2281
+ const visibleActive = active.slice(0, maxVisible)
2282
+ const visible = [
2283
+ ...visibleActive,
2284
+ ...recent.slice(0, Math.max(0, maxVisible - visibleActive.length)),
2285
+ ]
2286
+ const hiddenActive = Math.max(0, active.length - visibleActive.length)
2287
+ const hiddenTerminal = Math.max(0, recent.length - (visible.length - visibleActive.length))
2288
+ return { visible, hidden: hiddenActive + hiddenTerminal, hiddenActive, hiddenTerminal }
2289
+ }
2290
+
2291
+ /** Split a display list into native task() rows vs pantheon report rows.
2292
+ * Native = source 'children-only' (no delegate report); everything else counts
2293
+ * as pantheon. Pure — powers the hooks.log line. */
2037
2294
  export function countDelegationSources(entries: readonly DelegationEntry[]): {
2038
2295
  native: number
2039
2296
  pantheon: number
@@ -2045,7 +2302,7 @@ export function countDelegationSources(entries: readonly DelegationEntry[]): {
2045
2302
  }
2046
2303
 
2047
2304
  /** Diagnostic hooks.log line for a panel re-fetch, with the children
2048
- * breakdown (pantheon = children WITH a board report, native = children
2305
+ * breakdown (pantheon = children WITH a delegate report, native = children
2049
2306
  * WITHOUT one). Pure — the View logs the returned string verbatim. */
2050
2307
  export function formatPanelLogLine(
2051
2308
  children: number,
@@ -2064,20 +2321,45 @@ export function formatPanelLogLine(
2064
2321
  * report still renders: description from its title, agent from the child
2065
2322
  * itself (fallback 'agent'), state derived from its status, startedAt from
2066
2323
  * time.created. A report-less child is a NATIVE task() child (every
2067
- * 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
2324
+ * child of the current session — the native `task()` tool — carries
2325
+ * parentID = caller), so it gets source 'children-only', a
2326
+ * per-child alias 'native-<last4 of the child id>' (one identity per native
2327
+ * row) instead of a report alias. The 'task nativa' description fallback
2328
+ * keeps the row non-empty when the child carries no title.
2329
+ * A FRESH terminal md state wins over the derived state (a stale md report
2330
+ * from a previous incarnation never flips a live child — see
2331
+ * mdTerminalCoversChild); a running md defers to
2073
2332
  * the child's live status. A running child is NEVER archived — it always
2074
2333
  * lands in the active split (splitDelegationList active = running/retry).
2075
- * Sorted running-first (compareDelegationEntries).
2334
+ * `parentSessionID` (the focused session id) is stamped on report-less
2335
+ * children so the row carries its origin session; omit it and they stay
2336
+ * unscoped ('').
2337
+ * Children outside {@link DELEGATION_CHILDREN_RECENCY_MS} are dropped before
2338
+ * they can enter the list (the per-session child list grows for the whole
2339
+ * session lifetime); the survivors are sorted running-first, most recent
2340
+ * first (compareDelegationEntries), so a ceiling hides the OLDEST rows.
2341
+ * Pure — no I/O. */
2342
+ /** Temporal guard for the children channel (same recycled-alias class as the
2343
+ * md merge): an md terminal report matched by taskID may still belong to
2344
+ * a PREVIOUS job incarnation. Accept it only when its Finalized timestamp
2345
+ * covers the child's latest activity, when either side has no timestamp to
2346
+ * compare, or when both startedAt agree on a single job incarnation.
2076
2347
  * Pure — no I/O. */
2348
+ function mdTerminalCoversChild(md: DelegationEntry, child: ChildDelegationLike): boolean {
2349
+ const childUpdated = child.time?.updated
2350
+ if (md.updatedAt !== null && childUpdated !== undefined) {
2351
+ if (md.updatedAt >= childUpdated) return true
2352
+ // Stale Finalized — same incarnation only if both startedAt agree.
2353
+ return md.startedAt === child.time?.created
2354
+ }
2355
+ return true
2356
+ }
2357
+
2077
2358
  export function childrenToDelegationEntries(
2078
2359
  children: readonly ChildDelegationLike[] | undefined,
2079
2360
  md: readonly DelegationEntry[],
2080
2361
  now = Date.now(),
2362
+ parentSessionID = '',
2081
2363
  ): DelegationEntry[] {
2082
2364
  const byTaskID = new Map<string, DelegationEntry>()
2083
2365
  for (const m of md) {
@@ -2089,13 +2371,29 @@ export function childrenToDelegationEntries(
2089
2371
  if (child.id === '' || seen.has(child.id)) continue
2090
2372
  seen.add(child.id)
2091
2373
  const mdEntry = byTaskID.get(child.id)
2374
+ // Recency window (source-level): the children API returns EVERY child the
2375
+ // session ever spawned, so without this the whole session history enters
2376
+ // the list (the "197 children" panel). Effective recency = the latest of
2377
+ // the child's own activity and its matched report's Finalized timestamp;
2378
+ // a child with NO timestamp at all is kept (fail-open — it cannot be
2379
+ // judged). Oldest rows are the ones dropped.
2380
+ const lastActivity = Math.max(
2381
+ child.time?.updated ?? 0,
2382
+ child.time?.created ?? 0,
2383
+ mdEntry?.updatedAt ?? 0,
2384
+ )
2385
+ if (lastActivity > 0 && now - lastActivity > DELEGATION_CHILDREN_RECENCY_MS) continue
2092
2386
  const state =
2093
- mdEntry !== undefined && mdEntry.state !== 'running'
2387
+ mdEntry !== undefined && mdEntry.state !== 'running' && mdTerminalCoversChild(mdEntry, child)
2094
2388
  ? mdEntry.state
2095
- : childStatusToState(child.status)
2389
+ : childStatusToState(child.status, child.time, now)
2096
2390
  out.push({
2097
- alias: mdEntry?.alias ?? 'native-task',
2098
- sessionID: mdEntry?.sessionID ?? '',
2391
+ alias: mdEntry?.alias ?? `native-${child.id.slice(-4)}`,
2392
+ // A report-less child IS a child of the focused session
2393
+ // (session.children is session-scoped), so the caller-passed
2394
+ // parentSessionID is stamped on it as its origin session; an md
2395
+ // match always carries its own session (the report dir name).
2396
+ sessionID: mdEntry?.sessionID ?? parentSessionID,
2099
2397
  taskID: child.id,
2100
2398
  agent: mdEntry?.agent ?? child.agent ?? 'agent',
2101
2399
  state,
@@ -2123,17 +2421,46 @@ export function childrenToDelegationEntries(
2123
2421
  * server-valid session id ("ses...") ever reaches the router, so an
2124
2422
  * unsubstituted "{sessionID}" placeholder can never be routed. */
2125
2423
  export function navigateToDelegationSession(
2126
- route: { navigate?: (name: string, params?: Record<string, unknown>) => void } | undefined,
2424
+ route:
2425
+ | {
2426
+ navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
2427
+ }
2428
+ | undefined,
2127
2429
  taskID: string | undefined,
2128
2430
  ): boolean {
2129
2431
  if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
2130
- route.navigate('session', { sessionID: taskID })
2131
- return true
2432
+ try {
2433
+ const navigation = route.navigate('session', { sessionID: taskID })
2434
+ if (navigation !== undefined) {
2435
+ // A removed session can reject route.navigate for historical rows. That
2436
+ // rejection is expected and intentionally absorbed so it cannot break
2437
+ // the TUI; the no-op avoids logging sensitive session details.
2438
+ void Promise.resolve(navigation).catch(() => undefined)
2439
+ }
2440
+ return true
2441
+ } catch {
2442
+ // Synchronous router failures are also fail-open for an inert historical row.
2443
+ return false
2444
+ }
2445
+ }
2446
+
2447
+ /** Build the mouse handler used by each delegation row. */
2448
+ export function createDelegationRowOpenHandler(
2449
+ route:
2450
+ | {
2451
+ navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
2452
+ }
2453
+ | undefined,
2454
+ taskID: string | undefined,
2455
+ ): () => void {
2456
+ return () => {
2457
+ navigateToDelegationSession(route, taskID)
2458
+ }
2132
2459
  }
2133
2460
 
2134
2461
  /* ─── 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):
2462
+ * Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
2463
+ * ships only dist/ + package.json, so it cannot import from src/pantheon):
2137
2464
  * every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
2138
2465
  * echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
2139
2466
  * renders plugin console output directly into the TUI — the pollution bug).
@@ -2175,8 +2502,19 @@ function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui
2175
2502
  `${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
2176
2503
  'utf8',
2177
2504
  )
2178
- } catch {
2179
- /* best-effort file log — never break the panel over logging */
2505
+ } catch (err) {
2506
+ /* best-effort file log — never break the panel over logging, but do
2507
+ * NOT swallow the failure silently: a missing symbol here (e.g.
2508
+ * `appendFile` used without an import) previously threw an invisible
2509
+ * ReferenceError and the log silently stopped writing. Gated by the
2510
+ * echo flag to avoid the TUI-pollution bug described above. */
2511
+ if (echo) {
2512
+ try {
2513
+ process.stderr.write(`[${module}] file-log failed: ${formatArg(err)}\n`)
2514
+ } catch {
2515
+ /* stderr unavailable — never break the panel */
2516
+ }
2517
+ }
2180
2518
  }
2181
2519
  })()
2182
2520
  if (echo) {
@@ -2229,53 +2567,32 @@ function DelegationRow(props: {
2229
2567
  api: TuiPluginApi
2230
2568
  job: DelegationEntry
2231
2569
  now: number
2232
- animationNow: number
2570
+ frame: number
2233
2571
  }) {
2234
2572
  const theme = () => props.api.theme.current
2235
2573
 
2236
- const color = createMemo(() => {
2574
+ // Color is a status-wide channel: the WHOLE row (lead, elapsed, description,
2575
+ // live activity) carries the state tone, so red=failed / green=done /
2576
+ // yellow=in-flight reads at a glance. The glyph stays as a redundant,
2577
+ // monochrome-legible signal — never the only one.
2578
+ const tone = createMemo(() => {
2237
2579
  const t = theme()
2238
- switch (props.job.state) {
2239
- case 'running':
2580
+ switch (delegationStateTone(props.job.state)) {
2581
+ case 'warning':
2240
2582
  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
2583
  case 'error':
2248
2584
  return t.error
2585
+ case 'success':
2586
+ return t.success
2249
2587
  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 '
2588
+ return t.textMuted // defensive: unknown runtime state
2265
2589
  }
2266
2590
  })
2267
2591
 
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
- })
2592
+ const marker = createMemo(() => delegationRowMarker(props.job.state, props.frame))
2593
+ const lead = createMemo(() => formatDelegationRowLead(props.job, marker()))
2594
+ const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
2595
+ const description = createMemo(() => truncateDelegationDescription(props.job.description))
2279
2596
 
2280
2597
  // beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
2281
2598
  // fed by message.part.updated activity tracking. Null while idle/done.
@@ -2286,25 +2603,26 @@ function DelegationRow(props: {
2286
2603
  // Click a row → navigate to the child session (route API guarded in the
2287
2604
  // helper). Enter-key binding is left out (per-row keymap wiring is not
2288
2605
  // worth the complexity in this sidebar); mouse is enabled in tui.json.
2289
- const open = () => {
2290
- navigateToDelegationSession(props.api.route, props.job.taskID)
2291
- }
2606
+ const open = createDelegationRowOpenHandler(props.api.route, props.job.taskID)
2292
2607
 
2293
2608
  return (
2294
2609
  <box onMouseDown={open}>
2610
+ {/* Line 1: toned lead (glyph + identity) + toned elapsed tail. */}
2295
2611
  <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>
2612
+ <text>
2613
+ <span fg={tone()}>{lead()}</span>
2614
+ <span fg={tone()}>{elapsed()}</span>
2615
+ </text>
2301
2616
  </box>
2302
- <text fg={theme().textMuted}>{detail()}</text>
2617
+ {/* Line 2: the description, indented under the identity and toned. */}
2618
+ <Show when={description() !== ''}>
2619
+ <text fg={tone()}>{` ${description()}`}</text>
2620
+ </Show>
2303
2621
  <Show when={activity()}>
2304
2622
  {(a) => (
2305
2623
  <text
2306
- fg={theme().textMuted}
2307
- >{` \u21b3 ${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`}</text>
2624
+ fg={tone()}
2625
+ >{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2308
2626
  )}
2309
2627
  </Show>
2310
2628
  </box>
@@ -2407,20 +2725,22 @@ function View(props: {
2407
2725
 
2408
2726
  // ── Delegations: session.children (PRIMARY) + md reports (enrichment) ──
2409
2727
  // The panel's source of truth is the SDK child-session API
2410
- // (`api.client.session.children`) — every pantheon_delegate spawns a child
2728
+ // (`api.client.session.children`) — every native task() call spawns a child
2411
2729
  // session with parentID = this session, so children ARE the delegation
2412
2730
  // list (delegations-sidebar pattern). The md channel
2413
2731
  // (.pantheon/delegations) enriches each child with alias/agent/description
2414
2732
  // and terminal duration via the `Task ID` match. Live tool-call events are
2415
2733
  // merged optimistically so the row appears before those channels catch up.
2416
- const delegationsDir = createMemo(() => resolveDelegationsDir((props.api.state as any).path))
2734
+ // Consistent project root (directory ?? worktree, cwd fallback) — the md
2735
+ // history and the panel logger both read the SAME root (audit finding: the
2736
+ // channels resolved it independently).
2737
+ const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
2417
2738
  // 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()))
2739
+ const panelLog = createTuiLogger(projectRoot())
2740
+ // ONLY the active session renders: the final list is filtered by
2741
+ // filterDelegationsToSession after every merge (children + live + md).
2742
+ // Live-first ordering keeps running rows on top; the ceiling
2743
+ // collapses the rest into "… +N more".
2424
2744
  // The rendered list is childDelegations (children enriched with md);
2425
2745
  // there is no separate md-only signal — the md channel only feeds
2426
2746
  // childrenToDelegationEntries below.
@@ -2429,9 +2749,9 @@ function View(props: {
2429
2749
  // (oh-my-opencode-slim pattern): re-fetch children + re-read the md every
2430
2750
  // second so the panel updates even when NO event ever fires.
2431
2751
  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())
2752
+ // Frame counter for the spinner animation — incremented every ~80ms by a
2753
+ // dedicated fast timer, completely decoupled from the data poll cycle.
2754
+ const [frame, setFrame] = createSignal(0)
2435
2755
  // Cumulative count of event-triggered refreshes — logged with every fetch
2436
2756
  // ("panel: children=N(pantheon=M native=K) md=N events=N") so the (0)
2437
2757
  // symptom is diagnosable: children>0 means the source works; events=0
@@ -2445,15 +2765,17 @@ function View(props: {
2445
2765
  if (delegationsInflight !== null) return delegationsInflight
2446
2766
  delegationsInflight = (async () => {
2447
2767
  try {
2448
- const state = props.api.state as any
2768
+ // Typed (no `as any`): TuiState.session.status is a v2 sync accessor
2769
+ // taking the session id directly — same shape as the client call.
2770
+ const state = props.api.state
2449
2771
  // 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 [].
2772
+ // ENRICHMENT channel. Read unfiltered (taskID match supplies alias/
2773
+ // agent/description/terminal state); the active-session filter after
2774
+ // the merge keeps only the focused session's reports. Fail-open: a
2775
+ // missing dir yields [].
2454
2776
  let md: DelegationEntry[] = []
2455
2777
  try {
2456
- md = await readAllDelegationEntries(panelLogDir(delegationsDir()))
2778
+ md = await readAllDelegationEntries(projectRoot())
2457
2779
  } catch {
2458
2780
  md = [] // fail-open: never crash the sidebar on a read error
2459
2781
  }
@@ -2462,24 +2784,32 @@ function View(props: {
2462
2784
  // slot prop; forwarding it to session.children made the server reject
2463
2785
  // every poll (~1 err/s: "Expected a string starting with \"ses\", got
2464
2786
  // \"%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).
2787
+ // resolution SKIPS the fetch entirely: the active-session filter yields
2788
+ // an EMPTY panel (zero children, zero rows, zero errors).
2467
2789
  const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
2468
2790
  if (sessionID === null) {
2469
- setChildDelegations(md)
2791
+ // No focused session → no scope → empty list. The md read still ran
2792
+ // (enrichment source) but with no session there is nothing to scope
2793
+ // to: there is no cross-session panel.
2794
+ setChildDelegations([])
2470
2795
  panelLog.info(
2471
- `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
2796
+ `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — inactive panel)`,
2472
2797
  )
2473
2798
  return
2474
2799
  }
2475
2800
  // 2. children — PRIMARY source (SDK: api.client.session.children).
2801
+ // v2 SDK: `children` takes a FLAT `{ sessionID }` parameter object.
2802
+ // The v1 `{ path: { id } }` envelope is NOT understood by the v2
2803
+ // client — it left the URL template unsubstituted and the server
2804
+ // rejected every poll ("%7BsessionID%7D" regression).
2476
2805
  let children: ChildDelegationLike[] = []
2477
2806
  try {
2478
2807
  // buildChildrenPath re-validates: with sessionID !== null it is
2479
2808
  // never null, so no placeholder can reach the wire.
2480
- const result = await (props.api.client as any)?.session?.children?.(
2481
- buildChildrenPath(sessionID),
2482
- )
2809
+ const childrenPath = buildChildrenPath(sessionID)
2810
+ const result = childrenPath
2811
+ ? await props.api.client.session.children(childrenPath)
2812
+ : undefined
2483
2813
  const data = (result?.data ?? result) as unknown
2484
2814
  children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
2485
2815
  } catch (err) {
@@ -2493,13 +2823,16 @@ function View(props: {
2493
2823
  // path — "%7BsessionID%7D").
2494
2824
  if (!isValidSessionId(childID)) return undefined
2495
2825
  try {
2496
- return state?.session?.status?.(childID)?.type as string | undefined
2826
+ return state.session.status(childID)?.type
2497
2827
  } catch {
2498
2828
  return undefined // status API unavailable — child state fallback
2499
2829
  }
2500
2830
  }
2501
2831
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2502
- const childEntries = childrenToDelegationEntries(withStatus, md, now())
2832
+ // Report-less rows ARE children of the focused session
2833
+ // (session.children is session-scoped), so the resolved id is stamped on
2834
+ // the row as its origin session.
2835
+ const childEntries = childrenToDelegationEntries(withStatus, md, now(), sessionID)
2503
2836
  for (const [k, v] of props.liveStore.map)
2504
2837
  if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
2505
2838
  props.liveStore.map.delete(k)
@@ -2509,7 +2842,16 @@ function View(props: {
2509
2842
  const liveEntries = [...props.liveStore.map.values()].filter(
2510
2843
  (entry) => entry.sessionID === sessionID,
2511
2844
  )
2512
- setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
2845
+ // The event channel is optimistic; merge it over the children/md rows.
2846
+ // Then the active-session filter — applied ONCE after the merge — keeps
2847
+ // ONLY rows whose sessionID === the focused session, so md entries from
2848
+ // other sessions (and orphans with no sessionID) can never render.
2849
+ setChildDelegations(
2850
+ filterDelegationsToSession(
2851
+ mergeChildDelegationSources(childEntries, liveEntries),
2852
+ sessionID,
2853
+ ),
2854
+ )
2513
2855
  // 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
2514
2856
  const counts = countDelegationSources(childEntries)
2515
2857
  panelLog.info(
@@ -2527,23 +2869,16 @@ function View(props: {
2527
2869
  })()
2528
2870
  return delegationsInflight
2529
2871
  }
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
- }
2872
+ // Live-first ceiling: at most DELEGATION_VISIBLE_CEILING rows render; the
2873
+ // remainder collapses into a single "… +N more" line. Both the ceiling and
2874
+ // the header operate on the ALREADY-FILTERED active-session list
2875
+ // (childDelegations), so other-session / hidden rows never inflate counts.
2876
+ const delegationCeiling = createMemo(() =>
2877
+ ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
2878
+ )
2879
+ // Header counts the same retained rows shown below, so hidden/expired md
2880
+ // history cannot inflate the summary.
2881
+ const delegationHeader = createMemo(() => formatDelegationHeader(delegationCeiling().visible))
2547
2882
 
2548
2883
  // ── Event subscriptions for live session/delegation updates ──
2549
2884
  onMount(() => {
@@ -2591,7 +2926,9 @@ function View(props: {
2591
2926
  void refreshDelegations()
2592
2927
  }, 1_000)
2593
2928
  cleanup.push(() => clearInterval(poll))
2594
- const animation = setInterval(() => setAnimationNow(Date.now()), 140)
2929
+ // Fast spinner animation: 80ms frame counter, decoupled from data poll.
2930
+ // Only the spinner glyph depends on this; elapsed + data use the 1s timer.
2931
+ const animation = setInterval(() => setFrame((f) => f + 1), 80)
2595
2932
  cleanup.push(() => clearInterval(animation))
2596
2933
 
2597
2934
  // Compaction recovery: `message.part.removed` clears live entries while
@@ -2688,13 +3025,13 @@ function View(props: {
2688
3025
  <text fg={theme().text} attributes={1}>
2689
3026
  {`${showDelegations() ? '▼' : '▶'} Delegations`}
2690
3027
  </text>
2691
- <text
2692
- fg={theme().textMuted}
2693
- >{` (${String(delegationCounts().native)} native + ${String(delegationCounts().pantheon)} pantheon)`}</text>
3028
+ <text fg={theme().textMuted}>
3029
+ {delegationCeiling().visible.length > 0 ? ` ${delegationHeader()}` : ' — idle'}
3030
+ </text>
2694
3031
  </box>
2695
3032
  <Show when={showDelegations()}>
2696
3033
  <Show
2697
- when={visibleDelegations().length > 0}
3034
+ when={delegationCeiling().visible.length > 0}
2698
3035
  fallback={
2699
3036
  <box marginLeft={1}>
2700
3037
  <text fg={theme().textMuted}>No delegations</text>
@@ -2702,50 +3039,15 @@ function View(props: {
2702
3039
  }
2703
3040
  >
2704
3041
  <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
- )}
3042
+ <For each={delegationCeiling().visible}>
3043
+ {(job) => <DelegationRow api={props.api} job={job} now={now()} frame={frame()} />}
2714
3044
  </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
- })()}
3045
+ <Show when={delegationCeiling().hidden > 0}>
3046
+ <text fg={theme().textMuted}>
3047
+ {delegationCeiling().hiddenActive > 0
3048
+ ? `… +${delegationCeiling().hiddenActive} active`
3049
+ : `… +${delegationCeiling().hiddenTerminal} more`}
3050
+ </text>
2749
3051
  </Show>
2750
3052
  </box>
2751
3053
  </Show>
@@ -2770,16 +3072,15 @@ const tui: TuiPlugin = (api, _options, _meta) => {
2770
3072
  // Vendored feature (MIT): AI subscription usage gauges.
2771
3073
  void setupUsageBar(api) // async init — never blocks sidebar registration
2772
3074
 
2773
- // ── Live delegation store (legacy tool-part tracker, kept as a REFRESH
3075
+ // ── Live delegation store (tool-part tracker, kept as a REFRESH
2774
3076
  // TRIGGER) ──
2775
3077
  // The Delegations panel's PRIMARY source is `api.client.session.children`
2776
3078
  // (delegations-sidebar pattern) — `message.part.updated` may not fire for
2777
- // pantheon_delegate on runtime 1.18.13 (the "(0)" symptom). The tool-part
2778
- // lifecycle below still tracks pantheon_delegate / pantheon_delegation_read
2779
- // parts and bumps a version signal the View subscribes to: when part
2780
- // events DO arrive, the panel re-fetches children. Fail-open: if the
2781
- // events API is unavailable nothing subscribes and the 1s safety poll
2782
- // keeps the panel working (never crash).
3079
+ // the native task tool on runtime 1.18.13 (the "(0)" symptom). The
3080
+ // tool-part lifecycle below still tracks `task` parts and bumps a version
3081
+ // signal the View subscribes to: when part events DO arrive, the panel
3082
+ // re-fetches children. Fail-open: if the events API is unavailable nothing
3083
+ // subscribes and the 1s safety poll keeps the panel working (never crash).
2783
3084
  const [liveVersion, setLiveVersion] = createSignal(0)
2784
3085
  const liveStore: LiveDelegationStore = {
2785
3086
  map: new Map<string, LiveDelegationEntry>(),