pantheon-opencode 1.4.3 → 1.5.0-beta.10

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 (118) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/manifest.json +21 -0
  12. package/.pantheon/code-mode/session-end-save.py +186 -0
  13. package/.pantheon/code-mode/session-end-save.sh +18 -0
  14. package/.pantheon/code-mode/test-allowed.sh +5 -0
  15. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  16. package/.pantheon/code-mode/test-env.sh +7 -0
  17. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  18. package/.pantheon/code-mode/test-timeout.sh +6 -0
  19. package/CHANGELOG.md +1026 -0
  20. package/README.md +292 -1080
  21. package/README.pt-BR.md +308 -0
  22. package/bin/pantheon-init.mjs +207 -31
  23. package/opencode.json +1 -0
  24. package/package.json +34 -14
  25. package/plugin.json +15 -0
  26. package/pyproject.toml +65 -0
  27. package/scripts/_pantheon_paths.py +48 -7
  28. package/scripts/ci-validate-yaml.py +7 -2
  29. package/scripts/code_mode_server.py +451 -44
  30. package/scripts/cost.mjs +30 -14
  31. package/scripts/doctor.mjs +306 -129
  32. package/scripts/generate-preset-docs.mjs +127 -66
  33. package/scripts/generate-prompts.sh +2 -2
  34. package/scripts/install/cli-ui.mjs +23 -10
  35. package/scripts/install/config-migration.mjs +399 -0
  36. package/scripts/install/health-check.mjs +3 -3
  37. package/scripts/install/model-picker.mjs +72 -23
  38. package/scripts/install/opencode-version.mjs +47 -0
  39. package/scripts/install/opencode.mjs +396 -121
  40. package/scripts/install/plugin.mjs +89 -49
  41. package/scripts/install/shared.mjs +55 -159
  42. package/scripts/install/strings.mjs +129 -0
  43. package/scripts/install-mcp.mjs +45 -232
  44. package/scripts/manifest-inventory.mjs +410 -0
  45. package/scripts/mcp_persistence_server.py +798 -60
  46. package/scripts/mcp_resources_server.py +144 -40
  47. package/scripts/memory_mcp_server.py +2 -2
  48. package/scripts/package-evidence.mjs +242 -0
  49. package/scripts/probe-context-rehydrate.mjs +240 -0
  50. package/scripts/probe-pantheon-cost.mjs +313 -0
  51. package/scripts/recover-zenodo-deposition.mjs +146 -0
  52. package/scripts/release-beta-version.mjs +56 -60
  53. package/scripts/sync-artifacts.mjs +169 -0
  54. package/scripts/sync-tui.mjs +135 -42
  55. package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
  56. package/scripts/uninstall.mjs +298 -954
  57. package/scripts/validate-zenodo-release.mjs +154 -0
  58. package/scripts/validation-policy.mjs +7 -7
  59. package/scripts/version-check.mjs +113 -108
  60. package/scripts/versioning.mjs +230 -86
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/agents/mnemosyne.md +1 -1
  64. package/src/agents/zeus.md +27 -16
  65. package/src/mcp/_pantheon_paths.py +48 -7
  66. package/src/mcp/code_mode_server.py +451 -44
  67. package/src/mcp/eval_store.py +1 -1
  68. package/src/mcp/mcp_persistence_server.py +798 -60
  69. package/src/mcp/mcp_resources_server.py +144 -40
  70. package/src/mcp/pantheon_vision_server.py +1 -1
  71. package/src/mcp/toon_codec.py +491 -0
  72. package/src/pantheon/auto-wake.ts +1 -5
  73. package/src/pantheon/background-job-board.ts +110 -2
  74. package/src/pantheon/compaction-handoff.ts +304 -0
  75. package/src/pantheon/cost-command.ts +356 -69
  76. package/src/pantheon/delegate-manager.ts +766 -0
  77. package/src/pantheon/delegation-classifier.ts +307 -0
  78. package/src/pantheon/delegation-enforce.ts +13 -12
  79. package/src/pantheon/delegation-finalize.ts +406 -0
  80. package/src/pantheon/delegation-notify.ts +212 -0
  81. package/src/pantheon/delegation-toast.ts +73 -0
  82. package/src/pantheon/delegation.ts +1337 -0
  83. package/src/pantheon/file-persistence-salvage.ts +64 -0
  84. package/src/pantheon/file-persistence.ts +194 -45
  85. package/src/pantheon/goal-loop.ts +2 -2
  86. package/src/pantheon/goal-store.ts +1 -1
  87. package/src/pantheon/hashline/tool.ts +2 -2
  88. package/src/pantheon/model-command.ts +2 -1
  89. package/src/pantheon/native-probe.ts +234 -0
  90. package/src/pantheon/native-task-status.ts +139 -0
  91. package/src/pantheon/permission-eval.ts +284 -0
  92. package/src/pantheon/provider-capabilities.ts +221 -0
  93. package/src/pantheon/provider-stream-tracker.ts +182 -0
  94. package/src/pantheon/shared-board.ts +37 -0
  95. package/src/pantheon/step-cap.ts +110 -0
  96. package/src/pantheon/task-result-guard.ts +107 -0
  97. package/src/pantheon/token-opt.ts +689 -0
  98. package/src/pantheon/tool-ceiling.ts +159 -0
  99. package/src/pantheon/toon-codec.ts +481 -0
  100. package/src/pantheon/tui-version-check.ts +1 -3
  101. package/src/pantheon/v2-bridge.ts +129 -0
  102. package/src/pantheon/v2-events.ts +148 -0
  103. package/src/pantheon/v2-hooks.ts +287 -0
  104. package/src/pantheon/v2-tool-definitions.ts +331 -0
  105. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  106. package/src/plugin-v2/index.ts +21 -0
  107. package/src/plugin-v2.ts +824 -0
  108. package/src/plugin.ts +261 -51
  109. package/src/plugins/pantheon-hooks.ts +51 -0
  110. package/src/plugins/tui/dist/tui.d.ts +116 -24
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +416 -149
  113. package/src/plugins/tui/dist/tui.tsx +456 -87
  114. package/src/plugins/tui/package-lock.json +51 -51
  115. package/src/plugins/tui/package.json +7 -7
  116. package/src/plugins/tui/src/index.tsx +462 -95
  117. package/src/skills/README.md +0 -2
  118. package/src/pantheon/tool-context.ts +0 -41
@@ -47,7 +47,7 @@
47
47
 
48
48
  import { Buffer } from 'node:buffer'
49
49
  import type { Dirent } from 'node:fs'
50
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
50
+ import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
51
51
  import { homedir } from 'node:os'
52
52
  import { dirname, join } from 'node:path'
53
53
  import { fileURLToPath } from 'node:url'
@@ -892,23 +892,22 @@ async function setupUsageBar(api: TuiPluginApi) {
892
892
  * (`client.session.children` + `session.parentID === current`).
893
893
  * Live status comes from `api.state.session.status(childID)`
894
894
  * (busy/retry → running; idle → terminal). This does NOT depend on
895
- * tool-part events, which runtime 1.18.13 may not deliver for
896
- * pantheon_delegate (the "(0) even while delegating" symptom).
895
+ * tool-part events, which runtime 1.18.13 may not deliver for
896
+ * pantheon_delegate (the "(0) even while delegating" symptom).
897
897
  *
898
898
  * 1b. NATIVE task() CHILDREN — `session.children` returns ALL children,
899
899
  * and the native `task()` tool ALSO spawns a child session with
900
900
  * parentID = caller. A child WITHOUT a board report is a native
901
901
  * task() child: childrenToDelegationEntries tags it source
902
- * 'children-only' with the `[task]` row tag (distinct info color),
902
+ * 'children-only' with the `nat:` row tag (distinct info color),
903
903
  * so native task() work is visible in the panel alongside board
904
- * delegates ([apo-1]) — never filtered out, never duplicated with a
905
- * board row for the same child (a child WITH a report keeps the md
906
- * entry, source 'md').
904
+ * delegates (`pan:apo-1`) — never filtered out, never duplicated
905
+ * with a board row for the same child (a child WITH a report keeps the
906
+ * md entry, source 'md').
907
907
  *
908
- * 2. ENRICHMENT + HISTORY — markdown reports under
909
- * `.pantheon/delegations/<sessionID>/<alias>.md`. These are LEGACY files
910
- * (the writer, src/pantheon/delegation-finalize.ts, was removed); the
911
- * panel still reads them for history. Each child
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
912
911
  * is matched to its report by the `Task ID` header (== child session
913
912
  * id == board task id), which supplies the alias (apo-N), agent,
914
913
  * description and terminal duration/timedOut. No report → the child
@@ -922,9 +921,12 @@ async function setupUsageBar(api: TuiPluginApi) {
922
921
  * children; a 1s safety poll (oh-my-opencode-slim pattern) re-fetches
923
922
  * children + re-reads the md so the panel updates EVEN without events.
924
923
  *
925
- * 4. DIAGNOSTICS — every re-fetch logs "panel: children=N md=N events=N"
924
+ * 4. DIAGNOSTICS — every re-fetch logs
925
+ * "panel: children=N(pantheon=M native=K) md=N events=N"
926
926
  * to `.pantheon/logs/hooks.log` (silence-by-default, createPantheonLogger
927
- * policy) so the (0) symptom is diagnosable from disk. A children fetch
927
+ * policy) so the (0) symptom is diagnosable from disk: children>0 means
928
+ * the source works, pantheon/native splits board vs task() children.
929
+ * A children fetch
928
930
  * failure logs "panel: error <msg>" (previously silenced), and a missing
929
931
  * current session (null/placeholder sessionID) logs "panel: children=0
930
932
  * md=N ... (no sessionID — history shown)" — the md HISTORY renders
@@ -960,6 +962,7 @@ export type DelegationEntry = {
960
962
  agent: string
961
963
  state:
962
964
  | 'running'
965
+ | 'retry'
963
966
  | 'completed'
964
967
  | 'error'
965
968
  | 'startup_failed'
@@ -974,9 +977,13 @@ export type DelegationEntry = {
974
977
  description: string
975
978
  /** True while the panel is waiting for pantheon_delegation_read. */
976
979
  read?: boolean
980
+ // NOTE: the nested/tree view is BACKLOG. `session.children` returns direct
981
+ // children only, so no real grandchild rows exist yet; the parentTaskID /
982
+ // childCount fields + tree-connector helpers were removed as dead code.
983
+ // Reintroduce them once nested rows are actually produced.
977
984
  /** Internal provenance used to keep a finalized md report authoritative.
978
985
  * 'children-only' = a native task() child session with NO board report
979
- * (rendered with the distinct `[task]` tag); 'md' = board report wins. */
986
+ * (rendered with the distinct `nat:` prefix); 'md' = board report wins. */
980
987
  source?: 'child' | 'live' | 'md' | 'children-only'
981
988
  }
982
989
 
@@ -1152,8 +1159,7 @@ export async function readDelegationEntries(dir: string): Promise<DelegationEntr
1152
1159
 
1153
1160
  /** Read delegation reports from EVERY session under
1154
1161
  * `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
1155
- * HISTORY channel (reads legacy files; the writer, delegation-finalize.ts,
1156
- * was removed). Unlike the children/live channels it does NOT depend on a
1162
+ * HISTORY channel. Unlike the children/live channels it does NOT depend on a
1157
1163
  * resolved sessionID: with no focused session (null/placeholder), the panel
1158
1164
  * still shows the reports from all past sessions (running first, Finalized
1159
1165
  * desc — the sort applied by readDelegationEntries). Fail-open: a
@@ -1162,8 +1168,8 @@ export async function readAllDelegationEntries(root: string): Promise<Delegation
1162
1168
  return readDelegationEntries(join(root, '.pantheon', 'delegations'))
1163
1169
  }
1164
1170
 
1165
- /** Resolve the directory holding delegation md reports.
1166
- * The reports live under `.pantheon/delegations` RELATIVE to the server cwd,
1171
+ /** Resolve the directory where the job board writes delegation md reports.
1172
+ * The board writes `.pantheon/delegations` RELATIVE to the server cwd,
1167
1173
  * which the TUI exposes as `TuiState.path.directory`. `project` does NOT
1168
1174
  * exist on `TuiState.path` (the old `state?.project ?? state?.worktree`
1169
1175
  * resolution was always undefined for the first term) and `worktree` is
@@ -1197,27 +1203,52 @@ export function tuiLogPath(projectRoot: string): string {
1197
1203
  * falling back to startedAt, descending). Shared by the md reader and
1198
1204
  * mergeDelegationSources. */
1199
1205
  export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
1200
- const aRun = a.state === 'running' || a.state === 'stale-running' ? 1 : 0
1201
- const bRun = b.state === 'running' || b.state === 'stale-running' ? 1 : 0
1206
+ const isActive = (st: DelegationEntry['state']) =>
1207
+ st === 'running' || st === 'retry' || st === 'stale-running' ? 1 : 0
1208
+ const aRun = isActive(a.state)
1209
+ const bRun = isActive(b.state)
1202
1210
  if (aRun !== bRun) return bRun - aRun
1203
1211
  return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
1204
1212
  }
1205
1213
 
1206
- /** The list the panel actually renders: running jobs first, then the most
1207
- * recent terminal reports (capped). Pure — so the history-only panel (no
1208
- * sessionID) is testable without the TUI runtime. The header count uses the
1209
- * same "running + recentes" list. */
1214
+ /** Split the panel list: active jobs (running/retry, stale-marked) first,
1215
+ * then the most recent terminal reports, then the archived tail (paginated
1216
+ * in the View). Pure — so the history-only panel (no sessionID) is testable
1217
+ * without the TUI runtime. */
1218
+ export function splitDelegationList(
1219
+ all: readonly DelegationEntry[],
1220
+ maxRecent = 8,
1221
+ now = Date.now(),
1222
+ staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1223
+ ): { active: DelegationEntry[]; recent: DelegationEntry[]; archived: DelegationEntry[] } {
1224
+ // Active = running OR retry OR the display-only stale-running. A stale row
1225
+ // is still a live job (backend state unchanged), so it must never fall into
1226
+ // the terminal/archived tail — archiving a running delegation hid the row
1227
+ // the user launched.
1228
+ const isActiveState = (st: DelegationEntry['state']): boolean =>
1229
+ st === 'running' || st === 'retry' || st === 'stale-running'
1230
+ const active = all
1231
+ .filter((d) => isActiveState(d.state))
1232
+ .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1233
+ const terminal = all.filter((d) => !isActiveState(d.state))
1234
+ return {
1235
+ active,
1236
+ recent: terminal.slice(0, maxRecent),
1237
+ archived: terminal.slice(maxRecent),
1238
+ }
1239
+ }
1240
+
1241
+ /** The list the panel actually renders (kept for the header count and
1242
+ * existing tests): active jobs first, then the most recent terminal
1243
+ * reports (capped) — the archived tail is rendered separately. Pure. */
1210
1244
  export function visibleDelegationList(
1211
1245
  all: readonly DelegationEntry[],
1212
1246
  maxTerminal = 8,
1213
1247
  now = Date.now(),
1214
1248
  staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1215
1249
  ): DelegationEntry[] {
1216
- const running = all
1217
- .filter((d) => d.state === 'running')
1218
- .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1219
- const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
1220
- return [...running, ...terminal]
1250
+ const { active, recent } = splitDelegationList(all, maxTerminal, now, staleThresholdMs)
1251
+ return [...active, ...recent]
1221
1252
  }
1222
1253
 
1223
1254
  /** Default stale-running threshold: 30 minutes. */
@@ -1263,10 +1294,11 @@ export function fmtElapsed(ms: number): string {
1263
1294
  return `${seconds}s`
1264
1295
  }
1265
1296
 
1266
- /** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
1267
- * → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
1297
+ /** Elapsed label for one entry: an ACTIVE entry (running/retry/stale-running)
1298
+ * ticks `now - startedAt`; a terminal one is fixed at
1299
+ * `updatedAt - startedAt` (em dash when no finalized timestamp). */
1268
1300
  export function delegationElapsed(entry: DelegationEntry, now: number): string {
1269
- if (entry.state === 'running' || entry.state === 'stale-running')
1301
+ if (entry.state === 'running' || entry.state === 'retry' || entry.state === 'stale-running')
1270
1302
  return fmtElapsed(now - entry.startedAt)
1271
1303
  return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
1272
1304
  }
@@ -1296,6 +1328,7 @@ export function delegationActivity(entry: DelegationEntry): DelegationActivity {
1296
1328
  }
1297
1329
 
1298
1330
  export function delegationActivityLabel(entry: DelegationEntry): string {
1331
+ if (entry.state === 'retry') return 'RETRYING'
1299
1332
  switch (delegationActivity(entry)) {
1300
1333
  case 'delegating':
1301
1334
  return 'DELEGATING'
@@ -1314,12 +1347,96 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
1314
1347
 
1315
1348
  const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
1316
1349
 
1317
- /** Return a deterministic spinner frame. The View ticks this every 140ms. */
1350
+ /** Return a deterministic spinner frame. The View ticks this every 1000ms
1351
+ * (not 140ms — the fast tick flickered without adding information). */
1318
1352
  export function delegationSpinnerFrame(now: number): string {
1319
- const index = Math.floor(Math.max(0, now) / 140) % DELEGATION_SPINNER_FRAMES.length
1353
+ const index = Math.floor(Math.max(0, now) / 1_000) % DELEGATION_SPINNER_FRAMES.length
1320
1354
  return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
1321
1355
  }
1322
1356
 
1357
+ /* ─── Live tool activity per child session (beta.6) ────────────────────────
1358
+ * The delegation panel's activity line ("↳ bash npm test") — what the agent
1359
+ * is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
1360
+ * pending/running tool part updates a per-child-session record (latest wins).
1361
+ * Module-level by design: the sidebar remounts on route changes and the
1362
+ * "what is each agent doing" state should survive that (OMO pattern). */
1363
+
1364
+ export type ToolActivity = {
1365
+ tool: string
1366
+ summary: string
1367
+ at: number
1368
+ }
1369
+
1370
+ /** Bounded tracker: child session id → latest running tool call. */
1371
+ const latestToolActivity = new Map<string, ToolActivity>()
1372
+ const MAX_TOOL_ACTIVITY_ENTRIES = 200
1373
+ const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
1374
+
1375
+ /** Archived-section pagination state — module-level so it survives sidebar
1376
+ * remounts (route changes) without leaking into the component tree. */
1377
+ const archivedUiState = { page: 0 }
1378
+
1379
+ /** First usable summary string from common tool input fields. */
1380
+ function summarizeToolInput(input: Record<string, unknown> | undefined): string {
1381
+ if (!input) return ''
1382
+ const candidates = ['command', 'description', 'prompt', 'filePath', 'pattern', 'url', 'query']
1383
+ for (const key of candidates) {
1384
+ const value = input[key]
1385
+ if (typeof value === 'string' && value.trim() !== '') return value
1386
+ }
1387
+ return ''
1388
+ }
1389
+
1390
+ /** Reduce one message.part.updated tool part to displayable activity.
1391
+ * Returns null for non-tool parts, completed/error parts (no live activity)
1392
+ * or parts without a session id. Pure. */
1393
+ export function extractToolActivity(
1394
+ part: {
1395
+ type?: string
1396
+ tool?: string
1397
+ sessionID?: string
1398
+ state?: { status?: string; input?: Record<string, unknown> }
1399
+ },
1400
+ now = Date.now(),
1401
+ ): { sessionID: string; activity: ToolActivity } | null {
1402
+ if (part?.type !== 'tool') return null
1403
+ const status = part.state?.status
1404
+ if (status !== 'pending' && status !== 'running') return null
1405
+ const sessionID = typeof part.sessionID === 'string' ? part.sessionID : ''
1406
+ if (sessionID === '') return null
1407
+ const tool = typeof part.tool === 'string' && part.tool !== '' ? part.tool : 'tool'
1408
+ const summary = summarizeToolInput(part.state?.input).replace(/\s+/g, ' ').trim().slice(0, 48)
1409
+ return { sessionID, activity: { tool, summary, at: now } }
1410
+ }
1411
+
1412
+ /** Record an activity sample (bounded map, oldest dropped). */
1413
+ export function trackToolActivity(
1414
+ map: Map<string, ToolActivity>,
1415
+ sessionID: string,
1416
+ activity: ToolActivity,
1417
+ ): void {
1418
+ if (sessionID === '') return
1419
+ if (map.size >= MAX_TOOL_ACTIVITY_ENTRIES) {
1420
+ const oldest = map.keys().next().value
1421
+ if (oldest !== undefined) map.delete(oldest)
1422
+ }
1423
+ map.set(sessionID, activity)
1424
+ }
1425
+
1426
+ /** Latest live activity for a child session, or null when absent/stale. */
1427
+ export function latestToolActivityFor(
1428
+ map: Map<string, ToolActivity>,
1429
+ sessionID: string | undefined,
1430
+ now = Date.now(),
1431
+ ttlMs = TOOL_ACTIVITY_TTL_MS,
1432
+ ): ToolActivity | null {
1433
+ if (!sessionID) return null
1434
+ const hit = map.get(sessionID)
1435
+ if (hit === undefined) return null
1436
+ if (now - hit.at > ttlMs) return null
1437
+ return hit
1438
+ }
1439
+
1323
1440
  /** Merge the immediate tool-event channel into the child-session channel.
1324
1441
  *
1325
1442
  * Children remain the durable source, while live entries make a delegation
@@ -1407,9 +1524,11 @@ export function mergeChildDelegationSources(
1407
1524
  * Source of truth for jobs born while opencode is open. The TUI event bus
1408
1525
  * (api.event.on) delivers `message.part.updated` for every part change;
1409
1526
  * we only care about tool parts whose `tool` is pantheon_delegate (job
1410
- * launch) or pantheon_delegation_read (blocking until terminal → closes
1411
- * the entry). Shape-adapted from the SDK v2 ToolPart but duck-typed so the
1412
- * pure helpers are testable without the SDK. */
1527
+ * launch), the native `task` subagent tool (same parentID === caller
1528
+ * mechanism — its rows render `nat:`), or pantheon_delegation_read
1529
+ * (blocking until terminal → closes the entry). Shape-adapted from the
1530
+ * SDK v2 ToolPart but duck-typed so the pure helpers are testable without
1531
+ * the SDK. */
1413
1532
 
1414
1533
  /** Duck-typed subset of a tool part (SDK v2 `ToolPart` / `ToolState`). */
1415
1534
  export type DelegationToolPart = {
@@ -1435,7 +1554,7 @@ export type LiveDelegationEntry = {
1435
1554
  partID: string
1436
1555
  /** Parent session the delegation was launched from. */
1437
1556
  sessionID: string
1438
- tool: 'pantheon_delegate' | 'pantheon_delegation_read'
1557
+ tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
1439
1558
  /** Agent name (from the delegate input args). */
1440
1559
  agent: string
1441
1560
  description: string
@@ -1455,7 +1574,7 @@ export type ParsedDelegationToolPart = {
1455
1574
  callID: string
1456
1575
  partID: string
1457
1576
  sessionID: string
1458
- tool: 'pantheon_delegate' | 'pantheon_delegation_read'
1577
+ tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
1459
1578
  /** null for read parts (no agent arg — the id targets an existing job). */
1460
1579
  agent: string | null
1461
1580
  description: string
@@ -1475,13 +1594,20 @@ const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
1475
1594
 
1476
1595
  /** Extract the tool name + args from a `message.part.updated` part and
1477
1596
  * reduce it to what the panel needs. Returns null for anything that is
1478
- * not a pantheon delegation tool part (or is missing its callID). */
1597
+ * not a pantheon delegation tool part, the native `task` subagent tool
1598
+ * (same parentID === caller mechanism — its children render `nat:`),
1599
+ * or is missing its callID. */
1479
1600
  export function parseDelegationToolPart(
1480
1601
  part: DelegationToolPart,
1481
1602
  now = Date.now(),
1482
1603
  ): ParsedDelegationToolPart | null {
1483
1604
  if (part.type !== 'tool') return null
1484
- if (part.tool !== 'pantheon_delegate' && part.tool !== 'pantheon_delegation_read') return null
1605
+ if (
1606
+ part.tool !== 'pantheon_delegate' &&
1607
+ part.tool !== 'pantheon_delegation_read' &&
1608
+ part.tool !== 'task'
1609
+ )
1610
+ return null
1485
1611
  const callID = part.callID
1486
1612
  if (callID === undefined || callID === '') return null
1487
1613
  const sessionID = part.sessionID ?? ''
@@ -1516,10 +1642,22 @@ export function parseDelegationToolPart(
1516
1642
  }
1517
1643
  }
1518
1644
 
1519
- const agent = typeof input.agent === 'string' ? input.agent : 'agent'
1520
- const description = typeof input.description === 'string' ? input.description : ''
1645
+ const agent =
1646
+ typeof input.agent === 'string'
1647
+ ? input.agent
1648
+ : typeof input.subagent_type === 'string'
1649
+ ? (input.subagent_type as string)
1650
+ : 'agent'
1651
+ const description =
1652
+ typeof input.description === 'string'
1653
+ ? input.description
1654
+ : typeof input.prompt === 'string'
1655
+ ? (input.prompt as string).slice(0, 120)
1656
+ : ''
1521
1657
  // The alias/taskID only exist once the delegate tool COMPLETES (they are
1522
- // returned in its output); a running/pending part has neither.
1658
+ // returned in its output); a running/pending part has neither. Native
1659
+ // `task` parts never carry a board alias — the children channel supplies
1660
+ // the child session id, so the row renders `nat:`.
1523
1661
  let alias: string | null = null
1524
1662
  let taskID: string | null = null
1525
1663
  if (status === 'completed') {
@@ -1531,7 +1669,7 @@ export function parseDelegationToolPart(
1531
1669
  callID,
1532
1670
  partID: part.id ?? '',
1533
1671
  sessionID,
1534
- tool: 'pantheon_delegate',
1672
+ tool: part.tool === 'task' ? 'task' : 'pantheon_delegate',
1535
1673
  agent,
1536
1674
  description,
1537
1675
  status,
@@ -1589,9 +1727,11 @@ export function reduceDelegationToolPart(
1589
1727
  return changed
1590
1728
  }
1591
1729
 
1592
- // Delegate tool: pending/running → job launched (running). A COMPLETED
1593
- // delegate tool only means the job was registered — it stays running and
1594
- // we pick up alias + taskID from the output.
1730
+ // Delegate tool (pantheon_delegate AND native task): pending/running →
1731
+ // job launched (running). A COMPLETED delegate tool only means the job was
1732
+ // registered — it stays running and we pick up alias + taskID from the
1733
+ // output. Native `task` parts never yield a board alias, so their rows
1734
+ // stay `nat:` until the children channel resolves them.
1595
1735
  const existing = map.get(parsed.callID)
1596
1736
  if (parsed.status === 'error') {
1597
1737
  if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
@@ -1600,7 +1740,7 @@ export function reduceDelegationToolPart(
1600
1740
  callID: parsed.callID,
1601
1741
  partID: parsed.partID,
1602
1742
  sessionID: parsed.sessionID,
1603
- tool: 'pantheon_delegate',
1743
+ tool: parsed.tool,
1604
1744
  agent: parsed.agent ?? 'agent',
1605
1745
  description: parsed.description,
1606
1746
  alias: existing?.alias ?? null,
@@ -1617,7 +1757,7 @@ export function reduceDelegationToolPart(
1617
1757
  callID: parsed.callID,
1618
1758
  partID: parsed.partID,
1619
1759
  sessionID: parsed.sessionID,
1620
- tool: 'pantheon_delegate',
1760
+ tool: parsed.tool,
1621
1761
  agent: parsed.agent ?? 'agent',
1622
1762
  description: parsed.description,
1623
1763
  alias: parsed.alias,
@@ -1670,11 +1810,14 @@ export function removeDelegationEntry(
1670
1810
  return false
1671
1811
  }
1672
1812
 
1673
- /** Collect pantheon delegation tool parts from a session's messages.
1813
+ /** Collect pantheon delegation + native task tool parts from a session's messages.
1674
1814
  * Messages may carry their parts inline (duck-typed `msg.parts`); when
1675
1815
  * they don't, the optional `getParts(messageID)` callback is used (the TUI
1676
- * SDK exposes `api.state.part(messageID)`). Pure w.r.t. I/O — used by the
1677
- * mount re-scan to re-seed the live map after compaction/attach. */
1816
+ * SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
1817
+ * child session with parentID = caller — the same mechanism as
1818
+ * pantheon_delegate — so its parts feed the live-map as the native signal
1819
+ * (rows render `nat:` via the children channel). Pure w.r.t. I/O — used
1820
+ * by the mount re-scan to re-seed the live map after compaction/attach. */
1678
1821
  export function collectDelegationToolParts(
1679
1822
  messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
1680
1823
  getParts?: (messageID: string) => readonly unknown[] | undefined,
@@ -1692,7 +1835,9 @@ export function collectDelegationToolParts(
1692
1835
  const part = raw as DelegationToolPart
1693
1836
  if (
1694
1837
  part?.type === 'tool' &&
1695
- (part.tool === 'pantheon_delegate' || part.tool === 'pantheon_delegation_read')
1838
+ (part.tool === 'pantheon_delegate' ||
1839
+ part.tool === 'pantheon_delegation_read' ||
1840
+ part.tool === 'task')
1696
1841
  ) {
1697
1842
  out.push(part)
1698
1843
  }
@@ -1882,17 +2027,139 @@ export type ChildDelegationLike = {
1882
2027
  * (the child is actively working), idle → completed, unknown → running
1883
2028
  * (fail-open: a freshly-seen child is assumed active; the 1s poll + md
1884
2029
  * correct it as soon as terminal data exists). */
1885
- export function childStatusToState(status: string | undefined): 'running' | 'completed' {
2030
+ export function childStatusToState(status: string | undefined): 'running' | 'completed' | 'retry' {
1886
2031
  if (status === 'idle') return 'completed'
1887
- return 'running' // busy, retry, or unknown
2032
+ if (status === 'retry') return 'retry'
2033
+ return 'running' // busy or unknown
1888
2034
  }
1889
2035
 
1890
- /** Row tag for a delegation entry: `[task]` for native task() children
1891
- * (source 'children-only' — no board report), `[<alias>]` for board rows
1892
- * ([apo-1]). The panel renders the tag with a distinct style so native
1893
- * task() children are visually separable from pantheon_delegate jobs. */
2036
+ /** Short row prefix for a delegation entry (one of the two visual channels
2037
+ * that split native task() work from pantheon_delegate jobs — the other is
2038
+ * {@link delegationIcon}):
2039
+ *
2040
+ * - `nat:<agent>` — a native task() child (source 'children-only', no board
2041
+ * report). The child session carries no board alias, so the agent IS the
2042
+ * identity.
2043
+ * - `pan:<alias>` — a pantheon_delegate board row (`pan:apo-1`), the same
2044
+ * alias the manager prints in pantheon_delegation_list.
2045
+ *
2046
+ * Short prefixes keep the narrow sidebar readable (the old
2047
+ * `pan:apo-1`/`nat:` tags ate the row width). */
1894
2048
  export function delegationTag(entry: DelegationEntry): string {
1895
- return entry.source === 'children-only' ? '[task]' : `[${entry.alias}]`
2049
+ return entry.source === 'children-only' ? `nat:${entry.agent}` : `pan:${entry.alias}`
2050
+ }
2051
+
2052
+ /** Row glyph: hollow diamond `◇` for native task() children (info color,
2053
+ * outline) vs filled diamond `◆` for pantheon_delegate rows (state color).
2054
+ * Shape + color are independent channels, so the split stays legible even
2055
+ * without color. */
2056
+ export function delegationIcon(entry: DelegationEntry): string {
2057
+ return entry.source === 'children-only' ? '\u25c7' : '\u25c6'
2058
+ }
2059
+
2060
+ /** Row identity after the status marker + glyph. Pantheon rows append the
2061
+ * agent (`pan:apo-1 apollo`); native rows already carry it inside the tag
2062
+ * (`nat:apollo`) and must not duplicate it. */
2063
+ export function formatDelegationIdentity(entry: DelegationEntry): string {
2064
+ return entry.source === 'children-only'
2065
+ ? delegationTag(entry)
2066
+ : `${delegationTag(entry)} ${entry.agent}`
2067
+ }
2068
+
2069
+ /** Max description width on the row detail line — the old 180-char slice
2070
+ * wrapped the sidebar; 44 keeps one readable line. */
2071
+ export const DELEGATION_DESCRIPTION_MAX = 44
2072
+
2073
+ /** Lazily-built grapheme segmenter. `Intl.Segmenter` keeps ZWJ emoji families
2074
+ * (and skin-tone/variation sequences) intact; `Array.from` (code points) is
2075
+ * the fallback for runtimes without it. */
2076
+ let graphemeSegmenter: Intl.Segmenter | undefined
2077
+
2078
+ /** Split `text` into display graphemes so truncation never cuts a multi-unit
2079
+ * emoji in half (surrogate pair → mojibake). */
2080
+ function splitGraphemes(text: string): string[] {
2081
+ if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
2082
+ graphemeSegmenter ??= new Intl.Segmenter('pt', { granularity: 'grapheme' })
2083
+ return Array.from(graphemeSegmenter.segment(text), (s) => s.segment)
2084
+ }
2085
+ return Array.from(text)
2086
+ }
2087
+
2088
+ /** Truncate a description to `max` graphemes appending `…` when cut. Exact
2089
+ * `max`-length text is left untouched. Grapheme-granular, so an emoji made of
2090
+ * several code points is never split into mojibake at the boundary. Pure. */
2091
+ export function truncateDelegationDescription(
2092
+ text: string,
2093
+ max = DELEGATION_DESCRIPTION_MAX,
2094
+ ): string {
2095
+ const graphemes = splitGraphemes(text)
2096
+ if (graphemes.length <= max) return text
2097
+ return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
2098
+ }
2099
+
2100
+ /** Fixed elapsed-time box: right-aligned to `width` (default 8) so elapsed
2101
+ * values line up across rows, e.g. ` 12s`. A label that already fills
2102
+ * the box is returned untouched. Pure. */
2103
+ export const DELEGATION_ELAPSED_WIDTH = 8
2104
+
2105
+ export function formatDelegationElapsed(
2106
+ entry: DelegationEntry,
2107
+ now: number,
2108
+ width = DELEGATION_ELAPSED_WIDTH,
2109
+ ): string {
2110
+ const label = delegationElapsed(entry, now)
2111
+ if (label.length >= width) return label
2112
+ return `${' '.repeat(width - label.length)}${label}`
2113
+ }
2114
+
2115
+ /** First row line: `<marker><glyph> <identity>`. The marker already carries
2116
+ * its trailing space. Pure — the row renders the returned string verbatim. */
2117
+ export function formatDelegationRow(entry: DelegationEntry, marker: string): string {
2118
+ return `${marker}${delegationIcon(entry)} ${formatDelegationIdentity(entry)}`
2119
+ }
2120
+
2121
+ // Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
2122
+ // as dead code: `session.children` returns direct children only, so no row is
2123
+ // ever actually nested and the connectors were never populated in production.
2124
+ // BACKLOG: produce real grandchild rows first, then reintroduce them.
2125
+
2126
+ /** Header summary: `(N active · M done · nat:K pan:M)`. Active counts
2127
+ * running/retry AND display-only stale-running (a stale row is still a live
2128
+ * job — it must never read as done). Pure. */
2129
+ export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
2130
+ let active = 0
2131
+ for (const e of entries) {
2132
+ if (e.state === 'running' || e.state === 'retry' || e.state === 'stale-running') active++
2133
+ }
2134
+ const done = entries.length - active
2135
+ const counts = countDelegationSources(entries)
2136
+ return `(${active} active \u00b7 ${done} done \u00b7 nat:${counts.native} pan:${counts.pantheon})`
2137
+ }
2138
+
2139
+ /** Split a display list into native task() rows vs pantheon_delegate rows.
2140
+ * Native = source 'children-only' (no board report); everything else counts
2141
+ * as pantheon. Pure — powers the header breakdown + the hooks.log line. */
2142
+ export function countDelegationSources(entries: readonly DelegationEntry[]): {
2143
+ native: number
2144
+ pantheon: number
2145
+ total: number
2146
+ } {
2147
+ let native = 0
2148
+ for (const e of entries) if (e.source === 'children-only') native++
2149
+ return { native, pantheon: entries.length - native, total: entries.length }
2150
+ }
2151
+
2152
+ /** Diagnostic hooks.log line for a panel re-fetch, with the children
2153
+ * breakdown (pantheon = children WITH a board report, native = children
2154
+ * WITHOUT one). Pure — the View logs the returned string verbatim. */
2155
+ export function formatPanelLogLine(
2156
+ children: number,
2157
+ pantheon: number,
2158
+ native: number,
2159
+ md: number,
2160
+ events: number,
2161
+ ): string {
2162
+ return `panel: children=${children}(pantheon=${pantheon} native=${native}) md=${md} events=${events}`
1896
2163
  }
1897
2164
 
1898
2165
  /** Turn child sessions (PRIMARY) enriched with md reports into the display
@@ -1903,10 +2170,14 @@ export function delegationTag(entry: DelegationEntry): string {
1903
2170
  * itself (fallback 'agent'), state derived from its status, startedAt from
1904
2171
  * time.created. A report-less child is a NATIVE task() child (every
1905
2172
  * child of the current session — pantheon_delegate OR the native `task()`
1906
- * tool — carries parentID = caller), so it gets source 'children-only'
1907
- * and the `[task]` tag instead of a board alias.
2173
+ * tool — carries parentID = caller), so it gets source 'children-only',
2174
+ * the internal alias 'native-task' and the `nat:` tag instead of a
2175
+ * board alias. The 'task nativa' description fallback keeps the row
2176
+ * non-empty when the child carries no title.
1908
2177
  * Terminal md state wins over the derived state; a running md defers to
1909
- * the child's live status. Sorted running-first (compareDelegationEntries).
2178
+ * the child's live status. A running child is NEVER archived — it always
2179
+ * lands in the active split (splitDelegationList active = running/retry).
2180
+ * Sorted running-first (compareDelegationEntries).
1910
2181
  * Pure — no I/O. */
1911
2182
  export function childrenToDelegationEntries(
1912
2183
  children: readonly ChildDelegationLike[] | undefined,
@@ -1928,7 +2199,7 @@ export function childrenToDelegationEntries(
1928
2199
  ? mdEntry.state
1929
2200
  : childStatusToState(child.status)
1930
2201
  out.push({
1931
- alias: mdEntry?.alias ?? 'task',
2202
+ alias: mdEntry?.alias ?? 'native-task',
1932
2203
  sessionID: mdEntry?.sessionID ?? '',
1933
2204
  taskID: child.id,
1934
2205
  agent: mdEntry?.agent ?? child.agent ?? 'agent',
@@ -1941,7 +2212,9 @@ export function childrenToDelegationEntries(
1941
2212
  description:
1942
2213
  mdEntry !== undefined && mdEntry.description !== ''
1943
2214
  ? mdEntry.description
1944
- : (child.title ?? ''),
2215
+ : child.title !== undefined && child.title !== ''
2216
+ ? child.title
2217
+ : 'task nativa',
1945
2218
  source: mdEntry !== undefined ? 'md' : 'children-only',
1946
2219
  })
1947
2220
  }
@@ -2007,8 +2280,19 @@ function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui
2007
2280
  `${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
2008
2281
  'utf8',
2009
2282
  )
2010
- } catch {
2011
- /* best-effort file log — never break the panel over logging */
2283
+ } catch (err) {
2284
+ /* best-effort file log — never break the panel over logging, but do
2285
+ * NOT swallow the failure silently: a missing symbol here (e.g.
2286
+ * `appendFile` used without an import) previously threw an invisible
2287
+ * ReferenceError and the log silently stopped writing. Gated by the
2288
+ * echo flag to avoid the TUI-pollution bug described above. */
2289
+ if (echo) {
2290
+ try {
2291
+ process.stderr.write(`[${module}] file-log failed: ${formatArg(err)}\n`)
2292
+ } catch {
2293
+ /* stderr unavailable — never break the panel */
2294
+ }
2295
+ }
2012
2296
  }
2013
2297
  })()
2014
2298
  if (echo) {
@@ -2070,6 +2354,8 @@ function DelegationRow(props: {
2070
2354
  switch (props.job.state) {
2071
2355
  case 'running':
2072
2356
  return t.warning
2357
+ case 'retry':
2358
+ return t.warning
2073
2359
  case 'stale-running':
2074
2360
  return t.error // warning color for stale delegations
2075
2361
  case 'completed':
@@ -2083,6 +2369,7 @@ function DelegationRow(props: {
2083
2369
 
2084
2370
  const marker = createMemo(() => {
2085
2371
  if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
2372
+ if (props.job.state === 'retry') return '\u27f3 ' // ⟳ retrying
2086
2373
  if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
2087
2374
  switch (props.job.state) {
2088
2375
  case 'completed':
@@ -2094,17 +2381,20 @@ function DelegationRow(props: {
2094
2381
  }
2095
2382
  })
2096
2383
 
2097
- // The row tag: `[task]` for native task() children (no board report),
2098
- // `[apo-1]`-style alias for pantheon_delegate board rows. Rendered with
2099
- // the info color so task() children are visually distinct from delegates.
2384
+ // Two visual channels split native task() from pantheon_delegate: the tag
2385
+ // prefix (`nat:`/`pan:`, via delegationTag) and the glyph color — info
2386
+ // (cyan) for native, state color for board rows. The glyph shape (◇/◆) is
2387
+ // the third, color-independent channel.
2100
2388
  const tagColor = createMemo(() => (props.job.source === 'children-only' ? theme().info : color()))
2101
2389
 
2102
- const detail = createMemo(() => {
2103
- const elapsed = delegationElapsed(props.job, props.now)
2104
- const description = props.job.description !== '' ? ` \u2014 ${props.job.description}` : ''
2105
- const line = `${elapsed}${description}`
2106
- return line.length > 180 ? `${line.slice(0, 177)}\u2026` : line
2107
- })
2390
+ const description = createMemo(() => truncateDelegationDescription(props.job.description))
2391
+ const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
2392
+
2393
+ // beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
2394
+ // fed by message.part.updated activity tracking. Null while idle/done.
2395
+ const activity = createMemo(() =>
2396
+ latestToolActivityFor(latestToolActivity, props.job.taskID, props.now),
2397
+ )
2108
2398
 
2109
2399
  // Click a row → navigate to the child session (route API guarded in the
2110
2400
  // helper). Enter-key binding is left out (per-row keymap wiring is not
@@ -2116,13 +2406,23 @@ function DelegationRow(props: {
2116
2406
  return (
2117
2407
  <box onMouseDown={open}>
2118
2408
  <box flexDirection="row">
2119
- <text fg={color()}>{marker()}</text>
2120
- <text fg={tagColor()}>{delegationTag(props.job)}</text>
2121
- <text
2122
- fg={color()}
2123
- >{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
2409
+ <text fg={tagColor()}>{formatDelegationRow(props.job, marker())}</text>
2124
2410
  </box>
2125
- <text fg={theme().textMuted}>{detail()}</text>
2411
+ {/* Elapsed is ALWAYS rendered (outside the optional-description Show) so
2412
+ * an active job with no description never loses its timer. */}
2413
+ <box flexDirection="row" justifyContent={description() !== '' ? 'space-between' : 'flex-end'}>
2414
+ <Show when={description() !== ''}>
2415
+ <text fg={theme().textMuted}>{description()}</text>
2416
+ </Show>
2417
+ <text fg={theme().textMuted}>{elapsed()}</text>
2418
+ </box>
2419
+ <Show when={activity()}>
2420
+ {(a) => (
2421
+ <text
2422
+ fg={theme().textMuted}
2423
+ >{` \u21b3 ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2424
+ )}
2425
+ </Show>
2126
2426
  </box>
2127
2427
  )
2128
2428
  }
@@ -2249,9 +2549,10 @@ function View(props: {
2249
2549
  // remains on the 1s ticker to avoid unnecessary work.
2250
2550
  const [animationNow, setAnimationNow] = createSignal(Date.now())
2251
2551
  // Cumulative count of event-triggered refreshes — logged with every fetch
2252
- // ("panel: children=N md=N events=N") so the (0) symptom is diagnosable:
2253
- // children>0 means the source works; events=0 means the event bus isn't
2254
- // delivering, and the 1s poll is what keeps the panel honest.
2552
+ // ("panel: children=N(pantheon=M native=K) md=N events=N") so the (0)
2553
+ // symptom is diagnosable: children>0 means the source works; events=0
2554
+ // means the event bus isn't delivering, and the 1s poll is what keeps
2555
+ // the panel honest.
2255
2556
  let eventRefreshCount = 0
2256
2557
  // In-flight guard: overlap between the poll, events and the version effect
2257
2558
  // collapses into one fetch — no duplicate work, no duplicate entries.
@@ -2283,7 +2584,7 @@ function View(props: {
2283
2584
  if (sessionID === null) {
2284
2585
  setChildDelegations(md)
2285
2586
  panelLog.info(
2286
- `panel: children=0 md=${md.length} events=${eventRefreshCount} (no sessionID — history shown)`,
2587
+ `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
2287
2588
  )
2288
2589
  return
2289
2590
  }
@@ -2326,8 +2627,15 @@ function View(props: {
2326
2627
  )
2327
2628
  setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
2328
2629
  // 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
2630
+ const counts = countDelegationSources(childEntries)
2329
2631
  panelLog.info(
2330
- `panel: children=${children.length} md=${md.length} events=${eventRefreshCount}`,
2632
+ formatPanelLogLine(
2633
+ children.length,
2634
+ counts.pantheon,
2635
+ counts.native,
2636
+ md.length,
2637
+ eventRefreshCount,
2638
+ ),
2331
2639
  )
2332
2640
  } finally {
2333
2641
  delegationsInflight = null
@@ -2335,9 +2643,24 @@ function View(props: {
2335
2643
  })()
2336
2644
  return delegationsInflight
2337
2645
  }
2338
- // Running jobs first, then the 8 most recent terminal reports. The header
2339
- // count uses this same "running + recentes" list.
2340
- const visibleDelegations = createMemo(() => visibleDelegationList(childDelegations()))
2646
+ // beta.6: active (running/retry) first, then the 8 most recent terminal
2647
+ // reports, then the archived tail (paginated). Pagination state lives at
2648
+ // module level so sidebar remounts keep the user's page.
2649
+ const delegationSplit = createMemo(() => splitDelegationList(childDelegations()))
2650
+ const visibleDelegations = createMemo(() => [
2651
+ ...delegationSplit().active,
2652
+ ...delegationSplit().recent,
2653
+ ])
2654
+ // Header summary over the FULL list (active + recent + archived): active
2655
+ // vs done plus the native/pantheon breakdown, so a busy session reads
2656
+ // "Delegations (2 active · 9 done · nat:3 pan:8)".
2657
+ const delegationHeader = createMemo(() => formatDelegationHeader(childDelegations()))
2658
+ const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2659
+ const clampArchivedPage = (page: number, pages: number) => {
2660
+ const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
2661
+ archivedUiState.page = clamped
2662
+ setArchivedPage(clamped)
2663
+ }
2341
2664
 
2342
2665
  // ── Event subscriptions for live session/delegation updates ──
2343
2666
  onMount(() => {
@@ -2385,7 +2708,7 @@ function View(props: {
2385
2708
  void refreshDelegations()
2386
2709
  }, 1_000)
2387
2710
  cleanup.push(() => clearInterval(poll))
2388
- const animation = setInterval(() => setAnimationNow(Date.now()), 140)
2711
+ const animation = setInterval(() => setAnimationNow(Date.now()), 1_000)
2389
2712
  cleanup.push(() => clearInterval(animation))
2390
2713
 
2391
2714
  // Compaction recovery: `message.part.removed` clears live entries while
@@ -2480,9 +2803,9 @@ function View(props: {
2480
2803
  {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
2481
2804
  <box onMouseDown={() => setShowDelegations((x) => !x)}>
2482
2805
  <text fg={theme().text} attributes={1}>
2483
- {`${showDelegations() ? '\u25bc' : '\u25b6'} Delegations`}
2806
+ {`${showDelegations() ? '▼' : '▶'} Delegations`}
2484
2807
  </text>
2485
- <text fg={theme().textMuted}>{` (${String(visibleDelegations().length)})`}</text>
2808
+ <text fg={theme().textMuted}>{` ${delegationHeader()}`}</text>
2486
2809
  </box>
2487
2810
  <Show when={showDelegations()}>
2488
2811
  <Show
@@ -2504,6 +2827,41 @@ function View(props: {
2504
2827
  />
2505
2828
  )}
2506
2829
  </For>
2830
+ <Show when={delegationSplit().archived.length > 0}>
2831
+ {(() => {
2832
+ const archived = delegationSplit().archived
2833
+ const pageSize = 8
2834
+ const pages = Math.ceil(archived.length / pageSize)
2835
+ const page = Math.min(archivedPage(), pages - 1)
2836
+ const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
2837
+ return (
2838
+ <box flexDirection="column">
2839
+ <box flexDirection="row" marginLeft={0}>
2840
+ <text
2841
+ fg={theme().textMuted}
2842
+ onMouseDown={() => clampArchivedPage(page - 1, pages)}
2843
+ >{`\u2039 `}</text>
2844
+ <text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
2845
+ <text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
2846
+ <text
2847
+ fg={theme().textMuted}
2848
+ onMouseDown={() => clampArchivedPage(page + 1, pages)}
2849
+ >{` \u203a`}</text>
2850
+ </box>
2851
+ <For each={slice}>
2852
+ {(job) => (
2853
+ <DelegationRow
2854
+ api={props.api}
2855
+ job={job}
2856
+ now={now()}
2857
+ animationNow={animationNow()}
2858
+ />
2859
+ )}
2860
+ </For>
2861
+ </box>
2862
+ )
2863
+ })()}
2864
+ </Show>
2507
2865
  </box>
2508
2866
  </Show>
2509
2867
  </Show>
@@ -2552,6 +2910,11 @@ const tui: TuiPlugin = (api, _options, _meta) => {
2552
2910
  const props = (event?.properties ?? {}) as { part?: unknown; info?: { part?: unknown } }
2553
2911
  const part = (props.part ?? props.info?.part) as DelegationToolPart | undefined
2554
2912
  if (part === undefined) return
2913
+ // beta.6: track the live tool call per child session (panel's
2914
+ // activity line). Fire-and-forget — display-only state.
2915
+ const sample = extractToolActivity(part as any)
2916
+ if (sample !== null)
2917
+ trackToolActivity(latestToolActivity, sample.sessionID, sample.activity)
2555
2918
  if (reduceDelegationToolPart(liveStore.map, part)) liveStore.bump()
2556
2919
  }),
2557
2920
  )
@@ -2593,9 +2956,13 @@ const tui: TuiPlugin = (api, _options, _meta) => {
2593
2956
  })
2594
2957
  }
2595
2958
 
2596
- const plugin: TuiPluginModule & { id: string } = {
2959
+ const plugin: TuiPluginModule & { id: string; setup: () => Promise<void> } = {
2597
2960
  id: 'pantheon.tui',
2598
2961
  tui,
2962
+ // Beta loader contract: every `plugins` directory entry must expose a
2963
+ // no-op `setup` so the entry loads as `pantheon.tui` with zero errors
2964
+ // (sandbox probe evidence). The TUI itself boots via `tui()`.
2965
+ setup: async () => {},
2599
2966
  }
2600
2967
 
2601
2968
  export default plugin