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.
Files changed (42) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/plugin.json +1 -1
  4. package/pyproject.toml +2 -5
  5. package/src/agents/zeus.md +4 -26
  6. package/src/pantheon/background-job-board.ts +4 -4
  7. package/src/pantheon/delegate-manager.ts +95 -10
  8. package/src/pantheon/delegation-classifier.ts +3 -4
  9. package/src/pantheon/delegation-finalize.ts +69 -7
  10. package/src/pantheon/delegation.ts +16 -4
  11. package/src/pantheon/presets.d.mts +0 -20
  12. package/src/pantheon/presets.mjs +0 -64
  13. package/src/pantheon/toon-codec.ts +1 -1
  14. package/src/plugin-v2.ts +4 -6
  15. package/src/plugin.ts +6 -6
  16. package/src/plugins/pantheon-hooks.ts +1 -1
  17. package/src/plugins/tui/dist/tui.d.ts +43 -36
  18. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  19. package/src/plugins/tui/dist/tui.js +102 -38
  20. package/src/plugins/tui/package-lock.json +2 -2
  21. package/src/plugins/tui/package.json +1 -1
  22. package/src/plugins/tui/src/index.tsx +168 -67
  23. package/src/routing.yml +0 -16
  24. package/scripts/hash_verify.py +0 -192
  25. package/scripts/test-context-compression.sh +0 -169
  26. package/src/pantheon/auto-wake.ts +0 -85
  27. package/src/pantheon/chat-reminders.ts +0 -9
  28. package/src/pantheon/comment-checker.ts +0 -48
  29. package/src/pantheon/compaction-assert.ts +0 -160
  30. package/src/pantheon/compaction-handoff.ts +0 -304
  31. package/src/pantheon/cost-tracker.ts +0 -108
  32. package/src/pantheon/dispatch-guard.ts +0 -109
  33. package/src/pantheon/permission-eval.ts +0 -284
  34. package/src/pantheon/preemptive-compact.ts +0 -76
  35. package/src/pantheon/provider-capabilities.ts +0 -221
  36. package/src/pantheon/provider-stream-tracker.ts +0 -182
  37. package/src/pantheon/retry-policy.ts +0 -212
  38. package/src/pantheon/token-opt.ts +0 -689
  39. package/src/pantheon/v2-events.ts +0 -148
  40. package/src/pantheon/v2-hooks.ts +0 -287
  41. package/src/pantheon/v2-tool-definitions.ts +0 -331
  42. 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 + HISTORY — markdown reports the job board persists under
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 ALSO read from EVERY session (readAllDelegationEntries) —
917
- * the panel shows the full delegation history even without a focused
918
- * session.
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 — history shown)" — the md HISTORY renders
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 the md history — zero errors.
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
- * HISTORY channel. Unlike the children/live channels it does NOT depend on a
1165
- * resolved sessionID: with no focused session (null/placeholder), the panel
1166
- * still shows the reports from all past sessions (running first, Finalized
1167
- * desc — the sort applied by readDelegationEntries). Fail-open: a
1168
- * missing/unreadable directory yields []. */
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
- export type DelegationStateTone = 'warning' | 'error' | 'success' | 'muted'
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
- /** Color is the ONLY channel separating `stale-running` from a live `running`
1479
- * row — the 4-glyph row design has no dedicated stale shape. Display-only;
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 'muted'
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 > 30_000
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 — reuse the synthetic
1613
- // 'native-task' marker so the single identity falls back to the agent.
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) and it is the only channel
2118
- * that carries jobs from OTHER sessions. Dedup by taskID first, then by
2119
- * (sessionID, alias) — aliases are per parent session. Pure. */
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, alias `native-task`) shows the agent
2330
- * instead — never alias + agent stacked. Pure. */
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.alias === 'native-task' && entry.agent !== '') return entry.agent
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
- * the internal alias 'native-task' instead of a
2489
- * board alias. The 'task nativa' description fallback keeps the row
2490
- * non-empty when the child carries no title.
2491
- * Terminal md state wins over the derived state; a running md defers to
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 ?? 'native-task',
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 SEPARATE channel from the glyph: the lead (glyph + identity)
2704
- // carries the state tone, while elapsed + description stay muted so the
2705
- // state reads at a glance without shouting.
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 // muted (cancelled)
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: colored lead (glyph + identity) + muted elapsed tail. */}
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={theme().textMuted}>{elapsed()}</span>
2829
+ <span fg={tone()}>{elapsed()}</span>
2743
2830
  </text>
2744
2831
  </box>
2745
- {/* Line 2: the description, indented under the identity and muted. */}
2832
+ {/* Line 2: the description, indented under the identity and toned. */}
2746
2833
  <Show when={description() !== ''}>
2747
- <text fg={theme().textMuted}>{` ${description()}`}</text>
2834
+ <text fg={tone()}>{` ${description()}`}</text>
2748
2835
  </Show>
2749
2836
  <Show when={activity()}>
2750
2837
  {(a) => (
2751
2838
  <text
2752
- fg={theme().textMuted}
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
- // Every job the board knows about is shown — no scope filter ("showing is
2869
- // what matters"). Live-first ordering keeps running rows on top; the
2870
- // ceiling collapses the rest into "… +N more".
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
- // HISTORY channel. Read BEFORE the sessionID guard so the panel shows
2898
- // past reports (running first, Finalized desc) even when no session
2899
- // is focused (null/placeholder sessionID): the "(0)" symptom becomes
2900
- // the full historical list. Fail-open: a missing dir yields [].
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. Cross-session and AUTHORITATIVE
2908
- // for state/alias/agent (the persisted FSM). This is what makes a job
2909
- // running in ANOTHER session visible. Fail-open: absent/corrupt → [].
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 md history is shown alone
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
- setChildDelegations(mergeBoardDelegationSources(md, board))
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 — history+board shown)`,
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, and adds the rows from other sessions the children API cannot see.
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
- mergeBoardDelegationSources(
2975
- mergeChildDelegationSources(childEntries, liveEntries),
2976
- board,
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. The header counts the
2998
- // FULL board list — every session, no scope filter.
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
@@ -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()