pantheon-opencode 1.5.0-beta.8 → 1.5.0

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