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.
- package/CHANGELOG.md +48 -0
- package/README.md +13 -25
- package/README.pt-BR.md +4 -5
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/mcp_persistence_server.py +1 -1
- package/src/agents/apollo.md +1 -0
- package/src/agents/gaia.md +1 -0
- package/src/agents/zeus.md +8 -29
- package/src/mcp/mcp_persistence_server.py +1 -1
- package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
- package/src/pantheon/cost-command.ts +1 -2
- package/src/pantheon/delegation-enforce.ts +59 -14
- package/src/pantheon/goal-loop.ts +1 -1
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/idle-continuation.ts +4 -6
- package/src/pantheon/model-command.ts +1 -2
- package/src/pantheon/native-task-status.ts +2 -2
- package/src/pantheon/session-id.ts +30 -0
- package/src/pantheon/task-result-guard.ts +2 -3
- package/src/pantheon/todo-enforcer.ts +3 -3
- package/src/pantheon/tool-context.ts +23 -0
- package/src/pantheon/v2-bridge.ts +1 -4
- package/src/plugin-v2.ts +4 -15
- package/src/plugin.ts +61 -245
- package/src/plugins/tui/dist/tui.d.ts +49 -79
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +48 -189
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +97 -295
- package/src/skills/context-compression/SKILL.md +1 -1
- package/src/pantheon/delegate-manager.ts +0 -851
- package/src/pantheon/delegation-classifier.ts +0 -306
- package/src/pantheon/delegation-finalize.ts +0 -468
- package/src/pantheon/delegation-notify.ts +0 -212
- package/src/pantheon/delegation-toast.ts +0 -73
- package/src/pantheon/delegation.ts +0 -1349
- package/src/pantheon/native-probe.ts +0 -234
- 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
|
|
890
|
-
* parentID = caller (
|
|
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
|
-
*
|
|
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
|
|
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
|
|
905
|
-
*
|
|
906
|
-
* with a
|
|
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
|
|
910
|
-
* `.pantheon/delegations/<sessionID>/<alias>.md` (
|
|
911
|
-
*
|
|
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 ==
|
|
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
|
|
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 (=
|
|
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
|
|
988
|
-
* (a native child row); 'md' =
|
|
989
|
-
* '
|
|
990
|
-
|
|
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
|
|
1258
|
-
*
|
|
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
|
|
1270
|
-
* The
|
|
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
|
|
1369
|
-
*
|
|
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
|
|
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
|
|
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
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
2202
|
-
*
|
|
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
|
|
2208
|
-
*
|
|
2209
|
-
*
|
|
2210
|
-
* ONCE, after every merge (children + live + md
|
|
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({
|
|
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
|
|
2296
|
-
* null for anything else (placeholder,
|
|
2297
|
-
* skips the call entirely instead of
|
|
2298
|
-
* (the "%7BsessionID%7D" regression).
|
|
2299
|
-
*
|
|
2300
|
-
*
|
|
2301
|
-
|
|
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 {
|
|
2113
|
+
return { sessionID: id }
|
|
2304
2114
|
}
|
|
2305
2115
|
|
|
2306
|
-
/** Build the `session.children`
|
|
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
|
|
2309
|
-
* sending an unsubstituted placeholder (the
|
|
2310
|
-
|
|
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 (=
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
2394
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
2951
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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 →
|
|
3015
|
-
//
|
|
3016
|
-
//
|
|
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
|
|
3031
|
-
|
|
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
|
|
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
|
-
//
|
|
3066
|
-
//
|
|
3067
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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`}
|