pantheon-opencode 1.5.0-beta.13 → 1.5.0-beta.15
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 +59 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/pyproject.toml +2 -5
- package/src/agents/zeus.md +4 -26
- package/src/pantheon/background-job-board.ts +4 -4
- package/src/pantheon/delegate-manager.ts +95 -10
- package/src/pantheon/delegation-classifier.ts +3 -4
- package/src/pantheon/delegation-finalize.ts +69 -7
- package/src/pantheon/delegation.ts +16 -4
- package/src/pantheon/presets.d.mts +0 -20
- package/src/pantheon/presets.mjs +0 -64
- package/src/pantheon/toon-codec.ts +1 -1
- package/src/plugin-v2.ts +4 -6
- package/src/plugin.ts +6 -6
- package/src/plugins/pantheon-hooks.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts +57 -40
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +145 -54
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +245 -83
- package/src/routing.yml +0 -16
- package/scripts/hash_verify.py +0 -192
- package/scripts/test-context-compression.sh +0 -169
- package/src/pantheon/auto-wake.ts +0 -85
- package/src/pantheon/chat-reminders.ts +0 -9
- package/src/pantheon/comment-checker.ts +0 -48
- package/src/pantheon/compaction-assert.ts +0 -160
- package/src/pantheon/compaction-handoff.ts +0 -304
- package/src/pantheon/cost-tracker.ts +0 -108
- package/src/pantheon/dispatch-guard.ts +0 -109
- package/src/pantheon/permission-eval.ts +0 -284
- package/src/pantheon/preemptive-compact.ts +0 -76
- package/src/pantheon/provider-capabilities.ts +0 -221
- package/src/pantheon/provider-stream-tracker.ts +0 -182
- package/src/pantheon/retry-policy.ts +0 -212
- package/src/pantheon/token-opt.ts +0 -689
- package/src/pantheon/v2-events.ts +0 -148
- package/src/pantheon/v2-hooks.ts +0 -287
- package/src/pantheon/v2-tool-definitions.ts +0 -331
- package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
|
@@ -906,16 +906,17 @@ async function setupUsageBar(api: TuiPluginApi) {
|
|
|
906
906
|
* with a board row for the same child (a child WITH a report keeps the
|
|
907
907
|
* md entry, source 'md').
|
|
908
908
|
*
|
|
909
|
-
* 2. ENRICHMENT
|
|
909
|
+
* 2. ENRICHMENT — markdown reports the job board persists under
|
|
910
910
|
* `.pantheon/delegations/<sessionID>/<alias>.md` (written by
|
|
911
911
|
* src/pantheon/delegation-finalize.ts at terminal state). Each child
|
|
912
912
|
* is matched to its report by the `Task ID` header (== child session
|
|
913
913
|
* id == board task id), which supplies the alias (apo-N), agent,
|
|
914
914
|
* description and terminal duration/timedOut. No report → the child
|
|
915
915
|
* itself still renders (title as description, derived state). The md
|
|
916
|
-
* channel is
|
|
917
|
-
*
|
|
918
|
-
*
|
|
916
|
+
* channel is read from EVERY session (readAllDelegationEntries) as an
|
|
917
|
+
* enrichment source, but the ACTIVE-SESSION FILTER
|
|
918
|
+
* (filterDelegationsToSession) drops every non-focused report — the panel
|
|
919
|
+
* lists only the focused session.
|
|
919
920
|
*
|
|
920
921
|
* 3. REFRESH — session.created/updated/deleted/status events and
|
|
921
922
|
* message.part.updated/removed (via liveStore.version bumps) re-fetch
|
|
@@ -930,8 +931,8 @@ async function setupUsageBar(api: TuiPluginApi) {
|
|
|
930
931
|
* A children fetch
|
|
931
932
|
* failure logs "panel: error <msg>" (previously silenced), and a missing
|
|
932
933
|
* current session (null/placeholder sessionID) logs "panel: children=0
|
|
933
|
-
* md=N ... (no sessionID —
|
|
934
|
-
* instead of erroring.
|
|
934
|
+
* md=N ... (no sessionID — inactive panel)" — the panel stays EMPTY
|
|
935
|
+
* instead of erroring (no cross-session history).
|
|
935
936
|
*
|
|
936
937
|
* 5. SESSIONID GUARD (placeholder regression) — the runtime may hand the
|
|
937
938
|
* sidebar the UNSUBSTITUTED template literal "{sessionID}" as its
|
|
@@ -941,8 +942,8 @@ async function setupUsageBar(api: TuiPluginApi) {
|
|
|
941
942
|
* panel into "(0)". resolveCurrentSessionID (slot prop → api.state →
|
|
942
943
|
* route.current) never yields a placeholder (isValidSessionId rejects
|
|
943
944
|
* "{sessionID}" and "%7BsessionID%7D" via the startsWith("ses")
|
|
944
|
-
* contract); a null resolution SKIPS the children fetch entirely and
|
|
945
|
-
* renders
|
|
945
|
+
* contract); a null resolution SKIPS the children fetch entirely and the
|
|
946
|
+
* panel renders EMPTY — zero rows, zero errors.
|
|
946
947
|
*
|
|
947
948
|
* The live tool-part lifecycle helpers below remain (tested, exported) but
|
|
948
949
|
* are no longer the source of truth: they feed a version signal that merely
|
|
@@ -1161,11 +1162,12 @@ export async function readDelegationEntries(dir: string): Promise<DelegationEntr
|
|
|
1161
1162
|
|
|
1162
1163
|
/** Read delegation reports from EVERY session under
|
|
1163
1164
|
* `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1165
|
+
* ENRICHMENT source. The read is deliberately unfiltered (each entry carries
|
|
1166
|
+
* its directory's sessionID, so a taskID match can enrich any channel);
|
|
1167
|
+
* callers MUST scope the merged result with {@link filterDelegationsToSession}
|
|
1168
|
+
* before rendering — only the active session's reports are shown. Entries are
|
|
1169
|
+
* running first, Finalized desc (the sort applied by readDelegationEntries).
|
|
1170
|
+
* Fail-open: a missing/unreadable directory yields []. */
|
|
1169
1171
|
export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
|
|
1170
1172
|
return readDelegationEntries(join(root, '.pantheon', 'delegations'))
|
|
1171
1173
|
}
|
|
@@ -1320,7 +1322,22 @@ export function splitDelegationList(
|
|
|
1320
1322
|
const active = all
|
|
1321
1323
|
.filter((d) => isActiveState(d.state))
|
|
1322
1324
|
.map((d) => markStaleIfRunning(d, now, staleThresholdMs))
|
|
1323
|
-
|
|
1325
|
+
.sort(compareDelegationEntries)
|
|
1326
|
+
const terminal = all
|
|
1327
|
+
.filter((d) => !isActiveState(d.state))
|
|
1328
|
+
.filter((d) => {
|
|
1329
|
+
// A missing/invalid finalized timestamp is not evidence that a report is
|
|
1330
|
+
// old. Keep it visible rather than accidentally hiding a terminal job.
|
|
1331
|
+
if (d.updatedAt === null || !Number.isFinite(d.updatedAt)) return true
|
|
1332
|
+
const age = now - d.updatedAt
|
|
1333
|
+
if (age < 0) return true
|
|
1334
|
+
const retention =
|
|
1335
|
+
delegationRowStatus(d.state) === 'failed'
|
|
1336
|
+
? DELEGATION_FAILED_RETENTION_MS
|
|
1337
|
+
: DELEGATION_DONE_RETENTION_MS
|
|
1338
|
+
return age < retention
|
|
1339
|
+
})
|
|
1340
|
+
.sort(compareDelegationEntries)
|
|
1324
1341
|
return {
|
|
1325
1342
|
active,
|
|
1326
1343
|
recent: terminal.slice(0, maxRecent),
|
|
@@ -1348,6 +1365,17 @@ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
|
|
|
1348
1365
|
* display-only `stale-running` state. */
|
|
1349
1366
|
export const IDLE_SILENCE_MS = 60 * 1000
|
|
1350
1367
|
|
|
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. */
|
|
1370
|
+
export const DELEGATION_DONE_RETENTION_MS = 2 * 60 * 1000
|
|
1371
|
+
export const DELEGATION_FAILED_RETENTION_MS = 10 * 60 * 1000
|
|
1372
|
+
|
|
1373
|
+
/** Alias-less NATIVE task() live entries never receive a board alias (the
|
|
1374
|
+
* task tool output carries none), so the 30s alias-less prune in
|
|
1375
|
+
* mergeChildDelegationSources must not apply to them — 5 minutes covers a
|
|
1376
|
+
* slow child listing while still bounding the live map. */
|
|
1377
|
+
export const NATIVE_LIVE_ALIASLESS_TTL_MS = 5 * 60 * 1000
|
|
1378
|
+
|
|
1351
1379
|
/**
|
|
1352
1380
|
* Mark a running entry as `stale-running` if it has been running longer than
|
|
1353
1381
|
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
@@ -1452,26 +1480,26 @@ export type DelegationDisplayState = DelegationEntry['state']
|
|
|
1452
1480
|
|
|
1453
1481
|
/** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
|
|
1454
1482
|
* Kept separate + pure so the color channel is testable without booting the
|
|
1455
|
-
* renderer.
|
|
1456
|
-
|
|
1483
|
+
* renderer. Every display state resolves to one of the three status colors;
|
|
1484
|
+
* the row paints its whole content with it (see {@link DelegationRow}). */
|
|
1485
|
+
export type DelegationStateTone = 'warning' | 'error' | 'success'
|
|
1457
1486
|
|
|
1458
|
-
/**
|
|
1459
|
-
*
|
|
1460
|
-
* no behavior change. */
|
|
1487
|
+
/** Status → color mapping for the whole-row tone. Red = failure, green =
|
|
1488
|
+
* terminal, yellow = in flight. The glyph remains a redundant channel so the
|
|
1489
|
+
* state stays legible in monochrome. Display-only; no behavior change. */
|
|
1461
1490
|
export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
|
|
1462
1491
|
switch (state) {
|
|
1463
1492
|
case 'running':
|
|
1464
1493
|
case 'retry':
|
|
1465
1494
|
case 'startup_unknown':
|
|
1466
|
-
return 'warning'
|
|
1467
1495
|
case 'stale-running':
|
|
1496
|
+
return 'warning'
|
|
1468
1497
|
case 'error':
|
|
1469
1498
|
case 'startup_failed':
|
|
1470
1499
|
return 'error'
|
|
1471
1500
|
case 'completed':
|
|
1472
|
-
return 'success'
|
|
1473
1501
|
case 'cancelled':
|
|
1474
|
-
return '
|
|
1502
|
+
return 'success'
|
|
1475
1503
|
}
|
|
1476
1504
|
}
|
|
1477
1505
|
|
|
@@ -1575,10 +1603,13 @@ export function mergeChildDelegationSources(
|
|
|
1575
1603
|
})
|
|
1576
1604
|
|
|
1577
1605
|
for (const liveEntry of live) {
|
|
1606
|
+
// A native task() live part never carries a board alias or taskID, so it
|
|
1607
|
+
// is exempt from the 30s alias-less prune and lives on its own longer TTL.
|
|
1608
|
+
const aliaslessTTL = liveEntry.tool === 'task' ? NATIVE_LIVE_ALIASLESS_TTL_MS : 30_000
|
|
1578
1609
|
if (
|
|
1579
1610
|
liveEntry.alias === null &&
|
|
1580
1611
|
liveEntry.taskID === null &&
|
|
1581
|
-
Date.now() - liveEntry.startedAt >
|
|
1612
|
+
Date.now() - liveEntry.startedAt > aliaslessTTL
|
|
1582
1613
|
)
|
|
1583
1614
|
continue
|
|
1584
1615
|
const incoming = toDelegationEntry(liveEntry)
|
|
@@ -1588,10 +1619,12 @@ export function mergeChildDelegationSources(
|
|
|
1588
1619
|
// children-only one. pantheon_delegate pending parts keep source 'live'.
|
|
1589
1620
|
const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
|
|
1590
1621
|
if (isNativeLive) {
|
|
1591
|
-
// Native task() rows carry no board alias —
|
|
1592
|
-
//
|
|
1622
|
+
// Native task() rows carry no board alias — keep the per-call
|
|
1623
|
+
// `live-<callID>` identity from toDelegationEntry (one row per native
|
|
1624
|
+
// call) with the children-only provenance; the row identity still falls
|
|
1625
|
+
// back to the agent via delegationRowIdentity. A taskID-known native
|
|
1626
|
+
// still merges into its child row through the byTask lookup below.
|
|
1593
1627
|
incoming.source = 'children-only'
|
|
1594
|
-
incoming.alias = 'native-task'
|
|
1595
1628
|
}
|
|
1596
1629
|
const index =
|
|
1597
1630
|
(incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
|
|
@@ -2092,10 +2125,35 @@ export function boardRecordsToDelegationEntries(
|
|
|
2092
2125
|
return out
|
|
2093
2126
|
}
|
|
2094
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
|
+
|
|
2095
2151
|
/** Merge the board over any other channel. The board is AUTHORITATIVE for
|
|
2096
|
-
* state/alias/agent (it is the persisted FSM)
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
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. */
|
|
2099
2157
|
export function mergeBoardDelegationSources(
|
|
2100
2158
|
base: readonly DelegationEntry[],
|
|
2101
2159
|
board: readonly DelegationEntry[],
|
|
@@ -2120,6 +2178,10 @@ export function mergeBoardDelegationSources(
|
|
|
2120
2178
|
bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
|
|
2121
2179
|
continue
|
|
2122
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
|
|
2123
2185
|
out[index] = {
|
|
2124
2186
|
...incoming,
|
|
2125
2187
|
// Session preservation: the board is authoritative for state/alias/agent,
|
|
@@ -2134,6 +2196,30 @@ export function mergeBoardDelegationSources(
|
|
|
2134
2196
|
return out
|
|
2135
2197
|
}
|
|
2136
2198
|
|
|
2199
|
+
/** Scope a fully-merged display list to the ACTIVE session only.
|
|
2200
|
+
*
|
|
2201
|
+
* The panel is session-scoped: rows from other sessions (board snapshot, md
|
|
2202
|
+
* history under `.pantheon/delegations/<other-session>/`) and rows with no
|
|
2203
|
+
* attributable session (empty sessionID) are DROPPED. The previous
|
|
2204
|
+
* cross-session behavior rendered those rows and clicking them led to
|
|
2205
|
+
* "Session not found" — there is no cross-session channel anymore.
|
|
2206
|
+
*
|
|
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).
|
|
2211
|
+
*
|
|
2212
|
+
* Returns [] when no active session resolves (null/placeholder) — there is no
|
|
2213
|
+
* scope to show. Native children always carry the active session id (stamped
|
|
2214
|
+
* by `childrenToDelegationEntries`), so they survive the filter. Pure. */
|
|
2215
|
+
export function filterDelegationsToSession(
|
|
2216
|
+
entries: readonly DelegationEntry[],
|
|
2217
|
+
activeSessionID: string | null | undefined,
|
|
2218
|
+
): DelegationEntry[] {
|
|
2219
|
+
if (!isValidSessionId(activeSessionID)) return []
|
|
2220
|
+
return entries.filter((entry) => entry.sessionID === activeSessionID)
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2137
2223
|
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
2138
2224
|
* `api.client.session.children` returns the child sessions of the current
|
|
2139
2225
|
* session — every pantheon_delegate spawns one (parentID = caller). The
|
|
@@ -2305,10 +2391,13 @@ export function delegationRowMarker(state: DelegationDisplayState, now = Date.no
|
|
|
2305
2391
|
}
|
|
2306
2392
|
|
|
2307
2393
|
/** ONE identity per row: the short board alias (`apo-1`); a row without a
|
|
2308
|
-
* board alias (native task() child
|
|
2309
|
-
*
|
|
2394
|
+
* board alias (native task() child `native-task` / `native-<id>`, or an
|
|
2395
|
+
* alias-less native live row `live-<callID>` kept as children-only) shows
|
|
2396
|
+
* the agent instead — never alias + agent stacked. Pure. */
|
|
2310
2397
|
export function delegationRowIdentity(entry: DelegationEntry): string {
|
|
2311
|
-
if (entry.
|
|
2398
|
+
if (entry.agent === '') return entry.alias
|
|
2399
|
+
if (entry.alias === 'native-task' || entry.alias.startsWith('native-')) return entry.agent
|
|
2400
|
+
if (entry.alias.startsWith('live-') && entry.source === 'children-only') return entry.agent
|
|
2312
2401
|
return entry.alias
|
|
2313
2402
|
}
|
|
2314
2403
|
|
|
@@ -2410,16 +2499,23 @@ export function formatDelegationHeader(entries: readonly DelegationEntry[]): str
|
|
|
2410
2499
|
return `(${active} active · ${done} done${tail})`
|
|
2411
2500
|
}
|
|
2412
2501
|
|
|
2413
|
-
/** Cap the panel: live rows first, then most-recent terminal rows, at
|
|
2414
|
-
* `maxVisible` total.
|
|
2415
|
-
*
|
|
2502
|
+
/** Cap the panel: live rows first, then most-recent retained terminal rows, at
|
|
2503
|
+
* most `maxVisible` total. Hidden counts describe the retained render list,
|
|
2504
|
+
* not expired history. */
|
|
2416
2505
|
export function ceilingDelegationList(
|
|
2417
2506
|
all: readonly DelegationEntry[],
|
|
2418
2507
|
maxVisible = DELEGATION_VISIBLE_CEILING,
|
|
2419
2508
|
now = Date.now(),
|
|
2420
|
-
): { visible: DelegationEntry[]; hidden: number } {
|
|
2421
|
-
const
|
|
2422
|
-
|
|
2509
|
+
): { visible: DelegationEntry[]; hidden: number; hiddenActive: number; hiddenTerminal: number } {
|
|
2510
|
+
const { active, recent } = splitDelegationList(all, all.length, now)
|
|
2511
|
+
const visibleActive = active.slice(0, maxVisible)
|
|
2512
|
+
const visible = [
|
|
2513
|
+
...visibleActive,
|
|
2514
|
+
...recent.slice(0, Math.max(0, maxVisible - visibleActive.length)),
|
|
2515
|
+
]
|
|
2516
|
+
const hiddenActive = Math.max(0, active.length - visibleActive.length)
|
|
2517
|
+
const hiddenTerminal = Math.max(0, recent.length - (visible.length - visibleActive.length))
|
|
2518
|
+
return { visible, hidden: hiddenActive + hiddenTerminal, hiddenActive, hiddenTerminal }
|
|
2423
2519
|
}
|
|
2424
2520
|
|
|
2425
2521
|
/** Split a display list into native task() rows vs pantheon_delegate rows.
|
|
@@ -2456,11 +2552,13 @@ export function formatPanelLogLine(
|
|
|
2456
2552
|
* itself (fallback 'agent'), state derived from its status, startedAt from
|
|
2457
2553
|
* time.created. A report-less child is a NATIVE task() child (every
|
|
2458
2554
|
* child of the current session — pantheon_delegate OR the native `task()`
|
|
2459
|
-
* tool — carries parentID = caller), so it gets source 'children-only',
|
|
2460
|
-
*
|
|
2461
|
-
* board alias. The 'task nativa' description fallback
|
|
2462
|
-
* non-empty when the child carries no title.
|
|
2463
|
-
*
|
|
2555
|
+
* tool — carries parentID = caller), so it gets source 'children-only', a
|
|
2556
|
+
* 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
|
|
2558
|
+
* keeps the row non-empty when the child carries no title.
|
|
2559
|
+
* A FRESH terminal md state wins over the derived state (a stale md report
|
|
2560
|
+
* from a previous incarnation never flips a live child — see
|
|
2561
|
+
* mdTerminalCoversChild); a running md defers to
|
|
2464
2562
|
* the child's live status. A running child is NEVER archived — it always
|
|
2465
2563
|
* lands in the active split (splitDelegationList active = running/retry).
|
|
2466
2564
|
* `parentSessionID` (the focused session id) is stamped on report-less
|
|
@@ -2468,6 +2566,22 @@ export function formatPanelLogLine(
|
|
|
2468
2566
|
* unscoped ('').
|
|
2469
2567
|
* Sorted running-first (compareDelegationEntries).
|
|
2470
2568
|
* Pure — no I/O. */
|
|
2569
|
+
/** 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
|
|
2571
|
+
* a PREVIOUS job incarnation. Accept it only when its Finalized timestamp
|
|
2572
|
+
* covers the child's latest activity, when either side has no timestamp to
|
|
2573
|
+
* compare, or when both startedAt agree on a single job incarnation.
|
|
2574
|
+
* Pure — no I/O. */
|
|
2575
|
+
function mdTerminalCoversChild(md: DelegationEntry, child: ChildDelegationLike): boolean {
|
|
2576
|
+
const childUpdated = child.time?.updated
|
|
2577
|
+
if (md.updatedAt !== null && childUpdated !== undefined) {
|
|
2578
|
+
if (md.updatedAt >= childUpdated) return true
|
|
2579
|
+
// Stale Finalized — same incarnation only if both startedAt agree.
|
|
2580
|
+
return md.startedAt === child.time?.created
|
|
2581
|
+
}
|
|
2582
|
+
return true
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2471
2585
|
export function childrenToDelegationEntries(
|
|
2472
2586
|
children: readonly ChildDelegationLike[] | undefined,
|
|
2473
2587
|
md: readonly DelegationEntry[],
|
|
@@ -2485,11 +2599,11 @@ export function childrenToDelegationEntries(
|
|
|
2485
2599
|
seen.add(child.id)
|
|
2486
2600
|
const mdEntry = byTaskID.get(child.id)
|
|
2487
2601
|
const state =
|
|
2488
|
-
mdEntry !== undefined && mdEntry.state !== 'running'
|
|
2602
|
+
mdEntry !== undefined && mdEntry.state !== 'running' && mdTerminalCoversChild(mdEntry, child)
|
|
2489
2603
|
? mdEntry.state
|
|
2490
2604
|
: childStatusToState(child.status)
|
|
2491
2605
|
out.push({
|
|
2492
|
-
alias: mdEntry?.alias ??
|
|
2606
|
+
alias: mdEntry?.alias ?? `native-${child.id.slice(-4)}`,
|
|
2493
2607
|
// A report-less child IS a child of the focused session
|
|
2494
2608
|
// (session.children is session-scoped), so the caller-passed
|
|
2495
2609
|
// parentSessionID is stamped on it as its origin session; an md
|
|
@@ -2522,12 +2636,41 @@ export function childrenToDelegationEntries(
|
|
|
2522
2636
|
* server-valid session id ("ses...") ever reaches the router, so an
|
|
2523
2637
|
* unsubstituted "{sessionID}" placeholder can never be routed. */
|
|
2524
2638
|
export function navigateToDelegationSession(
|
|
2525
|
-
route:
|
|
2639
|
+
route:
|
|
2640
|
+
| {
|
|
2641
|
+
navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
|
|
2642
|
+
}
|
|
2643
|
+
| undefined,
|
|
2526
2644
|
taskID: string | undefined,
|
|
2527
2645
|
): boolean {
|
|
2528
2646
|
if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
|
|
2529
|
-
|
|
2530
|
-
|
|
2647
|
+
try {
|
|
2648
|
+
const navigation = route.navigate('session', { sessionID: taskID })
|
|
2649
|
+
if (navigation !== undefined) {
|
|
2650
|
+
// A removed session can reject route.navigate for historical rows. That
|
|
2651
|
+
// rejection is expected and intentionally absorbed so it cannot break
|
|
2652
|
+
// the TUI; the no-op avoids logging sensitive session details.
|
|
2653
|
+
void Promise.resolve(navigation).catch(() => undefined)
|
|
2654
|
+
}
|
|
2655
|
+
return true
|
|
2656
|
+
} catch {
|
|
2657
|
+
// Synchronous router failures are also fail-open for an inert historical row.
|
|
2658
|
+
return false
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
/** Build the mouse handler used by each delegation row. */
|
|
2663
|
+
export function createDelegationRowOpenHandler(
|
|
2664
|
+
route:
|
|
2665
|
+
| {
|
|
2666
|
+
navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
|
|
2667
|
+
}
|
|
2668
|
+
| undefined,
|
|
2669
|
+
taskID: string | undefined,
|
|
2670
|
+
): () => void {
|
|
2671
|
+
return () => {
|
|
2672
|
+
navigateToDelegationSession(route, taskID)
|
|
2673
|
+
}
|
|
2531
2674
|
}
|
|
2532
2675
|
|
|
2533
2676
|
/* ─── Silence-by-default panel logger ──────────────────────
|
|
@@ -2643,9 +2786,10 @@ function DelegationRow(props: {
|
|
|
2643
2786
|
}) {
|
|
2644
2787
|
const theme = () => props.api.theme.current
|
|
2645
2788
|
|
|
2646
|
-
// Color is a
|
|
2647
|
-
// carries the state tone,
|
|
2648
|
-
//
|
|
2789
|
+
// Color is a status-wide channel: the WHOLE row (lead, elapsed, description,
|
|
2790
|
+
// live activity) carries the state tone, so red=failed / green=done /
|
|
2791
|
+
// yellow=in-flight reads at a glance. The glyph stays as a redundant,
|
|
2792
|
+
// monochrome-legible signal — never the only one.
|
|
2649
2793
|
const tone = createMemo(() => {
|
|
2650
2794
|
const t = theme()
|
|
2651
2795
|
switch (delegationStateTone(props.job.state)) {
|
|
@@ -2656,7 +2800,7 @@ function DelegationRow(props: {
|
|
|
2656
2800
|
case 'success':
|
|
2657
2801
|
return t.success
|
|
2658
2802
|
default:
|
|
2659
|
-
return t.textMuted //
|
|
2803
|
+
return t.textMuted // defensive: unknown runtime state
|
|
2660
2804
|
}
|
|
2661
2805
|
})
|
|
2662
2806
|
|
|
@@ -2674,27 +2818,25 @@ function DelegationRow(props: {
|
|
|
2674
2818
|
// Click a row → navigate to the child session (route API guarded in the
|
|
2675
2819
|
// helper). Enter-key binding is left out (per-row keymap wiring is not
|
|
2676
2820
|
// worth the complexity in this sidebar); mouse is enabled in tui.json.
|
|
2677
|
-
const open = ()
|
|
2678
|
-
navigateToDelegationSession(props.api.route, props.job.taskID)
|
|
2679
|
-
}
|
|
2821
|
+
const open = createDelegationRowOpenHandler(props.api.route, props.job.taskID)
|
|
2680
2822
|
|
|
2681
2823
|
return (
|
|
2682
2824
|
<box onMouseDown={open}>
|
|
2683
|
-
{/* Line 1:
|
|
2825
|
+
{/* Line 1: toned lead (glyph + identity) + toned elapsed tail. */}
|
|
2684
2826
|
<box flexDirection="row">
|
|
2685
2827
|
<text>
|
|
2686
2828
|
<span fg={tone()}>{lead()}</span>
|
|
2687
|
-
<span fg={
|
|
2829
|
+
<span fg={tone()}>{elapsed()}</span>
|
|
2688
2830
|
</text>
|
|
2689
2831
|
</box>
|
|
2690
|
-
{/* Line 2: the description, indented under the identity and
|
|
2832
|
+
{/* Line 2: the description, indented under the identity and toned. */}
|
|
2691
2833
|
<Show when={description() !== ''}>
|
|
2692
|
-
<text fg={
|
|
2834
|
+
<text fg={tone()}>{` ${description()}`}</text>
|
|
2693
2835
|
</Show>
|
|
2694
2836
|
<Show when={activity()}>
|
|
2695
2837
|
{(a) => (
|
|
2696
2838
|
<text
|
|
2697
|
-
fg={
|
|
2839
|
+
fg={tone()}
|
|
2698
2840
|
>{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
|
|
2699
2841
|
)}
|
|
2700
2842
|
</Show>
|
|
@@ -2810,9 +2952,10 @@ function View(props: {
|
|
|
2810
2952
|
const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
|
|
2811
2953
|
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2812
2954
|
const panelLog = createTuiLogger(projectRoot())
|
|
2813
|
-
//
|
|
2814
|
-
//
|
|
2815
|
-
//
|
|
2955
|
+
// 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
|
|
2958
|
+
// collapses the rest into "… +N more".
|
|
2816
2959
|
// The rendered list is childDelegations (children enriched with md);
|
|
2817
2960
|
// there is no separate md-only signal — the md channel only feeds
|
|
2818
2961
|
// childrenToDelegationEntries below.
|
|
@@ -2839,19 +2982,20 @@ function View(props: {
|
|
|
2839
2982
|
try {
|
|
2840
2983
|
const state = props.api.state as any
|
|
2841
2984
|
// 0. md reports from EVERY session under .pantheon/delegations — the
|
|
2842
|
-
//
|
|
2843
|
-
//
|
|
2844
|
-
//
|
|
2845
|
-
//
|
|
2985
|
+
// ENRICHMENT channel. Read unfiltered (taskID match supplies alias/
|
|
2986
|
+
// agent/description/terminal state); the active-session filter after
|
|
2987
|
+
// the merge keeps only the focused session's reports. Fail-open: a
|
|
2988
|
+
// missing dir yields [].
|
|
2846
2989
|
let md: DelegationEntry[] = []
|
|
2847
2990
|
try {
|
|
2848
2991
|
md = await readAllDelegationEntries(projectRoot())
|
|
2849
2992
|
} catch {
|
|
2850
2993
|
md = [] // fail-open: never crash the sidebar on a read error
|
|
2851
2994
|
}
|
|
2852
|
-
// 0b. board snapshot — the 4th channel.
|
|
2853
|
-
//
|
|
2854
|
-
//
|
|
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 → [].
|
|
2855
2999
|
let board: DelegationEntry[] = []
|
|
2856
3000
|
try {
|
|
2857
3001
|
board = boardRecordsToDelegationEntries(await readBoardState(projectRoot()))
|
|
@@ -2863,13 +3007,18 @@ function View(props: {
|
|
|
2863
3007
|
// slot prop; forwarding it to session.children made the server reject
|
|
2864
3008
|
// every poll (~1 err/s: "Expected a string starting with \"ses\", got
|
|
2865
3009
|
// \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
|
|
2866
|
-
// resolution SKIPS the fetch entirely: the
|
|
2867
|
-
// (zero children, zero errors).
|
|
3010
|
+
// resolution SKIPS the fetch entirely: the active-session filter yields
|
|
3011
|
+
// an EMPTY panel (zero children, zero rows, zero errors).
|
|
2868
3012
|
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2869
3013
|
if (sessionID === null) {
|
|
2870
|
-
|
|
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
|
+
)
|
|
2871
3020
|
panelLog.info(
|
|
2872
|
-
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID —
|
|
3021
|
+
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — inactive panel)`,
|
|
2873
3022
|
)
|
|
2874
3023
|
return
|
|
2875
3024
|
}
|
|
@@ -2914,11 +3063,17 @@ function View(props: {
|
|
|
2914
3063
|
(entry) => entry.sessionID === sessionID,
|
|
2915
3064
|
)
|
|
2916
3065
|
// Board LAST so it is authoritative over children/md/live for the same
|
|
2917
|
-
// job
|
|
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
|
|
3069
|
+
// other sessions (and orphans with no sessionID) can never render.
|
|
2918
3070
|
setChildDelegations(
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
3071
|
+
filterDelegationsToSession(
|
|
3072
|
+
mergeBoardDelegationSources(
|
|
3073
|
+
mergeChildDelegationSources(childEntries, liveEntries),
|
|
3074
|
+
board,
|
|
3075
|
+
),
|
|
3076
|
+
sessionID,
|
|
2922
3077
|
),
|
|
2923
3078
|
)
|
|
2924
3079
|
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
@@ -2939,12 +3094,15 @@ function View(props: {
|
|
|
2939
3094
|
return delegationsInflight
|
|
2940
3095
|
}
|
|
2941
3096
|
// Live-first ceiling: at most DELEGATION_VISIBLE_CEILING rows render; the
|
|
2942
|
-
// remainder collapses into a single "… +N more" line.
|
|
2943
|
-
//
|
|
3097
|
+
// remainder collapses into a single "… +N more" line. Both the ceiling and
|
|
3098
|
+
// the header operate on the ALREADY-FILTERED active-session list
|
|
3099
|
+
// (childDelegations), so other-session / hidden rows never inflate counts.
|
|
2944
3100
|
const delegationCeiling = createMemo(() =>
|
|
2945
3101
|
ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
|
|
2946
3102
|
)
|
|
2947
|
-
|
|
3103
|
+
// Header counts the same retained rows shown below, so hidden/expired board
|
|
3104
|
+
// history cannot inflate the summary.
|
|
3105
|
+
const delegationHeader = createMemo(() => formatDelegationHeader(delegationCeiling().visible))
|
|
2948
3106
|
|
|
2949
3107
|
// ── Event subscriptions for live session/delegation updates ──
|
|
2950
3108
|
onMount(() => {
|
|
@@ -3090,7 +3248,7 @@ function View(props: {
|
|
|
3090
3248
|
{`${showDelegations() ? '▼' : '▶'} Delegations`}
|
|
3091
3249
|
</text>
|
|
3092
3250
|
<text fg={theme().textMuted}>
|
|
3093
|
-
{
|
|
3251
|
+
{delegationCeiling().visible.length > 0 ? ` ${delegationHeader()}` : ' — idle'}
|
|
3094
3252
|
</text>
|
|
3095
3253
|
</box>
|
|
3096
3254
|
<Show when={showDelegations()}>
|
|
@@ -3114,7 +3272,11 @@ function View(props: {
|
|
|
3114
3272
|
)}
|
|
3115
3273
|
</For>
|
|
3116
3274
|
<Show when={delegationCeiling().hidden > 0}>
|
|
3117
|
-
<text fg={theme().textMuted}>
|
|
3275
|
+
<text fg={theme().textMuted}>
|
|
3276
|
+
{delegationCeiling().hiddenActive > 0
|
|
3277
|
+
? `… +${delegationCeiling().hiddenActive} active`
|
|
3278
|
+
: `… +${delegationCeiling().hiddenTerminal} more`}
|
|
3279
|
+
</text>
|
|
3118
3280
|
</Show>
|
|
3119
3281
|
</box>
|
|
3120
3282
|
</Show>
|
package/src/routing.yml
CHANGED
|
@@ -535,22 +535,6 @@ background_delegation:
|
|
|
535
535
|
read_only_agents: [apollo, gaia]
|
|
536
536
|
session_max: 3
|
|
537
537
|
retry_count: 1
|
|
538
|
-
# ── R1: Per-error-type retry + provider cooldown (LiteLLM pattern) ──────
|
|
539
|
-
# retry_policy: max retries per error class before escalating. Exponential
|
|
540
|
-
# backoff (base 1s, doubling, capped 30s) applies between retries. auth
|
|
541
|
-
# errors are NEVER retried (0) — a credential problem will not fix itself.
|
|
542
|
-
# Enforcement: zeus-delegate-with-retry (manual waves) + RetryPolicyEngine.
|
|
543
|
-
retry_policy:
|
|
544
|
-
auth: 0
|
|
545
|
-
rate_limit: 3
|
|
546
|
-
timeout: 2
|
|
547
|
-
other: 1
|
|
548
|
-
# cooldown: after `allowed_fails` consecutive failures for a provider, skip
|
|
549
|
-
# that provider for `cooldown_time_seconds`. A success resets the counter.
|
|
550
|
-
# In-memory tracker (no Redis) — process-wide, shared across retry helpers.
|
|
551
|
-
cooldown:
|
|
552
|
-
allowed_fails: 3
|
|
553
|
-
cooldown_time_seconds: 60
|
|
554
538
|
# ── O5: permission.task — glob rules for which subagents an agent may
|
|
555
539
|
# invoke (last matching rule wins; deny removes the agent from the delegate
|
|
556
540
|
# tool description entirely — prevents circular delegation at the
|