pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.11

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 (83) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +1054 -0
  3. package/README.md +171 -16
  4. package/README.pt-BR.md +99 -13
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +23 -15
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +65 -0
  9. package/scripts/code_mode_server.py +431 -55
  10. package/scripts/cost.mjs +30 -14
  11. package/scripts/dev-tui.sh +48 -0
  12. package/scripts/doctor.mjs +207 -9
  13. package/scripts/install/cli-ui.mjs +17 -11
  14. package/scripts/install/config-migration.mjs +4 -6
  15. package/scripts/install/opencode.mjs +137 -35
  16. package/scripts/install/plugin.mjs +3 -33
  17. package/scripts/install/shared.mjs +43 -2
  18. package/scripts/install/strings.mjs +129 -0
  19. package/scripts/install-mcp.mjs +1 -0
  20. package/scripts/manifest-inventory.mjs +410 -0
  21. package/scripts/mcp_persistence_server.py +537 -128
  22. package/scripts/package-evidence.mjs +242 -0
  23. package/scripts/probe-context-rehydrate.mjs +240 -0
  24. package/scripts/probe-pantheon-cost.mjs +313 -0
  25. package/scripts/release-beta-version.mjs +56 -60
  26. package/scripts/sync-artifacts.mjs +169 -0
  27. package/scripts/sync-tui.mjs +121 -53
  28. package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
  29. package/scripts/validation-policy.mjs +7 -7
  30. package/scripts/version-check.mjs +113 -108
  31. package/scripts/versioning.mjs +230 -86
  32. package/src/agents/mnemosyne.md +1 -1
  33. package/src/agents/zeus.md +2 -2
  34. package/src/mcp/code_mode_server.py +431 -55
  35. package/src/mcp/mcp_persistence_server.py +537 -128
  36. package/src/pantheon/auto-wake.ts +1 -5
  37. package/src/pantheon/background-job-board.ts +43 -1
  38. package/src/pantheon/compaction-handoff.ts +304 -0
  39. package/src/pantheon/cost-command.ts +345 -62
  40. package/src/pantheon/delegate-manager.ts +390 -8
  41. package/src/pantheon/delegation-finalize.ts +32 -0
  42. package/src/pantheon/delegation.ts +235 -18
  43. package/src/pantheon/file-persistence-salvage.ts +64 -0
  44. package/src/pantheon/file-persistence.ts +194 -45
  45. package/src/pantheon/native-probe.ts +234 -0
  46. package/src/pantheon/native-task-status.ts +139 -0
  47. package/src/pantheon/provider-capabilities.ts +221 -0
  48. package/src/pantheon/provider-stream-tracker.ts +182 -0
  49. package/src/pantheon/shared-board.ts +37 -0
  50. package/src/pantheon/task-result-guard.ts +107 -0
  51. package/src/pantheon/token-opt.ts +146 -12
  52. package/src/pantheon/tool-ceiling.ts +159 -0
  53. package/src/pantheon/v2-events.ts +18 -1
  54. package/src/plugin-v2.ts +2 -14
  55. package/src/plugin.ts +28 -17
  56. package/src/plugins/pantheon-hooks.ts +51 -0
  57. package/src/plugins/tui/dist/server.d.ts +1 -1
  58. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  59. package/src/plugins/tui/dist/server.js +1 -1
  60. package/src/plugins/tui/dist/server.js.map +1 -1
  61. package/src/plugins/tui/dist/tui.d.ts +137 -27
  62. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  63. package/src/plugins/tui/dist/tui.js +459 -163
  64. package/src/plugins/tui/dist/tui.js.map +1 -1
  65. package/src/plugins/tui/dist/tui.tsx +506 -103
  66. package/src/plugins/tui/package-lock.json +2 -2
  67. package/src/plugins/tui/package.json +1 -1
  68. package/src/plugins/tui/src/index.tsx +506 -103
  69. package/.pantheon/code-mode/.gitkeep +0 -0
  70. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
  71. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
  72. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
  73. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
  74. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
  75. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
  76. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
  77. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
  78. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
  79. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
  80. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
  81. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
  82. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
  83. package/src/pantheon/delegate-task-adapter.ts +0 -444
@@ -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'
@@ -899,9 +899,9 @@ async function setupUsageBar(api: TuiPluginApi) {
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 `[native]` 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 ([pantheon:apo-1]) — never filtered out, never duplicated
904
+ * delegates (`pan:apo-1`) — never filtered out, never duplicated
905
905
  * with a board row for the same child (a child WITH a report keeps the
906
906
  * md entry, source 'md').
907
907
  *
@@ -921,9 +921,12 @@ async function setupUsageBar(api: TuiPluginApi) {
921
921
  * children; a 1s safety poll (oh-my-opencode-slim pattern) re-fetches
922
922
  * children + re-reads the md so the panel updates EVEN without events.
923
923
  *
924
- * 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"
925
926
  * to `.pantheon/logs/hooks.log` (silence-by-default, createPantheonLogger
926
- * 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
927
930
  * failure logs "panel: error <msg>" (previously silenced), and a missing
928
931
  * current session (null/placeholder sessionID) logs "panel: children=0
929
932
  * md=N ... (no sessionID — history shown)" — the md HISTORY renders
@@ -959,6 +962,7 @@ export type DelegationEntry = {
959
962
  agent: string
960
963
  state:
961
964
  | 'running'
965
+ | 'retry'
962
966
  | 'completed'
963
967
  | 'error'
964
968
  | 'startup_failed'
@@ -973,9 +977,13 @@ export type DelegationEntry = {
973
977
  description: string
974
978
  /** True while the panel is waiting for pantheon_delegation_read. */
975
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.
976
984
  /** Internal provenance used to keep a finalized md report authoritative.
977
985
  * 'children-only' = a native task() child session with NO board report
978
- * (rendered with the distinct `[native]` tag); 'md' = board report wins. */
986
+ * (rendered with the distinct `nat:` prefix); 'md' = board report wins. */
979
987
  source?: 'child' | 'live' | 'md' | 'children-only'
980
988
  }
981
989
 
@@ -1195,27 +1203,52 @@ export function tuiLogPath(projectRoot: string): string {
1195
1203
  * falling back to startedAt, descending). Shared by the md reader and
1196
1204
  * mergeDelegationSources. */
1197
1205
  export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
1198
- const aRun = a.state === 'running' || a.state === 'stale-running' ? 1 : 0
1199
- 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)
1200
1210
  if (aRun !== bRun) return bRun - aRun
1201
1211
  return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
1202
1212
  }
1203
1213
 
1204
- /** The list the panel actually renders: running jobs first, then the most
1205
- * recent terminal reports (capped). Pure — so the history-only panel (no
1206
- * sessionID) is testable without the TUI runtime. The header count uses the
1207
- * 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. */
1208
1244
  export function visibleDelegationList(
1209
1245
  all: readonly DelegationEntry[],
1210
1246
  maxTerminal = 8,
1211
1247
  now = Date.now(),
1212
1248
  staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1213
1249
  ): DelegationEntry[] {
1214
- const running = all
1215
- .filter((d) => d.state === 'running')
1216
- .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1217
- const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
1218
- return [...running, ...terminal]
1250
+ const { active, recent } = splitDelegationList(all, maxTerminal, now, staleThresholdMs)
1251
+ return [...active, ...recent]
1219
1252
  }
1220
1253
 
1221
1254
  /** Default stale-running threshold: 30 minutes. */
@@ -1261,10 +1294,11 @@ export function fmtElapsed(ms: number): string {
1261
1294
  return `${seconds}s`
1262
1295
  }
1263
1296
 
1264
- /** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
1265
- * → 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). */
1266
1300
  export function delegationElapsed(entry: DelegationEntry, now: number): string {
1267
- if (entry.state === 'running' || entry.state === 'stale-running')
1301
+ if (entry.state === 'running' || entry.state === 'retry' || entry.state === 'stale-running')
1268
1302
  return fmtElapsed(now - entry.startedAt)
1269
1303
  return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
1270
1304
  }
@@ -1294,6 +1328,7 @@ export function delegationActivity(entry: DelegationEntry): DelegationActivity {
1294
1328
  }
1295
1329
 
1296
1330
  export function delegationActivityLabel(entry: DelegationEntry): string {
1331
+ if (entry.state === 'retry') return 'RETRYING'
1297
1332
  switch (delegationActivity(entry)) {
1298
1333
  case 'delegating':
1299
1334
  return 'DELEGATING'
@@ -1312,12 +1347,152 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
1312
1347
 
1313
1348
  const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
1314
1349
 
1315
- /** 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). */
1316
1352
  export function delegationSpinnerFrame(now: number): string {
1317
- 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
1318
1354
  return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
1319
1355
  }
1320
1356
 
1357
+ /** Every state the row knows how to draw: the real BackgroundJobBoard FSM
1358
+ * (src/pantheon/background-job-board.ts) plus the TUI display-only states
1359
+ * (`retry`, `stale-running`). Fase 1 deliberately omits speculative
1360
+ * blocked/paused/scheduled/skipped. There is no `pending` display state: a
1361
+ * pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
1362
+ export type DelegationDisplayState = DelegationEntry['state']
1363
+
1364
+ /** Static glyph per display state. `running` shows its base spinner frame;
1365
+ * callers that animate must prefer {@link delegationStateMarker}. Unicode
1366
+ * geometric shapes only (no Nerd Font) — shape is an independent channel
1367
+ * from color, so rows stay legible without color. */
1368
+ export const DELEGATION_STATE_GLYPHS: Readonly<Record<DelegationDisplayState, string>> = {
1369
+ running: '\u280b', // ⠋ base frame of the animated spinner
1370
+ retry: '\u27f3', // ⟳ retrying
1371
+ 'stale-running': '\u26a0', // ⚠ stale (display-only alert)
1372
+ completed: '\u2713', // ✓
1373
+ error: '\u2715', // ✕
1374
+ cancelled: '\u2212', // − minus: stopped, distinct from error's ✕
1375
+ startup_failed: '\u26a0', // ⚠ boot failed
1376
+ startup_unknown: '\u26a0', // ⚠ boot outcome unknown
1377
+ }
1378
+
1379
+ export function delegationStateGlyph(state: DelegationDisplayState): string {
1380
+ return DELEGATION_STATE_GLYPHS[state]
1381
+ }
1382
+
1383
+ /** Row marker (`<glyph> `) — the state channel. `running` animates through
1384
+ * {@link delegationSpinnerFrame} for the given tick; every other state is
1385
+ * static. Pure. */
1386
+ export function delegationStateMarker(state: DelegationDisplayState, now = Date.now()): string {
1387
+ const glyph = state === 'running' ? delegationSpinnerFrame(now) : delegationStateGlyph(state)
1388
+ return `${glyph} `
1389
+ }
1390
+
1391
+ /** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
1392
+ * Kept separate + pure so the color channel is testable without booting the
1393
+ * renderer. */
1394
+ export type DelegationStateTone = 'warning' | 'error' | 'success' | 'muted'
1395
+
1396
+ export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
1397
+ switch (state) {
1398
+ case 'running':
1399
+ case 'retry':
1400
+ case 'startup_unknown':
1401
+ return 'warning'
1402
+ case 'stale-running':
1403
+ case 'error':
1404
+ case 'startup_failed':
1405
+ return 'error'
1406
+ case 'completed':
1407
+ return 'success'
1408
+ case 'cancelled':
1409
+ return 'muted'
1410
+ }
1411
+ }
1412
+
1413
+ /* ─── Live tool activity per child session (beta.6) ────────────────────────
1414
+ * The delegation panel's activity line ("↳ bash npm test") — what the agent
1415
+ * is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
1416
+ * pending/running tool part updates a per-child-session record (latest wins).
1417
+ * Module-level by design: the sidebar remounts on route changes and the
1418
+ * "what is each agent doing" state should survive that (OMO pattern). */
1419
+
1420
+ export type ToolActivity = {
1421
+ tool: string
1422
+ summary: string
1423
+ at: number
1424
+ }
1425
+
1426
+ /** Bounded tracker: child session id → latest running tool call. */
1427
+ const latestToolActivity = new Map<string, ToolActivity>()
1428
+ const MAX_TOOL_ACTIVITY_ENTRIES = 200
1429
+ const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
1430
+
1431
+ /** Archived-section pagination state — module-level so it survives sidebar
1432
+ * remounts (route changes) without leaking into the component tree. */
1433
+ const archivedUiState = { page: 0 }
1434
+
1435
+ /** First usable summary string from common tool input fields. */
1436
+ function summarizeToolInput(input: Record<string, unknown> | undefined): string {
1437
+ if (!input) return ''
1438
+ const candidates = ['command', 'description', 'prompt', 'filePath', 'pattern', 'url', 'query']
1439
+ for (const key of candidates) {
1440
+ const value = input[key]
1441
+ if (typeof value === 'string' && value.trim() !== '') return value
1442
+ }
1443
+ return ''
1444
+ }
1445
+
1446
+ /** Reduce one message.part.updated tool part to displayable activity.
1447
+ * Returns null for non-tool parts, completed/error parts (no live activity)
1448
+ * or parts without a session id. Pure. */
1449
+ export function extractToolActivity(
1450
+ part: {
1451
+ type?: string
1452
+ tool?: string
1453
+ sessionID?: string
1454
+ state?: { status?: string; input?: Record<string, unknown> }
1455
+ },
1456
+ now = Date.now(),
1457
+ ): { sessionID: string; activity: ToolActivity } | null {
1458
+ if (part?.type !== 'tool') return null
1459
+ const status = part.state?.status
1460
+ if (status !== 'pending' && status !== 'running') return null
1461
+ const sessionID = typeof part.sessionID === 'string' ? part.sessionID : ''
1462
+ if (sessionID === '') return null
1463
+ const tool = typeof part.tool === 'string' && part.tool !== '' ? part.tool : 'tool'
1464
+ const summary = summarizeToolInput(part.state?.input).replace(/\s+/g, ' ').trim().slice(0, 48)
1465
+ return { sessionID, activity: { tool, summary, at: now } }
1466
+ }
1467
+
1468
+ /** Record an activity sample (bounded map, oldest dropped). */
1469
+ export function trackToolActivity(
1470
+ map: Map<string, ToolActivity>,
1471
+ sessionID: string,
1472
+ activity: ToolActivity,
1473
+ ): void {
1474
+ if (sessionID === '') return
1475
+ if (map.size >= MAX_TOOL_ACTIVITY_ENTRIES) {
1476
+ const oldest = map.keys().next().value
1477
+ if (oldest !== undefined) map.delete(oldest)
1478
+ }
1479
+ map.set(sessionID, activity)
1480
+ }
1481
+
1482
+ /** Latest live activity for a child session, or null when absent/stale. */
1483
+ export function latestToolActivityFor(
1484
+ map: Map<string, ToolActivity>,
1485
+ sessionID: string | undefined,
1486
+ now = Date.now(),
1487
+ ttlMs = TOOL_ACTIVITY_TTL_MS,
1488
+ ): ToolActivity | null {
1489
+ if (!sessionID) return null
1490
+ const hit = map.get(sessionID)
1491
+ if (hit === undefined) return null
1492
+ if (now - hit.at > ttlMs) return null
1493
+ return hit
1494
+ }
1495
+
1321
1496
  /** Merge the immediate tool-event channel into the child-session channel.
1322
1497
  *
1323
1498
  * Children remain the durable source, while live entries make a delegation
@@ -1405,9 +1580,11 @@ export function mergeChildDelegationSources(
1405
1580
  * Source of truth for jobs born while opencode is open. The TUI event bus
1406
1581
  * (api.event.on) delivers `message.part.updated` for every part change;
1407
1582
  * we only care about tool parts whose `tool` is pantheon_delegate (job
1408
- * launch) or pantheon_delegation_read (blocking until terminal → closes
1409
- * the entry). Shape-adapted from the SDK v2 ToolPart but duck-typed so the
1410
- * pure helpers are testable without the SDK. */
1583
+ * launch), the native `task` subagent tool (same parentID === caller
1584
+ * mechanism — its rows render `nat:`), or pantheon_delegation_read
1585
+ * (blocking until terminal → closes the entry). Shape-adapted from the
1586
+ * SDK v2 ToolPart but duck-typed so the pure helpers are testable without
1587
+ * the SDK. */
1411
1588
 
1412
1589
  /** Duck-typed subset of a tool part (SDK v2 `ToolPart` / `ToolState`). */
1413
1590
  export type DelegationToolPart = {
@@ -1433,7 +1610,7 @@ export type LiveDelegationEntry = {
1433
1610
  partID: string
1434
1611
  /** Parent session the delegation was launched from. */
1435
1612
  sessionID: string
1436
- tool: 'pantheon_delegate' | 'pantheon_delegation_read'
1613
+ tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
1437
1614
  /** Agent name (from the delegate input args). */
1438
1615
  agent: string
1439
1616
  description: string
@@ -1453,7 +1630,7 @@ export type ParsedDelegationToolPart = {
1453
1630
  callID: string
1454
1631
  partID: string
1455
1632
  sessionID: string
1456
- tool: 'pantheon_delegate' | 'pantheon_delegation_read'
1633
+ tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
1457
1634
  /** null for read parts (no agent arg — the id targets an existing job). */
1458
1635
  agent: string | null
1459
1636
  description: string
@@ -1473,13 +1650,20 @@ const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
1473
1650
 
1474
1651
  /** Extract the tool name + args from a `message.part.updated` part and
1475
1652
  * reduce it to what the panel needs. Returns null for anything that is
1476
- * not a pantheon delegation tool part (or is missing its callID). */
1653
+ * not a pantheon delegation tool part, the native `task` subagent tool
1654
+ * (same parentID === caller mechanism — its children render `nat:`),
1655
+ * or is missing its callID. */
1477
1656
  export function parseDelegationToolPart(
1478
1657
  part: DelegationToolPart,
1479
1658
  now = Date.now(),
1480
1659
  ): ParsedDelegationToolPart | null {
1481
1660
  if (part.type !== 'tool') return null
1482
- if (part.tool !== 'pantheon_delegate' && part.tool !== 'pantheon_delegation_read') return null
1661
+ if (
1662
+ part.tool !== 'pantheon_delegate' &&
1663
+ part.tool !== 'pantheon_delegation_read' &&
1664
+ part.tool !== 'task'
1665
+ )
1666
+ return null
1483
1667
  const callID = part.callID
1484
1668
  if (callID === undefined || callID === '') return null
1485
1669
  const sessionID = part.sessionID ?? ''
@@ -1514,10 +1698,22 @@ export function parseDelegationToolPart(
1514
1698
  }
1515
1699
  }
1516
1700
 
1517
- const agent = typeof input.agent === 'string' ? input.agent : 'agent'
1518
- const description = typeof input.description === 'string' ? input.description : ''
1701
+ const agent =
1702
+ typeof input.agent === 'string'
1703
+ ? input.agent
1704
+ : typeof input.subagent_type === 'string'
1705
+ ? (input.subagent_type as string)
1706
+ : 'agent'
1707
+ const description =
1708
+ typeof input.description === 'string'
1709
+ ? input.description
1710
+ : typeof input.prompt === 'string'
1711
+ ? (input.prompt as string).slice(0, 120)
1712
+ : ''
1519
1713
  // The alias/taskID only exist once the delegate tool COMPLETES (they are
1520
- // returned in its output); a running/pending part has neither.
1714
+ // returned in its output); a running/pending part has neither. Native
1715
+ // `task` parts never carry a board alias — the children channel supplies
1716
+ // the child session id, so the row renders `nat:`.
1521
1717
  let alias: string | null = null
1522
1718
  let taskID: string | null = null
1523
1719
  if (status === 'completed') {
@@ -1529,7 +1725,7 @@ export function parseDelegationToolPart(
1529
1725
  callID,
1530
1726
  partID: part.id ?? '',
1531
1727
  sessionID,
1532
- tool: 'pantheon_delegate',
1728
+ tool: part.tool === 'task' ? 'task' : 'pantheon_delegate',
1533
1729
  agent,
1534
1730
  description,
1535
1731
  status,
@@ -1587,9 +1783,11 @@ export function reduceDelegationToolPart(
1587
1783
  return changed
1588
1784
  }
1589
1785
 
1590
- // Delegate tool: pending/running → job launched (running). A COMPLETED
1591
- // delegate tool only means the job was registered — it stays running and
1592
- // we pick up alias + taskID from the output.
1786
+ // Delegate tool (pantheon_delegate AND native task): pending/running →
1787
+ // job launched (running). A COMPLETED delegate tool only means the job was
1788
+ // registered — it stays running and we pick up alias + taskID from the
1789
+ // output. Native `task` parts never yield a board alias, so their rows
1790
+ // stay `nat:` until the children channel resolves them.
1593
1791
  const existing = map.get(parsed.callID)
1594
1792
  if (parsed.status === 'error') {
1595
1793
  if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
@@ -1598,7 +1796,7 @@ export function reduceDelegationToolPart(
1598
1796
  callID: parsed.callID,
1599
1797
  partID: parsed.partID,
1600
1798
  sessionID: parsed.sessionID,
1601
- tool: 'pantheon_delegate',
1799
+ tool: parsed.tool,
1602
1800
  agent: parsed.agent ?? 'agent',
1603
1801
  description: parsed.description,
1604
1802
  alias: existing?.alias ?? null,
@@ -1615,7 +1813,7 @@ export function reduceDelegationToolPart(
1615
1813
  callID: parsed.callID,
1616
1814
  partID: parsed.partID,
1617
1815
  sessionID: parsed.sessionID,
1618
- tool: 'pantheon_delegate',
1816
+ tool: parsed.tool,
1619
1817
  agent: parsed.agent ?? 'agent',
1620
1818
  description: parsed.description,
1621
1819
  alias: parsed.alias,
@@ -1668,11 +1866,14 @@ export function removeDelegationEntry(
1668
1866
  return false
1669
1867
  }
1670
1868
 
1671
- /** Collect pantheon delegation tool parts from a session's messages.
1869
+ /** Collect pantheon delegation + native task tool parts from a session's messages.
1672
1870
  * Messages may carry their parts inline (duck-typed `msg.parts`); when
1673
1871
  * they don't, the optional `getParts(messageID)` callback is used (the TUI
1674
- * SDK exposes `api.state.part(messageID)`). Pure w.r.t. I/O — used by the
1675
- * mount re-scan to re-seed the live map after compaction/attach. */
1872
+ * SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
1873
+ * child session with parentID = caller — the same mechanism as
1874
+ * pantheon_delegate — so its parts feed the live-map as the native signal
1875
+ * (rows render `nat:` via the children channel). Pure w.r.t. I/O — used
1876
+ * by the mount re-scan to re-seed the live map after compaction/attach. */
1676
1877
  export function collectDelegationToolParts(
1677
1878
  messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
1678
1879
  getParts?: (messageID: string) => readonly unknown[] | undefined,
@@ -1690,7 +1891,9 @@ export function collectDelegationToolParts(
1690
1891
  const part = raw as DelegationToolPart
1691
1892
  if (
1692
1893
  part?.type === 'tool' &&
1693
- (part.tool === 'pantheon_delegate' || part.tool === 'pantheon_delegation_read')
1894
+ (part.tool === 'pantheon_delegate' ||
1895
+ part.tool === 'pantheon_delegation_read' ||
1896
+ part.tool === 'task')
1694
1897
  ) {
1695
1898
  out.push(part)
1696
1899
  }
@@ -1880,19 +2083,139 @@ export type ChildDelegationLike = {
1880
2083
  * (the child is actively working), idle → completed, unknown → running
1881
2084
  * (fail-open: a freshly-seen child is assumed active; the 1s poll + md
1882
2085
  * correct it as soon as terminal data exists). */
1883
- export function childStatusToState(status: string | undefined): 'running' | 'completed' {
2086
+ export function childStatusToState(status: string | undefined): 'running' | 'completed' | 'retry' {
1884
2087
  if (status === 'idle') return 'completed'
1885
- return 'running' // busy, retry, or unknown
2088
+ if (status === 'retry') return 'retry'
2089
+ return 'running' // busy or unknown
1886
2090
  }
1887
2091
 
1888
- /** Row tag for a delegation entry: `[native]` for native task() children
1889
- * (source 'children-only' — no board report), `[pantheon:<alias>]` for board
1890
- * rows ([pantheon:apo-1]) — the same tags the manager prints in
1891
- * pantheon_delegation_list, so panel rows and CLI list lines match. The
1892
- * panel renders the tag with a distinct style so native task() children are
1893
- * visually separable from pantheon_delegate jobs. */
2092
+ /** Short row prefix for a delegation entry (one of the two visual channels
2093
+ * that split native task() work from pantheon_delegate jobs — the other is
2094
+ * {@link delegationIcon}):
2095
+ *
2096
+ * - `nat:<agent>` — a native task() child (source 'children-only', no board
2097
+ * report). The child session carries no board alias, so the agent IS the
2098
+ * identity.
2099
+ * - `pan:<alias>` — a pantheon_delegate board row (`pan:apo-1`), the same
2100
+ * alias the manager prints in pantheon_delegation_list.
2101
+ *
2102
+ * Short prefixes keep the narrow sidebar readable (the old
2103
+ * `pan:apo-1`/`nat:` tags ate the row width). */
1894
2104
  export function delegationTag(entry: DelegationEntry): string {
1895
- return entry.source === 'children-only' ? '[native]' : `[pantheon:${entry.alias}]`
2105
+ return entry.source === 'children-only' ? `nat:${entry.agent}` : `pan:${entry.alias}`
2106
+ }
2107
+
2108
+ /** Row glyph: hollow diamond `◇` for native task() children (info color,
2109
+ * outline) vs filled diamond `◆` for pantheon_delegate rows (state color).
2110
+ * Shape + color are independent channels, so the split stays legible even
2111
+ * without color. */
2112
+ export function delegationIcon(entry: DelegationEntry): string {
2113
+ return entry.source === 'children-only' ? '\u25c7' : '\u25c6'
2114
+ }
2115
+
2116
+ /** Row identity after the status marker + glyph. Pantheon rows append the
2117
+ * agent (`pan:apo-1 apollo`); native rows already carry it inside the tag
2118
+ * (`nat:apollo`) and must not duplicate it. */
2119
+ export function formatDelegationIdentity(entry: DelegationEntry): string {
2120
+ return entry.source === 'children-only'
2121
+ ? delegationTag(entry)
2122
+ : `${delegationTag(entry)} ${entry.agent}`
2123
+ }
2124
+
2125
+ /** Max description width on the row detail line — the old 180-char slice
2126
+ * wrapped the sidebar; 44 keeps one readable line. */
2127
+ export const DELEGATION_DESCRIPTION_MAX = 44
2128
+
2129
+ /** Lazily-built grapheme segmenter. `Intl.Segmenter` keeps ZWJ emoji families
2130
+ * (and skin-tone/variation sequences) intact; `Array.from` (code points) is
2131
+ * the fallback for runtimes without it. */
2132
+ let graphemeSegmenter: Intl.Segmenter | undefined
2133
+
2134
+ /** Split `text` into display graphemes so truncation never cuts a multi-unit
2135
+ * emoji in half (surrogate pair → mojibake). */
2136
+ function splitGraphemes(text: string): string[] {
2137
+ if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
2138
+ graphemeSegmenter ??= new Intl.Segmenter('pt', { granularity: 'grapheme' })
2139
+ return Array.from(graphemeSegmenter.segment(text), (s) => s.segment)
2140
+ }
2141
+ return Array.from(text)
2142
+ }
2143
+
2144
+ /** Truncate a description to `max` graphemes appending `…` when cut. Exact
2145
+ * `max`-length text is left untouched. Grapheme-granular, so an emoji made of
2146
+ * several code points is never split into mojibake at the boundary. Pure. */
2147
+ export function truncateDelegationDescription(
2148
+ text: string,
2149
+ max = DELEGATION_DESCRIPTION_MAX,
2150
+ ): string {
2151
+ const graphemes = splitGraphemes(text)
2152
+ if (graphemes.length <= max) return text
2153
+ return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
2154
+ }
2155
+
2156
+ /** Fixed elapsed-time box: right-aligned to `width` (default 8) so elapsed
2157
+ * values line up across rows, e.g. ` 12s`. A label that already fills
2158
+ * the box is returned untouched. Pure. */
2159
+ export const DELEGATION_ELAPSED_WIDTH = 8
2160
+
2161
+ export function formatDelegationElapsed(
2162
+ entry: DelegationEntry,
2163
+ now: number,
2164
+ width = DELEGATION_ELAPSED_WIDTH,
2165
+ ): string {
2166
+ const label = delegationElapsed(entry, now)
2167
+ if (label.length >= width) return label
2168
+ return `${' '.repeat(width - label.length)}${label}`
2169
+ }
2170
+
2171
+ /** First row line: `<marker><glyph> <identity>`. The marker already carries
2172
+ * its trailing space. Pure — the row renders the returned string verbatim. */
2173
+ export function formatDelegationRow(entry: DelegationEntry, marker: string): string {
2174
+ return `${marker}${delegationIcon(entry)} ${formatDelegationIdentity(entry)}`
2175
+ }
2176
+
2177
+ // Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
2178
+ // as dead code: `session.children` returns direct children only, so no row is
2179
+ // ever actually nested and the connectors were never populated in production.
2180
+ // BACKLOG: produce real grandchild rows first, then reintroduce them.
2181
+
2182
+ /** Header summary: `(N active · M done · nat:K pan:M)`. Active counts
2183
+ * running/retry AND display-only stale-running (a stale row is still a live
2184
+ * job — it must never read as done). Pure. */
2185
+ export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
2186
+ let active = 0
2187
+ for (const e of entries) {
2188
+ if (e.state === 'running' || e.state === 'retry' || e.state === 'stale-running') active++
2189
+ }
2190
+ const done = entries.length - active
2191
+ const counts = countDelegationSources(entries)
2192
+ return `(${active} active \u00b7 ${done} done \u00b7 nat:${counts.native} pan:${counts.pantheon})`
2193
+ }
2194
+
2195
+ /** Split a display list into native task() rows vs pantheon_delegate rows.
2196
+ * Native = source 'children-only' (no board report); everything else counts
2197
+ * as pantheon. Pure — powers the header breakdown + the hooks.log line. */
2198
+ export function countDelegationSources(entries: readonly DelegationEntry[]): {
2199
+ native: number
2200
+ pantheon: number
2201
+ total: number
2202
+ } {
2203
+ let native = 0
2204
+ for (const e of entries) if (e.source === 'children-only') native++
2205
+ return { native, pantheon: entries.length - native, total: entries.length }
2206
+ }
2207
+
2208
+ /** Diagnostic hooks.log line for a panel re-fetch, with the children
2209
+ * breakdown (pantheon = children WITH a board report, native = children
2210
+ * WITHOUT one). Pure — the View logs the returned string verbatim. */
2211
+ export function formatPanelLogLine(
2212
+ children: number,
2213
+ pantheon: number,
2214
+ native: number,
2215
+ md: number,
2216
+ events: number,
2217
+ ): string {
2218
+ return `panel: children=${children}(pantheon=${pantheon} native=${native}) md=${md} events=${events}`
1896
2219
  }
1897
2220
 
1898
2221
  /** Turn child sessions (PRIMARY) enriched with md reports into the display
@@ -1903,10 +2226,14 @@ export function delegationTag(entry: DelegationEntry): string {
1903
2226
  * itself (fallback 'agent'), state derived from its status, startedAt from
1904
2227
  * time.created. A report-less child is a NATIVE task() child (every
1905
2228
  * 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 `[native]` tag instead of a board alias.
2229
+ * tool — carries parentID = caller), so it gets source 'children-only',
2230
+ * the internal alias 'native-task' and the `nat:` tag instead of a
2231
+ * board alias. The 'task nativa' description fallback keeps the row
2232
+ * non-empty when the child carries no title.
1908
2233
  * Terminal md state wins over the derived state; a running md defers to
1909
- * the child's live status. Sorted running-first (compareDelegationEntries).
2234
+ * the child's live status. A running child is NEVER archived — it always
2235
+ * lands in the active split (splitDelegationList active = running/retry).
2236
+ * Sorted running-first (compareDelegationEntries).
1910
2237
  * Pure — no I/O. */
1911
2238
  export function childrenToDelegationEntries(
1912
2239
  children: readonly ChildDelegationLike[] | undefined,
@@ -1928,7 +2255,7 @@ export function childrenToDelegationEntries(
1928
2255
  ? mdEntry.state
1929
2256
  : childStatusToState(child.status)
1930
2257
  out.push({
1931
- alias: mdEntry?.alias ?? 'task',
2258
+ alias: mdEntry?.alias ?? 'native-task',
1932
2259
  sessionID: mdEntry?.sessionID ?? '',
1933
2260
  taskID: child.id,
1934
2261
  agent: mdEntry?.agent ?? child.agent ?? 'agent',
@@ -1941,7 +2268,9 @@ export function childrenToDelegationEntries(
1941
2268
  description:
1942
2269
  mdEntry !== undefined && mdEntry.description !== ''
1943
2270
  ? mdEntry.description
1944
- : (child.title ?? ''),
2271
+ : child.title !== undefined && child.title !== ''
2272
+ ? child.title
2273
+ : 'task nativa',
1945
2274
  source: mdEntry !== undefined ? 'md' : 'children-only',
1946
2275
  })
1947
2276
  }
@@ -2007,8 +2336,19 @@ function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui
2007
2336
  `${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
2008
2337
  'utf8',
2009
2338
  )
2010
- } catch {
2011
- /* best-effort file log — never break the panel over logging */
2339
+ } catch (err) {
2340
+ /* best-effort file log — never break the panel over logging, but do
2341
+ * NOT swallow the failure silently: a missing symbol here (e.g.
2342
+ * `appendFile` used without an import) previously threw an invisible
2343
+ * ReferenceError and the log silently stopped writing. Gated by the
2344
+ * echo flag to avoid the TUI-pollution bug described above. */
2345
+ if (echo) {
2346
+ try {
2347
+ process.stderr.write(`[${module}] file-log failed: ${formatArg(err)}\n`)
2348
+ } catch {
2349
+ /* stderr unavailable — never break the panel */
2350
+ }
2351
+ }
2012
2352
  }
2013
2353
  })()
2014
2354
  if (echo) {
@@ -2067,44 +2407,34 @@ function DelegationRow(props: {
2067
2407
 
2068
2408
  const color = createMemo(() => {
2069
2409
  const t = theme()
2070
- switch (props.job.state) {
2071
- case 'running':
2410
+ switch (delegationStateTone(props.job.state)) {
2411
+ case 'warning':
2072
2412
  return t.warning
2073
- case 'stale-running':
2074
- return t.error // warning color for stale delegations
2075
- case 'completed':
2076
- return t.success
2077
2413
  case 'error':
2078
2414
  return t.error
2415
+ case 'success':
2416
+ return t.success
2079
2417
  default:
2080
- return t.textMuted // cancelled
2418
+ return t.textMuted // muted (cancelled)
2081
2419
  }
2082
2420
  })
2083
2421
 
2084
- const marker = createMemo(() => {
2085
- if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
2086
- if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
2087
- switch (props.job.state) {
2088
- case 'completed':
2089
- return '\u2713 '
2090
- case 'error':
2091
- return '\u2715 '
2092
- default:
2093
- return '\u25cb '
2094
- }
2095
- })
2422
+ const marker = createMemo(() => delegationStateMarker(props.job.state, props.animationNow))
2096
2423
 
2097
- // The row tag: `[native]` for native task() children (no board report),
2098
- // `[pantheon:apo-1]`-style alias for pantheon_delegate board rows. Rendered with
2099
- // the info color so task() children are visually distinct from delegates.
2424
+ // Two visual channels split native task() from pantheon_delegate: the tag
2425
+ // prefix (`nat:`/`pan:`, via delegationTag) and the glyph color — info
2426
+ // (cyan) for native, state color for board rows. The glyph shape (◇/◆) is
2427
+ // the third, color-independent channel.
2100
2428
  const tagColor = createMemo(() => (props.job.source === 'children-only' ? theme().info : color()))
2101
2429
 
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
- })
2430
+ const description = createMemo(() => truncateDelegationDescription(props.job.description))
2431
+ const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
2432
+
2433
+ // beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
2434
+ // fed by message.part.updated activity tracking. Null while idle/done.
2435
+ const activity = createMemo(() =>
2436
+ latestToolActivityFor(latestToolActivity, props.job.taskID, props.now),
2437
+ )
2108
2438
 
2109
2439
  // Click a row → navigate to the child session (route API guarded in the
2110
2440
  // helper). Enter-key binding is left out (per-row keymap wiring is not
@@ -2116,13 +2446,23 @@ function DelegationRow(props: {
2116
2446
  return (
2117
2447
  <box onMouseDown={open}>
2118
2448
  <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>
2449
+ <text fg={tagColor()}>{formatDelegationRow(props.job, marker())}</text>
2124
2450
  </box>
2125
- <text fg={theme().textMuted}>{detail()}</text>
2451
+ {/* Elapsed is ALWAYS rendered (outside the optional-description Show) so
2452
+ * an active job with no description never loses its timer. */}
2453
+ <box flexDirection="row" justifyContent={description() !== '' ? 'space-between' : 'flex-end'}>
2454
+ <Show when={description() !== ''}>
2455
+ <text fg={theme().textMuted}>{description()}</text>
2456
+ </Show>
2457
+ <text fg={theme().textMuted}>{elapsed()}</text>
2458
+ </box>
2459
+ <Show when={activity()}>
2460
+ {(a) => (
2461
+ <text
2462
+ fg={theme().textMuted}
2463
+ >{` \u21b3 ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2464
+ )}
2465
+ </Show>
2126
2466
  </box>
2127
2467
  )
2128
2468
  }
@@ -2249,9 +2589,10 @@ function View(props: {
2249
2589
  // remains on the 1s ticker to avoid unnecessary work.
2250
2590
  const [animationNow, setAnimationNow] = createSignal(Date.now())
2251
2591
  // 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.
2592
+ // ("panel: children=N(pantheon=M native=K) md=N events=N") so the (0)
2593
+ // symptom is diagnosable: children>0 means the source works; events=0
2594
+ // means the event bus isn't delivering, and the 1s poll is what keeps
2595
+ // the panel honest.
2255
2596
  let eventRefreshCount = 0
2256
2597
  // In-flight guard: overlap between the poll, events and the version effect
2257
2598
  // collapses into one fetch — no duplicate work, no duplicate entries.
@@ -2283,7 +2624,7 @@ function View(props: {
2283
2624
  if (sessionID === null) {
2284
2625
  setChildDelegations(md)
2285
2626
  panelLog.info(
2286
- `panel: children=0 md=${md.length} events=${eventRefreshCount} (no sessionID — history shown)`,
2627
+ `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
2287
2628
  )
2288
2629
  return
2289
2630
  }
@@ -2326,8 +2667,15 @@ function View(props: {
2326
2667
  )
2327
2668
  setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
2328
2669
  // 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
2670
+ const counts = countDelegationSources(childEntries)
2329
2671
  panelLog.info(
2330
- `panel: children=${children.length} md=${md.length} events=${eventRefreshCount}`,
2672
+ formatPanelLogLine(
2673
+ children.length,
2674
+ counts.pantheon,
2675
+ counts.native,
2676
+ md.length,
2677
+ eventRefreshCount,
2678
+ ),
2331
2679
  )
2332
2680
  } finally {
2333
2681
  delegationsInflight = null
@@ -2335,9 +2683,24 @@ function View(props: {
2335
2683
  })()
2336
2684
  return delegationsInflight
2337
2685
  }
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()))
2686
+ // beta.6: active (running/retry) first, then the 8 most recent terminal
2687
+ // reports, then the archived tail (paginated). Pagination state lives at
2688
+ // module level so sidebar remounts keep the user's page.
2689
+ const delegationSplit = createMemo(() => splitDelegationList(childDelegations()))
2690
+ const visibleDelegations = createMemo(() => [
2691
+ ...delegationSplit().active,
2692
+ ...delegationSplit().recent,
2693
+ ])
2694
+ // Header summary over the FULL list (active + recent + archived): active
2695
+ // vs done plus the native/pantheon breakdown, so a busy session reads
2696
+ // "Delegations (2 active · 9 done · nat:3 pan:8)".
2697
+ const delegationHeader = createMemo(() => formatDelegationHeader(childDelegations()))
2698
+ const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2699
+ const clampArchivedPage = (page: number, pages: number) => {
2700
+ const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
2701
+ archivedUiState.page = clamped
2702
+ setArchivedPage(clamped)
2703
+ }
2341
2704
 
2342
2705
  // ── Event subscriptions for live session/delegation updates ──
2343
2706
  onMount(() => {
@@ -2385,7 +2748,7 @@ function View(props: {
2385
2748
  void refreshDelegations()
2386
2749
  }, 1_000)
2387
2750
  cleanup.push(() => clearInterval(poll))
2388
- const animation = setInterval(() => setAnimationNow(Date.now()), 140)
2751
+ const animation = setInterval(() => setAnimationNow(Date.now()), 1_000)
2389
2752
  cleanup.push(() => clearInterval(animation))
2390
2753
 
2391
2754
  // Compaction recovery: `message.part.removed` clears live entries while
@@ -2480,9 +2843,9 @@ function View(props: {
2480
2843
  {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
2481
2844
  <box onMouseDown={() => setShowDelegations((x) => !x)}>
2482
2845
  <text fg={theme().text} attributes={1}>
2483
- {`${showDelegations() ? '\u25bc' : '\u25b6'} Delegations`}
2846
+ {`${showDelegations() ? '▼' : '▶'} Delegations`}
2484
2847
  </text>
2485
- <text fg={theme().textMuted}>{` (${String(visibleDelegations().length)})`}</text>
2848
+ <text fg={theme().textMuted}>{` ${delegationHeader()}`}</text>
2486
2849
  </box>
2487
2850
  <Show when={showDelegations()}>
2488
2851
  <Show
@@ -2504,6 +2867,41 @@ function View(props: {
2504
2867
  />
2505
2868
  )}
2506
2869
  </For>
2870
+ <Show when={delegationSplit().archived.length > 0}>
2871
+ {(() => {
2872
+ const archived = delegationSplit().archived
2873
+ const pageSize = 8
2874
+ const pages = Math.ceil(archived.length / pageSize)
2875
+ const page = Math.min(archivedPage(), pages - 1)
2876
+ const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
2877
+ return (
2878
+ <box flexDirection="column">
2879
+ <box flexDirection="row" marginLeft={0}>
2880
+ <text
2881
+ fg={theme().textMuted}
2882
+ onMouseDown={() => clampArchivedPage(page - 1, pages)}
2883
+ >{`\u2039 `}</text>
2884
+ <text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
2885
+ <text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
2886
+ <text
2887
+ fg={theme().textMuted}
2888
+ onMouseDown={() => clampArchivedPage(page + 1, pages)}
2889
+ >{` \u203a`}</text>
2890
+ </box>
2891
+ <For each={slice}>
2892
+ {(job) => (
2893
+ <DelegationRow
2894
+ api={props.api}
2895
+ job={job}
2896
+ now={now()}
2897
+ animationNow={animationNow()}
2898
+ />
2899
+ )}
2900
+ </For>
2901
+ </box>
2902
+ )
2903
+ })()}
2904
+ </Show>
2507
2905
  </box>
2508
2906
  </Show>
2509
2907
  </Show>
@@ -2552,6 +2950,11 @@ const tui: TuiPlugin = (api, _options, _meta) => {
2552
2950
  const props = (event?.properties ?? {}) as { part?: unknown; info?: { part?: unknown } }
2553
2951
  const part = (props.part ?? props.info?.part) as DelegationToolPart | undefined
2554
2952
  if (part === undefined) return
2953
+ // beta.6: track the live tool call per child session (panel's
2954
+ // activity line). Fire-and-forget — display-only state.
2955
+ const sample = extractToolActivity(part as any)
2956
+ if (sample !== null)
2957
+ trackToolActivity(latestToolActivity, sample.sessionID, sample.activity)
2555
2958
  if (reduceDelegationToolPart(liveStore.map, part)) liveStore.bump()
2556
2959
  }),
2557
2960
  )