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

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.
@@ -900,9 +900,9 @@ async function setupUsageBar(api: TuiPluginApi) {
900
900
  * and the native `task()` tool ALSO spawns a child session with
901
901
  * parentID = caller. A child WITHOUT a board report is a native
902
902
  * task() child: childrenToDelegationEntries tags it source
903
- * 'children-only' with the `nat:` row tag (distinct info color),
903
+ * 'children-only' (distinct info color),
904
904
  * so native task() work is visible in the panel alongside board
905
- * delegates (`pan:apo-1`) — never filtered out, never duplicated
905
+ * delegates — never filtered out, never duplicated
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
  *
@@ -984,7 +984,7 @@ export type DelegationEntry = {
984
984
  // Reintroduce them once nested rows are actually produced.
985
985
  /** Internal provenance used to keep a finalized md report authoritative.
986
986
  * 'children-only' = a native task() child session with NO board report
987
- * (rendered with the distinct `nat:` prefix); 'md' = board report wins;
987
+ * (a native child row); 'md' = board report wins;
988
988
  * 'board' = read from .pantheon/board/state.json (cross-session FSM). */
989
989
  source?: 'child' | 'live' | 'md' | 'children-only' | 'board'
990
990
  }
@@ -1172,7 +1172,7 @@ export async function readAllDelegationEntries(root: string): Promise<Delegation
1172
1172
 
1173
1173
  /* ─── Job board channel (4th source: .pantheon/board/state.json) ────────
1174
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).
1175
+ * ANOTHER session is invisible to it (the "Delegations (0)" symptom).
1176
1176
  * The board state file is the only durable, cross-session source:
1177
1177
  * FilePersistence (src/pantheon/file-persistence.ts) stores every
1178
1178
  * BackgroundJobRecord there as a JSON array. Reads are fail-open — absent,
@@ -1302,35 +1302,49 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
1302
1302
  }
1303
1303
 
1304
1304
  /** Split the panel list: active jobs (running/retry, stale-marked) first,
1305
- * then the most recent terminal reports, then the archived tail (paginated
1306
- * 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
1307
1307
  * without the TUI runtime. */
1308
1308
  export function splitDelegationList(
1309
1309
  all: readonly DelegationEntry[],
1310
1310
  maxRecent = 8,
1311
1311
  now = Date.now(),
1312
1312
  staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1313
- ): { active: DelegationEntry[]; recent: DelegationEntry[]; archived: DelegationEntry[] } {
1313
+ ): { active: DelegationEntry[]; recent: DelegationEntry[] } {
1314
1314
  // Active = running OR retry OR the display-only stale-running. A stale row
1315
1315
  // is still a live job (backend state unchanged), so it must never fall into
1316
- // the terminal/archived tail — archiving a running delegation hid the row
1316
+ // the terminal tail — pushing a running delegation out hid the row
1317
1317
  // the user launched.
1318
1318
  const isActiveState = (st: DelegationEntry['state']): boolean =>
1319
1319
  st === 'running' || st === 'retry' || st === 'stale-running'
1320
1320
  const active = all
1321
1321
  .filter((d) => isActiveState(d.state))
1322
1322
  .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1323
- const terminal = all.filter((d) => !isActiveState(d.state))
1323
+ .sort(compareDelegationEntries)
1324
+ const terminal = all
1325
+ .filter((d) => !isActiveState(d.state))
1326
+ .filter((d) => {
1327
+ // A missing/invalid finalized timestamp is not evidence that a report is
1328
+ // old. Keep it visible rather than accidentally hiding a terminal job.
1329
+ if (d.updatedAt === null || !Number.isFinite(d.updatedAt)) return true
1330
+ const age = now - d.updatedAt
1331
+ if (age < 0) return true
1332
+ const retention =
1333
+ delegationRowStatus(d.state) === 'failed'
1334
+ ? DELEGATION_FAILED_RETENTION_MS
1335
+ : DELEGATION_DONE_RETENTION_MS
1336
+ return age < retention
1337
+ })
1338
+ .sort(compareDelegationEntries)
1324
1339
  return {
1325
1340
  active,
1326
1341
  recent: terminal.slice(0, maxRecent),
1327
- archived: terminal.slice(maxRecent),
1328
1342
  }
1329
1343
  }
1330
1344
 
1331
- /** The list the panel actually renders (kept for the header count and
1332
- * existing tests): active jobs first, then the most recent terminal
1333
- * reports (capped) — the archived tail is rendered separately. Pure. */
1345
+ /** Live-first window used by {@link ceilingDelegationList} (kept for the
1346
+ * ceiling helper and existing tests): active jobs first, then the most
1347
+ * recent terminal reports (capped). Pure. */
1334
1348
  export function visibleDelegationList(
1335
1349
  all: readonly DelegationEntry[],
1336
1350
  maxTerminal = 8,
@@ -1349,6 +1363,11 @@ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
1349
1363
  * display-only `stale-running` state. */
1350
1364
  export const IDLE_SILENCE_MS = 60 * 1000
1351
1365
 
1366
+ /** Visual-only terminal retention windows. Reports remain on disk and in the
1367
+ * board; these constants only control which rows enter the TUI window. */
1368
+ export const DELEGATION_DONE_RETENTION_MS = 2 * 60 * 1000
1369
+ export const DELEGATION_FAILED_RETENTION_MS = 10 * 60 * 1000
1370
+
1352
1371
  /**
1353
1372
  * Mark a running entry as `stale-running` if it has been running longer than
1354
1373
  * the threshold AND has no recent activity (no `updatedAt` change in the last
@@ -1370,7 +1389,7 @@ export function markStaleIfRunning(
1370
1389
  return { ...entry, state: 'stale-running' as const }
1371
1390
  }
1372
1391
 
1373
- /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
1392
+ /** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
1374
1393
  * second for running jobs. */
1375
1394
  export function fmtElapsed(ms: number): string {
1376
1395
  const total = Math.max(0, Math.floor(ms / 1000))
@@ -1378,9 +1397,9 @@ export function fmtElapsed(ms: number): string {
1378
1397
  const hours = Math.floor((total % 86_400) / 3_600)
1379
1398
  const minutes = Math.floor((total % 3_600) / 60)
1380
1399
  const seconds = total % 60
1381
- if (days > 0) return `${days}d ${hours}h`
1382
- if (hours > 0) return `${hours}h ${minutes}m`
1383
- if (minutes > 0) return `${minutes}m ${seconds}s`
1400
+ if (days > 0) return `${days}d`
1401
+ if (hours > 0) return `${hours}h`
1402
+ if (minutes > 0) return `${minutes}m`
1384
1403
  return `${seconds}s`
1385
1404
  }
1386
1405
 
@@ -1451,38 +1470,14 @@ export function delegationSpinnerFrame(now: number): string {
1451
1470
  * pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
1452
1471
  export type DelegationDisplayState = DelegationEntry['state']
1453
1472
 
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
1473
  /** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
1482
1474
  * Kept separate + pure so the color channel is testable without booting the
1483
1475
  * renderer. */
1484
1476
  export type DelegationStateTone = 'warning' | 'error' | 'success' | 'muted'
1485
1477
 
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. */
1486
1481
  export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
1487
1482
  switch (state) {
1488
1483
  case 'running':
@@ -1490,6 +1485,7 @@ export function delegationStateTone(state: DelegationDisplayState): DelegationSt
1490
1485
  case 'startup_unknown':
1491
1486
  return 'warning'
1492
1487
  case 'stale-running':
1488
+ return 'warning'
1493
1489
  case 'error':
1494
1490
  case 'startup_failed':
1495
1491
  return 'error'
@@ -1518,10 +1514,6 @@ const latestToolActivity = new Map<string, ToolActivity>()
1518
1514
  const MAX_TOOL_ACTIVITY_ENTRIES = 200
1519
1515
  const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
1520
1516
 
1521
- /** Archived-section pagination state — module-level so it survives sidebar
1522
- * remounts (route changes) without leaking into the component tree. */
1523
- const archivedUiState = { page: 0 }
1524
-
1525
1517
  /** First usable summary string from common tool input fields. */
1526
1518
  function summarizeToolInput(input: Record<string, unknown> | undefined): string {
1527
1519
  if (!input) return ''
@@ -1611,6 +1603,17 @@ export function mergeChildDelegationSources(
1611
1603
  )
1612
1604
  continue
1613
1605
  const incoming = toDelegationEntry(liveEntry)
1606
+ // A native task() live part never carries a board alias — it is a
1607
+ // children-only row by definition whether it lands on a new row or
1608
+ // upgrades an existing
1609
+ // children-only one. pantheon_delegate pending parts keep source 'live'.
1610
+ const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
1611
+ 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.
1614
+ incoming.source = 'children-only'
1615
+ incoming.alias = 'native-task'
1616
+ }
1614
1617
  const index =
1615
1618
  (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
1616
1619
  (incoming.alias !== ''
@@ -1658,7 +1661,10 @@ export function mergeChildDelegationSources(
1658
1661
  startedAt: Math.min(existing.startedAt, incoming.startedAt),
1659
1662
  updatedAt: incoming.updatedAt,
1660
1663
  read: incoming.read,
1661
- source: 'live',
1664
+ // A native task() live upgrade must not flip a children-only row to
1665
+ // 'live' (that would drop the children-only provenance); pantheon_delegate
1666
+ // upgrades still take source 'live'.
1667
+ source: isNativeLive ? existing.source : 'live',
1662
1668
  }
1663
1669
  }
1664
1670
 
@@ -1803,7 +1809,7 @@ export function parseDelegationToolPart(
1803
1809
  // The alias/taskID only exist once the delegate tool COMPLETES (they are
1804
1810
  // returned in its output); a running/pending part has neither. Native
1805
1811
  // `task` parts never carry a board alias — the children channel supplies
1806
- // the child session id, so the row renders `nat:`.
1812
+ // the child session id and the row's identity.
1807
1813
  let alias: string | null = null
1808
1814
  let taskID: string | null = null
1809
1815
  if (status === 'completed') {
@@ -1877,7 +1883,7 @@ export function reduceDelegationToolPart(
1877
1883
  // job launched (running). A COMPLETED delegate tool only means the job was
1878
1884
  // registered — it stays running and we pick up alias + taskID from the
1879
1885
  // output. Native `task` parts never yield a board alias, so their rows
1880
- // stay `nat:` until the children channel resolves them.
1886
+ // stay identity-less until the children channel resolves them.
1881
1887
  const existing = map.get(parsed.callID)
1882
1888
  if (parsed.status === 'error') {
1883
1889
  if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
@@ -1962,7 +1968,7 @@ export function removeDelegationEntry(
1962
1968
  * SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
1963
1969
  * child session with parentID = caller — the same mechanism as
1964
1970
  * pantheon_delegate — so its parts feed the live-map as the native signal
1965
- * (rows render `nat:` via the children channel). Pure w.r.t. I/O — used
1971
+ * (rows come from the children channel). Pure w.r.t. I/O — used
1966
1972
  * by the mount re-scan to re-seed the live map after compaction/attach. */
1967
1973
  export function collectDelegationToolParts(
1968
1974
  messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
@@ -2135,70 +2141,20 @@ export function mergeBoardDelegationSources(
2135
2141
  bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
2136
2142
  continue
2137
2143
  }
2138
- out[index] = incoming // board wins — it is the persisted source of truth
2144
+ out[index] = {
2145
+ ...incoming,
2146
+ // Session preservation: the board is authoritative for state/alias/agent,
2147
+ // but an EMPTY board sessionID (record without parentSessionID) must
2148
+ // never clobber a known session — otherwise a focused-session row would
2149
+ // lose its attribution. Keep the known session.
2150
+ sessionID: incoming.sessionID !== '' ? incoming.sessionID : (out[index]?.sessionID ?? ''),
2151
+ } // board wins — it is the persisted source of truth
2139
2152
  }
2140
2153
 
2141
2154
  out.sort(compareDelegationEntries)
2142
2155
  return out
2143
2156
  }
2144
2157
 
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
-
2202
2158
  /* ─── Children channel (primary source, delegations-sidebar pattern) ────
2203
2159
  * `api.client.session.children` returns the child sessions of the current
2204
2160
  * session — every pantheon_delegate spawns one (parentID = caller). The
@@ -2318,37 +2274,72 @@ export function childStatusToState(status: string | undefined): 'running' | 'com
2318
2274
  return 'running' // busy or unknown
2319
2275
  }
2320
2276
 
2321
- /** Short row prefix for a delegation entry (one of the two visual channels
2322
- * that split native task() work from pantheon_delegate jobs — the other is
2323
- * {@link delegationIcon}):
2324
- *
2325
- * - `nat:<agent>` — a native task() child (source 'children-only', no board
2326
- * report). The child session carries no board alias, so the agent IS the
2327
- * identity.
2328
- * - `pan:<alias>` — a pantheon_delegate board row (`pan:apo-1`), the same
2329
- * alias the manager prints in pantheon_delegation_list.
2277
+ /** Status-only row model: ONE glyph + ONE identity per row.
2330
2278
  *
2331
- * Short prefixes keep the narrow sidebar readable (the old
2332
- * `pan:apo-1`/`nat:` tags ate the row width). */
2333
- export function delegationTag(entry: DelegationEntry): string {
2334
- return entry.source === 'children-only' ? `nat:${entry.agent}` : `pan:${entry.alias}`
2279
+ * A row is
2280
+ * `{glyph} {alias:7} {elapsed:>5}` plus a muted description line. The short
2281
+ * alias (`apo-1`) is the single identity; rows without a board alias show
2282
+ * the agent instead (never both). */
2283
+ export type DelegationRowStatus = 'active' | 'done' | 'failed' | 'retry'
2284
+
2285
+ /** Map every FSM/display state to one of the 4 row kinds. reconciled never
2286
+ * reaches the panel (the board parser drops it) but reads as done;
2287
+ * cancelled reads as done; startup_failed reads as failed while
2288
+ * startup_unknown (no error known) reads as retry. Pure. */
2289
+ export function delegationRowStatus(state: DelegationDisplayState): DelegationRowStatus {
2290
+ switch (state) {
2291
+ case 'running':
2292
+ case 'stale-running':
2293
+ return 'active'
2294
+ case 'retry':
2295
+ case 'startup_unknown':
2296
+ return 'retry'
2297
+ case 'completed':
2298
+ case 'cancelled':
2299
+ return 'done'
2300
+ case 'error':
2301
+ case 'startup_failed':
2302
+ return 'failed'
2303
+ }
2304
+ }
2305
+
2306
+ /** The only 4 glyphs a row may show: animated active, done, failed, retry.
2307
+ * Shape is redundant with color (never the only signal). Pure. */
2308
+ export const DELEGATION_ROW_GLYPHS: Readonly<Record<DelegationRowStatus, string>> = {
2309
+ active: '⠋', // base frame of the animated spinner
2310
+ done: '✓',
2311
+ failed: '✕',
2312
+ retry: '⟳',
2313
+ }
2314
+
2315
+ export function delegationRowGlyph(status: DelegationRowStatus): string {
2316
+ return DELEGATION_ROW_GLYPHS[status]
2317
+ }
2318
+
2319
+ /** Row marker (`<glyph> `) — the single state channel. `active` animates
2320
+ * through the 1s spinner for the given tick; every other kind is static.
2321
+ * Pure. */
2322
+ export function delegationRowMarker(state: DelegationDisplayState, now = Date.now()): string {
2323
+ const status = delegationRowStatus(state)
2324
+ const glyph = status === 'active' ? delegationSpinnerFrame(now) : delegationRowGlyph(status)
2325
+ return `${glyph} `
2335
2326
  }
2336
2327
 
2337
- /** Row glyph: hollow diamond `◇` for native task() children (info color,
2338
- * outline) vs filled diamond `◆` for pantheon_delegate rows (state color).
2339
- * Shape + color are independent channels, so the split stays legible even
2340
- * without color. */
2341
- export function delegationIcon(entry: DelegationEntry): string {
2342
- return entry.source === 'children-only' ? '\u25c7' : '\u25c6'
2328
+ /** 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. */
2331
+ export function delegationRowIdentity(entry: DelegationEntry): string {
2332
+ if (entry.alias === 'native-task' && entry.agent !== '') return entry.agent
2333
+ return entry.alias
2343
2334
  }
2344
2335
 
2345
- /** Row identity after the status marker + glyph. Pantheon rows append the
2346
- * agent (`pan:apo-1 apollo`); native rows already carry it inside the tag
2347
- * (`nat:apollo`) and must not duplicate it. */
2348
- export function formatDelegationIdentity(entry: DelegationEntry): string {
2349
- return entry.source === 'children-only'
2350
- ? delegationTag(entry)
2351
- : `${delegationTag(entry)} ${entry.agent}`
2336
+ /** Fixed alias cell, pad-right + hard-truncate to `width` (default 7) so
2337
+ * identities line up across rows. Pure. */
2338
+ export const DELEGATION_ALIAS_WIDTH = 7
2339
+
2340
+ export function formatDelegationAlias(identity: string, width = DELEGATION_ALIAS_WIDTH): string {
2341
+ if (identity.length >= width) return identity.slice(0, width)
2342
+ return `${identity}${' '.repeat(width - identity.length)}`
2352
2343
  }
2353
2344
 
2354
2345
  /** Max description width on the row detail line — the old 180-char slice
@@ -2382,10 +2373,10 @@ export function truncateDelegationDescription(
2382
2373
  return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
2383
2374
  }
2384
2375
 
2385
- /** Fixed elapsed-time box: right-aligned to `width` (default 8) so elapsed
2386
- * values line up across rows, e.g. ` 12s`. A label that already fills
2376
+ /** Fixed elapsed-time box: right-aligned to `width` (default 5) so elapsed
2377
+ * values line up across rows, e.g. ` 12s`. A label that already fills
2387
2378
  * the box is returned untouched. Pure. */
2388
- export const DELEGATION_ELAPSED_WIDTH = 8
2379
+ export const DELEGATION_ELAPSED_WIDTH = 5
2389
2380
 
2390
2381
  export function formatDelegationElapsed(
2391
2382
  entry: DelegationEntry,
@@ -2397,10 +2388,11 @@ export function formatDelegationElapsed(
2397
2388
  return `${' '.repeat(width - label.length)}${label}`
2398
2389
  }
2399
2390
 
2400
- /** First row line: `<marker><glyph> <identity>`. The marker already carries
2401
- * its trailing space. Pure — the row renders the returned string verbatim. */
2402
- export function formatDelegationRow(entry: DelegationEntry, marker: string): string {
2403
- return `${marker}${delegationIcon(entry)} ${formatDelegationIdentity(entry)}`
2391
+ /** Colored left half of a row line: `<marker><alias:7> ` — the state glyph
2392
+ * plus the single identity, padded so the muted elapsed column aligns. The
2393
+ * row renders this colored lead separately from the muted elapsed tail. Pure. */
2394
+ export function formatDelegationRowLead(entry: DelegationEntry, marker: string): string {
2395
+ return `${marker}${formatDelegationAlias(delegationRowIdentity(entry))} `
2404
2396
  }
2405
2397
 
2406
2398
  // Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
@@ -2408,22 +2400,59 @@ export function formatDelegationRow(entry: DelegationEntry, marker: string): str
2408
2400
  // ever actually nested and the connectors were never populated in production.
2409
2401
  // BACKLOG: produce real grandchild rows first, then reintroduce them.
2410
2402
 
2411
- /** Header summary: `(N active · M done · nat:K pan:M)`. Active counts
2412
- * running/retry AND display-only stale-running (a stale row is still a live
2413
- * job — it must never read as done). Pure. */
2403
+ /** Visible-row ceiling for the panel: the remainder collapses into a single
2404
+ * "… +N more" line. Live rows render first, so a running job is never hidden
2405
+ * by the cap. */
2406
+ export const DELEGATION_VISIBLE_CEILING = 8
2407
+
2408
+ /** Header summary: `(N active · M done)` plus `· K failed` only when K > 0.
2409
+ * Active counts running/retry AND display-only stale-running (a stale row is
2410
+ * still a live job — it must never read as done); failed counts
2411
+ * error/startup_failed; cancelled reads as done. Pure. */
2414
2412
  export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
2415
2413
  let active = 0
2414
+ let done = 0
2415
+ let failed = 0
2416
2416
  for (const e of entries) {
2417
- if (e.state === 'running' || e.state === 'retry' || e.state === 'stale-running') active++
2417
+ switch (delegationRowStatus(e.state)) {
2418
+ case 'active':
2419
+ case 'retry':
2420
+ active++
2421
+ break
2422
+ case 'failed':
2423
+ failed++
2424
+ break
2425
+ case 'done':
2426
+ done++
2427
+ break
2428
+ }
2418
2429
  }
2419
- const done = entries.length - active
2420
- const counts = countDelegationSources(entries)
2421
- return `(${active} active \u00b7 ${done} done \u00b7 nat:${counts.native} pan:${counts.pantheon})`
2430
+ const tail = failed > 0 ? ` · ${failed} failed` : ''
2431
+ return `(${active} active · ${done} done${tail})`
2432
+ }
2433
+
2434
+ /** Cap the panel: live rows first, then most-recent retained terminal rows, at
2435
+ * most `maxVisible` total. Hidden counts describe the retained render list,
2436
+ * not expired history. */
2437
+ export function ceilingDelegationList(
2438
+ all: readonly DelegationEntry[],
2439
+ maxVisible = DELEGATION_VISIBLE_CEILING,
2440
+ now = Date.now(),
2441
+ ): { visible: DelegationEntry[]; hidden: number; hiddenActive: number; hiddenTerminal: number } {
2442
+ const { active, recent } = splitDelegationList(all, all.length, now)
2443
+ const visibleActive = active.slice(0, maxVisible)
2444
+ const visible = [
2445
+ ...visibleActive,
2446
+ ...recent.slice(0, Math.max(0, maxVisible - visibleActive.length)),
2447
+ ]
2448
+ const hiddenActive = Math.max(0, active.length - visibleActive.length)
2449
+ const hiddenTerminal = Math.max(0, recent.length - (visible.length - visibleActive.length))
2450
+ return { visible, hidden: hiddenActive + hiddenTerminal, hiddenActive, hiddenTerminal }
2422
2451
  }
2423
2452
 
2424
2453
  /** Split a display list into native task() rows vs pantheon_delegate rows.
2425
2454
  * Native = source 'children-only' (no board report); everything else counts
2426
- * as pantheon. Pure — powers the header breakdown + the hooks.log line. */
2455
+ * as pantheon. Pure — powers the hooks.log line. */
2427
2456
  export function countDelegationSources(entries: readonly DelegationEntry[]): {
2428
2457
  native: number
2429
2458
  pantheon: number
@@ -2456,18 +2485,22 @@ export function formatPanelLogLine(
2456
2485
  * time.created. A report-less child is a NATIVE task() child (every
2457
2486
  * child of the current session — pantheon_delegate OR the native `task()`
2458
2487
  * tool — carries parentID = caller), so it gets source 'children-only',
2459
- * the internal alias 'native-task' and the `nat:` tag instead of a
2488
+ * the internal alias 'native-task' instead of a
2460
2489
  * board alias. The 'task nativa' description fallback keeps the row
2461
2490
  * non-empty when the child carries no title.
2462
2491
  * Terminal md state wins over the derived state; a running md defers to
2463
2492
  * the child's live status. A running child is NEVER archived — it always
2464
2493
  * lands in the active split (splitDelegationList active = running/retry).
2494
+ * `parentSessionID` (the focused session id) is stamped on report-less
2495
+ * children so the row carries its origin session; omit it and they stay
2496
+ * unscoped ('').
2465
2497
  * Sorted running-first (compareDelegationEntries).
2466
2498
  * Pure — no I/O. */
2467
2499
  export function childrenToDelegationEntries(
2468
2500
  children: readonly ChildDelegationLike[] | undefined,
2469
2501
  md: readonly DelegationEntry[],
2470
2502
  now = Date.now(),
2503
+ parentSessionID = '',
2471
2504
  ): DelegationEntry[] {
2472
2505
  const byTaskID = new Map<string, DelegationEntry>()
2473
2506
  for (const m of md) {
@@ -2485,7 +2518,11 @@ export function childrenToDelegationEntries(
2485
2518
  : childStatusToState(child.status)
2486
2519
  out.push({
2487
2520
  alias: mdEntry?.alias ?? 'native-task',
2488
- sessionID: mdEntry?.sessionID ?? '',
2521
+ // A report-less child IS a child of the focused session
2522
+ // (session.children is session-scoped), so the caller-passed
2523
+ // parentSessionID is stamped on it as its origin session; an md
2524
+ // match always carries its own session (the report dir name).
2525
+ sessionID: mdEntry?.sessionID ?? parentSessionID,
2489
2526
  taskID: child.id,
2490
2527
  agent: mdEntry?.agent ?? child.agent ?? 'agent',
2491
2528
  state,
@@ -2513,12 +2550,41 @@ export function childrenToDelegationEntries(
2513
2550
  * server-valid session id ("ses...") ever reaches the router, so an
2514
2551
  * unsubstituted "{sessionID}" placeholder can never be routed. */
2515
2552
  export function navigateToDelegationSession(
2516
- route: { navigate?: (name: string, params?: Record<string, unknown>) => void } | undefined,
2553
+ route:
2554
+ | {
2555
+ navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
2556
+ }
2557
+ | undefined,
2517
2558
  taskID: string | undefined,
2518
2559
  ): boolean {
2519
2560
  if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
2520
- route.navigate('session', { sessionID: taskID })
2521
- return true
2561
+ try {
2562
+ const navigation = route.navigate('session', { sessionID: taskID })
2563
+ if (navigation !== undefined) {
2564
+ // A removed session can reject route.navigate for historical rows. That
2565
+ // rejection is expected and intentionally absorbed so it cannot break
2566
+ // the TUI; the no-op avoids logging sensitive session details.
2567
+ void Promise.resolve(navigation).catch(() => undefined)
2568
+ }
2569
+ return true
2570
+ } catch {
2571
+ // Synchronous router failures are also fail-open for an inert historical row.
2572
+ return false
2573
+ }
2574
+ }
2575
+
2576
+ /** Build the mouse handler used by each delegation row. */
2577
+ export function createDelegationRowOpenHandler(
2578
+ route:
2579
+ | {
2580
+ navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
2581
+ }
2582
+ | undefined,
2583
+ taskID: string | undefined,
2584
+ ): () => void {
2585
+ return () => {
2586
+ navigateToDelegationSession(route, taskID)
2587
+ }
2522
2588
  }
2523
2589
 
2524
2590
  /* ─── Silence-by-default panel logger ──────────────────────
@@ -2624,7 +2690,7 @@ function SessionRow(props: { api: TuiPluginApi; session: any }) {
2624
2690
  )
2625
2691
  }
2626
2692
 
2627
- /* ─── Delegation Row (single job from the children/md channels) ─ */
2693
+ /* ─── Delegation Row (single job from the children/md/board channels) ─ */
2628
2694
 
2629
2695
  function DelegationRow(props: {
2630
2696
  api: TuiPluginApi
@@ -2634,7 +2700,10 @@ function DelegationRow(props: {
2634
2700
  }) {
2635
2701
  const theme = () => props.api.theme.current
2636
2702
 
2637
- const color = createMemo(() => {
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.
2706
+ const tone = createMemo(() => {
2638
2707
  const t = theme()
2639
2708
  switch (delegationStateTone(props.job.state)) {
2640
2709
  case 'warning':
@@ -2648,16 +2717,10 @@ function DelegationRow(props: {
2648
2717
  }
2649
2718
  })
2650
2719
 
2651
- const marker = createMemo(() => delegationStateMarker(props.job.state, props.animationNow))
2652
-
2653
- // Two visual channels split native task() from pantheon_delegate: the tag
2654
- // prefix (`nat:`/`pan:`, via delegationTag) and the glyph color — info
2655
- // (cyan) for native, state color for board rows. The glyph shape (◇/◆) is
2656
- // the third, color-independent channel.
2657
- const tagColor = createMemo(() => (props.job.source === 'children-only' ? theme().info : color()))
2658
-
2659
- const description = createMemo(() => truncateDelegationDescription(props.job.description))
2720
+ const marker = createMemo(() => delegationRowMarker(props.job.state, props.animationNow))
2721
+ const lead = createMemo(() => formatDelegationRowLead(props.job, marker()))
2660
2722
  const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
2723
+ const description = createMemo(() => truncateDelegationDescription(props.job.description))
2661
2724
 
2662
2725
  // beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
2663
2726
  // fed by message.part.updated activity tracking. Null while idle/done.
@@ -2668,28 +2731,26 @@ function DelegationRow(props: {
2668
2731
  // Click a row → navigate to the child session (route API guarded in the
2669
2732
  // helper). Enter-key binding is left out (per-row keymap wiring is not
2670
2733
  // worth the complexity in this sidebar); mouse is enabled in tui.json.
2671
- const open = () => {
2672
- navigateToDelegationSession(props.api.route, props.job.taskID)
2673
- }
2734
+ const open = createDelegationRowOpenHandler(props.api.route, props.job.taskID)
2674
2735
 
2675
2736
  return (
2676
2737
  <box onMouseDown={open}>
2738
+ {/* Line 1: colored lead (glyph + identity) + muted elapsed tail. */}
2677
2739
  <box flexDirection="row">
2678
- <text fg={tagColor()}>{formatDelegationRow(props.job, marker())}</text>
2679
- </box>
2680
- {/* Elapsed is ALWAYS rendered (outside the optional-description Show) so
2681
- * an active job with no description never loses its timer. */}
2682
- <box flexDirection="row" justifyContent={description() !== '' ? 'space-between' : 'flex-end'}>
2683
- <Show when={description() !== ''}>
2684
- <text fg={theme().textMuted}>{description()}</text>
2685
- </Show>
2686
- <text fg={theme().textMuted}>{elapsed()}</text>
2740
+ <text>
2741
+ <span fg={tone()}>{lead()}</span>
2742
+ <span fg={theme().textMuted}>{elapsed()}</span>
2743
+ </text>
2687
2744
  </box>
2745
+ {/* Line 2: the description, indented under the identity and muted. */}
2746
+ <Show when={description() !== ''}>
2747
+ <text fg={theme().textMuted}>{` ${description()}`}</text>
2748
+ </Show>
2688
2749
  <Show when={activity()}>
2689
2750
  {(a) => (
2690
2751
  <text
2691
2752
  fg={theme().textMuted}
2692
- >{` \u21b3 ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2753
+ >{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2693
2754
  )}
2694
2755
  </Show>
2695
2756
  </box>
@@ -2804,16 +2865,9 @@ function View(props: {
2804
2865
  const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
2805
2866
  // Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
2806
2867
  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
- }
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".
2817
2871
  // The rendered list is childDelegations (children enriched with md);
2818
2872
  // there is no separate md-only signal — the md channel only feeds
2819
2873
  // childrenToDelegationEntries below.
@@ -2901,13 +2955,10 @@ function View(props: {
2901
2955
  }
2902
2956
  }
2903
2957
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
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
- }
2958
+ // Report-less rows ARE children of the focused session
2959
+ // (session.children is session-scoped), so the resolved id is stamped on
2960
+ // the row as its origin session.
2961
+ const childEntries = childrenToDelegationEntries(withStatus, md, now(), sessionID)
2911
2962
  for (const [k, v] of props.liveStore.map)
2912
2963
  if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
2913
2964
  props.liveStore.map.delete(k)
@@ -2942,33 +2993,15 @@ function View(props: {
2942
2993
  })()
2943
2994
  return delegationsInflight
2944
2995
  }
2945
- // beta.6: active (running/retry) first, then the 8 most recent terminal
2946
- // reports, then the archived tail (paginated). Pagination state lives at
2947
- // module level so sidebar remounts keep the user's page.
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
- ),
2996
+ // 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.
2999
+ const delegationCeiling = createMemo(() =>
3000
+ ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
2956
3001
  )
2957
- const delegationSplit = createMemo(() => splitDelegationList(scopedDelegations()))
2958
- const visibleDelegations = createMemo(() => [
2959
- ...delegationSplit().active,
2960
- ...delegationSplit().recent,
2961
- ])
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
2964
- // "Delegations (2 active · 9 done · nat:3 pan:8)".
2965
- const delegationHeader = createMemo(() => formatDelegationHeader(scopedDelegations()))
2966
- const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2967
- const clampArchivedPage = (page: number, pages: number) => {
2968
- const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
2969
- archivedUiState.page = clamped
2970
- setArchivedPage(clamped)
2971
- }
3002
+ // Header counts the same retained rows shown below, so hidden/expired board
3003
+ // history cannot inflate the summary.
3004
+ const delegationHeader = createMemo(() => formatDelegationHeader(delegationCeiling().visible))
2972
3005
 
2973
3006
  // ── Event subscriptions for live session/delegation updates ──
2974
3007
  onMount(() => {
@@ -3047,29 +3080,6 @@ function View(props: {
3047
3080
  /* re-scan unavailable — children API + 1s poll still cover it */
3048
3081
  }
3049
3082
 
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
-
3073
3083
  onCleanup(() =>
3074
3084
  cleanup.forEach((fn) => {
3075
3085
  fn()
@@ -3136,31 +3146,13 @@ function View(props: {
3136
3146
  <text fg={theme().text} attributes={1}>
3137
3147
  {`${showDelegations() ? '▼' : '▶'} Delegations`}
3138
3148
  </text>
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>
3149
+ <text fg={theme().textMuted}>
3150
+ {delegationCeiling().visible.length > 0 ? ` ${delegationHeader()}` : ' — idle'}
3151
+ </text>
3160
3152
  </box>
3161
3153
  <Show when={showDelegations()}>
3162
3154
  <Show
3163
- when={visibleDelegations().length > 0}
3155
+ when={delegationCeiling().visible.length > 0}
3164
3156
  fallback={
3165
3157
  <box marginLeft={1}>
3166
3158
  <text fg={theme().textMuted}>No delegations</text>
@@ -3168,7 +3160,7 @@ function View(props: {
3168
3160
  }
3169
3161
  >
3170
3162
  <box marginLeft={1} flexDirection="column">
3171
- <For each={visibleDelegations()}>
3163
+ <For each={delegationCeiling().visible}>
3172
3164
  {(job) => (
3173
3165
  <DelegationRow
3174
3166
  api={props.api}
@@ -3178,40 +3170,12 @@ function View(props: {
3178
3170
  />
3179
3171
  )}
3180
3172
  </For>
3181
- <Show when={delegationSplit().archived.length > 0}>
3182
- {(() => {
3183
- const archived = delegationSplit().archived
3184
- const pageSize = 8
3185
- const pages = Math.ceil(archived.length / pageSize)
3186
- const page = Math.min(archivedPage(), pages - 1)
3187
- const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
3188
- return (
3189
- <box flexDirection="column">
3190
- <box flexDirection="row" marginLeft={0}>
3191
- <text
3192
- fg={theme().textMuted}
3193
- onMouseDown={() => clampArchivedPage(page - 1, pages)}
3194
- >{`\u2039 `}</text>
3195
- <text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
3196
- <text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
3197
- <text
3198
- fg={theme().textMuted}
3199
- onMouseDown={() => clampArchivedPage(page + 1, pages)}
3200
- >{` \u203a`}</text>
3201
- </box>
3202
- <For each={slice}>
3203
- {(job) => (
3204
- <DelegationRow
3205
- api={props.api}
3206
- job={job}
3207
- now={now()}
3208
- animationNow={animationNow()}
3209
- />
3210
- )}
3211
- </For>
3212
- </box>
3213
- )
3214
- })()}
3173
+ <Show when={delegationCeiling().hidden > 0}>
3174
+ <text fg={theme().textMuted}>
3175
+ {delegationCeiling().hiddenActive > 0
3176
+ ? `… +${delegationCeiling().hiddenActive} active`
3177
+ : `… +${delegationCeiling().hiddenTerminal} more`}
3178
+ </text>
3215
3179
  </Show>
3216
3180
  </box>
3217
3181
  </Show>