pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d

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 (52) hide show
  1. package/AGENTS.md +919 -0
  2. package/README.md +157 -15
  3. package/bin/pantheon-init.mjs +177 -120
  4. package/opencode.json +3 -6
  5. package/package.json +8 -4
  6. package/scripts/build-agents-md.mjs +195 -0
  7. package/scripts/cost.mjs +17 -0
  8. package/scripts/doctor.mjs +56 -22
  9. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  10. package/scripts/install/opencode.mjs +189 -38
  11. package/scripts/install/plugin.mjs +112 -28
  12. package/scripts/install-mcp.mjs +1 -1
  13. package/scripts/redaction-gate.mjs +69 -0
  14. package/scripts/release-beta-version.mjs +80 -0
  15. package/scripts/release-notes.mjs +317 -0
  16. package/scripts/test-context-compression.sh +5 -2
  17. package/scripts/validate-package.mjs +56 -0
  18. package/scripts/versioning.mjs +117 -18
  19. package/src/agents/zeus.md +6 -2
  20. package/src/pantheon/background-job-board.ts +24 -38
  21. package/src/pantheon/chat-reminders.ts +79 -0
  22. package/src/pantheon/compaction-assert.ts +160 -0
  23. package/src/pantheon/cost-command.ts +57 -18
  24. package/src/pantheon/delegation-compaction.ts +143 -16
  25. package/src/pantheon/delegation-finalize.ts +8 -1
  26. package/src/pantheon/delegation-notify.ts +19 -157
  27. package/src/pantheon/delegation.ts +330 -28
  28. package/src/pantheon/dispatch-guard.ts +9 -4
  29. package/src/pantheon/goal-loop.ts +7 -4
  30. package/src/pantheon/hashline/tool.ts +2 -1
  31. package/src/pantheon/logger.ts +117 -0
  32. package/src/pantheon/plugin-once.ts +76 -0
  33. package/src/pantheon/preemptive-compact.ts +76 -0
  34. package/src/pantheon/presets.d.mts +31 -0
  35. package/src/pantheon/presets.mjs +105 -3
  36. package/src/pantheon/todo-enforcer.ts +79 -27
  37. package/src/pantheon/todo-preserve.ts +295 -0
  38. package/src/plugin.ts +177 -50
  39. package/src/plugins/pantheon-hooks.ts +35 -22
  40. package/src/plugins/tui/dist/server.d.ts +17 -0
  41. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  42. package/src/plugins/tui/dist/server.js +20 -0
  43. package/src/plugins/tui/dist/server.js.map +1 -0
  44. package/src/plugins/tui/dist/tui.d.ts +303 -1
  45. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  46. package/src/plugins/tui/dist/tui.js +977 -350
  47. package/src/plugins/tui/dist/tui.tsx +1427 -150
  48. package/src/plugins/tui/package.json +4 -3
  49. package/src/plugins/tui/src/index.tsx +1427 -150
  50. package/src/plugins/tui/src/server.ts +15 -0
  51. package/src/plugins/tui/tsdown.config.ts +1 -1
  52. package/src/routing.yml +3 -1
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Slots:
13
13
  * - sidebar_content (order 900) — Pantheon sidebar (header/version/
14
- * branch, Sessions, Commands, Agents, Config, Memory).
14
+ * branch, Sessions, real-time Delegations panel).
15
15
  * - app_bottom (order 60) — AI subscription usage gauges
16
16
  * (Anthropic/OpenAI quotas, OpenCode Go/Zen dollar limits + provider
17
17
  * status incidents).
@@ -46,56 +46,31 @@
46
46
  */
47
47
 
48
48
  import { Buffer } from 'node:buffer'
49
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
49
+ import type { Dirent } from 'node:fs'
50
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
50
51
  import { homedir } from 'node:os'
51
52
  import { dirname, join } from 'node:path'
52
53
  import { fileURLToPath } from 'node:url'
53
54
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui'
54
- import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
55
+ import {
56
+ createEffect,
57
+ createMemo,
58
+ createResource,
59
+ createSignal,
60
+ For,
61
+ onCleanup,
62
+ onMount,
63
+ Show,
64
+ } from 'solid-js'
55
65
 
56
66
  /* ─── Constants ─────────────────────────────────────────── */
57
67
 
58
- const COMMANDS = [
59
- { name: '/pantheon', desc: 'Council synthesis' },
60
- { name: '/pantheon-status', desc: 'System status' },
61
- { name: '/pantheon-audit', desc: 'Full audit' },
62
- { name: '/pantheon-bg', desc: 'List background tasks' },
63
- { name: '/pantheon-cancel', desc: 'Cancel task' },
64
- { name: '/pantheon-deepwork', desc: 'Deep work mode' },
65
- { name: '/pantheon-focus', desc: 'Focus on scope' },
66
- { name: '/pantheon-remember', desc: 'Memory store/recall' },
67
- { name: '/pantheon-search', desc: 'Memory search' },
68
- { name: '/pantheon-consolidate', desc: 'Merge memories' },
69
- { name: '/pantheon-forget', desc: 'Compress memories' },
70
- ] as const
71
-
72
68
  /** How often the sidebar re-reads .pantheon/active-preset.json so `set-tier`
73
69
  * changes made while opencode is open show up within ~30s. */
74
70
  const PRESET_REFRESH_MS = 30_000
75
71
 
76
- const AGENTS = [
77
- { name: 'zeus', tier: 'default' as const, role: 'Orchestrator' },
78
- { name: 'athena', tier: 'premium' as const, role: 'Strategic planner' },
79
- { name: 'apollo', tier: 'fast' as const, role: 'Codebase discovery' },
80
- { name: 'hermes', tier: 'default' as const, role: 'Backend' },
81
- { name: 'aphrodite', tier: 'default' as const, role: 'Frontend' },
82
- { name: 'demeter', tier: 'default' as const, role: 'Database' },
83
- { name: 'themis', tier: 'premium' as const, role: 'Quality & security' },
84
- { name: 'prometheus', tier: 'default' as const, role: 'Infrastructure' },
85
- { name: 'hephaestus', tier: 'default' as const, role: 'AI pipelines' },
86
- { name: 'nyx', tier: 'fast' as const, role: 'Observability' },
87
- { name: 'gaia', tier: 'fast' as const, role: 'Remote sensing' },
88
- { name: 'iris', tier: 'fast' as const, role: 'GitHub operations' },
89
- { name: 'mnemosyne', tier: 'fast' as const, role: 'Memory bank' },
90
- { name: 'talos', tier: 'fast' as const, role: 'Hotfixes' },
91
- ] as const
92
-
93
72
  /* ─── Helpers ───────────────────────────────────────────── */
94
73
 
95
- function fmtInt(n: number): string {
96
- return Intl.NumberFormat('en-US').format(Math.max(0, Math.round(n)))
97
- }
98
-
99
74
  /* ─── Active Preset Detection ──────────────────────────────
100
75
  * Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the TUI
101
76
  * plugin is self-contained (dist/tui.tsx is a raw copy of this file with no
@@ -873,12 +848,1075 @@ async function setupUsageBar(api: TuiPluginApi) {
873
848
  })
874
849
  }
875
850
 
851
+ /* ─── Delegations (real-time panel) ────────────────────────
852
+ * CHILDREN + MD (delegations-sidebar pattern, feat/compaction-134):
853
+ *
854
+ * 1. PRIMARY — SDK child sessions via `api.client.session.children`:
855
+ * every pantheon_delegate creates a child session with
856
+ * parentID = caller (src/pantheon/delegation.ts session.create), so
857
+ * the children of the current session ARE the delegation list — the
858
+ * same mechanism the opencode-delegations-sidebar reference uses
859
+ * (`client.session.children` + `session.parentID === current`).
860
+ * Live status comes from `api.state.session.status(childID)`
861
+ * (busy/retry → running; idle → terminal). This does NOT depend on
862
+ * tool-part events, which runtime 1.18.13 may not deliver for
863
+ * pantheon_delegate (the "(0) even while delegating" symptom).
864
+ *
865
+ * 1b. NATIVE task() CHILDREN — `session.children` returns ALL children,
866
+ * and the native `task()` tool ALSO spawns a child session with
867
+ * parentID = caller. A child WITHOUT a board report is a native
868
+ * task() child: childrenToDelegationEntries tags it source
869
+ * 'children-only' with the `[task]` row tag (distinct info color),
870
+ * so native task() work is visible in the panel alongside board
871
+ * delegates ([apo-1]) — never filtered out, never duplicated with a
872
+ * board row for the same child (a child WITH a report keeps the md
873
+ * entry, source 'md').
874
+ *
875
+ * 2. ENRICHMENT + HISTORY — markdown reports the job board persists under
876
+ * `.pantheon/delegations/<sessionID>/<alias>.md` (written by
877
+ * src/pantheon/delegation-finalize.ts at terminal state). Each child
878
+ * is matched to its report by the `Task ID` header (== child session
879
+ * id == board task id), which supplies the alias (apo-N), agent,
880
+ * description and terminal duration/timedOut. No report → the child
881
+ * itself still renders (title as description, derived state). The md
882
+ * channel is ALSO read from EVERY session (readAllDelegationEntries) —
883
+ * the panel shows the full delegation history even without a focused
884
+ * session.
885
+ *
886
+ * 3. REFRESH — session.created/updated/deleted/status events and
887
+ * message.part.updated/removed (via liveStore.version bumps) re-fetch
888
+ * children; a 1s safety poll (oh-my-opencode-slim pattern) re-fetches
889
+ * children + re-reads the md so the panel updates EVEN without events.
890
+ *
891
+ * 4. DIAGNOSTICS — every re-fetch logs "panel: children=N md=N events=N"
892
+ * to `.pantheon/logs/hooks.log` (silence-by-default, createPantheonLogger
893
+ * policy) so the (0) symptom is diagnosable from disk. A children fetch
894
+ * failure logs "panel: error <msg>" (previously silenced), and a missing
895
+ * current session (null/placeholder sessionID) logs "panel: children=0
896
+ * md=N ... (no sessionID — history shown)" — the md HISTORY renders
897
+ * instead of erroring.
898
+ *
899
+ * 5. SESSIONID GUARD (placeholder regression) — the runtime may hand the
900
+ * sidebar the UNSUBSTITUTED template literal "{sessionID}" as its
901
+ * session_id (no focused session). Forwarding it to session.children
902
+ * made opencode's server reject every poll (~1 err/s: "Expected a string
903
+ * starting with \"ses\", got \"%7BsessionID%7D\"") and fail-open the
904
+ * panel into "(0)". resolveCurrentSessionID (slot prop → api.state →
905
+ * route.current) never yields a placeholder (isValidSessionId rejects
906
+ * "{sessionID}" and "%7BsessionID%7D" via the startsWith("ses")
907
+ * contract); a null resolution SKIPS the children fetch entirely and
908
+ * renders the md history — zero errors.
909
+ *
910
+ * The live tool-part lifecycle helpers below remain (tested, exported) but
911
+ * are no longer the source of truth: they feed a version signal that merely
912
+ * re-triggers the children fetch. Everything is fail-open: a missing
913
+ * children API or directory yields [], and any unreadable input is skipped —
914
+ * never crash. */
915
+
916
+ export type DelegationEntry = {
917
+ /** Job alias, e.g. "apo-1" (from the H1 title, falling back to filename). */
918
+ alias: string
919
+ /** Parent session the job was launched from (dir name under .pantheon/delegations). */
920
+ sessionID: string
921
+ /** Child session id (= board task id, from the `Task ID` header). The
922
+ * children channel always sets it from the child session itself; the md
923
+ * channel parses it so child↔md matching works by taskID. */
924
+ taskID?: string
925
+ /** Agent name, e.g. "apollo". */
926
+ agent: string
927
+ state: 'running' | 'completed' | 'error' | 'cancelled'
928
+ /** Epoch ms of the `Started` header. */
929
+ startedAt: number
930
+ /** Epoch ms of the `Finalized` header — null while still running. */
931
+ updatedAt: number | null
932
+ timedOut: boolean
933
+ description: string
934
+ /** True while the panel is waiting for pantheon_delegation_read. */
935
+ read?: boolean
936
+ /** Internal provenance used to keep a finalized md report authoritative.
937
+ * 'children-only' = a native task() child session with NO board report
938
+ * (rendered with the distinct `[task]` tag); 'md' = board report wins. */
939
+ source?: 'child' | 'live' | 'md' | 'children-only'
940
+ }
941
+
942
+ /** True for `\s` characters (space, tab, newline, CR) — plain char checks so
943
+ * the parser stays regex-free (CodeQL flagged the old `\s*`/`\s+` + `(.+)`
944
+ * patterns as polynomial ReDoS: 12x HIGH). */
945
+ function isWs(ch: string | undefined): boolean {
946
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
947
+ }
948
+
949
+ /** Em/en dash + hyphen — the separators accepted after the H1 title. */
950
+ const TITLE_SEPARATORS = '—–-'
951
+
952
+ /** Parse one delegation report md header into a structured entry.
953
+ * Returns null (skip) when the file is not a recognizable report:
954
+ * missing agent/state/startedAt, an unknown state, or an unparsable
955
+ * Started timestamp. The alias falls back to the file name when the H1
956
+ * title is missing. Pure — no I/O.
957
+ *
958
+ * Linear, single-pass over `raw.split('\n')` with plain string operations
959
+ * (startsWith/indexOf/slice) — zero regex, so worst case is O(bytes) even
960
+ * on adversarial whitespace-heavy input (ReDoS regression, CodeQL 12x HIGH). */
961
+ export function parseDelegationMarkdown(
962
+ raw: string,
963
+ fileAlias?: string,
964
+ sessionID = '',
965
+ ): DelegationEntry | null {
966
+ let title: string | undefined
967
+ let agent: string | undefined
968
+ let description = ''
969
+ let state: string | undefined
970
+ let timedOut = false
971
+ let started: string | undefined
972
+ let finalized: string | undefined
973
+ let taskID: string | undefined
974
+
975
+ for (const rawLine of raw.split('\n')) {
976
+ // H1 title: `# Delegation Report — <alias>` (empty alias → fallback later).
977
+ if (rawLine[0] === '#' && isWs(rawLine[1])) {
978
+ let i = 2
979
+ while (i < rawLine.length && isWs(rawLine[i])) i++
980
+ if (rawLine.startsWith('Delegation Report', i)) {
981
+ i += 'Delegation Report'.length
982
+ while (i < rawLine.length && isWs(rawLine[i])) i++
983
+ if (i < rawLine.length && TITLE_SEPARATORS.includes(rawLine[i])) {
984
+ const rest = rawLine.slice(i + 1).trim()
985
+ if (rest !== '' && title === undefined) title = rest
986
+ }
987
+ }
988
+ continue
989
+ }
990
+ // Field bullets: `- **Name**: value` — unknown names are ignored.
991
+ if (rawLine[0] !== '-') continue
992
+ let i = 1
993
+ while (i < rawLine.length && isWs(rawLine[i])) i++
994
+ if (!rawLine.startsWith('**', i)) continue
995
+ const nameStart = i + 2
996
+ const valueEnd = rawLine.indexOf('**:', nameStart)
997
+ if (valueEnd < 0) continue
998
+ const name = rawLine.slice(nameStart, valueEnd)
999
+ const value = rawLine.slice(valueEnd + 3).trim()
1000
+ switch (name) {
1001
+ case 'Task ID':
1002
+ // `- **Task ID**: \`ses_x\`` — strip the surrounding backticks.
1003
+ if (value !== '' && taskID === undefined) taskID = stripTaskIdTicks(value)
1004
+ break
1005
+ case 'Agent':
1006
+ // Empty value counts as missing (old regex `(.+)` required 1+ chars).
1007
+ if (value !== '' && agent === undefined) agent = value
1008
+ break
1009
+ case 'Description':
1010
+ description = value
1011
+ break
1012
+ case 'State':
1013
+ if (value !== '' && state === undefined) state = value
1014
+ break
1015
+ case 'Timed out':
1016
+ // Old `(true|false)` prefix match, case-sensitive — kept verbatim.
1017
+ timedOut = value.startsWith('true')
1018
+ break
1019
+ case 'Started':
1020
+ if (value !== '' && started === undefined) started = value
1021
+ break
1022
+ case 'Finalized':
1023
+ finalized = value
1024
+ break
1025
+ default:
1026
+ break // unknown field (e.g. Task ID) → ignore the line
1027
+ }
1028
+ }
1029
+
1030
+ const startedAt = started !== undefined ? Date.parse(started) : NaN
1031
+ if (agent === undefined || state === undefined || Number.isNaN(startedAt)) return null
1032
+ const normalized = state.toLowerCase()
1033
+ if (
1034
+ normalized !== 'running' &&
1035
+ normalized !== 'completed' &&
1036
+ normalized !== 'error' &&
1037
+ normalized !== 'cancelled'
1038
+ ) {
1039
+ return null
1040
+ }
1041
+ const finalizedAt = finalized !== undefined ? Date.parse(finalized) : NaN
1042
+
1043
+ return {
1044
+ alias: title ?? (fileAlias !== undefined ? stripMdSuffix(fileAlias) : 'unknown'),
1045
+ sessionID,
1046
+ ...(taskID !== undefined ? { taskID } : {}),
1047
+ agent,
1048
+ state: normalized,
1049
+ startedAt,
1050
+ updatedAt: Number.isNaN(finalizedAt) ? null : finalizedAt,
1051
+ timedOut,
1052
+ description,
1053
+ source: 'md',
1054
+ }
1055
+ }
1056
+
1057
+ /** Strip a trailing `.md` (any case) — linear replacement for /\.md$/i. */
1058
+ function stripMdSuffix(name: string): string {
1059
+ return name.toLowerCase().endsWith('.md') ? name.slice(0, name.length - 3) : name
1060
+ }
1061
+
1062
+ /** Strip surrounding backticks from a `Task ID` value: "`ses_x`" → "ses_x". */
1063
+ function stripTaskIdTicks(value: string): string {
1064
+ const start = value[0] === '`' ? 1 : 0
1065
+ const end =
1066
+ value.length > start && value[value.length - 1] === '`' ? value.length - 1 : value.length
1067
+ return value.slice(start, end)
1068
+ }
1069
+
1070
+ /** Read every delegation report under `<dir>/<sessionID>/<alias>.md`.
1071
+ * Fail-open: a missing/unreadable directory yields [], and each unreadable
1072
+ * or malformed file is skipped individually. Entries are sorted running
1073
+ * first, then terminal by `updatedAt` (most recent first) so the panel can
1074
+ * render them in order directly. */
1075
+ export async function readDelegationEntries(dir: string): Promise<DelegationEntry[]> {
1076
+ let sessionDirs: Dirent[]
1077
+ try {
1078
+ sessionDirs = await readdir(dir, { withFileTypes: true })
1079
+ } catch {
1080
+ return [] // directory absent/unreadable — nothing to show
1081
+ }
1082
+
1083
+ const entries: DelegationEntry[] = []
1084
+ for (const session of sessionDirs) {
1085
+ if (!session.isDirectory()) continue
1086
+ let files: Dirent[]
1087
+ try {
1088
+ files = await readdir(join(dir, session.name), { withFileTypes: true })
1089
+ } catch {
1090
+ continue // unreadable session dir — skip
1091
+ }
1092
+ for (const file of files) {
1093
+ if (!file.isFile() || !file.name.endsWith('.md')) continue
1094
+ try {
1095
+ const raw = await readFile(join(dir, session.name, file.name), 'utf8')
1096
+ const entry = parseDelegationMarkdown(raw, file.name, session.name)
1097
+ if (entry !== null) entries.push(entry)
1098
+ } catch {
1099
+ // unreadable/malformed file — skip, never crash
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ entries.sort(compareDelegationEntries)
1105
+ return entries
1106
+ }
1107
+
1108
+ /** Read delegation reports from EVERY session under
1109
+ * `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
1110
+ * HISTORY channel. Unlike the children/live channels it does NOT depend on a
1111
+ * resolved sessionID: with no focused session (null/placeholder), the panel
1112
+ * still shows the reports from all past sessions (running first, Finalized
1113
+ * desc — the sort applied by readDelegationEntries). Fail-open: a
1114
+ * missing/unreadable directory yields []. */
1115
+ export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
1116
+ return readDelegationEntries(join(root, '.pantheon', 'delegations'))
1117
+ }
1118
+
1119
+ /** Resolve the directory where the job board writes delegation md reports.
1120
+ * The board writes `.pantheon/delegations` RELATIVE to the server cwd,
1121
+ * which the TUI exposes as `TuiState.path.directory`. `project` does NOT
1122
+ * exist on `TuiState.path` (the old `state?.project ?? state?.worktree`
1123
+ * resolution was always undefined for the first term) and `worktree` is
1124
+ * `/` when there is no git (e.g. the sandbox test project) — a root of
1125
+ * `''` or `'/'` must fall back to `process.cwd()`. */
1126
+ export function resolveDelegationsDir(
1127
+ state: { directory?: string; worktree?: string } | undefined,
1128
+ cwd = process.cwd(),
1129
+ ): string {
1130
+ const root = state?.directory ?? state?.worktree ?? ''
1131
+ if (root === '' || root === '/') return join(cwd, '.pantheon', 'delegations')
1132
+ return join(root, '.pantheon', 'delegations')
1133
+ }
1134
+
1135
+ /** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
1136
+ * → `<root>`. Used to point the panel logger at the REAL hooks.log — passing
1137
+ * the delegations dir (or its dirname) directly made createTuiLogger append
1138
+ * to `<root>/.pantheon/.pantheon/logs/hooks.log`, a nested empty dir the real
1139
+ * log never saw. Pure — no I/O. */
1140
+ export function panelLogDir(delegationsDir: string): string {
1141
+ return dirname(dirname(delegationsDir))
1142
+ }
1143
+
1144
+ /** Where the panel logger appends lines: `<projectRoot>/.pantheon/logs/hooks.log`.
1145
+ * Pure — testable without the runtime. */
1146
+ export function tuiLogPath(projectRoot: string): string {
1147
+ return join(projectRoot, '.pantheon', 'logs', 'hooks.log')
1148
+ }
1149
+
1150
+ /** Sort delegations: running first, then terminal by recency (updatedAt,
1151
+ * falling back to startedAt, descending). Shared by the md reader and
1152
+ * mergeDelegationSources. */
1153
+ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
1154
+ const aRun = a.state === 'running' ? 1 : 0
1155
+ const bRun = b.state === 'running' ? 1 : 0
1156
+ if (aRun !== bRun) return bRun - aRun
1157
+ return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
1158
+ }
1159
+
1160
+ /** The list the panel actually renders: running jobs first, then the most
1161
+ * recent terminal reports (capped). Pure — so the history-only panel (no
1162
+ * sessionID) is testable without the TUI runtime. The header count uses the
1163
+ * same "running + recentes" list. */
1164
+ export function visibleDelegationList(
1165
+ all: readonly DelegationEntry[],
1166
+ maxTerminal = 8,
1167
+ ): DelegationEntry[] {
1168
+ const running = all.filter((d) => d.state === 'running')
1169
+ const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
1170
+ return [...running, ...terminal]
1171
+ }
1172
+
1173
+ /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
1174
+ * second for running jobs. */
1175
+ export function fmtElapsed(ms: number): string {
1176
+ const total = Math.max(0, Math.floor(ms / 1000))
1177
+ const days = Math.floor(total / 86_400)
1178
+ const hours = Math.floor((total % 86_400) / 3_600)
1179
+ const minutes = Math.floor((total % 3_600) / 60)
1180
+ const seconds = total % 60
1181
+ if (days > 0) return `${days}d ${hours}h`
1182
+ if (hours > 0) return `${hours}h ${minutes}m`
1183
+ if (minutes > 0) return `${minutes}m ${seconds}s`
1184
+ return `${seconds}s`
1185
+ }
1186
+
1187
+ /** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
1188
+ * → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
1189
+ export function delegationElapsed(entry: DelegationEntry, now: number): string {
1190
+ if (entry.state === 'running') return fmtElapsed(now - entry.startedAt)
1191
+ return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
1192
+ }
1193
+
1194
+ /** The activity labels shown by the animated row. Keeping this pure makes the
1195
+ * state machine testable without booting OpenCode's renderer. */
1196
+ export type DelegationActivity =
1197
+ | 'delegating'
1198
+ | 'working'
1199
+ | 'reading'
1200
+ | 'completed'
1201
+ | 'error'
1202
+ | 'cancelled'
1203
+
1204
+ export function delegationActivity(entry: DelegationEntry): DelegationActivity {
1205
+ if (entry.state === 'completed') return 'completed'
1206
+ if (entry.state === 'error') return 'error'
1207
+ if (entry.state === 'cancelled') return 'cancelled'
1208
+ if (entry.read) return 'reading'
1209
+ if (entry.alias.startsWith('live-') && entry.taskID === undefined) return 'delegating'
1210
+ return 'working'
1211
+ }
1212
+
1213
+ export function delegationActivityLabel(entry: DelegationEntry): string {
1214
+ switch (delegationActivity(entry)) {
1215
+ case 'delegating':
1216
+ return 'DELEGATING'
1217
+ case 'working':
1218
+ return 'WORKING'
1219
+ case 'reading':
1220
+ return 'READING RESULT'
1221
+ case 'completed':
1222
+ return entry.timedOut ? 'DONE (TIMED OUT)' : 'DONE'
1223
+ case 'error':
1224
+ return 'ERROR'
1225
+ default:
1226
+ return 'CANCELLED'
1227
+ }
1228
+ }
1229
+
1230
+ const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
1231
+
1232
+ /** Return a deterministic spinner frame. The View ticks this every 140ms. */
1233
+ export function delegationSpinnerFrame(now: number): string {
1234
+ const index = Math.floor(Math.max(0, now) / 140) % DELEGATION_SPINNER_FRAMES.length
1235
+ return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
1236
+ }
1237
+
1238
+ /** Merge the immediate tool-event channel into the child-session channel.
1239
+ *
1240
+ * Children remain the durable source, while live entries make a delegation
1241
+ * visible before the child API/report catches up. A finalized md entry wins
1242
+ * over a stale live entry; a child-only row is upgraded with live agent,
1243
+ * alias, phase and timestamps. */
1244
+ export function mergeChildDelegationSources(
1245
+ children: readonly DelegationEntry[],
1246
+ live: readonly LiveDelegationEntry[],
1247
+ ): DelegationEntry[] {
1248
+ const result = [...children]
1249
+ const byTask = new Map<string, number>()
1250
+ const bySessionAlias = new Map<string, number>()
1251
+ const key = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
1252
+
1253
+ result.forEach((entry, index) => {
1254
+ if (entry.taskID !== undefined) byTask.set(entry.taskID, index)
1255
+ bySessionAlias.set(key(entry.sessionID, entry.alias), index)
1256
+ })
1257
+
1258
+ for (const liveEntry of live) {
1259
+ const incoming = toDelegationEntry(liveEntry)
1260
+ const index =
1261
+ (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
1262
+ (incoming.alias !== ''
1263
+ ? bySessionAlias.get(key(incoming.sessionID, incoming.alias))
1264
+ : undefined)
1265
+
1266
+ if (index === undefined) {
1267
+ result.push(incoming)
1268
+ const newIndex = result.length - 1
1269
+ if (incoming.taskID !== undefined) byTask.set(incoming.taskID, newIndex)
1270
+ bySessionAlias.set(key(incoming.sessionID, incoming.alias), newIndex)
1271
+ continue
1272
+ }
1273
+
1274
+ const existing = result[index]
1275
+ if (existing === undefined) continue
1276
+ const finalized = existing.source === 'md' && existing.state !== 'running'
1277
+ if (finalized) continue
1278
+ result[index] = {
1279
+ ...existing,
1280
+ alias: liveEntry.alias !== null ? incoming.alias : existing.alias,
1281
+ sessionID: existing.sessionID || incoming.sessionID,
1282
+ taskID: existing.taskID ?? incoming.taskID,
1283
+ agent: incoming.agent !== 'agent' ? incoming.agent : existing.agent,
1284
+ description: incoming.description !== '' ? incoming.description : existing.description,
1285
+ state: incoming.state,
1286
+ startedAt: Math.min(existing.startedAt, incoming.startedAt),
1287
+ updatedAt: incoming.updatedAt,
1288
+ read: incoming.read,
1289
+ source: 'live',
1290
+ }
1291
+ }
1292
+
1293
+ result.sort(compareDelegationEntries)
1294
+ return result
1295
+ }
1296
+
1297
+ /* ─── Live tool-call lifecycle (agent-sidebar pattern) ─────
1298
+ * Source of truth for jobs born while opencode is open. The TUI event bus
1299
+ * (api.event.on) delivers `message.part.updated` for every part change;
1300
+ * we only care about tool parts whose `tool` is pantheon_delegate (job
1301
+ * launch) or pantheon_delegation_read (blocking until terminal → closes
1302
+ * the entry). Shape-adapted from the SDK v2 ToolPart but duck-typed so the
1303
+ * pure helpers are testable without the SDK. */
1304
+
1305
+ /** Duck-typed subset of a tool part (SDK v2 `ToolPart` / `ToolState`). */
1306
+ export type DelegationToolPart = {
1307
+ id?: string
1308
+ callID?: string
1309
+ sessionID?: string
1310
+ type?: string
1311
+ tool?: string
1312
+ state?: {
1313
+ status?: string
1314
+ input?: Record<string, unknown>
1315
+ output?: string
1316
+ error?: string
1317
+ time?: { start?: number; end?: number }
1318
+ }
1319
+ }
1320
+
1321
+ /** One live delegation tracked in-memory, keyed by the delegate callID. */
1322
+ export type LiveDelegationEntry = {
1323
+ /** Tool call id of the pantheon_delegate part (stable across events). */
1324
+ callID: string
1325
+ /** Part id (for message.part.removed cleanup). */
1326
+ partID: string
1327
+ /** Parent session the delegation was launched from. */
1328
+ sessionID: string
1329
+ tool: 'pantheon_delegate' | 'pantheon_delegation_read'
1330
+ /** Agent name (from the delegate input args). */
1331
+ agent: string
1332
+ description: string
1333
+ /** Known after the delegate tool completes (parsed from its output). */
1334
+ alias: string | null
1335
+ /** Child session id (parsed from the delegate output). */
1336
+ taskID: string | null
1337
+ state: 'running' | 'completed' | 'error' | 'cancelled'
1338
+ startedAt: number
1339
+ updatedAt: number | null
1340
+ /** True once a pantheon_delegation_read for this job has been observed. */
1341
+ read: boolean
1342
+ }
1343
+
1344
+ /** Result of parsing one tool part into lifecycle-relevant fields. */
1345
+ export type ParsedDelegationToolPart = {
1346
+ callID: string
1347
+ partID: string
1348
+ sessionID: string
1349
+ tool: 'pantheon_delegate' | 'pantheon_delegation_read'
1350
+ /** null for read parts (no agent arg — the id targets an existing job). */
1351
+ agent: string | null
1352
+ description: string
1353
+ status: 'pending' | 'running' | 'completed' | 'error'
1354
+ alias: string | null
1355
+ taskID: string | null
1356
+ startedAt: number
1357
+ endAt: number | null
1358
+ }
1359
+
1360
+ /** Alias in the delegate output: "Delegated to apollo: [apo-1] (task …)". */
1361
+ const DELEGATE_ALIAS_PATTERN = /\[([a-z]{2,8}-\d+)\]/i
1362
+ /** Child task id in the delegate output: "(task ses_child_9)". */
1363
+ const DELEGATE_TASKID_PATTERN = /\(task\s+([a-z0-9_]+)\)/i
1364
+ /** Plain alias, as passed to pantheon_delegation_read input.id: "apo-1". */
1365
+ const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
1366
+
1367
+ /** Extract the tool name + args from a `message.part.updated` part and
1368
+ * reduce it to what the panel needs. Returns null for anything that is
1369
+ * not a pantheon delegation tool part (or is missing its callID). */
1370
+ export function parseDelegationToolPart(
1371
+ part: DelegationToolPart,
1372
+ now = Date.now(),
1373
+ ): ParsedDelegationToolPart | null {
1374
+ if (part.type !== 'tool') return null
1375
+ if (part.tool !== 'pantheon_delegate' && part.tool !== 'pantheon_delegation_read') return null
1376
+ const callID = part.callID
1377
+ if (callID === undefined || callID === '') return null
1378
+ const sessionID = part.sessionID ?? ''
1379
+ const state = part.state ?? {}
1380
+ const status = state.status ?? 'pending'
1381
+ if (status !== 'pending' && status !== 'running' && status !== 'completed' && status !== 'error')
1382
+ return null
1383
+ const input = state.input ?? {}
1384
+ const startedAt = state.time?.start ?? now
1385
+ const endAt = state.time?.end ?? null
1386
+
1387
+ if (part.tool === 'pantheon_delegation_read') {
1388
+ const target = typeof input.id === 'string' ? input.id : null
1389
+ let alias: string | null = null
1390
+ let taskID: string | null = null
1391
+ if (target !== null) {
1392
+ if (READ_ALIAS_PATTERN.test(target)) alias = target
1393
+ else if (target.startsWith('ses_')) taskID = target // raw child session id
1394
+ }
1395
+ return {
1396
+ callID,
1397
+ partID: part.id ?? '',
1398
+ sessionID,
1399
+ tool: 'pantheon_delegation_read',
1400
+ agent: null,
1401
+ description: '',
1402
+ status,
1403
+ alias,
1404
+ taskID,
1405
+ startedAt,
1406
+ endAt,
1407
+ }
1408
+ }
1409
+
1410
+ const agent = typeof input.agent === 'string' ? input.agent : 'agent'
1411
+ const description = typeof input.description === 'string' ? input.description : ''
1412
+ // The alias/taskID only exist once the delegate tool COMPLETES (they are
1413
+ // returned in its output); a running/pending part has neither.
1414
+ let alias: string | null = null
1415
+ let taskID: string | null = null
1416
+ if (status === 'completed') {
1417
+ const output = state.output ?? ''
1418
+ alias = DELEGATE_ALIAS_PATTERN.exec(output)?.[1] ?? null
1419
+ taskID = DELEGATE_TASKID_PATTERN.exec(output)?.[1] ?? null
1420
+ }
1421
+ return {
1422
+ callID,
1423
+ partID: part.id ?? '',
1424
+ sessionID,
1425
+ tool: 'pantheon_delegate',
1426
+ agent,
1427
+ description,
1428
+ status,
1429
+ alias,
1430
+ taskID,
1431
+ startedAt,
1432
+ endAt,
1433
+ }
1434
+ }
1435
+
1436
+ /** Find a live entry by alias or taskID (read parts resolve by id). */
1437
+ function findLiveByTarget(
1438
+ map: Map<string, LiveDelegationEntry>,
1439
+ alias: string | null,
1440
+ taskID: string | null,
1441
+ ): LiveDelegationEntry | undefined {
1442
+ if (alias === null && taskID === null) return undefined
1443
+ for (const entry of map.values()) {
1444
+ if (alias !== null && entry.alias === alias) return entry
1445
+ if (taskID !== null && entry.taskID === taskID) return entry
1446
+ }
1447
+ return undefined
1448
+ }
1449
+
1450
+ /** Apply one tool part to the live map. Returns true when the map changed.
1451
+ * Pure w.r.t. I/O — only mutates `map`. */
1452
+ export function reduceDelegationToolPart(
1453
+ map: Map<string, LiveDelegationEntry>,
1454
+ part: DelegationToolPart,
1455
+ now = Date.now(),
1456
+ ): boolean {
1457
+ const parsed = parseDelegationToolPart(part, now)
1458
+ if (parsed === null) return false
1459
+
1460
+ // Read tool: never creates a row — it resolves a delegation by id. It
1461
+ // marks the target `read` and (because it blocks until terminal) closes
1462
+ // the entry on completed/error with its end timestamp.
1463
+ if (parsed.tool === 'pantheon_delegation_read') {
1464
+ const target = findLiveByTarget(map, parsed.alias, parsed.taskID)
1465
+ if (target === undefined) return false
1466
+ let changed = false
1467
+ if (!target.read) {
1468
+ target.read = true
1469
+ changed = true
1470
+ }
1471
+ if (parsed.status === 'completed' && target.state === 'running') {
1472
+ target.state = 'completed'
1473
+ target.updatedAt = parsed.endAt ?? now
1474
+ changed = true
1475
+ } else if (parsed.status === 'error' && target.state === 'running') {
1476
+ target.state = 'error'
1477
+ target.updatedAt = parsed.endAt ?? now
1478
+ changed = true
1479
+ }
1480
+ return changed
1481
+ }
1482
+
1483
+ // Delegate tool: pending/running → job launched (running). A COMPLETED
1484
+ // delegate tool only means the job was registered — it stays running and
1485
+ // we pick up alias + taskID from the output.
1486
+ const existing = map.get(parsed.callID)
1487
+ if (parsed.status === 'error') {
1488
+ if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
1489
+ return false
1490
+ map.set(parsed.callID, {
1491
+ callID: parsed.callID,
1492
+ partID: parsed.partID,
1493
+ sessionID: parsed.sessionID,
1494
+ tool: 'pantheon_delegate',
1495
+ agent: parsed.agent ?? 'agent',
1496
+ description: parsed.description,
1497
+ alias: existing?.alias ?? null,
1498
+ taskID: existing?.taskID ?? null,
1499
+ state: 'error',
1500
+ startedAt: existing?.startedAt ?? parsed.startedAt,
1501
+ updatedAt: parsed.endAt ?? now,
1502
+ read: existing?.read ?? false,
1503
+ })
1504
+ return true
1505
+ }
1506
+ if (existing === undefined) {
1507
+ map.set(parsed.callID, {
1508
+ callID: parsed.callID,
1509
+ partID: parsed.partID,
1510
+ sessionID: parsed.sessionID,
1511
+ tool: 'pantheon_delegate',
1512
+ agent: parsed.agent ?? 'agent',
1513
+ description: parsed.description,
1514
+ alias: parsed.alias,
1515
+ taskID: parsed.taskID,
1516
+ state: 'running',
1517
+ startedAt: parsed.startedAt,
1518
+ updatedAt: null,
1519
+ read: false,
1520
+ })
1521
+ return true
1522
+ }
1523
+ // Existing entry: absorb newly-discovered alias/taskID/agent/description.
1524
+ let changed = false
1525
+ if (parsed.alias !== null && existing.alias !== parsed.alias) {
1526
+ existing.alias = parsed.alias
1527
+ changed = true
1528
+ }
1529
+ if (parsed.taskID !== null && existing.taskID !== parsed.taskID) {
1530
+ existing.taskID = parsed.taskID
1531
+ changed = true
1532
+ }
1533
+ if (parsed.agent !== null && existing.agent !== parsed.agent) {
1534
+ existing.agent = parsed.agent
1535
+ changed = true
1536
+ }
1537
+ if (parsed.description !== '' && existing.description !== parsed.description) {
1538
+ existing.description = parsed.description
1539
+ changed = true
1540
+ }
1541
+ if (existing.partID === '' && parsed.partID !== '') {
1542
+ existing.partID = parsed.partID
1543
+ changed = true
1544
+ }
1545
+ return changed
1546
+ }
1547
+
1548
+ /** Remove a live entry by part id (message.part.removed) or call id.
1549
+ * Returns true when something was removed. */
1550
+ export function removeDelegationEntry(
1551
+ map: Map<string, LiveDelegationEntry>,
1552
+ partIDOrCallID: string,
1553
+ ): boolean {
1554
+ if (map.delete(partIDOrCallID)) return true
1555
+ for (const [key, entry] of map) {
1556
+ if (entry.partID === partIDOrCallID) {
1557
+ map.delete(key)
1558
+ return true
1559
+ }
1560
+ }
1561
+ return false
1562
+ }
1563
+
1564
+ /** Collect pantheon delegation tool parts from a session's messages.
1565
+ * Messages may carry their parts inline (duck-typed `msg.parts`); when
1566
+ * they don't, the optional `getParts(messageID)` callback is used (the TUI
1567
+ * SDK exposes `api.state.part(messageID)`). Pure w.r.t. I/O — used by the
1568
+ * mount re-scan to re-seed the live map after compaction/attach. */
1569
+ export function collectDelegationToolParts(
1570
+ messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
1571
+ getParts?: (messageID: string) => readonly unknown[] | undefined,
1572
+ ): DelegationToolPart[] {
1573
+ const out: DelegationToolPart[] = []
1574
+ for (const msg of messages ?? []) {
1575
+ let parts: readonly unknown[] | undefined
1576
+ if (Array.isArray(msg?.parts)) {
1577
+ parts = msg.parts
1578
+ } else if (msg?.id !== undefined && typeof getParts === 'function') {
1579
+ parts = getParts(msg.id)
1580
+ }
1581
+ if (!parts) continue
1582
+ for (const raw of parts) {
1583
+ const part = raw as DelegationToolPart
1584
+ if (
1585
+ part?.type === 'tool' &&
1586
+ (part.tool === 'pantheon_delegate' || part.tool === 'pantheon_delegation_read')
1587
+ ) {
1588
+ out.push(part)
1589
+ }
1590
+ }
1591
+ }
1592
+ return out
1593
+ }
1594
+
1595
+ /** Apply a batch of tool parts (in message order) to the live map. Used on
1596
+ * mount to re-seed entries that `message.part.removed` (compaction) wiped,
1597
+ * from the session's existing tool parts. Returns how many parts changed
1598
+ * the map (0 on the second identical seed — idempotent, no extra bumps). */
1599
+ export function seedLiveDelegationMap(
1600
+ map: Map<string, LiveDelegationEntry>,
1601
+ parts: readonly DelegationToolPart[],
1602
+ now = Date.now(),
1603
+ ): number {
1604
+ let changed = 0
1605
+ for (const part of parts) {
1606
+ if (reduceDelegationToolPart(map, part, now)) changed++
1607
+ }
1608
+ return changed
1609
+ }
1610
+
1611
+ /** Convert a live entry into the shared display shape. Alias falls back to
1612
+ * a `live-<callID>` prefix while the delegate tool has not completed yet. */
1613
+ export function toDelegationEntry(live: LiveDelegationEntry): DelegationEntry {
1614
+ return {
1615
+ alias: live.alias ?? `live-${live.callID.slice(0, 8)}`,
1616
+ sessionID: live.sessionID,
1617
+ ...(live.taskID !== null ? { taskID: live.taskID } : {}),
1618
+ agent: live.agent,
1619
+ state: live.state,
1620
+ startedAt: live.startedAt,
1621
+ updatedAt: live.updatedAt,
1622
+ timedOut: false,
1623
+ description: live.description,
1624
+ read: live.read,
1625
+ source: 'live',
1626
+ }
1627
+ }
1628
+
1629
+ /** Combine the live channel with the md (historical) channel into one
1630
+ * display list. Dedupes by (sessionID, alias) — aliases are per-parent-
1631
+ * session, so the same alias in different sessions stays separate. A
1632
+ * terminal md entry is authoritative over a live running entry for the
1633
+ * same job (it carries Finalized/timedOut/cancelled from finalize). */
1634
+ export function mergeDelegationSources(
1635
+ live: readonly LiveDelegationEntry[],
1636
+ md: readonly DelegationEntry[],
1637
+ ): DelegationEntry[] {
1638
+ const keyOf = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
1639
+ const byKey = new Map<string, DelegationEntry>()
1640
+ for (const m of md) {
1641
+ const key = keyOf(m.sessionID, m.alias)
1642
+ const existing = byKey.get(key)
1643
+ if (existing === undefined || (existing.state === 'running' && m.state !== 'running'))
1644
+ byKey.set(key, m)
1645
+ }
1646
+ const aliasless: DelegationEntry[] = []
1647
+ for (const l of live) {
1648
+ const e = toDelegationEntry(l)
1649
+ if (l.alias === null) {
1650
+ aliasless.push(e)
1651
+ continue
1652
+ }
1653
+ const key = keyOf(l.sessionID, l.alias)
1654
+ const mdEntry = byKey.get(key)
1655
+ if (mdEntry !== undefined && mdEntry.state !== 'running') continue // md terminal wins
1656
+ byKey.set(key, e)
1657
+ }
1658
+ const all = [...aliasless, ...byKey.values()]
1659
+ all.sort(compareDelegationEntries)
1660
+ return all
1661
+ }
1662
+
1663
+ /* ─── Children channel (primary source, delegations-sidebar pattern) ────
1664
+ * `api.client.session.children` returns the child sessions of the current
1665
+ * session — every pantheon_delegate spawns one (parentID = caller). The
1666
+ * pure helpers here turn those children + the md reports into the display
1667
+ * list, and navigate to a child session on click. */
1668
+
1669
+ /* ─── Current-session resolution + path guard (placeholder regression) ──
1670
+ * The sidebar forwarded `props.session_id` verbatim into
1671
+ * `session.children({ path: { id } })`. When the TUI runtime renders the
1672
+ * sidebar without a focused session it leaves the route template
1673
+ * UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
1674
+ * rejected every poll (SchemaError: Expected a string starting with "ses",
1675
+ * got "%7BsessionID%7D"), ~1 error/s from the 1s safety poll, and the failed
1676
+ * call fail-opened into "Delegations (0)".
1677
+ *
1678
+ * Contract: resolution NEVER yields a placeholder — an invalid/absent id is
1679
+ * null, and a null resolution means the children fetch is SKIPPED (empty
1680
+ * panel, ZERO errors). Sources, in order:
1681
+ * 1. the sidebar_content slot prop (`session_id`);
1682
+ * 2. `api.state.sessionID` (duck-typed: the typed TuiState has no such
1683
+ * field, but the runtime state is a superset — the delegations-sidebar
1684
+ * reference reads the current session from api.state);
1685
+ * 3. `api.route.current.params.sessionID` (typed: TuiRouteCurrent's
1686
+ * `{ name: 'session', params: { sessionID } }` variant).
1687
+ * Each source passes the same server-aligned validity check (non-empty
1688
+ * string starting with "ses"), so a placeholder or garbage at any position
1689
+ * falls through to the next source instead of hitting the wire. */
1690
+
1691
+ /** Server-aligned session id validity: opencode rejects anything not starting
1692
+ * with "ses" (SchemaError). This deliberately mirrors that exact contract —
1693
+ * nothing stricter, nothing looser — so a template placeholder ("{sessionID}"),
1694
+ * its URL-encoded form ("%7BsessionID%7D", what the server reported in the
1695
+ * schema error), an empty/undefined value, or a foreign id (e.g. "wrk_") can
1696
+ * never reach a path and error-spam the log. Confirmed: "{sessionID}" starts
1697
+ * with "{" and "%7BsessionID%7D" with "%" — both fail startsWith("ses"), so
1698
+ * the placeholder is rejected WITHOUT an explicit denylist (covered by tests). */
1699
+ export function isValidSessionId(id: unknown): id is string {
1700
+ return typeof id === 'string' && id.startsWith('ses')
1701
+ }
1702
+
1703
+ /** Sources the sidebar can resolve the CURRENT session id from. Duck-typed
1704
+ * subsets of TuiPluginApi / TuiState / TuiRouteCurrent so the helper stays
1705
+ * pure and testable without the TUI runtime. */
1706
+ export type TuiSessionSources = {
1707
+ /** sidebar_content slot prop (`session_id`). */
1708
+ sessionID?: string | null
1709
+ api?: {
1710
+ /** Runtime state superset — may expose the current session id. */
1711
+ state?: { sessionID?: unknown }
1712
+ /** Typed route: { name: 'session', params: { sessionID } } when in one. */
1713
+ route?: { current?: { name?: string; params?: Record<string, unknown> } }
1714
+ } | null
1715
+ }
1716
+
1717
+ /** Resolve the current session id for the sidebar. Order: slot prop →
1718
+ * api.state.sessionID (runtime superset) → api.route.current.params.sessionID
1719
+ * (typed route). Every source is validated; invalid/absent → next source.
1720
+ * NEVER returns a placeholder or non-ses id. Null → callers MUST skip the
1721
+ * fetch (empty panel, zero errors). Pure — no I/O, no runtime required. */
1722
+ export function resolveCurrentSessionID(sources: TuiSessionSources): string | null {
1723
+ const candidates: unknown[] = [
1724
+ sources?.sessionID,
1725
+ sources?.api?.state?.sessionID,
1726
+ sources?.api?.route?.current?.params?.sessionID,
1727
+ ]
1728
+ for (const candidate of candidates) {
1729
+ if (isValidSessionId(candidate)) return candidate
1730
+ }
1731
+ return null
1732
+ }
1733
+
1734
+ /** THE single choke point for every `session.children` / session-API path.
1735
+ * Returns `{ path: { id } }` ONLY for a server-valid session id; returns
1736
+ * null for anything else (placeholder, empty, foreign id) so the caller
1737
+ * skips the call entirely instead of sending an unsubstituted placeholder
1738
+ * (the "%7BsessionID%7D" regression). Every session-API call site MUST go
1739
+ * through this function (enforced by the source-scan test in
1740
+ * tests/pantheon/tui-delegations.test.ts). */
1741
+ export function safeSessionPath(id: unknown): { path: { id: string } } | null {
1742
+ if (!isValidSessionId(id)) return null
1743
+ return { path: { id } }
1744
+ }
1745
+
1746
+ /** Build the `session.children` path ONLY from a validated session id.
1747
+ * Delegates to {@link safeSessionPath} — the single choke point. Returns
1748
+ * null for null/invalid ids so the caller skips the fetch instead of
1749
+ * sending an unsubstituted placeholder (the "%7BsessionID%7D" regression). */
1750
+ export function buildChildrenPath(id: string | null | undefined): { path: { id: string } } | null {
1751
+ return safeSessionPath(id)
1752
+ }
1753
+
1754
+ /** Duck-typed subset of a child Session (+ its live status type). */
1755
+ export type ChildDelegationLike = {
1756
+ /** Child session id (= board task id). */
1757
+ id: string
1758
+ /** Session title — the delegate's description or prompt prefix. */
1759
+ title?: string
1760
+ /** Agent name when the child session carries one (duck-typed; native
1761
+ * task() children may expose it, board reports always do). */
1762
+ agent?: string
1763
+ /** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
1764
+ * or undefined when the status API is unavailable. */
1765
+ status?: string
1766
+ time?: {
1767
+ created?: number
1768
+ updated?: number
1769
+ }
1770
+ }
1771
+
1772
+ /** Map a child status type to a display state. busy/retry → running
1773
+ * (the child is actively working), idle → completed, unknown → running
1774
+ * (fail-open: a freshly-seen child is assumed active; the 1s poll + md
1775
+ * correct it as soon as terminal data exists). */
1776
+ export function childStatusToState(status: string | undefined): 'running' | 'completed' {
1777
+ if (status === 'idle') return 'completed'
1778
+ return 'running' // busy, retry, or unknown
1779
+ }
1780
+
1781
+ /** Row tag for a delegation entry: `[task]` for native task() children
1782
+ * (source 'children-only' — no board report), `[<alias>]` for board rows
1783
+ * ([apo-1]). The panel renders the tag with a distinct style so native
1784
+ * task() children are visually separable from pantheon_delegate jobs. */
1785
+ export function delegationTag(entry: DelegationEntry): string {
1786
+ return entry.source === 'children-only' ? '[task]' : `[${entry.alias}]`
1787
+ }
1788
+
1789
+ /** Turn child sessions (PRIMARY) enriched with md reports into the display
1790
+ * list. One entry per child id (duplicates across re-fetches collapse).
1791
+ * The md report is matched by `Task ID` (== child.id) and supplies alias,
1792
+ * agent, description, terminal state and duration. A child without a
1793
+ * report still renders: description from its title, agent from the child
1794
+ * itself (fallback 'agent'), state derived from its status, startedAt from
1795
+ * time.created. A report-less child is a NATIVE task() child (every
1796
+ * child of the current session — pantheon_delegate OR the native `task()`
1797
+ * tool — carries parentID = caller), so it gets source 'children-only'
1798
+ * and the `[task]` tag instead of a board alias.
1799
+ * Terminal md state wins over the derived state; a running md defers to
1800
+ * the child's live status. Sorted running-first (compareDelegationEntries).
1801
+ * Pure — no I/O. */
1802
+ export function childrenToDelegationEntries(
1803
+ children: readonly ChildDelegationLike[] | undefined,
1804
+ md: readonly DelegationEntry[],
1805
+ now = Date.now(),
1806
+ ): DelegationEntry[] {
1807
+ const byTaskID = new Map<string, DelegationEntry>()
1808
+ for (const m of md) {
1809
+ if (m.taskID !== undefined && !byTaskID.has(m.taskID)) byTaskID.set(m.taskID, m)
1810
+ }
1811
+ const out: DelegationEntry[] = []
1812
+ const seen = new Set<string>()
1813
+ for (const child of children ?? []) {
1814
+ if (child.id === '' || seen.has(child.id)) continue
1815
+ seen.add(child.id)
1816
+ const mdEntry = byTaskID.get(child.id)
1817
+ const state =
1818
+ mdEntry !== undefined && mdEntry.state !== 'running'
1819
+ ? mdEntry.state
1820
+ : childStatusToState(child.status)
1821
+ out.push({
1822
+ alias: mdEntry?.alias ?? 'task',
1823
+ sessionID: mdEntry?.sessionID ?? '',
1824
+ taskID: child.id,
1825
+ agent: mdEntry?.agent ?? child.agent ?? 'agent',
1826
+ state,
1827
+ startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
1828
+ updatedAt: mdEntry?.updatedAt ?? (state === 'running' ? null : (child.time?.updated ?? null)),
1829
+ timedOut: mdEntry?.timedOut ?? false,
1830
+ description:
1831
+ mdEntry !== undefined && mdEntry.description !== ''
1832
+ ? mdEntry.description
1833
+ : (child.title ?? ''),
1834
+ source: mdEntry !== undefined ? 'md' : 'children-only',
1835
+ })
1836
+ }
1837
+ out.sort(compareDelegationEntries)
1838
+ return out
1839
+ }
1840
+
1841
+ /** Navigate the TUI to a child session (click/Enter on a delegation row).
1842
+ * Returns false when the route API is unavailable or the target id is
1843
+ * missing/placeholder — the row stays inert instead of crashing. Only a
1844
+ * server-valid session id ("ses...") ever reaches the router, so an
1845
+ * unsubstituted "{sessionID}" placeholder can never be routed. */
1846
+ export function navigateToDelegationSession(
1847
+ route: { navigate?: (name: string, params?: Record<string, unknown>) => void } | undefined,
1848
+ taskID: string | undefined,
1849
+ ): boolean {
1850
+ if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
1851
+ route.navigate('session', { sessionID: taskID })
1852
+ return true
1853
+ }
1854
+
1855
+ /* ─── Silence-by-default panel logger ──────────────────────
1856
+ * Mirrors src/pantheon/logger.ts createPantheonLogger (the TUI plugin is
1857
+ * self-contained — dist/tui.tsx is a raw copy with no relative imports):
1858
+ * every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
1859
+ * echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
1860
+ * renders plugin console output directly into the TUI — the pollution bug).
1861
+ * Best-effort and fire-and-forget: a logging failure must never break the
1862
+ * panel. */
1863
+ type TuiLogger = { info: (message: string, ...args: unknown[]) => void }
1864
+
1865
+ /** Silence-by-default panel logger.
1866
+ * @param projectRoot the PROJECT ROOT — the logger appends to
1867
+ * `<projectRoot>/.pantheon/logs/hooks.log` (tuiLogPath). Passing anything
1868
+ * deeper (e.g. the delegations dir) used to double-nest the path into
1869
+ * `<root>/.pantheon/.pantheon/logs/hooks.log` and never reach the real log;
1870
+ * derive the root with {@link panelLogDir}. */
1871
+ function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui'): TuiLogger {
1872
+ const echo = (process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
1873
+ const logPath = tuiLogPath(projectRoot ?? process.cwd())
1874
+ const formatArg = (arg: unknown): string => {
1875
+ if (arg instanceof Error) return arg.stack ?? arg.message
1876
+ if (typeof arg === 'string') return arg
1877
+ try {
1878
+ return JSON.stringify(arg)
1879
+ } catch {
1880
+ return String(arg)
1881
+ }
1882
+ }
1883
+ const write = (message: string, args: unknown[]) => {
1884
+ const lines = [message, ...args.map(formatArg)]
1885
+ .join(' ')
1886
+ .split('\n')
1887
+ .map((p) => p.trim())
1888
+ .filter((p) => p !== '')
1889
+ if (lines.length === 0) return
1890
+ void (async () => {
1891
+ try {
1892
+ await mkdir(dirname(logPath), { recursive: true })
1893
+ const stamp = new Date().toISOString()
1894
+ await appendFile(
1895
+ logPath,
1896
+ `${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
1897
+ 'utf8',
1898
+ )
1899
+ } catch {
1900
+ /* best-effort file log — never break the panel over logging */
1901
+ }
1902
+ })()
1903
+ if (echo) {
1904
+ for (const line of lines) console.log(`[${module}] ${line}`)
1905
+ }
1906
+ }
1907
+ return { info: (message, ...args) => write(message, args) }
1908
+ }
1909
+
876
1910
  /* ─── Session Row (single session with live status indicator) ─ */
877
1911
 
878
1912
  function SessionRow(props: { api: TuiPluginApi; session: any }) {
879
1913
  const theme = () => props.api.theme.current
880
1914
 
881
1915
  const status = createMemo(() => {
1916
+ // isValidSessionId guard: a placeholder / foreign session id must never
1917
+ // reach the status API (same "%7BsessionID%7D" regression class as the
1918
+ // children path). Invalid id → no status call, no icon.
1919
+ if (!isValidSessionId(props.session?.id)) return null
882
1920
  try {
883
1921
  const s = (props.api.state as any).session?.status?.(props.session.id)
884
1922
  if (s?.type) return s.type as string
@@ -906,13 +1944,98 @@ function SessionRow(props: { api: TuiPluginApi; session: any }) {
906
1944
  )
907
1945
  }
908
1946
 
1947
+ /* ─── Delegation Row (single job from the children/md channels) ─ */
1948
+
1949
+ function DelegationRow(props: {
1950
+ api: TuiPluginApi
1951
+ job: DelegationEntry
1952
+ now: number
1953
+ animationNow: number
1954
+ }) {
1955
+ const theme = () => props.api.theme.current
1956
+
1957
+ const color = createMemo(() => {
1958
+ const t = theme()
1959
+ switch (props.job.state) {
1960
+ case 'running':
1961
+ return t.warning
1962
+ case 'completed':
1963
+ return t.success
1964
+ case 'error':
1965
+ return t.error
1966
+ default:
1967
+ return t.textMuted // cancelled
1968
+ }
1969
+ })
1970
+
1971
+ const marker = createMemo(() => {
1972
+ if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
1973
+ switch (props.job.state) {
1974
+ case 'completed':
1975
+ return '\u2713 '
1976
+ case 'error':
1977
+ return '\u2715 '
1978
+ default:
1979
+ return '\u25cb '
1980
+ }
1981
+ })
1982
+
1983
+ // The row tag: `[task]` for native task() children (no board report),
1984
+ // `[apo-1]`-style alias for pantheon_delegate board rows. Rendered with
1985
+ // the info color so task() children are visually distinct from delegates.
1986
+ const tagColor = createMemo(() => (props.job.source === 'children-only' ? theme().info : color()))
1987
+
1988
+ const detail = createMemo(() => {
1989
+ const elapsed = delegationElapsed(props.job, props.now)
1990
+ const description = props.job.description !== '' ? ` \u2014 ${props.job.description}` : ''
1991
+ const line = `${elapsed}${description}`
1992
+ return line.length > 180 ? `${line.slice(0, 177)}\u2026` : line
1993
+ })
1994
+
1995
+ // Click a row → navigate to the child session (route API guarded in the
1996
+ // helper). Enter-key binding is left out (per-row keymap wiring is not
1997
+ // worth the complexity in this sidebar); mouse is enabled in tui.json.
1998
+ const open = () => {
1999
+ navigateToDelegationSession(props.api.route, props.job.taskID)
2000
+ }
2001
+
2002
+ return (
2003
+ <box onMouseDown={open}>
2004
+ <box flexDirection="row">
2005
+ <text fg={color()}>{marker()}</text>
2006
+ <text fg={tagColor()}>{delegationTag(props.job)}</text>
2007
+ <text
2008
+ fg={color()}
2009
+ >{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
2010
+ </box>
2011
+ <text fg={theme().textMuted}>{detail()}</text>
2012
+ </box>
2013
+ )
2014
+ }
2015
+
909
2016
  /* ─── Main Sidebar View ──────────────────────────────────── */
910
2017
 
911
- function View(props: { api: TuiPluginApi; sessionID: string; version: string | null }) {
912
- const [showCommands, setShowCommands] = createSignal(false)
913
- const [showAgents, setShowAgents] = createSignal(false)
914
- const [showConfig, setShowConfig] = createSignal(false)
2018
+ /** Plugin-level live delegation store shared with the event subscriptions
2019
+ * in `tui()`: the map of live entries + a version signal bumped on every
2020
+ * mutation. The View subscribes to the version (in an effect) to refresh the
2021
+ * durable child list and also reads the map as an optimistic live source. */
2022
+ export type LiveDelegationStore = {
2023
+ map: Map<string, LiveDelegationEntry>
2024
+ /** Reactive version getter — View reads it inside an effect to re-fetch. */
2025
+ version: () => number
2026
+ /** Bump the version after a live mutation. */
2027
+ bump: () => void
2028
+ }
2029
+
2030
+ function View(props: {
2031
+ api: TuiPluginApi
2032
+ sessionID: string | undefined
2033
+ version: string | null
2034
+ liveStore: LiveDelegationStore
2035
+ }) {
915
2036
  const [showSessions, setShowSessions] = createSignal(false)
2037
+ // Show live work immediately; the header remains clickable to collapse it.
2038
+ const [showDelegations, setShowDelegations] = createSignal(true)
916
2039
 
917
2040
  const theme = () => props.api.theme.current
918
2041
 
@@ -971,7 +2094,11 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
971
2094
  const data: any[] = (result as any).data ?? result
972
2095
  if (!Array.isArray(data)) return []
973
2096
  return data
974
- .filter((s: any) => !s.parentID && s.id !== props.sessionID)
2097
+ .filter(
2098
+ (s: any) =>
2099
+ !s.parentID &&
2100
+ s.id !== resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api }),
2101
+ )
975
2102
  .sort((a: any, b: any) => {
976
2103
  const ta = a.time?.updated ?? a.updated ?? 0
977
2104
  const tb = b.time?.updated ?? b.updated ?? 0
@@ -980,14 +2107,126 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
980
2107
  .slice(0, 8)
981
2108
  })
982
2109
 
983
- // ── Memory: starts at null (= "N/A"), tracks via events ──
984
- const [memoryCount, setMemoryCount] = createSignal<number | null>(null)
2110
+ // ── Delegations: session.children (PRIMARY) + md reports (enrichment) ──
2111
+ // The panel's source of truth is the SDK child-session API
2112
+ // (`api.client.session.children`) — every pantheon_delegate spawns a child
2113
+ // session with parentID = this session, so children ARE the delegation
2114
+ // list (delegations-sidebar pattern). The md channel
2115
+ // (.pantheon/delegations) enriches each child with alias/agent/description
2116
+ // and terminal duration via the `Task ID` match. Live tool-call events are
2117
+ // merged optimistically so the row appears before those channels catch up.
2118
+ const delegationsDir = createMemo(() => resolveDelegationsDir((props.api.state as any).path))
2119
+ // Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
2120
+ // panelLogDir(<root>/.pantheon/delegations) = <root>: the old code passed
2121
+ // dirname(delegationsDir()) (= <root>/.pantheon), which createTuiLogger
2122
+ // re-joined with `.pantheon/logs/hooks.log` → a nested
2123
+ // <root>/.pantheon/.pantheon/logs/hooks.log that never reached the real
2124
+ // hooks.log (the empty nested dirs observed in production).
2125
+ const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
2126
+ // The rendered list is childDelegations (children enriched with md);
2127
+ // there is no separate md-only signal — the md channel only feeds
2128
+ // childrenToDelegationEntries below.
2129
+ const [childDelegations, setChildDelegations] = createSignal<DelegationEntry[]>([])
2130
+ // 1s ticker drives the running-jobs elapsed counter AND the safety poll
2131
+ // (oh-my-opencode-slim pattern): re-fetch children + re-read the md every
2132
+ // second so the panel updates even when NO event ever fires.
2133
+ const [now, setNow] = createSignal(Date.now())
2134
+ // Separate fast ticker for the spinner. The elapsed label intentionally
2135
+ // remains on the 1s ticker to avoid unnecessary work.
2136
+ const [animationNow, setAnimationNow] = createSignal(Date.now())
2137
+ // Cumulative count of event-triggered refreshes — logged with every fetch
2138
+ // ("panel: children=N md=N events=N") so the (0) symptom is diagnosable:
2139
+ // children>0 means the source works; events=0 means the event bus isn't
2140
+ // delivering, and the 1s poll is what keeps the panel honest.
2141
+ let eventRefreshCount = 0
2142
+ // In-flight guard: overlap between the poll, events and the version effect
2143
+ // collapses into one fetch — no duplicate work, no duplicate entries.
2144
+ let delegationsInflight: Promise<void> | null = null
2145
+ const refreshDelegations = () => {
2146
+ if (delegationsInflight !== null) return delegationsInflight
2147
+ delegationsInflight = (async () => {
2148
+ try {
2149
+ const state = props.api.state as any
2150
+ // 0. md reports from EVERY session under .pantheon/delegations — the
2151
+ // HISTORY channel. Read BEFORE the sessionID guard so the panel shows
2152
+ // past reports (running first, Finalized desc) even when no session
2153
+ // is focused (null/placeholder sessionID): the "(0)" symptom becomes
2154
+ // the full historical list. Fail-open: a missing dir yields [].
2155
+ let md: DelegationEntry[] = []
2156
+ try {
2157
+ md = await readAllDelegationEntries(panelLogDir(delegationsDir()))
2158
+ } catch {
2159
+ md = [] // fail-open: never crash the sidebar on a read error
2160
+ }
2161
+ // 1. current session — resolve + GUARD (placeholder regression). The
2162
+ // runtime may render the sidebar with an unsubstituted "{sessionID}"
2163
+ // slot prop; forwarding it to session.children made the server reject
2164
+ // every poll (~1 err/s: "Expected a string starting with \"ses\", got
2165
+ // \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
2166
+ // resolution SKIPS the fetch entirely: the md history is shown alone
2167
+ // (zero children, zero errors).
2168
+ const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
2169
+ if (sessionID === null) {
2170
+ setChildDelegations(md)
2171
+ panelLog.info(
2172
+ `panel: children=0 md=${md.length} events=${eventRefreshCount} (no sessionID — history shown)`,
2173
+ )
2174
+ return
2175
+ }
2176
+ // 2. children — PRIMARY source (SDK: api.client.session.children).
2177
+ let children: ChildDelegationLike[] = []
2178
+ try {
2179
+ // buildChildrenPath re-validates: with sessionID !== null it is
2180
+ // never null, so no placeholder can reach the wire.
2181
+ const result = await (props.api.client as any)?.session?.children?.(
2182
+ buildChildrenPath(sessionID),
2183
+ )
2184
+ const data = (result?.data ?? result) as unknown
2185
+ children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
2186
+ } catch (err) {
2187
+ children = [] // children API unavailable — the panel shows md only
2188
+ panelLog.info('panel: error children fetch', err)
2189
+ }
2190
+ // 3. live status per child (sync API) + merge children with md.
2191
+ const resolveStatus = (childID: unknown): string | undefined => {
2192
+ // isValidSessionId guard: a placeholder / foreign id must never
2193
+ // reach the status API (same regression class as the children
2194
+ // path — "%7BsessionID%7D").
2195
+ if (!isValidSessionId(childID)) return undefined
2196
+ try {
2197
+ return state?.session?.status?.(childID)?.type as string | undefined
2198
+ } catch {
2199
+ return undefined // status API unavailable — child state fallback
2200
+ }
2201
+ }
2202
+ const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2203
+ const childEntries = childrenToDelegationEntries(withStatus, md, now())
2204
+ // The event channel is optimistic: it renders a job even while the
2205
+ // child session/report is still being created. Filter by parent so a
2206
+ // different focused session never leaks rows into this sidebar.
2207
+ const liveEntries = [...props.liveStore.map.values()].filter(
2208
+ (entry) => entry.sessionID === sessionID,
2209
+ )
2210
+ setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
2211
+ // 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
2212
+ panelLog.info(
2213
+ `panel: children=${children.length} md=${md.length} events=${eventRefreshCount}`,
2214
+ )
2215
+ } finally {
2216
+ delegationsInflight = null
2217
+ }
2218
+ })()
2219
+ return delegationsInflight
2220
+ }
2221
+ // Running jobs first, then the 8 most recent terminal reports. The header
2222
+ // count uses this same "running + recentes" list.
2223
+ const visibleDelegations = createMemo(() => visibleDelegationList(childDelegations()))
985
2224
 
986
- // ── Event subscriptions for live session/memory updates ──
2225
+ // ── Event subscriptions for live session/delegation updates ──
987
2226
  onMount(() => {
988
2227
  const cleanup: (() => void)[] = []
989
2228
 
990
- // Session events — triggers resource refetch
2229
+ // Session events — triggers the sessions resource refetch.
991
2230
  try {
992
2231
  cleanup.push(props.api.event.on('session.status', refetchSessions))
993
2232
  cleanup.push(props.api.event.on('session.created', refetchSessions))
@@ -997,19 +2236,67 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
997
2236
  /* events API not available in this runtime */
998
2237
  }
999
2238
 
1000
- // Memory events — tracks running counter (no api.state.memory)
2239
+ // Delegation panel: children are born/die with session lifecycle events
2240
+ // (a delegate spawns a child → session.created fires) — re-fetch on all
2241
+ // of them. `session.status` re-syncs busy→idle state transitions.
1001
2242
  try {
1002
- const ev = props.api.event as any
1003
- if (typeof ev?.on === 'function') {
1004
- cleanup.push(ev.on('memory.created', () => setMemoryCount((c) => (c !== null ? c + 1 : 1))))
1005
- cleanup.push(
1006
- ev.on('memory.deleted', () =>
1007
- setMemoryCount((c) => (c !== null ? Math.max(0, c - 1) : 0)),
1008
- ),
1009
- )
2243
+ const eventRefresh = () => {
2244
+ eventRefreshCount += 1
2245
+ void refreshDelegations()
1010
2246
  }
2247
+ cleanup.push(props.api.event.on('session.created', eventRefresh))
2248
+ cleanup.push(props.api.event.on('session.updated', eventRefresh))
2249
+ cleanup.push(props.api.event.on('session.deleted', eventRefresh))
2250
+ cleanup.push(props.api.event.on('session.status', eventRefresh))
1011
2251
  } catch {
1012
- /* memory events not available */
2252
+ /* events API not available — the 1s poll still covers it */
2253
+ }
2254
+
2255
+ // message.part.updated/removed → liveStore.version() bumps (tui()):
2256
+ // subscribe here so the panel ALSO re-fetches children when tool-part
2257
+ // events DO arrive and the updated live row is rendered immediately.
2258
+ createEffect(() => {
2259
+ props.liveStore.version() // subscribe to live mutations
2260
+ eventRefreshCount += 1
2261
+ void refreshDelegations()
2262
+ })
2263
+
2264
+ // Safety poll (OMO-slim pattern): 1s re-fetch children + md + tick the
2265
+ // elapsed counter. Guarantees the panel updates even with zero events.
2266
+ const poll = setInterval(() => {
2267
+ setNow(Date.now())
2268
+ void refreshDelegations()
2269
+ }, 1_000)
2270
+ cleanup.push(() => clearInterval(poll))
2271
+ const animation = setInterval(() => setAnimationNow(Date.now()), 140)
2272
+ cleanup.push(() => clearInterval(animation))
2273
+
2274
+ // Compaction recovery: `message.part.removed` clears live entries while
2275
+ // compacting, so after a compaction/attach the live map would start
2276
+ // empty even though the session still holds the delegation tool parts.
2277
+ // Re-seed the live map from the session's existing parts (in message
2278
+ // order) right after the subscriptions are registered — the version
2279
+ // bump re-triggers the children fetch. Fail-open: if the SDK access is
2280
+ // unavailable we skip — the children API + the 1s poll keep the panel
2281
+ // working.
2282
+ try {
2283
+ const sdk = (props.api.state as any)?.session
2284
+ const mountSessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
2285
+ if (mountSessionID !== null && typeof sdk?.messages === 'function') {
2286
+ const messages: readonly { id?: string; parts?: unknown[] }[] =
2287
+ sdk.messages(mountSessionID) ?? []
2288
+ const state = props.api.state as any
2289
+ const getParts =
2290
+ typeof state?.part === 'function'
2291
+ ? (messageID: string) => state.part(messageID)
2292
+ : undefined
2293
+ const parts = collectDelegationToolParts(messages, getParts)
2294
+ if (parts.length > 0 && seedLiveDelegationMap(props.liveStore.map, parts) > 0) {
2295
+ props.liveStore.bump()
2296
+ }
2297
+ }
2298
+ } catch {
2299
+ /* re-scan unavailable — children API + 1s poll still cover it */
1013
2300
  }
1014
2301
 
1015
2302
  onCleanup(() =>
@@ -1019,17 +2306,6 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
1019
2306
  )
1020
2307
  })
1021
2308
 
1022
- // ── Config summary ──
1023
- const configSummary = createMemo(() => {
1024
- const cfg = (props.api.state as any).config
1025
- if (!cfg) return null
1026
- return {
1027
- mcpCount: cfg.mcp ? Object.keys(cfg.mcp).length : 0,
1028
- pluginCount: Array.isArray(cfg.plugin) ? cfg.plugin.length : 0,
1029
- autoCompaction: cfg.compaction?.auto === true,
1030
- }
1031
- })
1032
-
1033
2309
  const HR = () => <text fg={theme().textMuted}>{'\u2500'.repeat(28)}</text>
1034
2310
 
1035
2311
  return (
@@ -1084,113 +2360,114 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
1084
2360
  </Show>
1085
2361
  </Show>
1086
2362
 
1087
- {/* ── Commands ── */}
1088
- <box onMouseDown={() => setShowCommands((x) => !x)}>
2363
+ {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
2364
+ <box onMouseDown={() => setShowDelegations((x) => !x)}>
1089
2365
  <text fg={theme().text} attributes={1}>
1090
- {`${showCommands() ? '\u25bc' : '\u25b6'} Commands`}
2366
+ {`${showDelegations() ? '\u25bc' : '\u25b6'} Delegations`}
1091
2367
  </text>
1092
- <text fg={theme().textMuted}>{` (${String(COMMANDS.length)})`}</text>
2368
+ <text fg={theme().textMuted}>{` (${String(visibleDelegations().length)})`}</text>
1093
2369
  </box>
1094
- <Show when={showCommands()}>
1095
- <box marginLeft={1} flexDirection="column">
1096
- <For each={COMMANDS}>
1097
- {(cmd) => (
1098
- <box
1099
- onMouseDown={(e) => {
1100
- e.stopPropagation()
1101
- try {
1102
- const cmdApi = (props.api as any).command
1103
- const cmdName = cmd.name.replace('/', '')
1104
- if (cmdApi?.trigger?.(cmdName)) return
1105
- } catch {
1106
- /* fall through to toast */
1107
- }
1108
- props.api.ui?.toast?.({ title: 'Command', message: `Type ${cmd.name} in chat` })
1109
- }}
1110
- >
1111
- <text fg={cmd.name === '/pantheon' ? theme().accent : theme().textMuted}>
1112
- {cmd.name}
1113
- </text>
1114
- <text fg={theme().textMuted}>{` \u2014 ${cmd.desc}`}</text>
1115
- </box>
1116
- )}
1117
- </For>
1118
- </box>
1119
- </Show>
1120
-
1121
- {/* ── Agents ── */}
1122
- <box onMouseDown={() => setShowAgents((x) => !x)}>
1123
- <text fg={theme().text} attributes={1}>
1124
- {`${showAgents() ? '\u25bc' : '\u25b6'} Agents`}
1125
- </text>
1126
- <text fg={theme().textMuted}>{` (${String(AGENTS.length)})`}</text>
1127
- </box>
1128
- <Show when={showAgents()}>
1129
- <box marginLeft={1} flexDirection="column">
1130
- <For each={AGENTS}>
1131
- {(agent) => (
1132
- <box>
1133
- <text fg={agent.tier === 'premium' ? theme().accent : theme().textMuted}>
1134
- {`${agent.tier === 'premium' ? '\u2726 ' : '\u00b7 '}${agent.name}`}
1135
- </text>
1136
- <text fg={theme().textMuted}>{` \u2014 ${agent.role}`}</text>
1137
- </box>
1138
- )}
1139
- </For>
1140
- </box>
1141
- </Show>
1142
-
1143
- {/* ── Config ── */}
1144
- <box onMouseDown={() => setShowConfig((x) => !x)}>
1145
- <text fg={theme().text} attributes={1}>
1146
- {`${showConfig() ? '\u25bc' : '\u25b6'} Config`}
1147
- </text>
1148
- </box>
1149
- <Show when={showConfig()}>
2370
+ <Show when={showDelegations()}>
1150
2371
  <Show
1151
- when={configSummary()}
2372
+ when={visibleDelegations().length > 0}
1152
2373
  fallback={
1153
2374
  <box marginLeft={1}>
1154
- <text fg={theme().textMuted}>No config data</text>
2375
+ <text fg={theme().textMuted}>No delegations</text>
1155
2376
  </box>
1156
2377
  }
1157
2378
  >
1158
- {(cfg) => (
1159
- <box marginLeft={1} flexDirection="column">
1160
- <text
1161
- fg={theme().textMuted}
1162
- >{`MCP: ${String(cfg().mcpCount)} Plugins: ${String(cfg().pluginCount)}`}</text>
1163
- <text
1164
- fg={theme().textMuted}
1165
- >{`Auto-compaction: ${cfg().autoCompaction ? 'ON' : 'OFF'}`}</text>
1166
- </box>
1167
- )}
2379
+ <box marginLeft={1} flexDirection="column">
2380
+ <For each={visibleDelegations()}>
2381
+ {(job) => (
2382
+ <DelegationRow
2383
+ api={props.api}
2384
+ job={job}
2385
+ now={now()}
2386
+ animationNow={animationNow()}
2387
+ />
2388
+ )}
2389
+ </For>
2390
+ </box>
1168
2391
  </Show>
1169
2392
  </Show>
1170
-
1171
- {/* ── Memory (always visible summary) ── */}
1172
- <box marginTop={1}>
1173
- <text fg={theme().textMuted}>
1174
- {memoryCount() !== null ? `Memory: ${fmtInt(memoryCount()!)} entries` : 'Memory: N/A'}
1175
- </text>
1176
- </box>
1177
2393
  </box>
1178
2394
  )
1179
2395
  }
1180
2396
 
1181
2397
  /* ─── Plugin Registration ────────────────────────────────── */
1182
2398
 
1183
- const tui: TuiPlugin = async (api, _options, _meta) => {
1184
- const version = await detectVersion(api)
2399
+ const tui: TuiPlugin = (api, _options, _meta) => {
2400
+ // Do not await project file/process probes here. OpenCode waits for the TUI
2401
+ // factory before it finishes startup, and a Windows host opening a WSL
2402
+ // project can leave those probes pending while the project bridge comes up.
2403
+ // Register the sidebar immediately and fill in the optional version later.
2404
+ const [version, setVersion] = createSignal<string | null>(null)
2405
+ void detectVersion(api)
2406
+ .then((detected) => setVersion(detected))
2407
+ .catch(() => setVersion(null))
1185
2408
 
1186
2409
  // Vendored feature (MIT): AI subscription usage gauges.
1187
2410
  void setupUsageBar(api) // async init — never blocks sidebar registration
1188
2411
 
2412
+ // ── Live delegation store (legacy tool-part tracker, kept as a REFRESH
2413
+ // TRIGGER) ──
2414
+ // The Delegations panel's PRIMARY source is `api.client.session.children`
2415
+ // (delegations-sidebar pattern) — `message.part.updated` may not fire for
2416
+ // pantheon_delegate on runtime 1.18.13 (the "(0)" symptom). The tool-part
2417
+ // lifecycle below still tracks pantheon_delegate / pantheon_delegation_read
2418
+ // parts and bumps a version signal the View subscribes to: when part
2419
+ // events DO arrive, the panel re-fetches children. Fail-open: if the
2420
+ // events API is unavailable nothing subscribes and the 1s safety poll
2421
+ // keeps the panel working (never crash).
2422
+ const [liveVersion, setLiveVersion] = createSignal(0)
2423
+ const liveStore: LiveDelegationStore = {
2424
+ map: new Map<string, LiveDelegationEntry>(),
2425
+ version: liveVersion,
2426
+ bump: () => setLiveVersion((v) => v + 1),
2427
+ }
2428
+ const unsubLive: (() => void)[] = []
2429
+
2430
+ try {
2431
+ unsubLive.push(
2432
+ api.event.on('message.part.updated', (event: any) => {
2433
+ // SDK v2 shape: properties.part (v1 drift: properties.info.part).
2434
+ const props = (event?.properties ?? {}) as { part?: unknown; info?: { part?: unknown } }
2435
+ const part = (props.part ?? props.info?.part) as DelegationToolPart | undefined
2436
+ if (part === undefined) return
2437
+ if (reduceDelegationToolPart(liveStore.map, part)) liveStore.bump()
2438
+ }),
2439
+ )
2440
+ } catch {
2441
+ /* events API unavailable — children API + 1s poll cover the panel */
2442
+ }
2443
+ try {
2444
+ unsubLive.push(
2445
+ api.event.on('message.part.removed', (event: any) => {
2446
+ // SDK v2 shape: properties.partID (v1 drift: properties.part.id).
2447
+ const props = (event?.properties ?? {}) as { partID?: string; part?: { id?: string } }
2448
+ const partID = props.partID ?? props.part?.id
2449
+ if (partID !== undefined && removeDelegationEntry(liveStore.map, partID)) liveStore.bump()
2450
+ }),
2451
+ )
2452
+ } catch {
2453
+ /* events API unavailable — children API + 1s poll cover the panel */
2454
+ }
2455
+ api.lifecycle.onDispose(() => {
2456
+ for (const unsub of unsubLive) {
2457
+ try {
2458
+ unsub()
2459
+ } catch {
2460
+ /* ignore */
2461
+ }
2462
+ }
2463
+ liveStore.map.clear()
2464
+ })
2465
+
1189
2466
  api.slots.register({
1190
2467
  order: 900,
1191
2468
  slots: {
1192
2469
  sidebar_content(_ctx, props) {
1193
- return <View api={api} sessionID={props.session_id} version={version} />
2470
+ return <View api={api} sessionID={props.session_id} version={version()} liveStore={liveStore} />
1194
2471
  },
1195
2472
  },
1196
2473
  })