pantheon-opencode 1.5.0-beta.14 → 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 +38 -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 +43 -36
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +102 -38
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +168 -67
- 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
|
}
|
|
@@ -1368,6 +1370,12 @@ export const IDLE_SILENCE_MS = 60 * 1000
|
|
|
1368
1370
|
export const DELEGATION_DONE_RETENTION_MS = 2 * 60 * 1000
|
|
1369
1371
|
export const DELEGATION_FAILED_RETENTION_MS = 10 * 60 * 1000
|
|
1370
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
|
+
|
|
1371
1379
|
/**
|
|
1372
1380
|
* Mark a running entry as `stale-running` if it has been running longer than
|
|
1373
1381
|
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
@@ -1472,27 +1480,26 @@ export type DelegationDisplayState = DelegationEntry['state']
|
|
|
1472
1480
|
|
|
1473
1481
|
/** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
|
|
1474
1482
|
* Kept separate + pure so the color channel is testable without booting the
|
|
1475
|
-
* renderer.
|
|
1476
|
-
|
|
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'
|
|
1477
1486
|
|
|
1478
|
-
/**
|
|
1479
|
-
*
|
|
1480
|
-
* 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. */
|
|
1481
1490
|
export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
|
|
1482
1491
|
switch (state) {
|
|
1483
1492
|
case 'running':
|
|
1484
1493
|
case 'retry':
|
|
1485
1494
|
case 'startup_unknown':
|
|
1486
|
-
return 'warning'
|
|
1487
1495
|
case 'stale-running':
|
|
1488
1496
|
return 'warning'
|
|
1489
1497
|
case 'error':
|
|
1490
1498
|
case 'startup_failed':
|
|
1491
1499
|
return 'error'
|
|
1492
1500
|
case 'completed':
|
|
1493
|
-
return 'success'
|
|
1494
1501
|
case 'cancelled':
|
|
1495
|
-
return '
|
|
1502
|
+
return 'success'
|
|
1496
1503
|
}
|
|
1497
1504
|
}
|
|
1498
1505
|
|
|
@@ -1596,10 +1603,13 @@ export function mergeChildDelegationSources(
|
|
|
1596
1603
|
})
|
|
1597
1604
|
|
|
1598
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
|
|
1599
1609
|
if (
|
|
1600
1610
|
liveEntry.alias === null &&
|
|
1601
1611
|
liveEntry.taskID === null &&
|
|
1602
|
-
Date.now() - liveEntry.startedAt >
|
|
1612
|
+
Date.now() - liveEntry.startedAt > aliaslessTTL
|
|
1603
1613
|
)
|
|
1604
1614
|
continue
|
|
1605
1615
|
const incoming = toDelegationEntry(liveEntry)
|
|
@@ -1609,10 +1619,12 @@ export function mergeChildDelegationSources(
|
|
|
1609
1619
|
// children-only one. pantheon_delegate pending parts keep source 'live'.
|
|
1610
1620
|
const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
|
|
1611
1621
|
if (isNativeLive) {
|
|
1612
|
-
// Native task() rows carry no board alias —
|
|
1613
|
-
//
|
|
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.
|
|
1614
1627
|
incoming.source = 'children-only'
|
|
1615
|
-
incoming.alias = 'native-task'
|
|
1616
1628
|
}
|
|
1617
1629
|
const index =
|
|
1618
1630
|
(incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
|
|
@@ -2113,10 +2125,35 @@ export function boardRecordsToDelegationEntries(
|
|
|
2113
2125
|
return out
|
|
2114
2126
|
}
|
|
2115
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
|
+
|
|
2116
2151
|
/** Merge the board over any other channel. The board is AUTHORITATIVE for
|
|
2117
|
-
* state/alias/agent (it is the persisted FSM)
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
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. */
|
|
2120
2157
|
export function mergeBoardDelegationSources(
|
|
2121
2158
|
base: readonly DelegationEntry[],
|
|
2122
2159
|
board: readonly DelegationEntry[],
|
|
@@ -2141,6 +2178,10 @@ export function mergeBoardDelegationSources(
|
|
|
2141
2178
|
bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
|
|
2142
2179
|
continue
|
|
2143
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
|
|
2144
2185
|
out[index] = {
|
|
2145
2186
|
...incoming,
|
|
2146
2187
|
// Session preservation: the board is authoritative for state/alias/agent,
|
|
@@ -2155,6 +2196,30 @@ export function mergeBoardDelegationSources(
|
|
|
2155
2196
|
return out
|
|
2156
2197
|
}
|
|
2157
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
|
+
|
|
2158
2223
|
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
2159
2224
|
* `api.client.session.children` returns the child sessions of the current
|
|
2160
2225
|
* session — every pantheon_delegate spawns one (parentID = caller). The
|
|
@@ -2326,10 +2391,13 @@ export function delegationRowMarker(state: DelegationDisplayState, now = Date.no
|
|
|
2326
2391
|
}
|
|
2327
2392
|
|
|
2328
2393
|
/** ONE identity per row: the short board alias (`apo-1`); a row without a
|
|
2329
|
-
* board alias (native task() child
|
|
2330
|
-
*
|
|
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. */
|
|
2331
2397
|
export function delegationRowIdentity(entry: DelegationEntry): string {
|
|
2332
|
-
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
|
|
2333
2401
|
return entry.alias
|
|
2334
2402
|
}
|
|
2335
2403
|
|
|
@@ -2484,11 +2552,13 @@ export function formatPanelLogLine(
|
|
|
2484
2552
|
* itself (fallback 'agent'), state derived from its status, startedAt from
|
|
2485
2553
|
* time.created. A report-less child is a NATIVE task() child (every
|
|
2486
2554
|
* child of the current session — pantheon_delegate OR the native `task()`
|
|
2487
|
-
* tool — carries parentID = caller), so it gets source 'children-only',
|
|
2488
|
-
*
|
|
2489
|
-
* board alias. The 'task nativa' description fallback
|
|
2490
|
-
* non-empty when the child carries no title.
|
|
2491
|
-
*
|
|
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
|
|
2492
2562
|
* the child's live status. A running child is NEVER archived — it always
|
|
2493
2563
|
* lands in the active split (splitDelegationList active = running/retry).
|
|
2494
2564
|
* `parentSessionID` (the focused session id) is stamped on report-less
|
|
@@ -2496,6 +2566,22 @@ export function formatPanelLogLine(
|
|
|
2496
2566
|
* unscoped ('').
|
|
2497
2567
|
* Sorted running-first (compareDelegationEntries).
|
|
2498
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
|
+
|
|
2499
2585
|
export function childrenToDelegationEntries(
|
|
2500
2586
|
children: readonly ChildDelegationLike[] | undefined,
|
|
2501
2587
|
md: readonly DelegationEntry[],
|
|
@@ -2513,11 +2599,11 @@ export function childrenToDelegationEntries(
|
|
|
2513
2599
|
seen.add(child.id)
|
|
2514
2600
|
const mdEntry = byTaskID.get(child.id)
|
|
2515
2601
|
const state =
|
|
2516
|
-
mdEntry !== undefined && mdEntry.state !== 'running'
|
|
2602
|
+
mdEntry !== undefined && mdEntry.state !== 'running' && mdTerminalCoversChild(mdEntry, child)
|
|
2517
2603
|
? mdEntry.state
|
|
2518
2604
|
: childStatusToState(child.status)
|
|
2519
2605
|
out.push({
|
|
2520
|
-
alias: mdEntry?.alias ??
|
|
2606
|
+
alias: mdEntry?.alias ?? `native-${child.id.slice(-4)}`,
|
|
2521
2607
|
// A report-less child IS a child of the focused session
|
|
2522
2608
|
// (session.children is session-scoped), so the caller-passed
|
|
2523
2609
|
// parentSessionID is stamped on it as its origin session; an md
|
|
@@ -2700,9 +2786,10 @@ function DelegationRow(props: {
|
|
|
2700
2786
|
}) {
|
|
2701
2787
|
const theme = () => props.api.theme.current
|
|
2702
2788
|
|
|
2703
|
-
// Color is a
|
|
2704
|
-
// carries the state tone,
|
|
2705
|
-
//
|
|
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.
|
|
2706
2793
|
const tone = createMemo(() => {
|
|
2707
2794
|
const t = theme()
|
|
2708
2795
|
switch (delegationStateTone(props.job.state)) {
|
|
@@ -2713,7 +2800,7 @@ function DelegationRow(props: {
|
|
|
2713
2800
|
case 'success':
|
|
2714
2801
|
return t.success
|
|
2715
2802
|
default:
|
|
2716
|
-
return t.textMuted //
|
|
2803
|
+
return t.textMuted // defensive: unknown runtime state
|
|
2717
2804
|
}
|
|
2718
2805
|
})
|
|
2719
2806
|
|
|
@@ -2735,21 +2822,21 @@ function DelegationRow(props: {
|
|
|
2735
2822
|
|
|
2736
2823
|
return (
|
|
2737
2824
|
<box onMouseDown={open}>
|
|
2738
|
-
{/* Line 1:
|
|
2825
|
+
{/* Line 1: toned lead (glyph + identity) + toned elapsed tail. */}
|
|
2739
2826
|
<box flexDirection="row">
|
|
2740
2827
|
<text>
|
|
2741
2828
|
<span fg={tone()}>{lead()}</span>
|
|
2742
|
-
<span fg={
|
|
2829
|
+
<span fg={tone()}>{elapsed()}</span>
|
|
2743
2830
|
</text>
|
|
2744
2831
|
</box>
|
|
2745
|
-
{/* Line 2: the description, indented under the identity and
|
|
2832
|
+
{/* Line 2: the description, indented under the identity and toned. */}
|
|
2746
2833
|
<Show when={description() !== ''}>
|
|
2747
|
-
<text fg={
|
|
2834
|
+
<text fg={tone()}>{` ${description()}`}</text>
|
|
2748
2835
|
</Show>
|
|
2749
2836
|
<Show when={activity()}>
|
|
2750
2837
|
{(a) => (
|
|
2751
2838
|
<text
|
|
2752
|
-
fg={
|
|
2839
|
+
fg={tone()}
|
|
2753
2840
|
>{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
|
|
2754
2841
|
)}
|
|
2755
2842
|
</Show>
|
|
@@ -2865,9 +2952,10 @@ function View(props: {
|
|
|
2865
2952
|
const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
|
|
2866
2953
|
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2867
2954
|
const panelLog = createTuiLogger(projectRoot())
|
|
2868
|
-
//
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
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".
|
|
2871
2959
|
// The rendered list is childDelegations (children enriched with md);
|
|
2872
2960
|
// there is no separate md-only signal — the md channel only feeds
|
|
2873
2961
|
// childrenToDelegationEntries below.
|
|
@@ -2894,19 +2982,20 @@ function View(props: {
|
|
|
2894
2982
|
try {
|
|
2895
2983
|
const state = props.api.state as any
|
|
2896
2984
|
// 0. md reports from EVERY session under .pantheon/delegations — the
|
|
2897
|
-
//
|
|
2898
|
-
//
|
|
2899
|
-
//
|
|
2900
|
-
//
|
|
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 [].
|
|
2901
2989
|
let md: DelegationEntry[] = []
|
|
2902
2990
|
try {
|
|
2903
2991
|
md = await readAllDelegationEntries(projectRoot())
|
|
2904
2992
|
} catch {
|
|
2905
2993
|
md = [] // fail-open: never crash the sidebar on a read error
|
|
2906
2994
|
}
|
|
2907
|
-
// 0b. board snapshot — the 4th channel.
|
|
2908
|
-
//
|
|
2909
|
-
//
|
|
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 → [].
|
|
2910
2999
|
let board: DelegationEntry[] = []
|
|
2911
3000
|
try {
|
|
2912
3001
|
board = boardRecordsToDelegationEntries(await readBoardState(projectRoot()))
|
|
@@ -2918,13 +3007,18 @@ function View(props: {
|
|
|
2918
3007
|
// slot prop; forwarding it to session.children made the server reject
|
|
2919
3008
|
// every poll (~1 err/s: "Expected a string starting with \"ses\", got
|
|
2920
3009
|
// \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
|
|
2921
|
-
// resolution SKIPS the fetch entirely: the
|
|
2922
|
-
// (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).
|
|
2923
3012
|
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2924
3013
|
if (sessionID === null) {
|
|
2925
|
-
|
|
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
|
+
)
|
|
2926
3020
|
panelLog.info(
|
|
2927
|
-
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID —
|
|
3021
|
+
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — inactive panel)`,
|
|
2928
3022
|
)
|
|
2929
3023
|
return
|
|
2930
3024
|
}
|
|
@@ -2969,11 +3063,17 @@ function View(props: {
|
|
|
2969
3063
|
(entry) => entry.sessionID === sessionID,
|
|
2970
3064
|
)
|
|
2971
3065
|
// Board LAST so it is authoritative over children/md/live for the same
|
|
2972
|
-
// 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.
|
|
2973
3070
|
setChildDelegations(
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
3071
|
+
filterDelegationsToSession(
|
|
3072
|
+
mergeBoardDelegationSources(
|
|
3073
|
+
mergeChildDelegationSources(childEntries, liveEntries),
|
|
3074
|
+
board,
|
|
3075
|
+
),
|
|
3076
|
+
sessionID,
|
|
2977
3077
|
),
|
|
2978
3078
|
)
|
|
2979
3079
|
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
@@ -2994,8 +3094,9 @@ function View(props: {
|
|
|
2994
3094
|
return delegationsInflight
|
|
2995
3095
|
}
|
|
2996
3096
|
// Live-first ceiling: at most DELEGATION_VISIBLE_CEILING rows render; the
|
|
2997
|
-
// remainder collapses into a single "… +N more" line.
|
|
2998
|
-
//
|
|
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.
|
|
2999
3100
|
const delegationCeiling = createMemo(() =>
|
|
3000
3101
|
ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
|
|
3001
3102
|
)
|
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
|
package/scripts/hash_verify.py
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Hash-Anchored Edit Verification — standalone script.
|
|
3
|
-
|
|
4
|
-
Verifies that file edits actually changed content by comparing SHA-256
|
|
5
|
-
hashes before and after an edit. Detects failed/no-op edits.
|
|
6
|
-
|
|
7
|
-
Usage:
|
|
8
|
-
python3 scripts/hash_verify.py <file> [--before-hash=<sha256>] [--diff-min-lines=10]
|
|
9
|
-
python3 scripts/hash_verify.py --staged # Check all staged files
|
|
10
|
-
python3 scripts/hash_verify.py --batch <file>... # Check multiple files
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import argparse
|
|
14
|
-
import hashlib
|
|
15
|
-
import subprocess
|
|
16
|
-
import sys
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
EXIT_SUCCESS = 0
|
|
20
|
-
EXIT_FAILURE = 1
|
|
21
|
-
EXIT_WARNING = 2
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def sha256_of(file: Path) -> str:
|
|
25
|
-
"""Return hex SHA-256 of file content."""
|
|
26
|
-
return hashlib.sha256(file.read_bytes()).hexdigest()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def compute_diff_size(file: Path) -> int:
|
|
30
|
-
"""Return number of changed lines in working-tree diff."""
|
|
31
|
-
try:
|
|
32
|
-
result = subprocess.run(
|
|
33
|
-
["git", "diff", "--", str(file)],
|
|
34
|
-
capture_output=True,
|
|
35
|
-
text=True,
|
|
36
|
-
timeout=5,
|
|
37
|
-
check=False,
|
|
38
|
-
)
|
|
39
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
40
|
-
return 0
|
|
41
|
-
|
|
42
|
-
diff = result.stdout
|
|
43
|
-
added = len(
|
|
44
|
-
[
|
|
45
|
-
line
|
|
46
|
-
for line in diff.split("\n")
|
|
47
|
-
if line.startswith("+") and not line.startswith("+++")
|
|
48
|
-
]
|
|
49
|
-
)
|
|
50
|
-
removed = len(
|
|
51
|
-
[
|
|
52
|
-
line
|
|
53
|
-
for line in diff.split("\n")
|
|
54
|
-
if line.startswith("-") and not line.startswith("---")
|
|
55
|
-
]
|
|
56
|
-
)
|
|
57
|
-
return added + removed
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def verify_file(file: Path, before_hash: str | None, diff_min_lines: int) -> dict:
|
|
61
|
-
"""Verify a single file edit."""
|
|
62
|
-
if not file.exists():
|
|
63
|
-
return {"file": str(file), "status": "error", "message": "File does not exist"}
|
|
64
|
-
|
|
65
|
-
after_hash = sha256_of(file)
|
|
66
|
-
|
|
67
|
-
if before_hash:
|
|
68
|
-
# Compare explicit before/after hashes
|
|
69
|
-
if after_hash == before_hash:
|
|
70
|
-
return {
|
|
71
|
-
"file": str(file),
|
|
72
|
-
"status": "failed",
|
|
73
|
-
"hash_before": before_hash,
|
|
74
|
-
"hash_after": after_hash,
|
|
75
|
-
"message": "Hash unchanged — edit did not modify file content",
|
|
76
|
-
}
|
|
77
|
-
else:
|
|
78
|
-
# Auto-detect: check git diff
|
|
79
|
-
diff_changes = compute_diff_size(file)
|
|
80
|
-
if diff_changes == 0:
|
|
81
|
-
return {
|
|
82
|
-
"file": str(file),
|
|
83
|
-
"status": "failed",
|
|
84
|
-
"hash_after": after_hash,
|
|
85
|
-
"message": "No git diff detected — edit did not modify file",
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if diff_changes < diff_min_lines:
|
|
89
|
-
return {
|
|
90
|
-
"file": str(file),
|
|
91
|
-
"status": "warning",
|
|
92
|
-
"hash_after": after_hash,
|
|
93
|
-
"diff_lines": diff_changes,
|
|
94
|
-
"message": f"Edit detected but diff is small ({diff_changes} lines vs min {diff_min_lines})",
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return {
|
|
98
|
-
"file": str(file),
|
|
99
|
-
"status": "verified",
|
|
100
|
-
"hash_after": after_hash,
|
|
101
|
-
"message": "Edit verified — file content changed",
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def check_staged_files(diff_min_lines: int) -> list[dict]:
|
|
106
|
-
"""Verify all staged (git-add'ed) files."""
|
|
107
|
-
try:
|
|
108
|
-
result = subprocess.run(
|
|
109
|
-
["git", "diff", "--cached", "--name-only"],
|
|
110
|
-
capture_output=True,
|
|
111
|
-
text=True,
|
|
112
|
-
timeout=5,
|
|
113
|
-
check=False,
|
|
114
|
-
)
|
|
115
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
116
|
-
return [{"status": "error", "message": "Git not available"}]
|
|
117
|
-
|
|
118
|
-
staged = [f.strip() for f in result.stdout.split("\n") if f.strip()]
|
|
119
|
-
results = []
|
|
120
|
-
for fname in staged[:20]: # limit to 20 files
|
|
121
|
-
fpath = Path(fname)
|
|
122
|
-
if fpath.exists():
|
|
123
|
-
results.append(verify_file(fpath, None, diff_min_lines))
|
|
124
|
-
return results
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def main() -> None:
|
|
128
|
-
parser = argparse.ArgumentParser(
|
|
129
|
-
description="Hash-anchored edit verification — ensures file edits actually changed content."
|
|
130
|
-
)
|
|
131
|
-
parser.add_argument("files", nargs="*", help="File(s) to verify")
|
|
132
|
-
parser.add_argument(
|
|
133
|
-
"--before-hash", help="Expected SHA-256 hash before edit (for single-file mode)"
|
|
134
|
-
)
|
|
135
|
-
parser.add_argument(
|
|
136
|
-
"--diff-min-lines",
|
|
137
|
-
type=int,
|
|
138
|
-
default=3,
|
|
139
|
-
help="Minimum diff lines to consider edit meaningful (default: 3)",
|
|
140
|
-
)
|
|
141
|
-
parser.add_argument(
|
|
142
|
-
"--staged", action="store_true", help="Verify all staged files instead"
|
|
143
|
-
)
|
|
144
|
-
parser.add_argument(
|
|
145
|
-
"--batch", action="store_true", help="Treat positional args as batch file list"
|
|
146
|
-
)
|
|
147
|
-
args = parser.parse_args()
|
|
148
|
-
|
|
149
|
-
if args.staged:
|
|
150
|
-
results = check_staged_files(args.diff_min_lines)
|
|
151
|
-
elif args.batch or len(args.files) > 1:
|
|
152
|
-
results = [verify_file(Path(f), None, args.diff_min_lines) for f in args.files]
|
|
153
|
-
elif args.files:
|
|
154
|
-
results = [
|
|
155
|
-
verify_file(Path(args.files[0]), args.before_hash, args.diff_min_lines)
|
|
156
|
-
]
|
|
157
|
-
else:
|
|
158
|
-
print("Usage: python3 scripts/hash_verify.py <file> [--before-hash=<sha256>]")
|
|
159
|
-
print(" python3 scripts/hash_verify.py --staged")
|
|
160
|
-
print(" python3 scripts/hash_verify.py --batch <file>...")
|
|
161
|
-
sys.exit(EXIT_FAILURE)
|
|
162
|
-
|
|
163
|
-
# Print results
|
|
164
|
-
any_failed = False
|
|
165
|
-
any_warning = False
|
|
166
|
-
|
|
167
|
-
for r in results:
|
|
168
|
-
status_tag = {
|
|
169
|
-
"verified": "✅",
|
|
170
|
-
"warning": "⚠️",
|
|
171
|
-
"failed": "❌",
|
|
172
|
-
"error": "❌",
|
|
173
|
-
}.get(r["status"], "❓")
|
|
174
|
-
print(f"{status_tag} {r['file']}: {r['message']}")
|
|
175
|
-
if r["status"] == "failed" or r["status"] == "error":
|
|
176
|
-
any_failed = True
|
|
177
|
-
elif r["status"] == "warning":
|
|
178
|
-
any_warning = True
|
|
179
|
-
|
|
180
|
-
if any_failed:
|
|
181
|
-
print("\n❌ HASH VERIFY FAILED — some edits did not modify files")
|
|
182
|
-
sys.exit(EXIT_FAILURE)
|
|
183
|
-
elif any_warning:
|
|
184
|
-
print("\n⚠️ HASH VERIFY PASSED WITH WARNINGS — small diffs detected")
|
|
185
|
-
sys.exit(EXIT_WARNING)
|
|
186
|
-
else:
|
|
187
|
-
print("\n✅ HASH VERIFY: all edits verified")
|
|
188
|
-
sys.exit(EXIT_SUCCESS)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if __name__ == "__main__":
|
|
192
|
-
main()
|