pantheon-opencode 1.5.0-beta.16 → 1.5.0-beta.18

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 (41) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +13 -25
  3. package/README.pt-BR.md +4 -5
  4. package/package.json +1 -1
  5. package/plugin.json +1 -1
  6. package/pyproject.toml +1 -1
  7. package/scripts/mcp_persistence_server.py +1 -1
  8. package/src/agents/apollo.md +1 -0
  9. package/src/agents/gaia.md +1 -0
  10. package/src/agents/zeus.md +8 -29
  11. package/src/mcp/mcp_persistence_server.py +1 -1
  12. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  13. package/src/pantheon/cost-command.ts +1 -2
  14. package/src/pantheon/delegation-enforce.ts +59 -14
  15. package/src/pantheon/goal-loop.ts +1 -1
  16. package/src/pantheon/goal-store.ts +1 -1
  17. package/src/pantheon/idle-continuation.ts +4 -6
  18. package/src/pantheon/model-command.ts +1 -2
  19. package/src/pantheon/native-task-status.ts +2 -2
  20. package/src/pantheon/session-id.ts +30 -0
  21. package/src/pantheon/task-result-guard.ts +2 -3
  22. package/src/pantheon/todo-enforcer.ts +3 -3
  23. package/src/pantheon/tool-context.ts +23 -0
  24. package/src/pantheon/v2-bridge.ts +1 -4
  25. package/src/plugin-v2.ts +4 -15
  26. package/src/plugin.ts +61 -245
  27. package/src/plugins/tui/dist/tui.d.ts +49 -79
  28. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  29. package/src/plugins/tui/dist/tui.js +48 -189
  30. package/src/plugins/tui/package-lock.json +2 -2
  31. package/src/plugins/tui/package.json +1 -1
  32. package/src/plugins/tui/src/index.tsx +97 -295
  33. package/src/skills/context-compression/SKILL.md +1 -1
  34. package/src/pantheon/delegate-manager.ts +0 -851
  35. package/src/pantheon/delegation-classifier.ts +0 -306
  36. package/src/pantheon/delegation-finalize.ts +0 -468
  37. package/src/pantheon/delegation-notify.ts +0 -212
  38. package/src/pantheon/delegation-toast.ts +0 -73
  39. package/src/pantheon/delegation.ts +0 -1349
  40. package/src/pantheon/native-probe.ts +0 -234
  41. package/src/pantheon/step-cap.ts +0 -110
@@ -886,31 +886,32 @@ async function setupUsageBar(api: TuiPluginApi) {
886
886
  * CHILDREN + MD (delegations-sidebar pattern, feat/compaction-134):
887
887
  *
888
888
  * 1. PRIMARY — SDK child sessions via `api.client.session.children`:
889
- * every pantheon_delegate creates a child session with
890
- * parentID = caller (src/pantheon/delegation.ts session.create), so
889
+ * every delegated child session is created with
890
+ * parentID = caller (native `task()` child session), so
891
891
  * the children of the current session ARE the delegation list — the
892
892
  * same mechanism the opencode-delegations-sidebar reference uses
893
893
  * (`client.session.children` + `session.parentID === current`).
894
894
  * Live status comes from `api.state.session.status(childID)`
895
895
  * (busy/retry → running; idle → terminal). This does NOT depend on
896
896
  * tool-part events, which runtime 1.18.13 may not deliver for
897
- * pantheon_delegate (the "(0) even while delegating" symptom).
897
+ * native `task()` (the "(0) even while delegating" symptom).
898
898
  *
899
899
  * 1b. NATIVE task() CHILDREN — `session.children` returns ALL children,
900
900
  * and the native `task()` tool ALSO spawns a child session with
901
- * parentID = caller. A child WITHOUT a board report is a native
901
+ * parentID = caller. A child WITHOUT a delegate report is a native
902
902
  * task() child: childrenToDelegationEntries tags it source
903
903
  * 'children-only' (distinct info color),
904
- * so native task() work is visible in the panel alongside board
905
- * delegates — never filtered out, never duplicated
906
- * with a board row for the same child (a child WITH a report keeps the
904
+ * so native task() work is visible in the panel alongside delegate
905
+ * children — never filtered out, never duplicated
906
+ * with a report row for the same child (a child WITH a report keeps the
907
907
  * md entry, source 'md').
908
908
  *
909
- * 2. ENRICHMENT — markdown reports the job board persists under
910
- * `.pantheon/delegations/<sessionID>/<alias>.md` (written by
911
- * src/pantheon/delegation-finalize.ts at terminal state). Each child
909
+ * 2. ENRICHMENT — markdown reports written under
910
+ * `.pantheon/delegations/<sessionID>/<alias>.md` (legacy delegate output;
911
+ * the delegate layer was removed, existing reports stay readable). Each
912
+ * child
912
913
  * is matched to its report by the `Task ID` header (== child session
913
- * id == board task id), which supplies the alias (apo-N), agent,
914
+ * id == task id), which supplies the alias (apo-N), agent,
914
915
  * description and terminal duration/timedOut. No report → the child
915
916
  * itself still renders (title as description, derived state). The md
916
917
  * channel is read from EVERY session (readAllDelegationEntries) as an
@@ -927,7 +928,7 @@ async function setupUsageBar(api: TuiPluginApi) {
927
928
  * "panel: children=N(pantheon=M native=K) md=N events=N"
928
929
  * to `.pantheon/logs/hooks.log` (silence-by-default, createPantheonLogger
929
930
  * policy) so the (0) symptom is diagnosable from disk: children>0 means
930
- * the source works, pantheon/native splits board vs task() children.
931
+ * the source works, pantheon/native splits report vs task() children.
931
932
  * A children fetch
932
933
  * failure logs "panel: error <msg>" (previously silenced), and a missing
933
934
  * current session (null/placeholder sessionID) logs "panel: children=0
@@ -956,7 +957,7 @@ export type DelegationEntry = {
956
957
  alias: string
957
958
  /** Parent session the job was launched from (dir name under .pantheon/delegations). */
958
959
  sessionID: string
959
- /** Child session id (= board task id, from the `Task ID` header). The
960
+ /** Child session id (= task id, from the `Task ID` header). The
960
961
  * children channel always sets it from the child session itself; the md
961
962
  * channel parses it so child↔md matching works by taskID. */
962
963
  taskID?: string
@@ -984,10 +985,11 @@ export type DelegationEntry = {
984
985
  // childCount fields + tree-connector helpers were removed as dead code.
985
986
  // Reintroduce them once nested rows are actually produced.
986
987
  /** Internal provenance used to keep a finalized md report authoritative.
987
- * 'children-only' = a native task() child session with NO board report
988
- * (a native child row); 'md' = board report wins;
989
- * 'board' = read from .pantheon/board/state.json (cross-session FSM). */
990
- source?: 'child' | 'live' | 'md' | 'children-only' | 'board'
988
+ * 'children-only' = a native task() child session with NO delegate report
989
+ * (a native child row); 'md' = the child has a matching report;
990
+ * 'child' = the plain children channel; 'live' = the optimistic tool-event
991
+ * channel. */
992
+ source?: 'child' | 'live' | 'md' | 'children-only'
991
993
  }
992
994
 
993
995
  /** True for `\s` characters (space, tab, newline, CR) — plain char checks so
@@ -1172,90 +1174,11 @@ export async function readAllDelegationEntries(root: string): Promise<Delegation
1172
1174
  return readDelegationEntries(join(root, '.pantheon', 'delegations'))
1173
1175
  }
1174
1176
 
1175
- /* ─── Job board channel (4th source: .pantheon/board/state.json) ────────
1176
- * `session.children` is scoped to the focused session, so a job running in
1177
- * ANOTHER session is invisible to it (the "Delegations (0)" symptom).
1178
- * The board state file is the only durable, cross-session source:
1179
- * FilePersistence (src/pantheon/file-persistence.ts) stores every
1180
- * BackgroundJobRecord there as a JSON array. Reads are fail-open — absent,
1181
- * corrupt or unreadable → [] — so a broken board never crashes the panel. */
1182
-
1183
- /** The persisted subset of BackgroundJobRecord the panel consumes. Duck-typed
1184
- * so a corrupt/older record never breaks parsing. */
1185
- export type BoardJobRecord = {
1186
- taskID: string
1187
- parentSessionID: string
1188
- agent: string
1189
- description?: string
1190
- state: string
1191
- alias: string
1192
- launchedAt?: number
1193
- completedAt?: number
1194
- updatedAt?: number
1195
- timedOut?: boolean
1196
- }
1197
-
1198
- /** The FilePersistence state path: `<root>/.pantheon/board/state.json`. */
1199
- export function boardStatePath(root: string): string {
1200
- return join(root, '.pantheon', 'board', 'state.json')
1201
- }
1202
-
1203
- /** Validate one parsed record, keeping only the fields the panel uses.
1204
- * Returns null for anything without a taskID/state (a corrupt entry is
1205
- * skipped individually — the rest of the snapshot still loads). */
1206
- function normalizeBoardRecord(item: unknown): BoardJobRecord | null {
1207
- if (typeof item !== 'object' || item === null) return null
1208
- const rec = item as Record<string, unknown>
1209
- if (typeof rec.taskID !== 'string' || rec.taskID === '') return null
1210
- if (typeof rec.state !== 'string' || rec.state === '') return null
1211
- const record: BoardJobRecord = {
1212
- taskID: rec.taskID,
1213
- parentSessionID: typeof rec.parentSessionID === 'string' ? rec.parentSessionID : '',
1214
- agent: typeof rec.agent === 'string' && rec.agent !== '' ? rec.agent : 'agent',
1215
- state: rec.state,
1216
- alias: typeof rec.alias === 'string' ? rec.alias : '',
1217
- }
1218
- if (typeof rec.description === 'string') record.description = rec.description
1219
- if (typeof rec.launchedAt === 'number' && Number.isFinite(rec.launchedAt))
1220
- record.launchedAt = rec.launchedAt
1221
- if (typeof rec.completedAt === 'number' && Number.isFinite(rec.completedAt))
1222
- record.completedAt = rec.completedAt
1223
- if (typeof rec.updatedAt === 'number' && Number.isFinite(rec.updatedAt))
1224
- record.updatedAt = rec.updatedAt
1225
- if (typeof rec.timedOut === 'boolean') record.timedOut = rec.timedOut
1226
- return record
1227
- }
1228
-
1229
- /** Read `.pantheon/board/state.json` (the cross-session job board snapshot).
1230
- * Fail-open: missing file, corrupt JSON, non-array payload or an unreadable
1231
- * path all yield [] — the panel keeps rendering from the other channels. */
1232
- export async function readBoardState(root: string): Promise<BoardJobRecord[]> {
1233
- let raw: string
1234
- try {
1235
- raw = await readFile(boardStatePath(root), 'utf8')
1236
- } catch {
1237
- return [] // absent/unreadable — no board yet
1238
- }
1239
- let parsed: unknown
1240
- try {
1241
- parsed = JSON.parse(raw)
1242
- } catch {
1243
- return [] // corrupt JSON — never crash the sidebar
1244
- }
1245
- if (!Array.isArray(parsed)) return []
1246
- const records: BoardJobRecord[] = []
1247
- for (const item of parsed) {
1248
- const record = normalizeBoardRecord(item)
1249
- if (record !== null) records.push(record)
1250
- }
1251
- return records
1252
- }
1253
-
1254
1177
  /** Resolve the PROJECT ROOT used by every pantheon file channel. `directory`
1255
1178
  * wins over `worktree` (the old `resolveDelegationsDir` already did this);
1256
1179
  * an absent/empty root or `/` (no git — e.g. the sandbox test project) falls
1257
- * back to cwd. Standardised here so the delegations md, the board state file
1258
- * and the panel logger all read the SAME root (audit finding: the channels
1180
+ * back to cwd. Standardised here so the delegations md and the panel logger
1181
+ * all read the SAME root (audit finding: the channels
1259
1182
  * resolved the root independently). Pure — no I/O. */
1260
1183
  export function resolvePantheonRoot(
1261
1184
  state: { directory?: string; worktree?: string } | undefined,
@@ -1266,8 +1189,8 @@ export function resolvePantheonRoot(
1266
1189
  return root
1267
1190
  }
1268
1191
 
1269
- /** Resolve the directory where the job board writes delegation md reports.
1270
- * The board writes `.pantheon/delegations` RELATIVE to the server cwd,
1192
+ /** Resolve the directory where delegation md reports are written.
1193
+ * The finalizer writes `.pantheon/delegations` RELATIVE to the server cwd,
1271
1194
  * which the TUI exposes as `TuiState.path.directory`. */
1272
1195
  export function resolveDelegationsDir(
1273
1196
  state: { directory?: string; worktree?: string } | undefined,
@@ -1365,12 +1288,12 @@ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
1365
1288
  * display-only `stale-running` state. */
1366
1289
  export const IDLE_SILENCE_MS = 60 * 1000
1367
1290
 
1368
- /** Visual-only terminal retention windows. Reports remain on disk and in the
1369
- * board; these constants only control which rows enter the TUI window. */
1291
+ /** Visual-only terminal retention windows. Reports remain on disk; these
1292
+ * constants only control which rows enter the TUI window. */
1370
1293
  export const DELEGATION_DONE_RETENTION_MS = 2 * 60 * 1000
1371
1294
  export const DELEGATION_FAILED_RETENTION_MS = 10 * 60 * 1000
1372
1295
 
1373
- /** Alias-less NATIVE task() live entries never receive a board alias (the
1296
+ /** Alias-less NATIVE task() live entries never receive a report alias (the
1374
1297
  * task tool output carries none), so the 30s alias-less prune in
1375
1298
  * mergeChildDelegationSources must not apply to them — 5 minutes covers a
1376
1299
  * slow child listing while still bounding the live map. */
@@ -1379,7 +1302,7 @@ export const NATIVE_LIVE_ALIASLESS_TTL_MS = 5 * 60 * 1000
1379
1302
  /**
1380
1303
  * Mark a running entry as `stale-running` if it has been running longer than
1381
1304
  * the threshold AND has no recent activity (no `updatedAt` change in the last
1382
- * `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
1305
+ * `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the persisted state is unchanged.
1383
1306
  *
1384
1307
  * A `stale-running` entry renders with a warning indicator but the underlying
1385
1308
  * delegation is still treated as running by the backend.
@@ -1471,9 +1394,10 @@ export function delegationSpinnerFrame(now: number): string {
1471
1394
  return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
1472
1395
  }
1473
1396
 
1474
- /** Every state the row knows how to draw: the real BackgroundJobBoard FSM
1475
- * (src/pantheon/background-job-board.ts) plus the TUI display-only states
1476
- * (`retry`, `stale-running`). Fase 1 deliberately omits speculative
1397
+ /** Every state the row knows how to draw: the delegation lifecycle states
1398
+ * derived from the children status + md reports (`completed`, `error`,
1399
+ * `cancelled`, `startup_failed`, `startup_unknown`) plus the TUI-only
1400
+ * states (`retry`, `stale-running`). Fase 1 deliberately omits speculative
1477
1401
  * blocked/paused/scheduled/skipped. There is no `pending` display state: a
1478
1402
  * pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
1479
1403
  export type DelegationDisplayState = DelegationEntry['state']
@@ -1603,7 +1527,7 @@ export function mergeChildDelegationSources(
1603
1527
  })
1604
1528
 
1605
1529
  for (const liveEntry of live) {
1606
- // A native task() live part never carries a board alias or taskID, so it
1530
+ // A native task() live part never carries a report alias or taskID, so it
1607
1531
  // is exempt from the 30s alias-less prune and lives on its own longer TTL.
1608
1532
  const aliaslessTTL = liveEntry.tool === 'task' ? NATIVE_LIVE_ALIASLESS_TTL_MS : 30_000
1609
1533
  if (
@@ -1613,13 +1537,13 @@ export function mergeChildDelegationSources(
1613
1537
  )
1614
1538
  continue
1615
1539
  const incoming = toDelegationEntry(liveEntry)
1616
- // A native task() live part never carries a board alias — it is a
1540
+ // A native task() live part never carries a report alias — it is a
1617
1541
  // children-only row by definition whether it lands on a new row or
1618
1542
  // upgrades an existing
1619
1543
  // children-only one. pantheon_delegate pending parts keep source 'live'.
1620
1544
  const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
1621
1545
  if (isNativeLive) {
1622
- // Native task() rows carry no board alias — keep the per-call
1546
+ // Native task() rows carry no report alias — keep the per-call
1623
1547
  // `live-<callID>` identity from toDelegationEntry (one row per native
1624
1548
  // call) with the children-only provenance; the row identity still falls
1625
1549
  // back to the agent via delegationRowIdentity. A taskID-known native
@@ -1820,7 +1744,7 @@ export function parseDelegationToolPart(
1820
1744
  : ''
1821
1745
  // The alias/taskID only exist once the delegate tool COMPLETES (they are
1822
1746
  // returned in its output); a running/pending part has neither. Native
1823
- // `task` parts never carry a board alias — the children channel supplies
1747
+ // `task` parts never carry a report alias — the children channel supplies
1824
1748
  // the child session id and the row's identity.
1825
1749
  let alias: string | null = null
1826
1750
  let taskID: string | null = null
@@ -1894,7 +1818,7 @@ export function reduceDelegationToolPart(
1894
1818
  // Delegate tool (pantheon_delegate AND native task): pending/running →
1895
1819
  // job launched (running). A COMPLETED delegate tool only means the job was
1896
1820
  // registered — it stays running and we pick up alias + taskID from the
1897
- // output. Native `task` parts never yield a board alias, so their rows
1821
+ // output. Native `task` parts never yield a report alias, so their rows
1898
1822
  // stay identity-less until the children channel resolves them.
1899
1823
  const existing = map.get(parsed.callID)
1900
1824
  if (parsed.status === 'error') {
@@ -2078,136 +2002,18 @@ export function mergeDelegationSources(
2078
2002
  return all
2079
2003
  }
2080
2004
 
2081
- /* ─── Board channel: record → display + authoritative merge ───────────── */
2082
-
2083
- /** Job-board FSM → panel display state. `reconciled` is intentionally absent:
2084
- * the panel parser rejects it (a reconciled job is done and folded away). */
2085
- const BOARD_STATE_TO_DISPLAY: Record<string, DelegationEntry['state']> = {
2086
- running: 'running',
2087
- completed: 'completed',
2088
- error: 'error',
2089
- startup_failed: 'startup_failed',
2090
- startup_unknown: 'startup_unknown',
2091
- cancelled: 'cancelled',
2092
- }
2093
-
2094
- /** Map one persisted board record into the display shape. Returns null for
2095
- * states the panel does not render (reconciled / unknown). Pure. */
2096
- export function boardRecordToDelegationEntry(record: BoardJobRecord): DelegationEntry | null {
2097
- const state = BOARD_STATE_TO_DISPLAY[record.state]
2098
- if (state === undefined) return null
2099
- const running = state === 'running'
2100
- return {
2101
- alias: record.alias !== '' ? record.alias : `job-${record.taskID.slice(0, 8)}`,
2102
- sessionID: record.parentSessionID,
2103
- taskID: record.taskID,
2104
- agent: record.agent,
2105
- state,
2106
- startedAt: record.launchedAt ?? record.updatedAt ?? 0,
2107
- updatedAt: running ? null : (record.completedAt ?? record.updatedAt ?? null),
2108
- timedOut: record.timedOut === true,
2109
- description: record.description ?? '',
2110
- source: 'board',
2111
- }
2112
- }
2113
-
2114
- /** Map a whole board snapshot, dropping unrenderable states. Sorted like every
2115
- * other channel (running first, then most recent). Pure. */
2116
- export function boardRecordsToDelegationEntries(
2117
- records: readonly BoardJobRecord[],
2118
- ): DelegationEntry[] {
2119
- const out: DelegationEntry[] = []
2120
- for (const record of records) {
2121
- const entry = boardRecordToDelegationEntry(record)
2122
- if (entry !== null) out.push(entry)
2123
- }
2124
- out.sort(compareDelegationEntries)
2125
- return out
2126
- }
2127
-
2128
- /** Active (non-terminal) display states — a job in one of these is still live. */
2129
- function isActiveDelegationState(state: DelegationEntry['state']): boolean {
2130
- return state === 'running' || state === 'retry' || state === 'stale-running'
2131
- }
2132
-
2133
- /** Temporal guard for the board merge (recycled-alias regression): board
2134
- * aliases recycle across jobs (apo-1 of a previous job), so a board
2135
- * terminal record only closes a running entry when its Finalized timestamp
2136
- * is STRICTLY NEWER than the entry it would close. Older/equal/missing
2137
- * keeps the live entry — when in doubt the live job wins. Pure. */
2138
- function shouldKeepRunningOverBoardTerminal(
2139
- existing: DelegationEntry | undefined,
2140
- incoming: DelegationEntry,
2141
- ): boolean {
2142
- if (existing === undefined) return false
2143
- if (!isActiveDelegationState(existing.state)) return false
2144
- if (isActiveDelegationState(incoming.state)) return false
2145
- const incomingTs = incoming.updatedAt
2146
- if (incomingTs === null) return true
2147
- const existingTs = existing.updatedAt ?? existing.startedAt
2148
- return incomingTs <= existingTs
2149
- }
2150
-
2151
- /** Merge the board over any other channel. The board is AUTHORITATIVE for
2152
- * state/alias/agent (it is the persisted FSM). This merge is intentionally
2153
- * UNSCOPED so the board can enrich an active-session row (dedup by taskID);
2154
- * the caller scopes the merged list with {@link filterDelegationsToSession},
2155
- * which is what keeps other-session board jobs out of the panel. Dedup by
2156
- * taskID first, then by (sessionID, alias) — aliases are per parent session. Pure. */
2157
- export function mergeBoardDelegationSources(
2158
- base: readonly DelegationEntry[],
2159
- board: readonly DelegationEntry[],
2160
- ): DelegationEntry[] {
2161
- const key = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
2162
- const byTaskID = new Map<string, number>()
2163
- const bySessionAlias = new Map<string, number>()
2164
- const out = [...base]
2165
- out.forEach((entry, index) => {
2166
- if (entry.taskID !== undefined) byTaskID.set(entry.taskID, index)
2167
- bySessionAlias.set(key(entry.sessionID, entry.alias), index)
2168
- })
2169
-
2170
- for (const incoming of board) {
2171
- const index =
2172
- (incoming.taskID !== undefined ? byTaskID.get(incoming.taskID) : undefined) ??
2173
- bySessionAlias.get(key(incoming.sessionID, incoming.alias))
2174
- if (index === undefined) {
2175
- out.push(incoming)
2176
- const next = out.length - 1
2177
- if (incoming.taskID !== undefined) byTaskID.set(incoming.taskID, next)
2178
- bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
2179
- continue
2180
- }
2181
- // Temporal guard (recycled-alias regression): a STALE board terminal must
2182
- // never downgrade a live running entry — skip the board record entirely
2183
- // and keep the live row (same job alias, previous incarnation).
2184
- if (shouldKeepRunningOverBoardTerminal(out[index], incoming)) continue
2185
- out[index] = {
2186
- ...incoming,
2187
- // Session preservation: the board is authoritative for state/alias/agent,
2188
- // but an EMPTY board sessionID (record without parentSessionID) must
2189
- // never clobber a known session — otherwise a focused-session row would
2190
- // lose its attribution. Keep the known session.
2191
- sessionID: incoming.sessionID !== '' ? incoming.sessionID : (out[index]?.sessionID ?? ''),
2192
- } // board wins — it is the persisted source of truth
2193
- }
2194
-
2195
- out.sort(compareDelegationEntries)
2196
- return out
2197
- }
2198
-
2199
2005
  /** Scope a fully-merged display list to the ACTIVE session only.
2200
2006
  *
2201
- * The panel is session-scoped: rows from other sessions (board snapshot, md
2202
- * history under `.pantheon/delegations/<other-session>/`) and rows with no
2007
+ * The panel is session-scoped: rows from other sessions (md history under
2008
+ * `.pantheon/delegations/<other-session>/`) and rows with no
2203
2009
  * attributable session (empty sessionID) are DROPPED. The previous
2204
2010
  * cross-session behavior rendered those rows and clicking them led to
2205
2011
  * "Session not found" — there is no cross-session channel anymore.
2206
2012
  *
2207
- * The board/md channels still feed the merge UNFILTERED so the board can
2208
- * ENRICH an active-session row with state/alias/agent (dedup by taskID), but
2209
- * they can never introduce a row for another session: this filter is applied
2210
- * ONCE, after every merge (children + live + md + board).
2013
+ * The md channel still feeds the merge UNFILTERED so it can ENRICH an
2014
+ * active-session row with state/alias/agent (dedup by taskID), but it can
2015
+ * never introduce a row for another session: this filter is applied
2016
+ * ONCE, after every merge (children + live + md).
2211
2017
  *
2212
2018
  * Returns [] when no active session resolves (null/placeholder) — there is no
2213
2019
  * scope to show. Native children always carry the active session id (stamped
@@ -2228,7 +2034,7 @@ export function filterDelegationsToSession(
2228
2034
 
2229
2035
  /* ─── Current-session resolution + path guard (placeholder regression) ──
2230
2036
  * The sidebar forwarded `props.session_id` verbatim into
2231
- * `session.children({ path: { id } })`. When the TUI runtime renders the
2037
+ * `session.children({ sessionID })`. When the TUI runtime renders the
2232
2038
  * sidebar without a focused session it leaves the route template
2233
2039
  * UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
2234
2040
  * rejected every poll (SchemaError: Expected a string starting with "ses",
@@ -2292,33 +2098,38 @@ export function resolveCurrentSessionID(sources: TuiSessionSources): string | nu
2292
2098
  }
2293
2099
 
2294
2100
  /** THE single choke point for every `session.children` / session-API path.
2295
- * Returns `{ path: { id } }` ONLY for a server-valid session id; returns
2296
- * null for anything else (placeholder, empty, foreign id) so the caller
2297
- * skips the call entirely instead of sending an unsubstituted placeholder
2298
- * (the "%7BsessionID%7D" regression). Every session-API call site MUST go
2299
- * through this function (enforced by the source-scan test in
2300
- * tests/pantheon/tui-delegations.test.ts). */
2301
- export function safeSessionPath(id: unknown): { path: { id: string } } | null {
2101
+ * Returns the v2 SDK parameter shape `{ sessionID }` ONLY for a
2102
+ * server-valid session id; returns null for anything else (placeholder,
2103
+ * empty, foreign id) so the caller skips the call entirely instead of
2104
+ * sending an unsubstituted placeholder (the "%7BsessionID%7D" regression).
2105
+ * The TUI client is `@opencode-ai/sdk/v2`, whose session methods take a
2106
+ * FLAT parameter object (`{ sessionID }`), NOT the v1 `{ path: { id } }`
2107
+ * envelope — passing the v1 shape left the v2 `{sessionID}` URL template
2108
+ * unsubstituted (`/session/%7BsessionID%7D/children`). Every session-API
2109
+ * call site MUST go through this function (enforced by the source-scan
2110
+ * test in tests/pantheon/tui-delegations.test.ts). */
2111
+ export function safeSessionPath(id: unknown): { sessionID: string } | null {
2302
2112
  if (!isValidSessionId(id)) return null
2303
- return { path: { id } }
2113
+ return { sessionID: id }
2304
2114
  }
2305
2115
 
2306
- /** Build the `session.children` path ONLY from a validated session id.
2116
+ /** Build the `session.children` parameters ONLY from a validated session id.
2307
2117
  * Delegates to {@link safeSessionPath} — the single choke point. Returns
2308
- * null for null/invalid ids so the caller skips the fetch instead of
2309
- * sending an unsubstituted placeholder (the "%7BsessionID%7D" regression). */
2310
- export function buildChildrenPath(id: string | null | undefined): { path: { id: string } } | null {
2118
+ * the v2 SDK shape `{ sessionID }`; null for null/invalid ids so the caller
2119
+ * skips the fetch instead of sending an unsubstituted placeholder (the
2120
+ * "%7BsessionID%7D" regression). */
2121
+ export function buildChildrenPath(id: string | null | undefined): { sessionID: string } | null {
2311
2122
  return safeSessionPath(id)
2312
2123
  }
2313
2124
 
2314
2125
  /** Duck-typed subset of a child Session (+ its live status type). */
2315
2126
  export type ChildDelegationLike = {
2316
- /** Child session id (= board task id). */
2127
+ /** Child session id (= task id). */
2317
2128
  id: string
2318
2129
  /** Session title — the delegate's description or prompt prefix. */
2319
2130
  title?: string
2320
2131
  /** Agent name when the child session carries one (duck-typed; native
2321
- * task() children may expose it, board reports always do). */
2132
+ * task() children may expose it, md reports always do). */
2322
2133
  agent?: string
2323
2134
  /** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
2324
2135
  * or undefined when the status API is unavailable. */
@@ -2343,12 +2154,12 @@ export function childStatusToState(status: string | undefined): 'running' | 'com
2343
2154
  *
2344
2155
  * A row is
2345
2156
  * `{glyph} {alias:7} {elapsed:>5}` plus a muted description line. The short
2346
- * alias (`apo-1`) is the single identity; rows without a board alias show
2157
+ * alias (`apo-1`) is the single identity; rows without a report alias show
2347
2158
  * the agent instead (never both). */
2348
2159
  export type DelegationRowStatus = 'active' | 'done' | 'failed' | 'retry'
2349
2160
 
2350
2161
  /** Map every FSM/display state to one of the 4 row kinds. reconciled never
2351
- * reaches the panel (the board parser drops it) but reads as done;
2162
+ * reaches the panel (the md parser drops it) but reads as done;
2352
2163
  * cancelled reads as done; startup_failed reads as failed while
2353
2164
  * startup_unknown (no error known) reads as retry. Pure. */
2354
2165
  export function delegationRowStatus(state: DelegationDisplayState): DelegationRowStatus {
@@ -2390,8 +2201,8 @@ export function delegationRowMarker(state: DelegationDisplayState, now = Date.no
2390
2201
  return `${glyph} `
2391
2202
  }
2392
2203
 
2393
- /** ONE identity per row: the short board alias (`apo-1`); a row without a
2394
- * board alias (native task() child `native-task` / `native-<id>`, or an
2204
+ /** ONE identity per row: the short report alias (`apo-1`); a row without a
2205
+ * report alias (native task() child `native-task` / `native-<id>`, or an
2395
2206
  * alias-less native live row `live-<callID>` kept as children-only) shows
2396
2207
  * the agent instead — never alias + agent stacked. Pure. */
2397
2208
  export function delegationRowIdentity(entry: DelegationEntry): string {
@@ -2519,7 +2330,7 @@ export function ceilingDelegationList(
2519
2330
  }
2520
2331
 
2521
2332
  /** Split a display list into native task() rows vs pantheon_delegate rows.
2522
- * Native = source 'children-only' (no board report); everything else counts
2333
+ * Native = source 'children-only' (no delegate report); everything else counts
2523
2334
  * as pantheon. Pure — powers the hooks.log line. */
2524
2335
  export function countDelegationSources(entries: readonly DelegationEntry[]): {
2525
2336
  native: number
@@ -2532,7 +2343,7 @@ export function countDelegationSources(entries: readonly DelegationEntry[]): {
2532
2343
  }
2533
2344
 
2534
2345
  /** Diagnostic hooks.log line for a panel re-fetch, with the children
2535
- * breakdown (pantheon = children WITH a board report, native = children
2346
+ * breakdown (pantheon = children WITH a delegate report, native = children
2536
2347
  * WITHOUT one). Pure — the View logs the returned string verbatim. */
2537
2348
  export function formatPanelLogLine(
2538
2349
  children: number,
@@ -2554,7 +2365,7 @@ export function formatPanelLogLine(
2554
2365
  * child of the current session — pantheon_delegate OR the native `task()`
2555
2366
  * tool — carries parentID = caller), so it gets source 'children-only', a
2556
2367
  * per-child alias 'native-<last4 of the child id>' (one identity per native
2557
- * row) instead of a board alias. The 'task nativa' description fallback
2368
+ * row) instead of a report alias. The 'task nativa' description fallback
2558
2369
  * keeps the row non-empty when the child carries no title.
2559
2370
  * A FRESH terminal md state wins over the derived state (a stale md report
2560
2371
  * from a previous incarnation never flips a live child — see
@@ -2567,7 +2378,7 @@ export function formatPanelLogLine(
2567
2378
  * Sorted running-first (compareDelegationEntries).
2568
2379
  * Pure — no I/O. */
2569
2380
  /** Temporal guard for the children channel (same recycled-alias class as the
2570
- * board merge): an md terminal report matched by taskID may still belong to
2381
+ * md merge): an md terminal report matched by taskID may still belong to
2571
2382
  * a PREVIOUS job incarnation. Accept it only when its Finalized timestamp
2572
2383
  * covers the child's latest activity, when either side has no timestamp to
2573
2384
  * compare, or when both startedAt agree on a single job incarnation.
@@ -2776,7 +2587,7 @@ function SessionRow(props: { api: TuiPluginApi; session: any }) {
2776
2587
  )
2777
2588
  }
2778
2589
 
2779
- /* ─── Delegation Row (single job from the children/md/board channels) ─ */
2590
+ /* ─── Delegation Row (single job from the children/md channels) ─ */
2780
2591
 
2781
2592
  function DelegationRow(props: {
2782
2593
  api: TuiPluginApi
@@ -2947,14 +2758,14 @@ function View(props: {
2947
2758
  // and terminal duration via the `Task ID` match. Live tool-call events are
2948
2759
  // merged optimistically so the row appears before those channels catch up.
2949
2760
  // Consistent project root (directory ?? worktree, cwd fallback) — the md
2950
- // history, the board state file and the panel logger all read the SAME
2951
- // root (audit finding: the channels resolved it independently).
2761
+ // history and the panel logger both read the SAME root (audit finding: the
2762
+ // channels resolved it independently).
2952
2763
  const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
2953
2764
  // Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
2954
2765
  const panelLog = createTuiLogger(projectRoot())
2955
2766
  // ONLY the active session renders: the final list is filtered by
2956
- // filterDelegationsToSession after every merge (children + live + md +
2957
- // board). Live-first ordering keeps running rows on top; the ceiling
2767
+ // filterDelegationsToSession after every merge (children + live + md).
2768
+ // Live-first ordering keeps running rows on top; the ceiling
2958
2769
  // collapses the rest into "… +N more".
2959
2770
  // The rendered list is childDelegations (children enriched with md);
2960
2771
  // there is no separate md-only signal — the md channel only feeds
@@ -2980,7 +2791,9 @@ function View(props: {
2980
2791
  if (delegationsInflight !== null) return delegationsInflight
2981
2792
  delegationsInflight = (async () => {
2982
2793
  try {
2983
- const state = props.api.state as any
2794
+ // Typed (no `as any`): TuiState.session.status is a v2 sync accessor
2795
+ // taking the session id directly — same shape as the client call.
2796
+ const state = props.api.state
2984
2797
  // 0. md reports from EVERY session under .pantheon/delegations — the
2985
2798
  // ENRICHMENT channel. Read unfiltered (taskID match supplies alias/
2986
2799
  // agent/description/terminal state); the active-session filter after
@@ -2992,16 +2805,6 @@ function View(props: {
2992
2805
  } catch {
2993
2806
  md = [] // fail-open: never crash the sidebar on a read error
2994
2807
  }
2995
- // 0b. board snapshot — the 4th channel. It is cross-session on disk but
2996
- // used here ONLY to ENRICH an active-session row (state/alias/agent,
2997
- // dedup by taskID) — the active-session filter applied after the merge
2998
- // drops its other-session records. Fail-open: absent/corrupt → [].
2999
- let board: DelegationEntry[] = []
3000
- try {
3001
- board = boardRecordsToDelegationEntries(await readBoardState(projectRoot()))
3002
- } catch {
3003
- board = [] // fail-open: never crash the sidebar on a board read error
3004
- }
3005
2808
  // 1. current session — resolve + GUARD (placeholder regression). The
3006
2809
  // runtime may render the sidebar with an unsubstituted "{sessionID}"
3007
2810
  // slot prop; forwarding it to session.children made the server reject
@@ -3011,25 +2814,28 @@ function View(props: {
3011
2814
  // an EMPTY panel (zero children, zero rows, zero errors).
3012
2815
  const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
3013
2816
  if (sessionID === null) {
3014
- // No focused session → no scope → nothing renders. The md/board reads
3015
- // still ran (enrichment sources) but the active-session filter drops
3016
- // every row: there is no cross-session panel.
3017
- setChildDelegations(
3018
- filterDelegationsToSession(mergeBoardDelegationSources(md, board), sessionID),
3019
- )
2817
+ // No focused session → no scope → empty list. The md read still ran
2818
+ // (enrichment source) but with no session there is nothing to scope
2819
+ // to: there is no cross-session panel.
2820
+ setChildDelegations([])
3020
2821
  panelLog.info(
3021
2822
  `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — inactive panel)`,
3022
2823
  )
3023
2824
  return
3024
2825
  }
3025
2826
  // 2. children — PRIMARY source (SDK: api.client.session.children).
2827
+ // v2 SDK: `children` takes a FLAT `{ sessionID }` parameter object.
2828
+ // The v1 `{ path: { id } }` envelope is NOT understood by the v2
2829
+ // client — it left the URL template unsubstituted and the server
2830
+ // rejected every poll ("%7BsessionID%7D" regression).
3026
2831
  let children: ChildDelegationLike[] = []
3027
2832
  try {
3028
2833
  // buildChildrenPath re-validates: with sessionID !== null it is
3029
2834
  // never null, so no placeholder can reach the wire.
3030
- const result = await (props.api.client as any)?.session?.children?.(
3031
- buildChildrenPath(sessionID),
3032
- )
2835
+ const childrenPath = buildChildrenPath(sessionID)
2836
+ const result = childrenPath
2837
+ ? await props.api.client.session.children(childrenPath)
2838
+ : undefined
3033
2839
  const data = (result?.data ?? result) as unknown
3034
2840
  children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
3035
2841
  } catch (err) {
@@ -3043,7 +2849,7 @@ function View(props: {
3043
2849
  // path — "%7BsessionID%7D").
3044
2850
  if (!isValidSessionId(childID)) return undefined
3045
2851
  try {
3046
- return state?.session?.status?.(childID)?.type as string | undefined
2852
+ return state.session.status(childID)?.type
3047
2853
  } catch {
3048
2854
  return undefined // status API unavailable — child state fallback
3049
2855
  }
@@ -3062,17 +2868,13 @@ function View(props: {
3062
2868
  const liveEntries = [...props.liveStore.map.values()].filter(
3063
2869
  (entry) => entry.sessionID === sessionID,
3064
2870
  )
3065
- // Board LAST so it is authoritative over children/md/live for the same
3066
- // job and can ENRICH an active-session row (state/alias/agent). Then the
3067
- // active-session filter — applied ONCE after every merge — keeps ONLY
3068
- // rows whose sessionID === the focused session, so board/md entries from
2871
+ // The event channel is optimistic; merge it over the children/md rows.
2872
+ // Then the active-session filter — applied ONCE after the merge — keeps
2873
+ // ONLY rows whose sessionID === the focused session, so md entries from
3069
2874
  // other sessions (and orphans with no sessionID) can never render.
3070
2875
  setChildDelegations(
3071
2876
  filterDelegationsToSession(
3072
- mergeBoardDelegationSources(
3073
- mergeChildDelegationSources(childEntries, liveEntries),
3074
- board,
3075
- ),
2877
+ mergeChildDelegationSources(childEntries, liveEntries),
3076
2878
  sessionID,
3077
2879
  ),
3078
2880
  )
@@ -3100,7 +2902,7 @@ function View(props: {
3100
2902
  const delegationCeiling = createMemo(() =>
3101
2903
  ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
3102
2904
  )
3103
- // Header counts the same retained rows shown below, so hidden/expired board
2905
+ // Header counts the same retained rows shown below, so hidden/expired md
3104
2906
  // history cannot inflate the summary.
3105
2907
  const delegationHeader = createMemo(() => formatDelegationHeader(delegationCeiling().visible))
3106
2908
 
@@ -3242,7 +3044,7 @@ function View(props: {
3242
3044
  </Show>
3243
3045
  </Show>
3244
3046
 
3245
- {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment + board snapshot) ── */}
3047
+ {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
3246
3048
  <box onMouseDown={() => setShowDelegations((x) => !x)}>
3247
3049
  <text fg={theme().text} attributes={1}>
3248
3050
  {`${showDelegations() ? '▼' : '▶'} Delegations`}