pantheon-opencode 1.5.0-beta.11 → 1.5.0-beta.13

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.
@@ -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')
@@ -899,9 +900,9 @@ async function setupUsageBar(api: TuiPluginApi) {
899
900
  * and the native `task()` tool ALSO spawns a child session with
900
901
  * parentID = caller. A child WITHOUT a board report is a native
901
902
  * task() child: childrenToDelegationEntries tags it source
902
- * 'children-only' with the `nat:` row tag (distinct info color),
903
+ * 'children-only' (distinct info color),
903
904
  * so native task() work is visible in the panel alongside board
904
- * delegates (`pan:apo-1`) — never filtered out, never duplicated
905
+ * delegates — never filtered out, never duplicated
905
906
  * with a board row for the same child (a child WITH a report keeps the
906
907
  * md entry, source 'md').
907
908
  *
@@ -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
+ * (a native child row); '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)" 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`
@@ -1212,18 +1302,18 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
1212
1302
  }
1213
1303
 
1214
1304
  /** Split the panel list: active jobs (running/retry, stale-marked) first,
1215
- * then the most recent terminal reports, then the archived tail (paginated
1216
- * in the View). Pure — so the history-only panel (no sessionID) is testable
1305
+ * then the most recent terminal reports; the remaining tail is collapsed
1306
+ * by the View into a single "… +N more" line. Pure — so the history-only panel (no sessionID) is testable
1217
1307
  * without the TUI runtime. */
1218
1308
  export function splitDelegationList(
1219
1309
  all: readonly DelegationEntry[],
1220
1310
  maxRecent = 8,
1221
1311
  now = Date.now(),
1222
1312
  staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1223
- ): { active: DelegationEntry[]; recent: DelegationEntry[]; archived: DelegationEntry[] } {
1313
+ ): { active: DelegationEntry[]; recent: DelegationEntry[] } {
1224
1314
  // Active = running OR retry OR the display-only stale-running. A stale row
1225
1315
  // is still a live job (backend state unchanged), so it must never fall into
1226
- // the terminal/archived tail — archiving a running delegation hid the row
1316
+ // the terminal tail — pushing a running delegation out hid the row
1227
1317
  // the user launched.
1228
1318
  const isActiveState = (st: DelegationEntry['state']): boolean =>
1229
1319
  st === 'running' || st === 'retry' || st === 'stale-running'
@@ -1234,13 +1324,12 @@ export function splitDelegationList(
1234
1324
  return {
1235
1325
  active,
1236
1326
  recent: terminal.slice(0, maxRecent),
1237
- archived: terminal.slice(maxRecent),
1238
1327
  }
1239
1328
  }
1240
1329
 
1241
- /** The list the panel actually renders (kept for the header count and
1242
- * existing tests): active jobs first, then the most recent terminal
1243
- * reports (capped) — the archived tail is rendered separately. Pure. */
1330
+ /** Live-first window used by {@link ceilingDelegationList} (kept for the
1331
+ * ceiling helper and existing tests): active jobs first, then the most
1332
+ * recent terminal reports (capped). Pure. */
1244
1333
  export function visibleDelegationList(
1245
1334
  all: readonly DelegationEntry[],
1246
1335
  maxTerminal = 8,
@@ -1280,7 +1369,7 @@ export function markStaleIfRunning(
1280
1369
  return { ...entry, state: 'stale-running' as const }
1281
1370
  }
1282
1371
 
1283
- /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
1372
+ /** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
1284
1373
  * second for running jobs. */
1285
1374
  export function fmtElapsed(ms: number): string {
1286
1375
  const total = Math.max(0, Math.floor(ms / 1000))
@@ -1288,9 +1377,9 @@ export function fmtElapsed(ms: number): string {
1288
1377
  const hours = Math.floor((total % 86_400) / 3_600)
1289
1378
  const minutes = Math.floor((total % 3_600) / 60)
1290
1379
  const seconds = total % 60
1291
- if (days > 0) return `${days}d ${hours}h`
1292
- if (hours > 0) return `${hours}h ${minutes}m`
1293
- if (minutes > 0) return `${minutes}m ${seconds}s`
1380
+ if (days > 0) return `${days}d`
1381
+ if (hours > 0) return `${hours}h`
1382
+ if (minutes > 0) return `${minutes}m`
1294
1383
  return `${seconds}s`
1295
1384
  }
1296
1385
 
@@ -1361,38 +1450,14 @@ export function delegationSpinnerFrame(now: number): string {
1361
1450
  * pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
1362
1451
  export type DelegationDisplayState = DelegationEntry['state']
1363
1452
 
1364
- /** Static glyph per display state. `running` shows its base spinner frame;
1365
- * callers that animate must prefer {@link delegationStateMarker}. Unicode
1366
- * geometric shapes only (no Nerd Font) — shape is an independent channel
1367
- * from color, so rows stay legible without color. */
1368
- export const DELEGATION_STATE_GLYPHS: Readonly<Record<DelegationDisplayState, string>> = {
1369
- running: '\u280b', // ⠋ base frame of the animated spinner
1370
- retry: '\u27f3', // ⟳ retrying
1371
- 'stale-running': '\u26a0', // ⚠ stale (display-only alert)
1372
- completed: '\u2713', // ✓
1373
- error: '\u2715', // ✕
1374
- cancelled: '\u2212', // − minus: stopped, distinct from error's ✕
1375
- startup_failed: '\u26a0', // ⚠ boot failed
1376
- startup_unknown: '\u26a0', // ⚠ boot outcome unknown
1377
- }
1378
-
1379
- export function delegationStateGlyph(state: DelegationDisplayState): string {
1380
- return DELEGATION_STATE_GLYPHS[state]
1381
- }
1382
-
1383
- /** Row marker (`<glyph> `) — the state channel. `running` animates through
1384
- * {@link delegationSpinnerFrame} for the given tick; every other state is
1385
- * static. Pure. */
1386
- export function delegationStateMarker(state: DelegationDisplayState, now = Date.now()): string {
1387
- const glyph = state === 'running' ? delegationSpinnerFrame(now) : delegationStateGlyph(state)
1388
- return `${glyph} `
1389
- }
1390
-
1391
1453
  /** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
1392
1454
  * Kept separate + pure so the color channel is testable without booting the
1393
1455
  * renderer. */
1394
1456
  export type DelegationStateTone = 'warning' | 'error' | 'success' | 'muted'
1395
1457
 
1458
+ /** Color is the ONLY channel separating `stale-running` from a live `running`
1459
+ * row — the 4-glyph row design has no dedicated stale shape. Display-only;
1460
+ * no behavior change. */
1396
1461
  export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
1397
1462
  switch (state) {
1398
1463
  case 'running':
@@ -1428,10 +1493,6 @@ const latestToolActivity = new Map<string, ToolActivity>()
1428
1493
  const MAX_TOOL_ACTIVITY_ENTRIES = 200
1429
1494
  const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
1430
1495
 
1431
- /** Archived-section pagination state — module-level so it survives sidebar
1432
- * remounts (route changes) without leaking into the component tree. */
1433
- const archivedUiState = { page: 0 }
1434
-
1435
1496
  /** First usable summary string from common tool input fields. */
1436
1497
  function summarizeToolInput(input: Record<string, unknown> | undefined): string {
1437
1498
  if (!input) return ''
@@ -1521,6 +1582,17 @@ export function mergeChildDelegationSources(
1521
1582
  )
1522
1583
  continue
1523
1584
  const incoming = toDelegationEntry(liveEntry)
1585
+ // A native task() live part never carries a board alias — it is a
1586
+ // children-only row by definition whether it lands on a new row or
1587
+ // upgrades an existing
1588
+ // children-only one. pantheon_delegate pending parts keep source 'live'.
1589
+ const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
1590
+ if (isNativeLive) {
1591
+ // Native task() rows carry no board alias — reuse the synthetic
1592
+ // 'native-task' marker so the single identity falls back to the agent.
1593
+ incoming.source = 'children-only'
1594
+ incoming.alias = 'native-task'
1595
+ }
1524
1596
  const index =
1525
1597
  (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
1526
1598
  (incoming.alias !== ''
@@ -1568,7 +1640,10 @@ export function mergeChildDelegationSources(
1568
1640
  startedAt: Math.min(existing.startedAt, incoming.startedAt),
1569
1641
  updatedAt: incoming.updatedAt,
1570
1642
  read: incoming.read,
1571
- source: 'live',
1643
+ // A native task() live upgrade must not flip a children-only row to
1644
+ // 'live' (that would drop the children-only provenance); pantheon_delegate
1645
+ // upgrades still take source 'live'.
1646
+ source: isNativeLive ? existing.source : 'live',
1572
1647
  }
1573
1648
  }
1574
1649
 
@@ -1713,7 +1788,7 @@ export function parseDelegationToolPart(
1713
1788
  // The alias/taskID only exist once the delegate tool COMPLETES (they are
1714
1789
  // returned in its output); a running/pending part has neither. Native
1715
1790
  // `task` parts never carry a board alias — the children channel supplies
1716
- // the child session id, so the row renders `nat:`.
1791
+ // the child session id and the row's identity.
1717
1792
  let alias: string | null = null
1718
1793
  let taskID: string | null = null
1719
1794
  if (status === 'completed') {
@@ -1787,7 +1862,7 @@ export function reduceDelegationToolPart(
1787
1862
  // job launched (running). A COMPLETED delegate tool only means the job was
1788
1863
  // registered — it stays running and we pick up alias + taskID from the
1789
1864
  // output. Native `task` parts never yield a board alias, so their rows
1790
- // stay `nat:` until the children channel resolves them.
1865
+ // stay identity-less until the children channel resolves them.
1791
1866
  const existing = map.get(parsed.callID)
1792
1867
  if (parsed.status === 'error') {
1793
1868
  if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
@@ -1872,7 +1947,7 @@ export function removeDelegationEntry(
1872
1947
  * SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
1873
1948
  * child session with parentID = caller — the same mechanism as
1874
1949
  * pantheon_delegate — so its parts feed the live-map as the native signal
1875
- * (rows render `nat:` via the children channel). Pure w.r.t. I/O — used
1950
+ * (rows come from the children channel). Pure w.r.t. I/O — used
1876
1951
  * by the mount re-scan to re-seed the live map after compaction/attach. */
1877
1952
  export function collectDelegationToolParts(
1878
1953
  messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
@@ -1970,6 +2045,95 @@ export function mergeDelegationSources(
1970
2045
  return all
1971
2046
  }
1972
2047
 
2048
+ /* ─── Board channel: record → display + authoritative merge ───────────── */
2049
+
2050
+ /** Job-board FSM → panel display state. `reconciled` is intentionally absent:
2051
+ * the panel parser rejects it (a reconciled job is done and folded away). */
2052
+ const BOARD_STATE_TO_DISPLAY: Record<string, DelegationEntry['state']> = {
2053
+ running: 'running',
2054
+ completed: 'completed',
2055
+ error: 'error',
2056
+ startup_failed: 'startup_failed',
2057
+ startup_unknown: 'startup_unknown',
2058
+ cancelled: 'cancelled',
2059
+ }
2060
+
2061
+ /** Map one persisted board record into the display shape. Returns null for
2062
+ * states the panel does not render (reconciled / unknown). Pure. */
2063
+ export function boardRecordToDelegationEntry(record: BoardJobRecord): DelegationEntry | null {
2064
+ const state = BOARD_STATE_TO_DISPLAY[record.state]
2065
+ if (state === undefined) return null
2066
+ const running = state === 'running'
2067
+ return {
2068
+ alias: record.alias !== '' ? record.alias : `job-${record.taskID.slice(0, 8)}`,
2069
+ sessionID: record.parentSessionID,
2070
+ taskID: record.taskID,
2071
+ agent: record.agent,
2072
+ state,
2073
+ startedAt: record.launchedAt ?? record.updatedAt ?? 0,
2074
+ updatedAt: running ? null : (record.completedAt ?? record.updatedAt ?? null),
2075
+ timedOut: record.timedOut === true,
2076
+ description: record.description ?? '',
2077
+ source: 'board',
2078
+ }
2079
+ }
2080
+
2081
+ /** Map a whole board snapshot, dropping unrenderable states. Sorted like every
2082
+ * other channel (running first, then most recent). Pure. */
2083
+ export function boardRecordsToDelegationEntries(
2084
+ records: readonly BoardJobRecord[],
2085
+ ): DelegationEntry[] {
2086
+ const out: DelegationEntry[] = []
2087
+ for (const record of records) {
2088
+ const entry = boardRecordToDelegationEntry(record)
2089
+ if (entry !== null) out.push(entry)
2090
+ }
2091
+ out.sort(compareDelegationEntries)
2092
+ return out
2093
+ }
2094
+
2095
+ /** Merge the board over any other channel. The board is AUTHORITATIVE for
2096
+ * state/alias/agent (it is the persisted FSM) and it is the only channel
2097
+ * that carries jobs from OTHER sessions. Dedup by taskID first, then by
2098
+ * (sessionID, alias) — aliases are per parent session. Pure. */
2099
+ export function mergeBoardDelegationSources(
2100
+ base: readonly DelegationEntry[],
2101
+ board: readonly DelegationEntry[],
2102
+ ): DelegationEntry[] {
2103
+ const key = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
2104
+ const byTaskID = new Map<string, number>()
2105
+ const bySessionAlias = new Map<string, number>()
2106
+ const out = [...base]
2107
+ out.forEach((entry, index) => {
2108
+ if (entry.taskID !== undefined) byTaskID.set(entry.taskID, index)
2109
+ bySessionAlias.set(key(entry.sessionID, entry.alias), index)
2110
+ })
2111
+
2112
+ for (const incoming of board) {
2113
+ const index =
2114
+ (incoming.taskID !== undefined ? byTaskID.get(incoming.taskID) : undefined) ??
2115
+ bySessionAlias.get(key(incoming.sessionID, incoming.alias))
2116
+ if (index === undefined) {
2117
+ out.push(incoming)
2118
+ const next = out.length - 1
2119
+ if (incoming.taskID !== undefined) byTaskID.set(incoming.taskID, next)
2120
+ bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
2121
+ continue
2122
+ }
2123
+ out[index] = {
2124
+ ...incoming,
2125
+ // Session preservation: the board is authoritative for state/alias/agent,
2126
+ // but an EMPTY board sessionID (record without parentSessionID) must
2127
+ // never clobber a known session — otherwise a focused-session row would
2128
+ // lose its attribution. Keep the known session.
2129
+ sessionID: incoming.sessionID !== '' ? incoming.sessionID : (out[index]?.sessionID ?? ''),
2130
+ } // board wins — it is the persisted source of truth
2131
+ }
2132
+
2133
+ out.sort(compareDelegationEntries)
2134
+ return out
2135
+ }
2136
+
1973
2137
  /* ─── Children channel (primary source, delegations-sidebar pattern) ────
1974
2138
  * `api.client.session.children` returns the child sessions of the current
1975
2139
  * session — every pantheon_delegate spawns one (parentID = caller). The
@@ -2089,37 +2253,72 @@ export function childStatusToState(status: string | undefined): 'running' | 'com
2089
2253
  return 'running' // busy or unknown
2090
2254
  }
2091
2255
 
2092
- /** Short row prefix for a delegation entry (one of the two visual channels
2093
- * that split native task() work from pantheon_delegate jobs — the other is
2094
- * {@link delegationIcon}):
2256
+ /** Status-only row model: ONE glyph + ONE identity per row.
2095
2257
  *
2096
- * - `nat:<agent>` — a native task() child (source 'children-only', no board
2097
- * report). The child session carries no board alias, so the agent IS the
2098
- * identity.
2099
- * - `pan:<alias>` — a pantheon_delegate board row (`pan:apo-1`), the same
2100
- * alias the manager prints in pantheon_delegation_list.
2101
- *
2102
- * Short prefixes keep the narrow sidebar readable (the old
2103
- * `pan:apo-1`/`nat:` tags ate the row width). */
2104
- export function delegationTag(entry: DelegationEntry): string {
2105
- return entry.source === 'children-only' ? `nat:${entry.agent}` : `pan:${entry.alias}`
2258
+ * A row is
2259
+ * `{glyph} {alias:7} {elapsed:>5}` plus a muted description line. The short
2260
+ * alias (`apo-1`) is the single identity; rows without a board alias show
2261
+ * the agent instead (never both). */
2262
+ export type DelegationRowStatus = 'active' | 'done' | 'failed' | 'retry'
2263
+
2264
+ /** Map every FSM/display state to one of the 4 row kinds. reconciled never
2265
+ * reaches the panel (the board parser drops it) but reads as done;
2266
+ * cancelled reads as done; startup_failed reads as failed while
2267
+ * startup_unknown (no error known) reads as retry. Pure. */
2268
+ export function delegationRowStatus(state: DelegationDisplayState): DelegationRowStatus {
2269
+ switch (state) {
2270
+ case 'running':
2271
+ case 'stale-running':
2272
+ return 'active'
2273
+ case 'retry':
2274
+ case 'startup_unknown':
2275
+ return 'retry'
2276
+ case 'completed':
2277
+ case 'cancelled':
2278
+ return 'done'
2279
+ case 'error':
2280
+ case 'startup_failed':
2281
+ return 'failed'
2282
+ }
2283
+ }
2284
+
2285
+ /** The only 4 glyphs a row may show: animated active, done, failed, retry.
2286
+ * Shape is redundant with color (never the only signal). Pure. */
2287
+ export const DELEGATION_ROW_GLYPHS: Readonly<Record<DelegationRowStatus, string>> = {
2288
+ active: '⠋', // base frame of the animated spinner
2289
+ done: '✓',
2290
+ failed: '✕',
2291
+ retry: '⟳',
2106
2292
  }
2107
2293
 
2108
- /** Row glyph: hollow diamond `◇` for native task() children (info color,
2109
- * outline) vs filled diamond `◆` for pantheon_delegate rows (state color).
2110
- * Shape + color are independent channels, so the split stays legible even
2111
- * without color. */
2112
- export function delegationIcon(entry: DelegationEntry): string {
2113
- return entry.source === 'children-only' ? '\u25c7' : '\u25c6'
2294
+ export function delegationRowGlyph(status: DelegationRowStatus): string {
2295
+ return DELEGATION_ROW_GLYPHS[status]
2296
+ }
2297
+
2298
+ /** Row marker (`<glyph> `) — the single state channel. `active` animates
2299
+ * through the 1s spinner for the given tick; every other kind is static.
2300
+ * Pure. */
2301
+ export function delegationRowMarker(state: DelegationDisplayState, now = Date.now()): string {
2302
+ const status = delegationRowStatus(state)
2303
+ const glyph = status === 'active' ? delegationSpinnerFrame(now) : delegationRowGlyph(status)
2304
+ return `${glyph} `
2114
2305
  }
2115
2306
 
2116
- /** Row identity after the status marker + glyph. Pantheon rows append the
2117
- * agent (`pan:apo-1 apollo`); native rows already carry it inside the tag
2118
- * (`nat:apollo`) and must not duplicate it. */
2119
- export function formatDelegationIdentity(entry: DelegationEntry): string {
2120
- return entry.source === 'children-only'
2121
- ? delegationTag(entry)
2122
- : `${delegationTag(entry)} ${entry.agent}`
2307
+ /** ONE identity per row: the short board alias (`apo-1`); a row without a
2308
+ * board alias (native task() child, alias `native-task`) shows the agent
2309
+ * instead — never alias + agent stacked. Pure. */
2310
+ export function delegationRowIdentity(entry: DelegationEntry): string {
2311
+ if (entry.alias === 'native-task' && entry.agent !== '') return entry.agent
2312
+ return entry.alias
2313
+ }
2314
+
2315
+ /** Fixed alias cell, pad-right + hard-truncate to `width` (default 7) so
2316
+ * identities line up across rows. Pure. */
2317
+ export const DELEGATION_ALIAS_WIDTH = 7
2318
+
2319
+ export function formatDelegationAlias(identity: string, width = DELEGATION_ALIAS_WIDTH): string {
2320
+ if (identity.length >= width) return identity.slice(0, width)
2321
+ return `${identity}${' '.repeat(width - identity.length)}`
2123
2322
  }
2124
2323
 
2125
2324
  /** Max description width on the row detail line — the old 180-char slice
@@ -2153,10 +2352,10 @@ export function truncateDelegationDescription(
2153
2352
  return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
2154
2353
  }
2155
2354
 
2156
- /** Fixed elapsed-time box: right-aligned to `width` (default 8) so elapsed
2157
- * values line up across rows, e.g. ` 12s`. A label that already fills
2355
+ /** Fixed elapsed-time box: right-aligned to `width` (default 5) so elapsed
2356
+ * values line up across rows, e.g. ` 12s`. A label that already fills
2158
2357
  * the box is returned untouched. Pure. */
2159
- export const DELEGATION_ELAPSED_WIDTH = 8
2358
+ export const DELEGATION_ELAPSED_WIDTH = 5
2160
2359
 
2161
2360
  export function formatDelegationElapsed(
2162
2361
  entry: DelegationEntry,
@@ -2168,10 +2367,11 @@ export function formatDelegationElapsed(
2168
2367
  return `${' '.repeat(width - label.length)}${label}`
2169
2368
  }
2170
2369
 
2171
- /** First row line: `<marker><glyph> <identity>`. The marker already carries
2172
- * its trailing space. Pure — the row renders the returned string verbatim. */
2173
- export function formatDelegationRow(entry: DelegationEntry, marker: string): string {
2174
- return `${marker}${delegationIcon(entry)} ${formatDelegationIdentity(entry)}`
2370
+ /** Colored left half of a row line: `<marker><alias:7> ` — the state glyph
2371
+ * plus the single identity, padded so the muted elapsed column aligns. The
2372
+ * row renders this colored lead separately from the muted elapsed tail. Pure. */
2373
+ export function formatDelegationRowLead(entry: DelegationEntry, marker: string): string {
2374
+ return `${marker}${formatDelegationAlias(delegationRowIdentity(entry))} `
2175
2375
  }
2176
2376
 
2177
2377
  // Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
@@ -2179,22 +2379,52 @@ export function formatDelegationRow(entry: DelegationEntry, marker: string): str
2179
2379
  // ever actually nested and the connectors were never populated in production.
2180
2380
  // BACKLOG: produce real grandchild rows first, then reintroduce them.
2181
2381
 
2182
- /** Header summary: `(N active · M done · nat:K pan:M)`. Active counts
2183
- * running/retry AND display-only stale-running (a stale row is still a live
2184
- * job — it must never read as done). Pure. */
2382
+ /** Visible-row ceiling for the panel: the remainder collapses into a single
2383
+ * "… +N more" line. Live rows render first, so a running job is never hidden
2384
+ * by the cap. */
2385
+ export const DELEGATION_VISIBLE_CEILING = 8
2386
+
2387
+ /** Header summary: `(N active · M done)` plus `· K failed` only when K > 0.
2388
+ * Active counts running/retry AND display-only stale-running (a stale row is
2389
+ * still a live job — it must never read as done); failed counts
2390
+ * error/startup_failed; cancelled reads as done. Pure. */
2185
2391
  export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
2186
2392
  let active = 0
2393
+ let done = 0
2394
+ let failed = 0
2187
2395
  for (const e of entries) {
2188
- if (e.state === 'running' || e.state === 'retry' || e.state === 'stale-running') active++
2396
+ switch (delegationRowStatus(e.state)) {
2397
+ case 'active':
2398
+ case 'retry':
2399
+ active++
2400
+ break
2401
+ case 'failed':
2402
+ failed++
2403
+ break
2404
+ case 'done':
2405
+ done++
2406
+ break
2407
+ }
2189
2408
  }
2190
- const done = entries.length - active
2191
- const counts = countDelegationSources(entries)
2192
- return `(${active} active \u00b7 ${done} done \u00b7 nat:${counts.native} pan:${counts.pantheon})`
2409
+ const tail = failed > 0 ? ` · ${failed} failed` : ''
2410
+ return `(${active} active · ${done} done${tail})`
2411
+ }
2412
+
2413
+ /** Cap the panel: live rows first, then most-recent terminal rows, at most
2414
+ * `maxVisible` total. `hidden` is how many were dropped (rendered as
2415
+ * "… +N more"). Pure. */
2416
+ export function ceilingDelegationList(
2417
+ all: readonly DelegationEntry[],
2418
+ maxVisible = DELEGATION_VISIBLE_CEILING,
2419
+ now = Date.now(),
2420
+ ): { visible: DelegationEntry[]; hidden: number } {
2421
+ const visible = visibleDelegationList(all, maxVisible, now).slice(0, maxVisible)
2422
+ return { visible, hidden: Math.max(0, all.length - visible.length) }
2193
2423
  }
2194
2424
 
2195
2425
  /** Split a display list into native task() rows vs pantheon_delegate rows.
2196
2426
  * Native = source 'children-only' (no board report); everything else counts
2197
- * as pantheon. Pure — powers the header breakdown + the hooks.log line. */
2427
+ * as pantheon. Pure — powers the hooks.log line. */
2198
2428
  export function countDelegationSources(entries: readonly DelegationEntry[]): {
2199
2429
  native: number
2200
2430
  pantheon: number
@@ -2227,18 +2457,22 @@ export function formatPanelLogLine(
2227
2457
  * time.created. A report-less child is a NATIVE task() child (every
2228
2458
  * child of the current session — pantheon_delegate OR the native `task()`
2229
2459
  * tool — carries parentID = caller), so it gets source 'children-only',
2230
- * the internal alias 'native-task' and the `nat:` tag instead of a
2460
+ * the internal alias 'native-task' instead of a
2231
2461
  * board alias. The 'task nativa' description fallback keeps the row
2232
2462
  * non-empty when the child carries no title.
2233
2463
  * Terminal md state wins over the derived state; a running md defers to
2234
2464
  * the child's live status. A running child is NEVER archived — it always
2235
2465
  * lands in the active split (splitDelegationList active = running/retry).
2466
+ * `parentSessionID` (the focused session id) is stamped on report-less
2467
+ * children so the row carries its origin session; omit it and they stay
2468
+ * unscoped ('').
2236
2469
  * Sorted running-first (compareDelegationEntries).
2237
2470
  * Pure — no I/O. */
2238
2471
  export function childrenToDelegationEntries(
2239
2472
  children: readonly ChildDelegationLike[] | undefined,
2240
2473
  md: readonly DelegationEntry[],
2241
2474
  now = Date.now(),
2475
+ parentSessionID = '',
2242
2476
  ): DelegationEntry[] {
2243
2477
  const byTaskID = new Map<string, DelegationEntry>()
2244
2478
  for (const m of md) {
@@ -2256,7 +2490,11 @@ export function childrenToDelegationEntries(
2256
2490
  : childStatusToState(child.status)
2257
2491
  out.push({
2258
2492
  alias: mdEntry?.alias ?? 'native-task',
2259
- sessionID: mdEntry?.sessionID ?? '',
2493
+ // A report-less child IS a child of the focused session
2494
+ // (session.children is session-scoped), so the caller-passed
2495
+ // parentSessionID is stamped on it as its origin session; an md
2496
+ // match always carries its own session (the report dir name).
2497
+ sessionID: mdEntry?.sessionID ?? parentSessionID,
2260
2498
  taskID: child.id,
2261
2499
  agent: mdEntry?.agent ?? child.agent ?? 'agent',
2262
2500
  state,
@@ -2293,8 +2531,8 @@ export function navigateToDelegationSession(
2293
2531
  }
2294
2532
 
2295
2533
  /* ─── Silence-by-default panel logger ──────────────────────
2296
- * Mirrors src/pantheon/logger.ts createPantheonLogger (the TUI plugin is
2297
- * self-contained — dist/tui.tsx is a raw copy with no relative imports):
2534
+ * Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
2535
+ * ships only dist/ + package.json, so it cannot import from src/pantheon):
2298
2536
  * every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
2299
2537
  * echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
2300
2538
  * renders plugin console output directly into the TUI — the pollution bug).
@@ -2395,7 +2633,7 @@ function SessionRow(props: { api: TuiPluginApi; session: any }) {
2395
2633
  )
2396
2634
  }
2397
2635
 
2398
- /* ─── Delegation Row (single job from the children/md channels) ─ */
2636
+ /* ─── Delegation Row (single job from the children/md/board channels) ─ */
2399
2637
 
2400
2638
  function DelegationRow(props: {
2401
2639
  api: TuiPluginApi
@@ -2405,7 +2643,10 @@ function DelegationRow(props: {
2405
2643
  }) {
2406
2644
  const theme = () => props.api.theme.current
2407
2645
 
2408
- const color = createMemo(() => {
2646
+ // Color is a SEPARATE channel from the glyph: the lead (glyph + identity)
2647
+ // carries the state tone, while elapsed + description stay muted so the
2648
+ // state reads at a glance without shouting.
2649
+ const tone = createMemo(() => {
2409
2650
  const t = theme()
2410
2651
  switch (delegationStateTone(props.job.state)) {
2411
2652
  case 'warning':
@@ -2419,16 +2660,10 @@ function DelegationRow(props: {
2419
2660
  }
2420
2661
  })
2421
2662
 
2422
- const marker = createMemo(() => delegationStateMarker(props.job.state, props.animationNow))
2423
-
2424
- // Two visual channels split native task() from pantheon_delegate: the tag
2425
- // prefix (`nat:`/`pan:`, via delegationTag) and the glyph color — info
2426
- // (cyan) for native, state color for board rows. The glyph shape (◇/◆) is
2427
- // the third, color-independent channel.
2428
- const tagColor = createMemo(() => (props.job.source === 'children-only' ? theme().info : color()))
2429
-
2430
- const description = createMemo(() => truncateDelegationDescription(props.job.description))
2663
+ const marker = createMemo(() => delegationRowMarker(props.job.state, props.animationNow))
2664
+ const lead = createMemo(() => formatDelegationRowLead(props.job, marker()))
2431
2665
  const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
2666
+ const description = createMemo(() => truncateDelegationDescription(props.job.description))
2432
2667
 
2433
2668
  // beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
2434
2669
  // fed by message.part.updated activity tracking. Null while idle/done.
@@ -2445,22 +2680,22 @@ function DelegationRow(props: {
2445
2680
 
2446
2681
  return (
2447
2682
  <box onMouseDown={open}>
2683
+ {/* Line 1: colored lead (glyph + identity) + muted elapsed tail. */}
2448
2684
  <box flexDirection="row">
2449
- <text fg={tagColor()}>{formatDelegationRow(props.job, marker())}</text>
2450
- </box>
2451
- {/* Elapsed is ALWAYS rendered (outside the optional-description Show) so
2452
- * an active job with no description never loses its timer. */}
2453
- <box flexDirection="row" justifyContent={description() !== '' ? 'space-between' : 'flex-end'}>
2454
- <Show when={description() !== ''}>
2455
- <text fg={theme().textMuted}>{description()}</text>
2456
- </Show>
2457
- <text fg={theme().textMuted}>{elapsed()}</text>
2685
+ <text>
2686
+ <span fg={tone()}>{lead()}</span>
2687
+ <span fg={theme().textMuted}>{elapsed()}</span>
2688
+ </text>
2458
2689
  </box>
2690
+ {/* Line 2: the description, indented under the identity and muted. */}
2691
+ <Show when={description() !== ''}>
2692
+ <text fg={theme().textMuted}>{` ${description()}`}</text>
2693
+ </Show>
2459
2694
  <Show when={activity()}>
2460
2695
  {(a) => (
2461
2696
  <text
2462
2697
  fg={theme().textMuted}
2463
- >{` \u21b3 ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2698
+ >{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2464
2699
  )}
2465
2700
  </Show>
2466
2701
  </box>
@@ -2569,14 +2804,15 @@ function View(props: {
2569
2804
  // (.pantheon/delegations) enriches each child with alias/agent/description
2570
2805
  // and terminal duration via the `Task ID` match. Live tool-call events are
2571
2806
  // merged optimistically so the row appears before those channels catch up.
2572
- const delegationsDir = createMemo(() => resolveDelegationsDir((props.api.state as any).path))
2807
+ // Consistent project root (directory ?? worktree, cwd fallback) — the md
2808
+ // history, the board state file and the panel logger all read the SAME
2809
+ // root (audit finding: the channels resolved it independently).
2810
+ const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
2573
2811
  // Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
2574
- // panelLogDir(<root>/.pantheon/delegations) = <root>: the old code passed
2575
- // dirname(delegationsDir()) (= <root>/.pantheon), which createTuiLogger
2576
- // re-joined with `.pantheon/logs/hooks.log` → a nested
2577
- // <root>/.pantheon/.pantheon/logs/hooks.log that never reached the real
2578
- // hooks.log (the empty nested dirs observed in production).
2579
- const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
2812
+ const panelLog = createTuiLogger(projectRoot())
2813
+ // Every job the board knows about is shown — no scope filter ("showing is
2814
+ // what matters"). Live-first ordering keeps running rows on top; the
2815
+ // ceiling collapses the rest into "… +N more".
2580
2816
  // The rendered list is childDelegations (children enriched with md);
2581
2817
  // there is no separate md-only signal — the md channel only feeds
2582
2818
  // childrenToDelegationEntries below.
@@ -2609,10 +2845,19 @@ function View(props: {
2609
2845
  // the full historical list. Fail-open: a missing dir yields [].
2610
2846
  let md: DelegationEntry[] = []
2611
2847
  try {
2612
- md = await readAllDelegationEntries(panelLogDir(delegationsDir()))
2848
+ md = await readAllDelegationEntries(projectRoot())
2613
2849
  } catch {
2614
2850
  md = [] // fail-open: never crash the sidebar on a read error
2615
2851
  }
2852
+ // 0b. board snapshot — the 4th channel. Cross-session and AUTHORITATIVE
2853
+ // for state/alias/agent (the persisted FSM). This is what makes a job
2854
+ // running in ANOTHER session visible. Fail-open: absent/corrupt → [].
2855
+ let board: DelegationEntry[] = []
2856
+ try {
2857
+ board = boardRecordsToDelegationEntries(await readBoardState(projectRoot()))
2858
+ } catch {
2859
+ board = [] // fail-open: never crash the sidebar on a board read error
2860
+ }
2616
2861
  // 1. current session — resolve + GUARD (placeholder regression). The
2617
2862
  // runtime may render the sidebar with an unsubstituted "{sessionID}"
2618
2863
  // slot prop; forwarding it to session.children made the server reject
@@ -2622,9 +2867,9 @@ function View(props: {
2622
2867
  // (zero children, zero errors).
2623
2868
  const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
2624
2869
  if (sessionID === null) {
2625
- setChildDelegations(md)
2870
+ setChildDelegations(mergeBoardDelegationSources(md, board))
2626
2871
  panelLog.info(
2627
- `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
2872
+ `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history+board shown)`,
2628
2873
  )
2629
2874
  return
2630
2875
  }
@@ -2655,7 +2900,10 @@ function View(props: {
2655
2900
  }
2656
2901
  }
2657
2902
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2658
- const childEntries = childrenToDelegationEntries(withStatus, md, now())
2903
+ // Report-less rows ARE children of the focused session
2904
+ // (session.children is session-scoped), so the resolved id is stamped on
2905
+ // the row as its origin session.
2906
+ const childEntries = childrenToDelegationEntries(withStatus, md, now(), sessionID)
2659
2907
  for (const [k, v] of props.liveStore.map)
2660
2908
  if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
2661
2909
  props.liveStore.map.delete(k)
@@ -2665,7 +2913,14 @@ function View(props: {
2665
2913
  const liveEntries = [...props.liveStore.map.values()].filter(
2666
2914
  (entry) => entry.sessionID === sessionID,
2667
2915
  )
2668
- setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
2916
+ // Board LAST so it is authoritative over children/md/live for the same
2917
+ // job, and adds the rows from other sessions the children API cannot see.
2918
+ setChildDelegations(
2919
+ mergeBoardDelegationSources(
2920
+ mergeChildDelegationSources(childEntries, liveEntries),
2921
+ board,
2922
+ ),
2923
+ )
2669
2924
  // 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
2670
2925
  const counts = countDelegationSources(childEntries)
2671
2926
  panelLog.info(
@@ -2683,24 +2938,13 @@ function View(props: {
2683
2938
  })()
2684
2939
  return delegationsInflight
2685
2940
  }
2686
- // beta.6: active (running/retry) first, then the 8 most recent terminal
2687
- // reports, then the archived tail (paginated). Pagination state lives at
2688
- // module level so sidebar remounts keep the user's page.
2689
- const delegationSplit = createMemo(() => splitDelegationList(childDelegations()))
2690
- const visibleDelegations = createMemo(() => [
2691
- ...delegationSplit().active,
2692
- ...delegationSplit().recent,
2693
- ])
2694
- // Header summary over the FULL list (active + recent + archived): active
2695
- // vs done plus the native/pantheon breakdown, so a busy session reads
2696
- // "Delegations (2 active · 9 done · nat:3 pan:8)".
2941
+ // Live-first ceiling: at most DELEGATION_VISIBLE_CEILING rows render; the
2942
+ // remainder collapses into a single "… +N more" line. The header counts the
2943
+ // FULL board list — every session, no scope filter.
2944
+ const delegationCeiling = createMemo(() =>
2945
+ ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
2946
+ )
2697
2947
  const delegationHeader = createMemo(() => formatDelegationHeader(childDelegations()))
2698
- const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2699
- const clampArchivedPage = (page: number, pages: number) => {
2700
- const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
2701
- archivedUiState.page = clamped
2702
- setArchivedPage(clamped)
2703
- }
2704
2948
 
2705
2949
  // ── Event subscriptions for live session/delegation updates ──
2706
2950
  onMount(() => {
@@ -2840,16 +3084,18 @@ function View(props: {
2840
3084
  </Show>
2841
3085
  </Show>
2842
3086
 
2843
- {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
3087
+ {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment + board snapshot) ── */}
2844
3088
  <box onMouseDown={() => setShowDelegations((x) => !x)}>
2845
3089
  <text fg={theme().text} attributes={1}>
2846
3090
  {`${showDelegations() ? '▼' : '▶'} Delegations`}
2847
3091
  </text>
2848
- <text fg={theme().textMuted}>{` ${delegationHeader()}`}</text>
3092
+ <text fg={theme().textMuted}>
3093
+ {childDelegations().length > 0 ? ` ${delegationHeader()}` : ' — idle'}
3094
+ </text>
2849
3095
  </box>
2850
3096
  <Show when={showDelegations()}>
2851
3097
  <Show
2852
- when={visibleDelegations().length > 0}
3098
+ when={delegationCeiling().visible.length > 0}
2853
3099
  fallback={
2854
3100
  <box marginLeft={1}>
2855
3101
  <text fg={theme().textMuted}>No delegations</text>
@@ -2857,7 +3103,7 @@ function View(props: {
2857
3103
  }
2858
3104
  >
2859
3105
  <box marginLeft={1} flexDirection="column">
2860
- <For each={visibleDelegations()}>
3106
+ <For each={delegationCeiling().visible}>
2861
3107
  {(job) => (
2862
3108
  <DelegationRow
2863
3109
  api={props.api}
@@ -2867,40 +3113,8 @@ function View(props: {
2867
3113
  />
2868
3114
  )}
2869
3115
  </For>
2870
- <Show when={delegationSplit().archived.length > 0}>
2871
- {(() => {
2872
- const archived = delegationSplit().archived
2873
- const pageSize = 8
2874
- const pages = Math.ceil(archived.length / pageSize)
2875
- const page = Math.min(archivedPage(), pages - 1)
2876
- const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
2877
- return (
2878
- <box flexDirection="column">
2879
- <box flexDirection="row" marginLeft={0}>
2880
- <text
2881
- fg={theme().textMuted}
2882
- onMouseDown={() => clampArchivedPage(page - 1, pages)}
2883
- >{`\u2039 `}</text>
2884
- <text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
2885
- <text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
2886
- <text
2887
- fg={theme().textMuted}
2888
- onMouseDown={() => clampArchivedPage(page + 1, pages)}
2889
- >{` \u203a`}</text>
2890
- </box>
2891
- <For each={slice}>
2892
- {(job) => (
2893
- <DelegationRow
2894
- api={props.api}
2895
- job={job}
2896
- now={now()}
2897
- animationNow={animationNow()}
2898
- />
2899
- )}
2900
- </For>
2901
- </box>
2902
- )
2903
- })()}
3116
+ <Show when={delegationCeiling().hidden > 0}>
3117
+ <text fg={theme().textMuted}>{`… +${delegationCeiling().hidden} more`}</text>
2904
3118
  </Show>
2905
3119
  </box>
2906
3120
  </Show>