pantheon-opencode 1.5.0-beta.10 → 1.5.0-beta.12

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
- "version": "1.5.0-beta.10",
3
+ "version": "1.5.0-beta.12",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pantheon-tui",
9
- "version": "1.5.0-beta.10",
9
+ "version": "1.5.0-beta.12",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@opencode-ai/plugin": ">=1.14.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.5.0-beta.10",
4
+ "version": "1.5.0-beta.12",
5
5
  "description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -13,7 +13,7 @@
13
13
  "src"
14
14
  ],
15
15
  "scripts": {
16
- "build": "tsdown && cp src/index.tsx dist/tui.tsx",
16
+ "build": "tsdown",
17
17
  "typecheck": "tsc --noEmit"
18
18
  },
19
19
  "peerDependencies": {
@@ -5,9 +5,10 @@
5
5
  /**
6
6
  * pantheon-tui — Pantheon TUI plugin for opencode.
7
7
  *
8
- * Single self-contained entry file: `tsdown && cp src/index.tsx dist/tui.tsx`
9
- * keeps the no-build load path working (dist/tui.tsx is raw source, transpiled
10
- * on the fly by opencode). All feature code lives in this one file.
8
+ * Bundled entry: `tsdown` emits `dist/tui.js`, and the package `exports`
9
+ * (`./tui` → ./dist/tui.js, `./server` → ./dist/server.js) is the ONLY load
10
+ * path — no raw TSX copy is shipped. Because the bundle inlines relative
11
+ * imports, this entry may be split into modules without breaking the loader.
11
12
  *
12
13
  * Slots:
13
14
  * - sidebar_content (order 900) — Pantheon sidebar (header/version/
@@ -90,9 +91,9 @@ const PRESET_REFRESH_MS = 30_000
90
91
  /* ─── Helpers ───────────────────────────────────────────── */
91
92
 
92
93
  /* ─── Active Preset Detection ──────────────────────────────
93
- * Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the TUI
94
- * plugin is self-contained (dist/tui.tsx is a raw copy of this file with no
95
- * relative imports), so the resolution logic is mirrored here rather than
94
+ * Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the
95
+ * copied plugin ships only dist/ + package.json, so it cannot reach
96
+ * src/pantheon at runtime; the resolution logic is mirrored here rather than
96
97
  * importing presets.mjs.
97
98
  *
98
99
  * Priority: env PANTHEON_MODEL_PRESET > first existing candidate file > none.
@@ -155,7 +156,7 @@ async function resolvePresetForTui(
155
156
  *
156
157
  * Try order:
157
158
  * 0a. pantheon-tui/package.json (installed: dist/tui.js → 2 níveis)
158
- * 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.tsx → 4 níveis)
159
+ * 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.js → 4 níveis)
159
160
  * 1. api.client.file.read package.json
160
161
  * 2. git describe --tags
161
162
  * 3. opencode --version
@@ -163,7 +164,7 @@ async function resolvePresetForTui(
163
164
  * 5. null */
164
165
 
165
166
  // Build-time embedded version (tsdown `define` in tsdown.config.ts). Injected
166
- // only into the bundled dist/tui.js; the raw dist/tui.tsx copy falls through.
167
+ // into the bundled dist/tui.js — the only artifact the loader consumes.
167
168
  declare const __PANTHEON_VERSION__: string | undefined
168
169
 
169
170
  async function detectVersion(api: TuiPluginApi): Promise<string | null> {
@@ -175,7 +176,7 @@ async function detectVersion(api: TuiPluginApi): Promise<string | null> {
175
176
  if (pkg.version) return pkg.version
176
177
  } catch {}
177
178
 
178
- // Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.tsx → package.json)
179
+ // Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.js → package.json)
179
180
  try {
180
181
  const pkgUrl = new URL('../../../../package.json', import.meta.url)
181
182
  const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
@@ -983,8 +984,9 @@ export type DelegationEntry = {
983
984
  // Reintroduce them once nested rows are actually produced.
984
985
  /** Internal provenance used to keep a finalized md report authoritative.
985
986
  * 'children-only' = a native task() child session with NO board report
986
- * (rendered with the distinct `nat:` prefix); 'md' = board report wins. */
987
- source?: 'child' | 'live' | 'md' | 'children-only'
987
+ * (rendered with the distinct `nat:` prefix); 'md' = board report wins;
988
+ * 'board' = read from .pantheon/board/state.json (cross-session FSM). */
989
+ source?: 'child' | 'live' | 'md' | 'children-only' | 'board'
988
990
  }
989
991
 
990
992
  /** True for `\s` characters (space, tab, newline, CR) — plain char checks so
@@ -1168,20 +1170,108 @@ export async function readAllDelegationEntries(root: string): Promise<Delegation
1168
1170
  return readDelegationEntries(join(root, '.pantheon', 'delegations'))
1169
1171
  }
1170
1172
 
1173
+ /* ─── Job board channel (4th source: .pantheon/board/state.json) ────────
1174
+ * `session.children` is scoped to the focused session, so a job running in
1175
+ * ANOTHER session is invisible to it (the "Delegations (0)" / nat:0 symptom).
1176
+ * The board state file is the only durable, cross-session source:
1177
+ * FilePersistence (src/pantheon/file-persistence.ts) stores every
1178
+ * BackgroundJobRecord there as a JSON array. Reads are fail-open — absent,
1179
+ * corrupt or unreadable → [] — so a broken board never crashes the panel. */
1180
+
1181
+ /** The persisted subset of BackgroundJobRecord the panel consumes. Duck-typed
1182
+ * so a corrupt/older record never breaks parsing. */
1183
+ export type BoardJobRecord = {
1184
+ taskID: string
1185
+ parentSessionID: string
1186
+ agent: string
1187
+ description?: string
1188
+ state: string
1189
+ alias: string
1190
+ launchedAt?: number
1191
+ completedAt?: number
1192
+ updatedAt?: number
1193
+ timedOut?: boolean
1194
+ }
1195
+
1196
+ /** The FilePersistence state path: `<root>/.pantheon/board/state.json`. */
1197
+ export function boardStatePath(root: string): string {
1198
+ return join(root, '.pantheon', 'board', 'state.json')
1199
+ }
1200
+
1201
+ /** Validate one parsed record, keeping only the fields the panel uses.
1202
+ * Returns null for anything without a taskID/state (a corrupt entry is
1203
+ * skipped individually — the rest of the snapshot still loads). */
1204
+ function normalizeBoardRecord(item: unknown): BoardJobRecord | null {
1205
+ if (typeof item !== 'object' || item === null) return null
1206
+ const rec = item as Record<string, unknown>
1207
+ if (typeof rec.taskID !== 'string' || rec.taskID === '') return null
1208
+ if (typeof rec.state !== 'string' || rec.state === '') return null
1209
+ const record: BoardJobRecord = {
1210
+ taskID: rec.taskID,
1211
+ parentSessionID: typeof rec.parentSessionID === 'string' ? rec.parentSessionID : '',
1212
+ agent: typeof rec.agent === 'string' && rec.agent !== '' ? rec.agent : 'agent',
1213
+ state: rec.state,
1214
+ alias: typeof rec.alias === 'string' ? rec.alias : '',
1215
+ }
1216
+ if (typeof rec.description === 'string') record.description = rec.description
1217
+ if (typeof rec.launchedAt === 'number' && Number.isFinite(rec.launchedAt))
1218
+ record.launchedAt = rec.launchedAt
1219
+ if (typeof rec.completedAt === 'number' && Number.isFinite(rec.completedAt))
1220
+ record.completedAt = rec.completedAt
1221
+ if (typeof rec.updatedAt === 'number' && Number.isFinite(rec.updatedAt))
1222
+ record.updatedAt = rec.updatedAt
1223
+ if (typeof rec.timedOut === 'boolean') record.timedOut = rec.timedOut
1224
+ return record
1225
+ }
1226
+
1227
+ /** Read `.pantheon/board/state.json` (the cross-session job board snapshot).
1228
+ * Fail-open: missing file, corrupt JSON, non-array payload or an unreadable
1229
+ * path all yield [] — the panel keeps rendering from the other channels. */
1230
+ export async function readBoardState(root: string): Promise<BoardJobRecord[]> {
1231
+ let raw: string
1232
+ try {
1233
+ raw = await readFile(boardStatePath(root), 'utf8')
1234
+ } catch {
1235
+ return [] // absent/unreadable — no board yet
1236
+ }
1237
+ let parsed: unknown
1238
+ try {
1239
+ parsed = JSON.parse(raw)
1240
+ } catch {
1241
+ return [] // corrupt JSON — never crash the sidebar
1242
+ }
1243
+ if (!Array.isArray(parsed)) return []
1244
+ const records: BoardJobRecord[] = []
1245
+ for (const item of parsed) {
1246
+ const record = normalizeBoardRecord(item)
1247
+ if (record !== null) records.push(record)
1248
+ }
1249
+ return records
1250
+ }
1251
+
1252
+ /** Resolve the PROJECT ROOT used by every pantheon file channel. `directory`
1253
+ * wins over `worktree` (the old `resolveDelegationsDir` already did this);
1254
+ * an absent/empty root or `/` (no git — e.g. the sandbox test project) falls
1255
+ * back to cwd. Standardised here so the delegations md, the board state file
1256
+ * and the panel logger all read the SAME root (audit finding: the channels
1257
+ * resolved the root independently). Pure — no I/O. */
1258
+ export function resolvePantheonRoot(
1259
+ state: { directory?: string; worktree?: string } | undefined,
1260
+ cwd = process.cwd(),
1261
+ ): string {
1262
+ const root = state?.directory ?? state?.worktree ?? ''
1263
+ if (root === '' || root === '/') return cwd
1264
+ return root
1265
+ }
1266
+
1171
1267
  /** Resolve the directory where the job board writes delegation md reports.
1172
1268
  * The board writes `.pantheon/delegations` RELATIVE to the server cwd,
1173
- * which the TUI exposes as `TuiState.path.directory`. `project` does NOT
1174
- * exist on `TuiState.path` (the old `state?.project ?? state?.worktree`
1175
- * resolution was always undefined for the first term) and `worktree` is
1176
- * `/` when there is no git (e.g. the sandbox test project) — a root of
1177
- * `''` or `'/'` must fall back to `process.cwd()`. */
1269
+ * which the TUI exposes as `TuiState.path.directory`. */
1178
1270
  export function resolveDelegationsDir(
1179
1271
  state: { directory?: string; worktree?: string } | undefined,
1180
1272
  cwd = process.cwd(),
1181
1273
  ): string {
1182
- const root = state?.directory ?? state?.worktree ?? ''
1183
- if (root === '' || root === '/') return join(cwd, '.pantheon', 'delegations')
1184
- return join(root, '.pantheon', 'delegations')
1274
+ return join(resolvePantheonRoot(state, cwd), '.pantheon', 'delegations')
1185
1275
  }
1186
1276
 
1187
1277
  /** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
@@ -1354,6 +1444,62 @@ export function delegationSpinnerFrame(now: number): string {
1354
1444
  return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
1355
1445
  }
1356
1446
 
1447
+ /** Every state the row knows how to draw: the real BackgroundJobBoard FSM
1448
+ * (src/pantheon/background-job-board.ts) plus the TUI display-only states
1449
+ * (`retry`, `stale-running`). Fase 1 deliberately omits speculative
1450
+ * blocked/paused/scheduled/skipped. There is no `pending` display state: a
1451
+ * pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
1452
+ export type DelegationDisplayState = DelegationEntry['state']
1453
+
1454
+ /** Static glyph per display state. `running` shows its base spinner frame;
1455
+ * callers that animate must prefer {@link delegationStateMarker}. Unicode
1456
+ * geometric shapes only (no Nerd Font) — shape is an independent channel
1457
+ * from color, so rows stay legible without color. */
1458
+ export const DELEGATION_STATE_GLYPHS: Readonly<Record<DelegationDisplayState, string>> = {
1459
+ running: '\u280b', // ⠋ base frame of the animated spinner
1460
+ retry: '\u27f3', // ⟳ retrying
1461
+ 'stale-running': '\u26a0', // ⚠ stale (display-only alert)
1462
+ completed: '\u2713', // ✓
1463
+ error: '\u2715', // ✕
1464
+ cancelled: '\u2212', // − minus: stopped, distinct from error's ✕
1465
+ startup_failed: '\u26a0', // ⚠ boot failed
1466
+ startup_unknown: '\u26a0', // ⚠ boot outcome unknown
1467
+ }
1468
+
1469
+ export function delegationStateGlyph(state: DelegationDisplayState): string {
1470
+ return DELEGATION_STATE_GLYPHS[state]
1471
+ }
1472
+
1473
+ /** Row marker (`<glyph> `) — the state channel. `running` animates through
1474
+ * {@link delegationSpinnerFrame} for the given tick; every other state is
1475
+ * static. Pure. */
1476
+ export function delegationStateMarker(state: DelegationDisplayState, now = Date.now()): string {
1477
+ const glyph = state === 'running' ? delegationSpinnerFrame(now) : delegationStateGlyph(state)
1478
+ return `${glyph} `
1479
+ }
1480
+
1481
+ /** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
1482
+ * Kept separate + pure so the color channel is testable without booting the
1483
+ * renderer. */
1484
+ export type DelegationStateTone = 'warning' | 'error' | 'success' | 'muted'
1485
+
1486
+ export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
1487
+ switch (state) {
1488
+ case 'running':
1489
+ case 'retry':
1490
+ case 'startup_unknown':
1491
+ return 'warning'
1492
+ case 'stale-running':
1493
+ case 'error':
1494
+ case 'startup_failed':
1495
+ return 'error'
1496
+ case 'completed':
1497
+ return 'success'
1498
+ case 'cancelled':
1499
+ return 'muted'
1500
+ }
1501
+ }
1502
+
1357
1503
  /* ─── Live tool activity per child session (beta.6) ────────────────────────
1358
1504
  * The delegation panel's activity line ("↳ bash npm test") — what the agent
1359
1505
  * is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
@@ -1914,6 +2060,145 @@ export function mergeDelegationSources(
1914
2060
  return all
1915
2061
  }
1916
2062
 
2063
+ /* ─── Board channel: record → display + authoritative merge ───────────── */
2064
+
2065
+ /** Job-board FSM → panel display state. `reconciled` is intentionally absent:
2066
+ * the panel parser rejects it (a reconciled job is done and folded away). */
2067
+ const BOARD_STATE_TO_DISPLAY: Record<string, DelegationEntry['state']> = {
2068
+ running: 'running',
2069
+ completed: 'completed',
2070
+ error: 'error',
2071
+ startup_failed: 'startup_failed',
2072
+ startup_unknown: 'startup_unknown',
2073
+ cancelled: 'cancelled',
2074
+ }
2075
+
2076
+ /** Map one persisted board record into the display shape. Returns null for
2077
+ * states the panel does not render (reconciled / unknown). Pure. */
2078
+ export function boardRecordToDelegationEntry(record: BoardJobRecord): DelegationEntry | null {
2079
+ const state = BOARD_STATE_TO_DISPLAY[record.state]
2080
+ if (state === undefined) return null
2081
+ const running = state === 'running'
2082
+ return {
2083
+ alias: record.alias !== '' ? record.alias : `job-${record.taskID.slice(0, 8)}`,
2084
+ sessionID: record.parentSessionID,
2085
+ taskID: record.taskID,
2086
+ agent: record.agent,
2087
+ state,
2088
+ startedAt: record.launchedAt ?? record.updatedAt ?? 0,
2089
+ updatedAt: running ? null : (record.completedAt ?? record.updatedAt ?? null),
2090
+ timedOut: record.timedOut === true,
2091
+ description: record.description ?? '',
2092
+ source: 'board',
2093
+ }
2094
+ }
2095
+
2096
+ /** Map a whole board snapshot, dropping unrenderable states. Sorted like every
2097
+ * other channel (running first, then most recent). Pure. */
2098
+ export function boardRecordsToDelegationEntries(
2099
+ records: readonly BoardJobRecord[],
2100
+ ): DelegationEntry[] {
2101
+ const out: DelegationEntry[] = []
2102
+ for (const record of records) {
2103
+ const entry = boardRecordToDelegationEntry(record)
2104
+ if (entry !== null) out.push(entry)
2105
+ }
2106
+ out.sort(compareDelegationEntries)
2107
+ return out
2108
+ }
2109
+
2110
+ /** Merge the board over any other channel. The board is AUTHORITATIVE for
2111
+ * state/alias/agent (it is the persisted FSM) and it is the only channel
2112
+ * that carries jobs from OTHER sessions. Dedup by taskID first, then by
2113
+ * (sessionID, alias) — aliases are per parent session. Pure. */
2114
+ export function mergeBoardDelegationSources(
2115
+ base: readonly DelegationEntry[],
2116
+ board: readonly DelegationEntry[],
2117
+ ): DelegationEntry[] {
2118
+ const key = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
2119
+ const byTaskID = new Map<string, number>()
2120
+ const bySessionAlias = new Map<string, number>()
2121
+ const out = [...base]
2122
+ out.forEach((entry, index) => {
2123
+ if (entry.taskID !== undefined) byTaskID.set(entry.taskID, index)
2124
+ bySessionAlias.set(key(entry.sessionID, entry.alias), index)
2125
+ })
2126
+
2127
+ for (const incoming of board) {
2128
+ const index =
2129
+ (incoming.taskID !== undefined ? byTaskID.get(incoming.taskID) : undefined) ??
2130
+ bySessionAlias.get(key(incoming.sessionID, incoming.alias))
2131
+ if (index === undefined) {
2132
+ out.push(incoming)
2133
+ const next = out.length - 1
2134
+ if (incoming.taskID !== undefined) byTaskID.set(incoming.taskID, next)
2135
+ bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
2136
+ continue
2137
+ }
2138
+ out[index] = incoming // board wins — it is the persisted source of truth
2139
+ }
2140
+
2141
+ out.sort(compareDelegationEntries)
2142
+ return out
2143
+ }
2144
+
2145
+ /* ─── Delegation scope toggle ([Sessão]/[Tudo]) ───────────────────────── */
2146
+
2147
+ /** 'session' = focused session only (default); 'all' = every session the
2148
+ * board knows about. */
2149
+ export type DelegationScope = 'session' | 'all'
2150
+
2151
+ /** api.kv key holding the persisted scope (defaults to 'session'). */
2152
+ export const DELEGATION_SCOPE_KV_KEY = 'delegations.scope'
2153
+
2154
+ /** Structural subset of TuiKV — kept local so the pure helpers are testable
2155
+ * without the TUI runtime. */
2156
+ export type DelegationScopeKv = {
2157
+ get: (key: string, fallback?: unknown) => unknown
2158
+ set: (key: string, value: unknown) => void
2159
+ }
2160
+
2161
+ /** Read the persisted scope; anything malformed/absent → 'session'. */
2162
+ export function readDelegationScope(kv: DelegationScopeKv | undefined): DelegationScope {
2163
+ try {
2164
+ const raw = kv?.get?.(DELEGATION_SCOPE_KV_KEY, 'session')
2165
+ return raw === 'all' ? 'all' : 'session'
2166
+ } catch {
2167
+ return 'session'
2168
+ }
2169
+ }
2170
+
2171
+ /** Persist the scope. Failure is non-fatal — the signal keeps it in memory. */
2172
+ export function writeDelegationScope(
2173
+ kv: DelegationScopeKv | undefined,
2174
+ scope: DelegationScope,
2175
+ ): void {
2176
+ try {
2177
+ kv?.set?.(DELEGATION_SCOPE_KV_KEY, scope)
2178
+ } catch {
2179
+ /* in-memory only */
2180
+ }
2181
+ }
2182
+
2183
+ /** Keyboard/click toggle target. Pure. */
2184
+ export function nextDelegationScope(scope: DelegationScope): DelegationScope {
2185
+ return scope === 'session' ? 'all' : 'session'
2186
+ }
2187
+
2188
+ /** Keep only the focused session's jobs ('session') or all of them ('all').
2189
+ * An empty sessionID marks a current-session child (the children channel is
2190
+ * session-scoped and only fills sessionID from a matching md report), so it
2191
+ * counts as the current session. With no resolved session there is nothing
2192
+ * to scope against — fail-open to the full list. Pure. */
2193
+ export function filterDelegationsByScope(
2194
+ entries: readonly DelegationEntry[],
2195
+ scope: DelegationScope,
2196
+ sessionID: string | null,
2197
+ ): DelegationEntry[] {
2198
+ if (scope === 'all' || sessionID === null) return [...entries]
2199
+ return entries.filter((entry) => entry.sessionID === sessionID || entry.sessionID === '')
2200
+ }
2201
+
1917
2202
  /* ─── Children channel (primary source, delegations-sidebar pattern) ────
1918
2203
  * `api.client.session.children` returns the child sessions of the current
1919
2204
  * session — every pantheon_delegate spawns one (parentID = caller). The
@@ -2237,8 +2522,8 @@ export function navigateToDelegationSession(
2237
2522
  }
2238
2523
 
2239
2524
  /* ─── Silence-by-default panel logger ──────────────────────
2240
- * Mirrors src/pantheon/logger.ts createPantheonLogger (the TUI plugin is
2241
- * self-contained — dist/tui.tsx is a raw copy with no relative imports):
2525
+ * Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
2526
+ * ships only dist/ + package.json, so it cannot import from src/pantheon):
2242
2527
  * every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
2243
2528
  * echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
2244
2529
  * renders plugin console output directly into the TUI — the pollution bug).
@@ -2351,35 +2636,19 @@ function DelegationRow(props: {
2351
2636
 
2352
2637
  const color = createMemo(() => {
2353
2638
  const t = theme()
2354
- switch (props.job.state) {
2355
- case 'running':
2356
- return t.warning
2357
- case 'retry':
2639
+ switch (delegationStateTone(props.job.state)) {
2640
+ case 'warning':
2358
2641
  return t.warning
2359
- case 'stale-running':
2360
- return t.error // warning color for stale delegations
2361
- case 'completed':
2362
- return t.success
2363
2642
  case 'error':
2364
2643
  return t.error
2644
+ case 'success':
2645
+ return t.success
2365
2646
  default:
2366
- return t.textMuted // cancelled
2647
+ return t.textMuted // muted (cancelled)
2367
2648
  }
2368
2649
  })
2369
2650
 
2370
- const marker = createMemo(() => {
2371
- if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
2372
- if (props.job.state === 'retry') return '\u27f3 ' // ⟳ retrying
2373
- if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
2374
- switch (props.job.state) {
2375
- case 'completed':
2376
- return '\u2713 '
2377
- case 'error':
2378
- return '\u2715 '
2379
- default:
2380
- return '\u25cb '
2381
- }
2382
- })
2651
+ const marker = createMemo(() => delegationStateMarker(props.job.state, props.animationNow))
2383
2652
 
2384
2653
  // Two visual channels split native task() from pantheon_delegate: the tag
2385
2654
  // prefix (`nat:`/`pan:`, via delegationTag) and the glyph color — info
@@ -2529,14 +2798,22 @@ function View(props: {
2529
2798
  // (.pantheon/delegations) enriches each child with alias/agent/description
2530
2799
  // and terminal duration via the `Task ID` match. Live tool-call events are
2531
2800
  // merged optimistically so the row appears before those channels catch up.
2532
- const delegationsDir = createMemo(() => resolveDelegationsDir((props.api.state as any).path))
2801
+ // Consistent project root (directory ?? worktree, cwd fallback) — the md
2802
+ // history, the board state file and the panel logger all read the SAME
2803
+ // root (audit finding: the channels resolved it independently).
2804
+ const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
2533
2805
  // Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
2534
- // panelLogDir(<root>/.pantheon/delegations) = <root>: the old code passed
2535
- // dirname(delegationsDir()) (= <root>/.pantheon), which createTuiLogger
2536
- // re-joined with `.pantheon/logs/hooks.log` → a nested
2537
- // <root>/.pantheon/.pantheon/logs/hooks.log that never reached the real
2538
- // hooks.log (the empty nested dirs observed in production).
2539
- const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
2806
+ const panelLog = createTuiLogger(projectRoot())
2807
+ // Scope toggle: [Sessão] = focused session only (default), [Tudo] = every
2808
+ // session the board knows about. Persisted best-effort in api.kv; if the kv
2809
+ // is unavailable the signal keeps it for the process lifetime.
2810
+ const [delegationScope, setDelegationScope] = createSignal<DelegationScope>(
2811
+ readDelegationScope(props.api.kv as unknown as DelegationScopeKv),
2812
+ )
2813
+ const applyDelegationScope = (scope: DelegationScope) => {
2814
+ setDelegationScope(scope)
2815
+ writeDelegationScope(props.api.kv as unknown as DelegationScopeKv, scope)
2816
+ }
2540
2817
  // The rendered list is childDelegations (children enriched with md);
2541
2818
  // there is no separate md-only signal — the md channel only feeds
2542
2819
  // childrenToDelegationEntries below.
@@ -2569,10 +2846,19 @@ function View(props: {
2569
2846
  // the full historical list. Fail-open: a missing dir yields [].
2570
2847
  let md: DelegationEntry[] = []
2571
2848
  try {
2572
- md = await readAllDelegationEntries(panelLogDir(delegationsDir()))
2849
+ md = await readAllDelegationEntries(projectRoot())
2573
2850
  } catch {
2574
2851
  md = [] // fail-open: never crash the sidebar on a read error
2575
2852
  }
2853
+ // 0b. board snapshot — the 4th channel. Cross-session and AUTHORITATIVE
2854
+ // for state/alias/agent (the persisted FSM). This is what makes a job
2855
+ // running in ANOTHER session visible. Fail-open: absent/corrupt → [].
2856
+ let board: DelegationEntry[] = []
2857
+ try {
2858
+ board = boardRecordsToDelegationEntries(await readBoardState(projectRoot()))
2859
+ } catch {
2860
+ board = [] // fail-open: never crash the sidebar on a board read error
2861
+ }
2576
2862
  // 1. current session — resolve + GUARD (placeholder regression). The
2577
2863
  // runtime may render the sidebar with an unsubstituted "{sessionID}"
2578
2864
  // slot prop; forwarding it to session.children made the server reject
@@ -2582,9 +2868,9 @@ function View(props: {
2582
2868
  // (zero children, zero errors).
2583
2869
  const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
2584
2870
  if (sessionID === null) {
2585
- setChildDelegations(md)
2871
+ setChildDelegations(mergeBoardDelegationSources(md, board))
2586
2872
  panelLog.info(
2587
- `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
2873
+ `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history+board shown)`,
2588
2874
  )
2589
2875
  return
2590
2876
  }
@@ -2616,6 +2902,12 @@ function View(props: {
2616
2902
  }
2617
2903
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2618
2904
  const childEntries = childrenToDelegationEntries(withStatus, md, now())
2905
+ // childrenToDelegationEntries only fills sessionID from a matching md
2906
+ // report; a report-less row IS a child of the focused session, so tag
2907
+ // it with the resolved id — the scope filter can then keep it.
2908
+ for (const entry of childEntries) {
2909
+ if (entry.sessionID === '') entry.sessionID = sessionID
2910
+ }
2619
2911
  for (const [k, v] of props.liveStore.map)
2620
2912
  if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
2621
2913
  props.liveStore.map.delete(k)
@@ -2625,7 +2917,14 @@ function View(props: {
2625
2917
  const liveEntries = [...props.liveStore.map.values()].filter(
2626
2918
  (entry) => entry.sessionID === sessionID,
2627
2919
  )
2628
- setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
2920
+ // Board LAST so it is authoritative over children/md/live for the same
2921
+ // job, and adds the rows from other sessions the children API cannot see.
2922
+ setChildDelegations(
2923
+ mergeBoardDelegationSources(
2924
+ mergeChildDelegationSources(childEntries, liveEntries),
2925
+ board,
2926
+ ),
2927
+ )
2629
2928
  // 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
2630
2929
  const counts = countDelegationSources(childEntries)
2631
2930
  panelLog.info(
@@ -2646,15 +2945,24 @@ function View(props: {
2646
2945
  // beta.6: active (running/retry) first, then the 8 most recent terminal
2647
2946
  // reports, then the archived tail (paginated). Pagination state lives at
2648
2947
  // module level so sidebar remounts keep the user's page.
2649
- const delegationSplit = createMemo(() => splitDelegationList(childDelegations()))
2948
+ // The scope filters the rendered list (and the header counts): children/live
2949
+ // are already session-scoped, md and the board are not.
2950
+ const scopedDelegations = createMemo(() =>
2951
+ filterDelegationsByScope(
2952
+ childDelegations(),
2953
+ delegationScope(),
2954
+ resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api }),
2955
+ ),
2956
+ )
2957
+ const delegationSplit = createMemo(() => splitDelegationList(scopedDelegations()))
2650
2958
  const visibleDelegations = createMemo(() => [
2651
2959
  ...delegationSplit().active,
2652
2960
  ...delegationSplit().recent,
2653
2961
  ])
2654
- // Header summary over the FULL list (active + recent + archived): active
2655
- // vs done plus the native/pantheon breakdown, so a busy session reads
2962
+ // Header summary over the FULL scoped list (active + recent + archived):
2963
+ // active vs done plus the native/pantheon breakdown, so a busy session reads
2656
2964
  // "Delegations (2 active · 9 done · nat:3 pan:8)".
2657
- const delegationHeader = createMemo(() => formatDelegationHeader(childDelegations()))
2965
+ const delegationHeader = createMemo(() => formatDelegationHeader(scopedDelegations()))
2658
2966
  const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2659
2967
  const clampArchivedPage = (page: number, pages: number) => {
2660
2968
  const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
@@ -2739,6 +3047,29 @@ function View(props: {
2739
3047
  /* re-scan unavailable — children API + 1s poll still cover it */
2740
3048
  }
2741
3049
 
3050
+ // Keyboard shortcut for the scope toggle. `api.keymap` is an optional
3051
+ // peer (@opentui/keymap) — guard the duck-typed call so a degraded/missing
3052
+ // keymap never breaks the sidebar. The clickable [Sessão]/[Tudo] chips are
3053
+ // the always-available fallback.
3054
+ try {
3055
+ const keymap = (props.api as any)?.keymap
3056
+ if (typeof keymap?.registerLayer === 'function') {
3057
+ const off = keymap.registerLayer({
3058
+ priority: 1000,
3059
+ commands: [
3060
+ {
3061
+ name: 'pantheon.delegations.toggleScope',
3062
+ run: () => applyDelegationScope(nextDelegationScope(delegationScope())),
3063
+ },
3064
+ ],
3065
+ bindings: [{ key: 'ctrl+shift+d', cmd: 'pantheon.delegations.toggleScope' }],
3066
+ })
3067
+ if (typeof off === 'function') cleanup.push(off)
3068
+ }
3069
+ } catch {
3070
+ /* keymap unavailable — the click toggle still works */
3071
+ }
3072
+
2742
3073
  onCleanup(() =>
2743
3074
  cleanup.forEach((fn) => {
2744
3075
  fn()
@@ -2800,12 +3131,32 @@ function View(props: {
2800
3131
  </Show>
2801
3132
  </Show>
2802
3133
 
2803
- {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
3134
+ {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment + board snapshot) ── */}
2804
3135
  <box onMouseDown={() => setShowDelegations((x) => !x)}>
2805
3136
  <text fg={theme().text} attributes={1}>
2806
3137
  {`${showDelegations() ? '▼' : '▶'} Delegations`}
2807
3138
  </text>
2808
3139
  <text fg={theme().textMuted}>{` ${delegationHeader()}`}</text>
3140
+ <text fg={theme().textMuted}>{` `}</text>
3141
+ <text
3142
+ fg={delegationScope() === 'session' ? theme().accent : theme().textMuted}
3143
+ attributes={delegationScope() === 'session' ? 1 : undefined}
3144
+ onMouseDown={(e: unknown) => {
3145
+ if (typeof e === 'object' && e !== null && 'stopPropagation' in e)
3146
+ (e as { stopPropagation: () => void }).stopPropagation()
3147
+ applyDelegationScope('session')
3148
+ }}
3149
+ >{`[Sessão]`}</text>
3150
+ <text fg={theme().textMuted}>{`/`}</text>
3151
+ <text
3152
+ fg={delegationScope() === 'all' ? theme().accent : theme().textMuted}
3153
+ attributes={delegationScope() === 'all' ? 1 : undefined}
3154
+ onMouseDown={(e: unknown) => {
3155
+ if (typeof e === 'object' && e !== null && 'stopPropagation' in e)
3156
+ (e as { stopPropagation: () => void }).stopPropagation()
3157
+ applyDelegationScope('all')
3158
+ }}
3159
+ >{`[Tudo]`}</text>
2809
3160
  </box>
2810
3161
  <Show when={showDelegations()}>
2811
3162
  <Show