pantheon-opencode 1.5.0-beta.5 → 1.5.0-beta.7

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.
@@ -15,14 +15,19 @@
15
15
  *
16
16
  * @module cost-command
17
17
  */
18
- import { existsSync } from 'node:fs'
18
+ import { execFile } from 'node:child_process'
19
+ import { accessSync, constants, existsSync, statSync } from 'node:fs'
19
20
  import { homedir } from 'node:os'
20
- import { join } from 'node:path'
21
+ import { delimiter, dirname, join } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
21
23
  import { z } from 'zod'
22
24
 
23
25
  import type { ToolContextLike } from './delegation.ts'
24
26
  import type { NativeTaskStatus } from './native-task-status.ts'
25
27
 
28
+ const __filename = fileURLToPath(import.meta.url)
29
+ const __dirname = dirname(__filename)
30
+
26
31
  /** One aggregated token row (per agent and phase). */
27
32
  export interface CostRow {
28
33
  agent: string
@@ -37,6 +42,8 @@ export interface CostCommandOptions {
37
42
  dbPath?: string
38
43
  /** Injectable loader for tests; production always loads node:sqlite. */
39
44
  sqliteLoader?: () => Promise<SqliteModule>
45
+ /** Injectable CLI-fallback process boundary (tests / unusual runtimes). */
46
+ cliFallback?: CliFallbackDeps
40
47
  }
41
48
 
42
49
  export type OpenCodeVersion = 'v1' | 'v2'
@@ -220,6 +227,230 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
220
227
  return lines.join('\n')
221
228
  }
222
229
 
230
+ // ─── CLI fallback: resolve a REAL Node, not the plugin runtime ──────────
231
+ //
232
+ // Under Bun (the runtime that motivates this fallback), process.execPath IS
233
+ // the Bun binary, which has no node:sqlite — so spawning it could never work.
234
+ // Resolve a genuine Node instead: PANTHEON_NODE → `node` on PATH → clear error.
235
+
236
+ /** First Node release exposing the built-in `node:sqlite` module. */
237
+ const NODE_SQLITE_MIN_VERSION = '22.5'
238
+
239
+ /** Minimal injectable child-process boundary (tests / unusual runtimes). */
240
+ export type ExecFileLike = (
241
+ file: string,
242
+ args: readonly string[],
243
+ ) => Promise<{ stdout: string; stderr?: string }>
244
+
245
+ /** Injectables so the fallback never depends on the real machine in tests. */
246
+ export interface CliFallbackDeps {
247
+ /** Environment for PANTHEON_NODE / PATH lookup. Default: process.env. */
248
+ env?: NodeJS.ProcessEnv
249
+ /** Child-process boundary. Default: node:child_process.execFile. */
250
+ execFile?: ExecFileLike
251
+ /** Executable predicate used by the PANTHEON_NODE / PATH lookups. */
252
+ isExecutable?: (path: string) => boolean
253
+ }
254
+
255
+ /** A real Node executable located for the CLI fallback. */
256
+ export interface ResolvedNode {
257
+ path: string
258
+ source: 'env' | 'path'
259
+ }
260
+
261
+ type ExecFailure = Error & { stdout?: string; stderr?: string }
262
+
263
+ function defaultIsExecutable(path: string): boolean {
264
+ try {
265
+ accessSync(path, constants.X_OK)
266
+ return statSync(path).isFile()
267
+ } catch {
268
+ return false
269
+ }
270
+ }
271
+
272
+ function defaultExecFile(
273
+ file: string,
274
+ args: readonly string[],
275
+ ): Promise<{ stdout: string; stderr: string }> {
276
+ return new Promise((resolve, reject) => {
277
+ execFile(file, [...args], { encoding: 'utf8' }, (error, stdout, stderr) => {
278
+ if (error) {
279
+ // execFile's callback error does not always carry the captured output;
280
+ // attach it so callers can surface cost.mjs's structured {ok:false}.
281
+ const failure = error as ExecFailure
282
+ if (typeof failure.stdout !== 'string') failure.stdout = stdout
283
+ if (typeof failure.stderr !== 'string') failure.stderr = stderr
284
+ reject(failure)
285
+ return
286
+ }
287
+ resolve({ stdout, stderr })
288
+ })
289
+ })
290
+ }
291
+
292
+ /** Scan PATH (no shell) for a `node` executable. */
293
+ export function resolveNodeFromPath(
294
+ env: NodeJS.ProcessEnv,
295
+ isExecutable: (path: string) => boolean = defaultIsExecutable,
296
+ ): string | undefined {
297
+ const pathValue = env.PATH ?? env.Path ?? ''
298
+ if (pathValue === '') return undefined
299
+ const isWindows = process.platform === 'win32'
300
+ const extensions = isWindows
301
+ ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter((ext) => ext !== '')
302
+ : ['']
303
+ for (const dir of pathValue.split(delimiter)) {
304
+ if (dir === '') continue
305
+ for (const ext of extensions) {
306
+ const candidate = join(dir, `node${ext}`)
307
+ if (isExecutable(candidate)) return candidate
308
+ }
309
+ }
310
+ return undefined
311
+ }
312
+
313
+ /**
314
+ * Resolve a REAL Node.js binary for the CLI fallback.
315
+ *
316
+ * Order: PANTHEON_NODE (must exist and be executable — an explicit override
317
+ * that is unusable fails fast instead of being silently ignored) → `node`
318
+ * from PATH. Returns undefined only when neither source is available.
319
+ */
320
+ export function resolveNodeExecutable(
321
+ env: NodeJS.ProcessEnv = process.env,
322
+ isExecutable: (path: string) => boolean = defaultIsExecutable,
323
+ ): ResolvedNode | undefined {
324
+ const explicit = env.PANTHEON_NODE
325
+ if (explicit !== undefined && explicit.trim() !== '') {
326
+ if (isExecutable(explicit)) return { path: explicit, source: 'env' }
327
+ throw new Error(
328
+ `PANTHEON_NODE is set to "${explicit}" but that is not an existing executable file`,
329
+ )
330
+ }
331
+ const fromPath = resolveNodeFromPath(env, isExecutable)
332
+ return fromPath === undefined ? undefined : { path: fromPath, source: 'path' }
333
+ }
334
+
335
+ function execErrorMessage(err: unknown): string {
336
+ if (err instanceof Error) {
337
+ const failure = err as ExecFailure
338
+ const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
339
+ if (stderr !== '') return stderr
340
+ return failure.message
341
+ }
342
+ return String(err)
343
+ }
344
+
345
+ function tryParseJson(text: string): unknown {
346
+ try {
347
+ return JSON.parse(text)
348
+ } catch {
349
+ return undefined
350
+ }
351
+ }
352
+
353
+ function truncateForError(text: string): string {
354
+ const trimmed = text.trim()
355
+ return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed
356
+ }
357
+
358
+ /** Probe the resolved binary for built-in node:sqlite support. */
359
+ async function assertNodeSqliteSupport(
360
+ nodePath: string,
361
+ execFileAsync: ExecFileLike,
362
+ ): Promise<void> {
363
+ try {
364
+ await execFileAsync(nodePath, ['-e', "require('node:sqlite')"])
365
+ } catch (err: unknown) {
366
+ throw new Error(
367
+ `resolved Node binary "${nodePath}" does not support node:sqlite (Node.js >= ${NODE_SQLITE_MIN_VERSION} required): ${execErrorMessage(err)}`,
368
+ )
369
+ }
370
+ }
371
+
372
+ /** Pull the structured failure message cost.mjs prints to stdout (exit 1). */
373
+ function extractScriptFailure(err: unknown): string {
374
+ const failure = err as { stdout?: unknown; stderr?: unknown }
375
+ const stdout = typeof failure.stdout === 'string' ? failure.stdout : ''
376
+ const parsed = tryParseJson(stdout)
377
+ if (parsed !== null && typeof parsed === 'object') {
378
+ const record = parsed as { ok?: unknown; error?: unknown }
379
+ if (record.ok === false && typeof record.error === 'string' && record.error !== '') {
380
+ return record.error
381
+ }
382
+ }
383
+ const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
384
+ if (stderr !== '') return stderr
385
+ return execErrorMessage(err)
386
+ }
387
+
388
+ /**
389
+ * Spawn `node scripts/cost.mjs` as a CLI fallback when node:sqlite is
390
+ * unavailable in the plugin process (e.g. Bun runtime). Returns CostRow[] by
391
+ * parsing the JSON the script writes to stdout.
392
+ *
393
+ * Resolves a REAL Node binary (PANTHEON_NODE → PATH): process.execPath under
394
+ * Bun is the Bun binary, which cannot run node:sqlite. The resolved binary is
395
+ * probed for node:sqlite support and the script is always run read-only with
396
+ * an argument array (never a shell).
397
+ */
398
+ export async function queryWithCliFallback(
399
+ dbPath: string,
400
+ days: number,
401
+ deps?: CliFallbackDeps,
402
+ ): Promise<CostRow[]> {
403
+ const env = deps?.env ?? process.env
404
+ const execFileAsync = deps?.execFile ?? defaultExecFile
405
+ const isExecutable = deps?.isExecutable ?? defaultIsExecutable
406
+
407
+ const costScript = join(__dirname, '..', '..', 'scripts', 'cost.mjs')
408
+ if (!existsSync(costScript)) {
409
+ throw new Error(`CLI fallback script not found: ${costScript}`)
410
+ }
411
+
412
+ const node = resolveNodeExecutable(env, isExecutable)
413
+ if (node === undefined) {
414
+ throw new Error(
415
+ `no Node.js executable found for the node:sqlite CLI fallback: set PANTHEON_NODE to a Node.js >= ${NODE_SQLITE_MIN_VERSION} binary or put "node" on PATH`,
416
+ )
417
+ }
418
+
419
+ await assertNodeSqliteSupport(node.path, execFileAsync)
420
+
421
+ let stdout: string
422
+ try {
423
+ ;({ stdout } = await execFileAsync(node.path, [
424
+ costScript,
425
+ '--db-path',
426
+ dbPath,
427
+ '--days',
428
+ String(days),
429
+ ]))
430
+ } catch (err: unknown) {
431
+ // cost.mjs sets exitCode=1 (execFile rejects) and writes {ok:false,error}
432
+ // to stdout before exiting — surface that structured failure.
433
+ throw new Error(`CLI fallback failed: ${extractScriptFailure(err)}`)
434
+ }
435
+
436
+ const parsed = tryParseJson(stdout)
437
+ if (parsed === null || typeof parsed !== 'object') {
438
+ throw new Error(`CLI fallback returned non-JSON output: ${truncateForError(stdout)}`)
439
+ }
440
+ const record = parsed as { ok?: unknown; rows?: unknown; error?: unknown }
441
+ if (record.ok !== true) {
442
+ const detail =
443
+ typeof record.error === 'string' && record.error !== ''
444
+ ? record.error
445
+ : 'unexpected response shape from scripts/cost.mjs'
446
+ throw new Error(`CLI fallback failed: ${detail}`)
447
+ }
448
+ if (!Array.isArray(record.rows)) {
449
+ throw new Error('CLI fallback failed: scripts/cost.mjs response is missing the rows array')
450
+ }
451
+ return record.rows as CostRow[]
452
+ }
453
+
223
454
  export function createCostCommand(options?: CostCommandOptions): CostCommand {
224
455
  const loadSqlite =
225
456
  options?.sqliteLoader ?? (async () => (await import('node:sqlite')) as SqliteModule)
@@ -269,7 +500,22 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
269
500
  `opencode.db not found (expected at ${expected}). No token history is available until opencode has stored session data.`,
270
501
  )
271
502
  }
272
- const sqlite = await loadSqlite()
503
+ let sqlite: SqliteModule | undefined
504
+ try {
505
+ sqlite = await loadSqlite()
506
+ } catch (loadErr: unknown) {
507
+ // node:sqlite unavailable (e.g. Bun runtime) — fall back to CLI.
508
+ const status = statusForError(loadErr)
509
+ if (status === 'UNSUPPORTED') {
510
+ try {
511
+ const rows = await queryWithCliFallback(dbPath, days, options?.cliFallback)
512
+ return `status: OK\n${renderMarkdown(rows, days)}`
513
+ } catch (cliErr: unknown) {
514
+ return failure(statusForError(cliErr), diagnostic(cliErr))
515
+ }
516
+ }
517
+ throw loadErr
518
+ }
273
519
  const rows = await queryWithNodeSqlite(dbPath, days, sqlite)
274
520
  return `status: OK\n${renderMarkdown(rows, days)}`
275
521
  } catch (err: unknown) {
@@ -144,9 +144,25 @@ export interface DelegationOptions {
144
144
  promptTimeoutMs?: number
145
145
  /** Poll interval used by the bootstrap watchdog. */
146
146
  bootstrapPollIntervalMs?: number
147
+ /**
148
+ * Max completed jobs to keep per agent after pruning (default: 10).
149
+ * Matches the native delegate-manager default.
150
+ */
151
+ keepCompleted?: number
152
+ /**
153
+ * Hard cap on total board entries (default: 50).
154
+ * Matches the native delegate-manager default.
155
+ */
156
+ maxEntries?: number
147
157
  /** Injectable clock/sleeper for deterministic watchdog tests. */
148
158
  now?: () => number
149
159
  sleep?: (ms: number) => Promise<void>
160
+ /**
161
+ * Kill-switch: when false, all delegation tools (delegate/read/list) throw
162
+ * immediately. Mirrors `PANTHEON_DELEGATION=off` from the native manager.
163
+ * Defaults to true (delegation enabled) when not specified.
164
+ */
165
+ delegationEnabled?: boolean
150
166
  }
151
167
 
152
168
  /** Dependencies threaded to the finalize path. */
@@ -370,5 +386,21 @@ export async function finalizeDelegation(
370
386
  // Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
371
387
  // report above still captures this finalize; the board state stands.
372
388
  }
389
+
390
+ // P1-1: Delete the auto-wake signal file to prevent leaks. The board
391
+ // writes .signal.json on every terminal transition, but the legacy path
392
+ // never called deleteSignal — only the native delegate-manager did.
393
+ // The consumer (auto-wake.ts consumeWakeSignals) is intentionally NOT
394
+ // wired in the event hook: finalizeDelegation already handles the
395
+ // terminal transition, so consuming signals would be redundant and risks
396
+ // double-processing. deleteSignal on finalize is sufficient.
397
+ await deps.board.deleteSignal(job.alias)
398
+
399
+ // P1-4: Prune old completed jobs and enforce the hard entry cap. The
400
+ // native delegate-manager called these in settle(), but the legacy
401
+ // finalize path omitted them, allowing unbounded board growth.
402
+ await deps.board.pruneCompleted(deps.options.keepCompleted ?? 10)
403
+ await deps.board.enforceEntryCap(deps.options.maxEntries ?? 50)
404
+
373
405
  return deps.board.get(childSessionID) ?? job
374
406
  }
@@ -740,10 +740,54 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
740
740
  return finalizeDelegationReport(deps, childSessionID, opts)
741
741
  }
742
742
 
743
+ // Kill-switch gate: when delegationEnabled is explicitly false (mirrors
744
+ // PANTHEON_DELEGATION=off from the native manager), all legacy tools throw.
745
+ function guardEnabled(): void {
746
+ if (options.delegationEnabled === false) {
747
+ throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Best-effort teardown of a child session whose `registerLaunch` was refused
753
+ * because the per-agent concurrency limit was hit between the pre-check and
754
+ * the registration (a race with another concurrent dispatch). Removes every
755
+ * side effect of the aborted dispatch — known-child marker, read-only
756
+ * registry entry and any reserved budget slot — then deletes the session when
757
+ * the host client exposes `session.delete`. Never throws: cleanup must not
758
+ * mask the original rejection.
759
+ */
760
+ async function abortRejectedLaunch(
761
+ childSessionID: string,
762
+ wasReadOnly: boolean,
763
+ refund?: { key: string | undefined; reserved: boolean },
764
+ ): Promise<void> {
765
+ knownChildren.delete(childSessionID)
766
+ if (wasReadOnly) readOnlyRegistry.unregister(childSessionID)
767
+ if (refund?.reserved && refund.key !== undefined) {
768
+ budgetUsage.set(refund.key, Math.max(0, (budgetUsage.get(refund.key) ?? 1) - 1))
769
+ }
770
+ // The plugin's adapted client (adaptDelegationClient) does not expose
771
+ // session.delete, and older hosts may not either. Delete best-effort so the
772
+ // abandoned session is not left behind; the registry cleanup above already
773
+ // removes the side effects that matter for read-only enforcement.
774
+ const session = client.session as {
775
+ delete?: (input: { path: { id: string } }) => Promise<unknown>
776
+ }
777
+ if (typeof session.delete !== 'function') return
778
+ try {
779
+ await session.delete({ path: { id: childSessionID } })
780
+ } catch (err: unknown) {
781
+ const reason = err instanceof Error ? err.message : String(err)
782
+ log.warn(`[pantheon-delegate] failed to delete orphan session ${childSessionID}: ${reason}`)
783
+ }
784
+ }
785
+
743
786
  const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
744
787
  description: buildDelegateDescription(options),
745
788
  args: delegateArgs,
746
789
  execute: async (args, ctx) => {
790
+ guardEnabled()
747
791
  if (!isRootSession(ctx.sessionID)) {
748
792
  throw new Error(
749
793
  `pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
@@ -871,13 +915,27 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
871
915
  readOnlyRegistry.register(childSessionID, entry)
872
916
  }
873
917
 
874
- const job = await board.registerLaunch({
875
- taskID: childSessionID,
876
- parentSessionID: ctx.sessionID,
877
- agent: args.agent,
878
- description: args.description ?? effectivePrompt,
879
- objective: effectivePrompt,
880
- })
918
+ let job: BackgroundJobRecord
919
+ try {
920
+ job = await board.registerLaunch({
921
+ taskID: childSessionID,
922
+ parentSessionID: ctx.sessionID,
923
+ agent: args.agent,
924
+ description: args.description ?? effectivePrompt,
925
+ objective: effectivePrompt,
926
+ })
927
+ } catch (err: unknown) {
928
+ // Concurrency race: another dispatch claimed the last slot after the
929
+ // pre-check above but before this registration. Undo every side effect
930
+ // of the aborted dispatch so no orphan session/registry survives, then
931
+ // surface a clear rejection.
932
+ const reason = err instanceof Error ? err.message : String(err)
933
+ await abortRejectedLaunch(childSessionID, readOnly, {
934
+ key: budgetKey,
935
+ reserved: budgetReserved,
936
+ })
937
+ return `pantheon_delegate rejected: ${reason}`
938
+ }
881
939
 
882
940
  // Timeout manager: cleared on finalize, unref'd so the timer never
883
941
  // keeps the process alive on its own.
@@ -968,13 +1026,34 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
968
1026
  const retryID = retry.id
969
1027
  knownChildren.add(retryID)
970
1028
  options.registerChildSession?.(retryID, ctx.sessionID)
971
- const retryJob = await board.registerLaunch({
972
- taskID: retryID,
973
- parentSessionID: ctx.sessionID,
974
- agent: args.agent,
975
- description: args.description ?? effectivePrompt,
976
- objective: effectivePrompt,
977
- })
1029
+
1030
+ // Phase 4 read-only enforcement for the retry: recalculate readOnly
1031
+ // (same logic as the initial child at :866-872) and register the retry
1032
+ // session so the plugin's tool.execute.before guard enforces it.
1033
+ const retryReadOnly =
1034
+ args.read_only === true || (options.readOnlyAgents?.has(args.agent.toLowerCase()) ?? false)
1035
+ if (retryReadOnly) {
1036
+ const entry: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
1037
+ if (args.read_only !== undefined) entry.readOnlyFlag = args.read_only
1038
+ readOnlyRegistry.register(retryID, entry)
1039
+ }
1040
+
1041
+ let retryJob: BackgroundJobRecord
1042
+ try {
1043
+ retryJob = await board.registerLaunch({
1044
+ taskID: retryID,
1045
+ parentSessionID: ctx.sessionID,
1046
+ agent: args.agent,
1047
+ description: args.description ?? effectivePrompt,
1048
+ objective: effectivePrompt,
1049
+ })
1050
+ } catch (err: unknown) {
1051
+ // Same race as the initial child: the retry registration can be
1052
+ // refused. Clean up the retry session + registry entry before failing.
1053
+ const reason = err instanceof Error ? err.message : String(err)
1054
+ await abortRejectedLaunch(retryID, retryReadOnly)
1055
+ return `pantheon_delegate rejected: ${reason} (retry)`
1056
+ }
978
1057
  const retryTimer = setTimeout(() => {
979
1058
  timers.delete(retryID)
980
1059
  void finalize(retryID, {
@@ -1035,6 +1114,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1035
1114
  'Resolves by alias or task ID.',
1036
1115
  args: readArgs,
1037
1116
  execute: async (args, ctx) => {
1117
+ guardEnabled()
1038
1118
  const job = board.resolve(ctx.sessionID, args.id)
1039
1119
  if (!job) {
1040
1120
  return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
@@ -1093,6 +1173,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1093
1173
  'List background delegations for the current session, with [unread] for finished jobs.',
1094
1174
  args: listArgs,
1095
1175
  execute: async (_args, ctx) => {
1176
+ guardEnabled()
1096
1177
  const jobs = board.list(ctx.sessionID)
1097
1178
  if (jobs.length === 0) return 'No background delegations for this session.'
1098
1179
 
@@ -104,12 +104,20 @@ export function createV2EventDispatcher(deps: V2EventDeps): V2EventDispatcher {
104
104
  } else {
105
105
  // Native task(background=true) child: register on the board
106
106
  // atomically (skip if V1 idle hook or re-dispatch already claimed it).
107
- await deps.board.registerLaunchIfAbsent({
108
- taskID: info.id,
109
- parentSessionID: info.parentID,
110
- agent: 'native',
111
- description: `native child ${info.id}`,
112
- })
107
+ try {
108
+ await deps.board.registerLaunchIfAbsent({
109
+ taskID: info.id,
110
+ parentSessionID: info.parentID,
111
+ agent: 'native',
112
+ description: `native child ${info.id}`,
113
+ })
114
+ } catch (err) {
115
+ // The per-agent concurrency limit can refuse the launch. Do not
116
+ // abort the rest of the handler: this child is simply not
117
+ // mirrored on the board, while finalize/idle dispatch for every
118
+ // other event must still run.
119
+ log.warn(`[Pantheon V2] native child ${info.id} not mirrored on board:`, err)
120
+ }
113
121
  }
114
122
  }
115
123
  }
@@ -11,7 +11,7 @@ type DelegationEntry = {
11
11
  taskID?: string;
12
12
  /** Agent name, e.g. "apollo". */
13
13
  agent: string;
14
- state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled' | 'stale-running';
14
+ state: 'running' | 'retry' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled' | 'stale-running';
15
15
  /** Epoch ms of the `Started` header. */
16
16
  startedAt: number;
17
17
  /** Epoch ms of the `Finalized` header — null while still running. */
@@ -73,10 +73,18 @@ declare function tuiLogPath(projectRoot: string): string;
73
73
  * falling back to startedAt, descending). Shared by the md reader and
74
74
  * mergeDelegationSources. */
75
75
  declare function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number;
76
- /** The list the panel actually renders: running jobs first, then the most
77
- * recent terminal reports (capped). Pure — so the history-only panel (no
78
- * sessionID) is testable without the TUI runtime. The header count uses the
79
- * same "running + recentes" list. */
76
+ /** Split the panel list: active jobs (running/retry, stale-marked) first,
77
+ * then the most recent terminal reports, then the archived tail (paginated
78
+ * in the View). Pure — so the history-only panel (no sessionID) is testable
79
+ * without the TUI runtime. */
80
+ declare function splitDelegationList(all: readonly DelegationEntry[], maxRecent?: number, now?: number, staleThresholdMs?: number): {
81
+ active: DelegationEntry[];
82
+ recent: DelegationEntry[];
83
+ archived: DelegationEntry[];
84
+ };
85
+ /** The list the panel actually renders (kept for the header count and
86
+ * existing tests): active jobs first, then the most recent terminal
87
+ * reports (capped) — the archived tail is rendered separately. Pure. */
80
88
  declare function visibleDelegationList(all: readonly DelegationEntry[], maxTerminal?: number, now?: number, staleThresholdMs?: number): DelegationEntry[];
81
89
  /** Default stale-running threshold: 30 minutes. */
82
90
  declare const STALE_RUNNING_THRESHOLD_MS: number;
@@ -106,6 +114,30 @@ declare function delegationActivity(entry: DelegationEntry): DelegationActivity;
106
114
  declare function delegationActivityLabel(entry: DelegationEntry): string;
107
115
  /** Return a deterministic spinner frame. The View ticks this every 140ms. */
108
116
  declare function delegationSpinnerFrame(now: number): string;
117
+ type ToolActivity = {
118
+ tool: string;
119
+ summary: string;
120
+ at: number;
121
+ };
122
+ /** Reduce one message.part.updated tool part to displayable activity.
123
+ * Returns null for non-tool parts, completed/error parts (no live activity)
124
+ * or parts without a session id. Pure. */
125
+ declare function extractToolActivity(part: {
126
+ type?: string;
127
+ tool?: string;
128
+ sessionID?: string;
129
+ state?: {
130
+ status?: string;
131
+ input?: Record<string, unknown>;
132
+ };
133
+ }, now?: number): {
134
+ sessionID: string;
135
+ activity: ToolActivity;
136
+ } | null;
137
+ /** Record an activity sample (bounded map, oldest dropped). */
138
+ declare function trackToolActivity(map: Map<string, ToolActivity>, sessionID: string, activity: ToolActivity): void;
139
+ /** Latest live activity for a child session, or null when absent/stale. */
140
+ declare function latestToolActivityFor(map: Map<string, ToolActivity>, sessionID: string | undefined, now?: number, ttlMs?: number): ToolActivity | null;
109
141
  /** Merge the immediate tool-event channel into the child-session channel.
110
142
  *
111
143
  * Children remain the durable source, while live entries make a delegation
@@ -278,7 +310,7 @@ type ChildDelegationLike = {
278
310
  * (the child is actively working), idle → completed, unknown → running
279
311
  * (fail-open: a freshly-seen child is assumed active; the 1s poll + md
280
312
  * correct it as soon as terminal data exists). */
281
- declare function childStatusToState(status: string | undefined): 'running' | 'completed';
313
+ declare function childStatusToState(status: string | undefined): 'running' | 'completed' | 'retry';
282
314
  /** Row tag for a delegation entry: `[native]` for native task() children
283
315
  * (source 'children-only' — no board report), `[pantheon:<alias>]` for board
284
316
  * rows ([pantheon:apo-1]) — the same tags the manager prints in
@@ -324,5 +356,5 @@ declare const plugin: TuiPluginModule & {
324
356
  setup: () => Promise<void>;
325
357
  };
326
358
  //#endregion
327
- export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, IDLE_SILENCE_MS, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, STALE_RUNNING_THRESHOLD_MS, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
359
+ export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, IDLE_SILENCE_MS, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, STALE_RUNNING_THRESHOLD_MS, ToolActivity, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, extractToolActivity, fmtElapsed, isValidSessionId, latestToolActivityFor, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, splitDelegationList, toDelegationEntry, trackToolActivity, tuiLogPath, visibleDelegationList };
328
360
  //# sourceMappingURL=tui.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAo7BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EASA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBAkHmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAWhD,sBACd,cAAc,mBACd,sBACA,cACA,4BACC;;cASU;;;;cAKA;;;;;;;;;iBAUG,mBACd,OAAO,iBACP,aACA,uBACC;;;iBAWa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAQ9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAc5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KAmFS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;iBAac,wBACd,MAAM,oBACN,eACC;;;iBA+Ea,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBA+Fc,sBACd,KAAK,YAAY,sBACjB;;;;;;iBAiBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA2Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;;;iBAWnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBA0Ca,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KAiLU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAucI,QAAQ;EAAoB;EAAY,aAAa"}
1
+ {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAo7BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EAUA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBAkHmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAahD,oBACd,cAAc,mBACd,oBACA,cACA;EACG,QAAQ;EAAmB,QAAQ;EAAmB,UAAU;;;;;iBAerD,sBACd,cAAc,mBACd,sBACA,cACA,4BACC;;cAMU;;;;cAKA;;;;;;;;;iBAUG,mBACd,OAAO,iBACP,aACA,uBACC;;;iBAWa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAQ9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAc5C,wBAAwB,OAAO;;iBAqB/B,uBAAuB;KAY3B;EACV;EACA;EACA;;;;;iBA0Bc,oBACd;EACE;EACA;EACA;EACA;IAAU;IAAiB,QAAQ;;GAErC;EACG;EAAmB,UAAU;;;iBAYlB,kBACd,KAAK,YAAY,eACjB,mBACA,UAAU;;iBAWI,sBACd,KAAK,YAAY,eACjB,+BACA,cACA,iBACC;;;;;;;iBAca,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KAmFS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;iBAac,wBACd,MAAM,oBACN,eACC;;;iBA+Ea,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBA+Fc,sBACd,KAAK,YAAY,sBACjB;;;;;;iBAiBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA2Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;;;iBAYnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBA0Ca,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KAiMU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cA0fI,QAAQ;EAAoB;EAAY,aAAa"}