pantheon-opencode 1.4.2 → 1.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.pantheon/code-mode/.gitkeep +0 -0
  2. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
  3. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
  4. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
  5. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
  6. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
  7. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
  8. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
  9. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
  10. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
  11. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
  12. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
  13. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
  14. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
  15. package/.pantheon/code-mode/README.md +93 -0
  16. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  17. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  18. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  19. package/.pantheon/code-mode/compress-inline.py +450 -0
  20. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  21. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  22. package/.pantheon/code-mode/eval-run.py +197 -0
  23. package/.pantheon/code-mode/eval-static.py +321 -0
  24. package/.pantheon/code-mode/example-sync.sh +5 -0
  25. package/.pantheon/code-mode/session-end-save.py +186 -0
  26. package/.pantheon/code-mode/session-end-save.sh +18 -0
  27. package/.pantheon/code-mode/test-allowed.sh +5 -0
  28. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  29. package/.pantheon/code-mode/test-env.sh +7 -0
  30. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  31. package/.pantheon/code-mode/test-timeout.sh +6 -0
  32. package/README.md +158 -1189
  33. package/README.pt-BR.md +222 -0
  34. package/bin/pantheon-init.mjs +30 -11
  35. package/opencode.json +1 -0
  36. package/package.json +14 -3
  37. package/plugin.json +15 -0
  38. package/scripts/_pantheon_paths.py +48 -7
  39. package/scripts/ci-validate-yaml.py +7 -2
  40. package/scripts/code_mode_server.py +33 -2
  41. package/scripts/doctor.mjs +135 -127
  42. package/scripts/generate-preset-docs.mjs +127 -66
  43. package/scripts/generate-prompts.sh +2 -2
  44. package/scripts/install/cli-ui.mjs +8 -1
  45. package/scripts/install/config-migration.mjs +401 -0
  46. package/scripts/install/health-check.mjs +3 -3
  47. package/scripts/install/model-picker.mjs +72 -23
  48. package/scripts/install/opencode-version.mjs +47 -0
  49. package/scripts/install/opencode.mjs +261 -88
  50. package/scripts/install/plugin.mjs +112 -42
  51. package/scripts/install/shared.mjs +13 -158
  52. package/scripts/install-mcp.mjs +44 -232
  53. package/scripts/mcp_persistence_server.py +344 -15
  54. package/scripts/mcp_resources_server.py +144 -40
  55. package/scripts/memory_mcp_server.py +2 -2
  56. package/scripts/recover-zenodo-deposition.mjs +146 -0
  57. package/scripts/sync-tui.mjs +32 -7
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +591 -0
  59. package/scripts/uninstall.mjs +298 -954
  60. package/scripts/validate-zenodo-release.mjs +154 -0
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/mcp/_pantheon_paths.py +48 -7
  64. package/src/mcp/code_mode_server.py +33 -2
  65. package/src/mcp/eval_store.py +1 -1
  66. package/src/mcp/mcp_persistence_server.py +344 -15
  67. package/src/mcp/mcp_resources_server.py +144 -40
  68. package/src/mcp/pantheon_vision_server.py +1 -1
  69. package/src/mcp/toon_codec.py +491 -0
  70. package/src/pantheon/background-job-board.ts +67 -1
  71. package/src/pantheon/cost-command.ts +6 -2
  72. package/src/pantheon/delegate-manager.ts +384 -0
  73. package/src/pantheon/delegate-task-adapter.ts +444 -0
  74. package/src/pantheon/delegation-notify.ts +35 -20
  75. package/src/pantheon/model-command.ts +86 -32
  76. package/src/pantheon/token-opt.ts +555 -0
  77. package/src/pantheon/toon-codec.ts +481 -0
  78. package/src/pantheon/tui-version-check.ts +1 -3
  79. package/src/pantheon/v2-bridge.ts +129 -0
  80. package/src/pantheon/v2-events.ts +131 -0
  81. package/src/pantheon/v2-hooks.ts +287 -0
  82. package/src/pantheon/v2-tool-definitions.ts +331 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +836 -0
  85. package/src/plugin.ts +57 -10
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +22 -16
  98. package/src/skills/README.md +0 -2
@@ -0,0 +1,444 @@
1
+ /**
2
+ * V1 Session Task Adapter (WS1, PR #94) — task()-shaped function over the V1
3
+ * SDK session surface (`session.create` / `promptAsync` / `messages`).
4
+ *
5
+ * The thin manager (delegate-manager.ts) orchestrates launch → monitor →
6
+ * reconcile → verify; THIS module is the only place that touches the host:
7
+ *
8
+ * - `createSessionTaskFn()` — prompt acceptance (bounded) + completion
9
+ * observed via the board (the idle hook / finalize path owns the terminal
10
+ * transition) + verified content pulled from the MD report written by
11
+ * `finalizeDelegation`. No MD report (or an empty one) means the result is
12
+ * NOT verified — the manager records `error`, never `completed`.
13
+ * - `createNativeDelegateTools()` — the three delegation tools in the same
14
+ * structural shape as the legacy toolset, wired to per-parent managers.
15
+ * The child session is created FIRST so `taskID == childSessionID`: the
16
+ * V1 idle hook and the manager share one board record (no double
17
+ * registration, no scheduler of its own).
18
+ *
19
+ * The legacy fire-and-forget path (delegation.ts) is untouched; the plugin
20
+ * selects legacy vs native via `PANTHEON_DELEGATE_MODE`.
21
+ *
22
+ * @module delegate-task-adapter
23
+ */
24
+
25
+ import { z } from 'zod'
26
+
27
+ import type { BackgroundJobBoard } from './background-job-board.ts'
28
+ import {
29
+ createDelegateManager,
30
+ type DelegateManager,
31
+ type DelegateManagerOptions,
32
+ isDelegationEnabled,
33
+ type NativeTaskFn,
34
+ } from './delegate-manager.ts'
35
+ import type { DelegationToolset } from './delegation.ts'
36
+ import {
37
+ DELEGATION_DEFAULTS,
38
+ type DelegationClient,
39
+ readDelegationReport,
40
+ } from './delegation-finalize.ts'
41
+ import type { StepCapTracker } from './step-cap.ts'
42
+
43
+ // ─── Mode gate + kill-switch ───────────────────────────────────────────
44
+
45
+ /** Delegation backend selected by `PANTHEON_DELEGATE_MODE`. */
46
+ export type DelegateMode = 'native' | 'legacy'
47
+
48
+ /**
49
+ * Resolve the delegation backend: `PANTHEON_DELEGATE_MODE=native` selects the
50
+ * thin native manager; anything else (unset, `legacy`, garbage) stays on the
51
+ * V1 fire-and-forget path. Fail-safe default = legacy (V1 intacto).
52
+ */
53
+ export function resolveDelegateMode(
54
+ env: Record<string, string | undefined> = process.env,
55
+ ): DelegateMode {
56
+ return (env.PANTHEON_DELEGATE_MODE ?? '').trim().toLowerCase() === 'native' ? 'native' : 'legacy'
57
+ }
58
+
59
+ /** Minimal structural shape of a delegation tool (legacy or native). */
60
+ export interface KillSwitchableTool {
61
+ description: string
62
+ args: unknown
63
+ // biome-ignore lint/suspicious/noExplicitAny: tool args/ctx shapes differ per toolset
64
+ execute: (args: any, ctx: any) => Promise<string>
65
+ }
66
+
67
+ /**
68
+ * Wrap a delegation toolset with the `PANTHEON_DELEGATION=off` kill-switch.
69
+ * Every entry exposing `execute()` is guarded (throws when disabled);
70
+ * non-tool entries (e.g. legacy `finalizeDelegation`) pass through
71
+ * untouched — the completion observer must keep working so children never
72
+ * orphan. Native tools already guard internally; wrapping them again is a
73
+ * harmless double-check. Returns a new object; the input is untouched.
74
+ */
75
+ export function withDelegationKillSwitch<T extends object>(
76
+ toolset: T,
77
+ env: Record<string, string | undefined> = process.env,
78
+ ): T {
79
+ const out: Record<string, unknown> = {}
80
+ for (const [key, value] of Object.entries(toolset)) {
81
+ const tool = value as Partial<KillSwitchableTool>
82
+ if (tool !== null && typeof tool === 'object' && typeof tool.execute === 'function') {
83
+ const execute = tool.execute.bind(tool)
84
+ out[key] = {
85
+ ...(tool as Record<string, unknown>),
86
+ // biome-ignore lint/suspicious/noExplicitAny: passthrough to the wrapped tool
87
+ execute: async (args: any, ctx: any) => {
88
+ if (!isDelegationEnabled(env)) {
89
+ throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
90
+ }
91
+ return execute(args, ctx)
92
+ },
93
+ }
94
+ } else {
95
+ out[key] = value
96
+ }
97
+ }
98
+ return out as T
99
+ }
100
+
101
+ // ─── Args schemas (same contract as the legacy toolset) ────────────────
102
+
103
+ const delegateArgs = {
104
+ prompt: z.string().min(1).describe('Task prompt delivered to the background agent.'),
105
+ agent: z.string().min(1).describe('Agent name, e.g. "apollo" or "hermes".'),
106
+ description: z.string().optional().describe('Human-readable description shown on the job board.'),
107
+ read_only: z.boolean().optional().describe('Advisory flag for Phase 4 read-only enforcement.'),
108
+ model: z
109
+ .string()
110
+ .optional()
111
+ .describe(
112
+ 'Explicit model for the child session (provider/model, e.g. "opencode/deepseek-v4-flash-free").',
113
+ ),
114
+ } satisfies z.ZodRawShape
115
+
116
+ const readArgs = {
117
+ id: z.string().min(1).describe('Job alias (e.g. "apo-1") or task ID to read.'),
118
+ } satisfies z.ZodRawShape
119
+
120
+ const listArgs = {} satisfies z.ZodRawShape
121
+
122
+ export interface ToolContextLike {
123
+ sessionID: string
124
+ agent?: string
125
+ }
126
+
127
+ // ─── Session task function ─────────────────────────────────────────────
128
+
129
+ export interface SessionTaskAdapterOptions {
130
+ client: DelegationClient
131
+ board: BackgroundJobBoard
132
+ outputDir?: string
133
+ /** Bound for the promptAsync acceptance request (default: 60_000). */
134
+ promptTimeoutMs?: number
135
+ /** Bound for observing the terminal transition (default: 900_000). */
136
+ settleTimeoutMs?: number
137
+ }
138
+
139
+ /** Split `provider/model` into the `{ id, providerID }` ref session.create expects. */
140
+ export function parseModelRef(model: string): { id: string; providerID: string } | undefined {
141
+ const idx = model.indexOf('/')
142
+ if (idx <= 0 || idx === model.length - 1) return undefined
143
+ return { providerID: model.slice(0, idx), id: model.slice(idx + 1) }
144
+ }
145
+
146
+ /** Text after `## Output` (cut before a trailing `**Error**:` line). */
147
+ export function extractOutputSection(md: string | undefined): string {
148
+ if (md === undefined) return ''
149
+ const marker = '## Output'
150
+ const at = md.indexOf(marker)
151
+ const body = at < 0 ? md : md.slice(at + marker.length)
152
+ const errAt = body.indexOf('\n**Error**:')
153
+ return (errAt < 0 ? body : body.slice(0, errAt)).trim()
154
+ }
155
+
156
+ async function promptAccept(
157
+ client: DelegationClient,
158
+ taskID: string,
159
+ agent: string,
160
+ prompt: string,
161
+ model: string | undefined,
162
+ signal: AbortSignal | undefined,
163
+ timeoutMs: number,
164
+ ): Promise<void> {
165
+ const modelRef = model !== undefined ? parseModelRef(model) : undefined
166
+ let timer: ReturnType<typeof setTimeout> | undefined
167
+ try {
168
+ await Promise.race([
169
+ client.session
170
+ .promptAsync({
171
+ path: { id: taskID },
172
+ body: {
173
+ agent,
174
+ ...(modelRef !== undefined ? { model: modelRef } : {}),
175
+ parts: [{ type: 'text', text: prompt }],
176
+ },
177
+ ...(signal !== undefined ? { signal } : {}),
178
+ })
179
+ .then(
180
+ () => undefined,
181
+ (error: unknown) => {
182
+ throw new Error(
183
+ `promptAsync rejected: ${error instanceof Error ? error.message : String(error)}`,
184
+ )
185
+ },
186
+ ),
187
+ new Promise<never>((_, reject) => {
188
+ timer = setTimeout(
189
+ () => reject(new Error(`promptAsync timed out after ${timeoutMs}ms`)),
190
+ timeoutMs,
191
+ )
192
+ timer.unref?.()
193
+ }),
194
+ ])
195
+ } finally {
196
+ if (timer !== undefined) clearTimeout(timer)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Build the task()-shaped function over a V1 session client.
202
+ * The child session MUST already exist with id == input.taskID (created by
203
+ * the tool wrapper before manager.launch so both share one board record).
204
+ */
205
+ export function createSessionTaskFn(options: SessionTaskAdapterOptions): NativeTaskFn {
206
+ const {
207
+ client,
208
+ board,
209
+ outputDir = DELEGATION_DEFAULTS.outputDir,
210
+ promptTimeoutMs = 60_000,
211
+ settleTimeoutMs = DELEGATION_DEFAULTS.timeoutMs,
212
+ } = options
213
+ return async ({ agent, prompt, model, signal, taskID }) => {
214
+ await promptAccept(client, taskID, agent, prompt, model, signal, promptTimeoutMs)
215
+ const terminal = await board.waitForTerminal(taskID, settleTimeoutMs)
216
+ const md = await readDelegationReport(outputDir, terminal)
217
+ const content = extractOutputSection(md) || terminal.resultSummary || ''
218
+ return { content }
219
+ }
220
+ }
221
+
222
+ // ─── Native toolset ────────────────────────────────────────────────────
223
+
224
+ export interface NativeDelegateWiring {
225
+ board: BackgroundJobBoard
226
+ client: DelegationClient
227
+ outputDir?: string
228
+ env?: Record<string, string | undefined>
229
+ isRootSession: (sessionID: string) => boolean
230
+ registerChildSession?: (sessionID: string, parentID: string) => void
231
+ registerReadOnlySession?: (
232
+ sessionID: string,
233
+ info: { agent: string; readOnlyFlag?: boolean },
234
+ ) => void
235
+ isReadOnlyAgent?: (agent: string) => boolean
236
+ /** Per-agent model override in `provider/model` form (preset/routing). */
237
+ agentModel?: (agent: string) => string | undefined
238
+ managerOptions?: Partial<
239
+ Omit<DelegateManagerOptions, 'board' | 'task' | 'parentSessionID' | 'env'>
240
+ >
241
+ stepCap?: StepCapTracker
242
+ }
243
+
244
+ export interface NativeDelegateToolset {
245
+ pantheon_delegate: {
246
+ description: string
247
+ args: typeof delegateArgs
248
+ execute(args: z.infer<z.ZodObject<typeof delegateArgs>>, ctx: ToolContextLike): Promise<string>
249
+ }
250
+ pantheon_delegation_read: {
251
+ description: string
252
+ args: typeof readArgs
253
+ execute(args: z.infer<z.ZodObject<typeof readArgs>>, ctx: ToolContextLike): Promise<string>
254
+ }
255
+ pantheon_delegation_list: {
256
+ description: string
257
+ args: typeof listArgs
258
+ execute(args: z.infer<z.ZodObject<typeof listArgs>>, ctx: ToolContextLike): Promise<string>
259
+ }
260
+ }
261
+
262
+ function firstLine(text: string, max = 60): string {
263
+ const line =
264
+ (text ?? '')
265
+ .split('\n')
266
+ .map((l) => l.trim())
267
+ .find((l) => l !== '') ?? ''
268
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line
269
+ }
270
+
271
+ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeDelegateToolset {
272
+ const {
273
+ board,
274
+ client,
275
+ outputDir = DELEGATION_DEFAULTS.outputDir,
276
+ env = process.env,
277
+ isRootSession,
278
+ registerChildSession,
279
+ registerReadOnlySession,
280
+ isReadOnlyAgent,
281
+ agentModel,
282
+ managerOptions = {},
283
+ stepCap,
284
+ } = wiring
285
+ const task = createSessionTaskFn({
286
+ client,
287
+ board,
288
+ outputDir,
289
+ ...(managerOptions.timeoutMs !== undefined
290
+ ? { promptTimeoutMs: managerOptions.timeoutMs }
291
+ : {}),
292
+ })
293
+ // One thin manager per parent session; the board is shared.
294
+ const managers = new Map<string, DelegateManager>()
295
+ function managerFor(parentSessionID: string): DelegateManager {
296
+ let manager = managers.get(parentSessionID)
297
+ if (!manager) {
298
+ manager = createDelegateManager({
299
+ ...managerOptions,
300
+ board,
301
+ task,
302
+ parentSessionID,
303
+ env,
304
+ ...(stepCap !== undefined ? { stepCap } : {}),
305
+ })
306
+ managers.set(parentSessionID, manager)
307
+ }
308
+ return manager
309
+ }
310
+
311
+ return {
312
+ pantheon_delegate: {
313
+ description:
314
+ 'Dispatch a background agent as a child session (native task() semantics: the call returns only after the verified result). Returns one line `[pantheon:<alias>] <agent> — <summary> — <state>`.',
315
+ args: delegateArgs,
316
+ execute: async (args, ctx) => {
317
+ if (!isDelegationEnabled(env)) {
318
+ throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
319
+ }
320
+ if (!isRootSession(ctx.sessionID)) {
321
+ throw new Error(
322
+ `pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
323
+ )
324
+ }
325
+ const model = args.model ?? agentModel?.(args.agent)
326
+ let childID: string
327
+ try {
328
+ const modelRef = model !== undefined ? parseModelRef(model) : undefined
329
+ const created = await client.session.create({
330
+ body: {
331
+ parentID: ctx.sessionID,
332
+ title: args.description ?? firstLine(args.prompt),
333
+ ...(modelRef !== undefined ? { model: modelRef } : {}),
334
+ },
335
+ })
336
+ childID = created.id
337
+ } catch (err) {
338
+ const reason = err instanceof Error ? err.message : String(err)
339
+ return `pantheon_delegate failed: session.create rejected: ${reason}`
340
+ }
341
+ registerChildSession?.(childID, ctx.sessionID)
342
+ if (args.read_only === true || isReadOnlyAgent?.(args.agent) === true) {
343
+ const info: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
344
+ if (args.read_only !== undefined) info.readOnlyFlag = args.read_only
345
+ registerReadOnlySession?.(childID, info)
346
+ }
347
+ const receipt = await managerFor(ctx.sessionID).launch({
348
+ agent: args.agent,
349
+ prompt: args.prompt,
350
+ ...(args.description !== undefined ? { description: args.description } : {}),
351
+ ...(args.model !== undefined ? { model: args.model } : {}),
352
+ taskID: childID,
353
+ })
354
+ return receipt.line
355
+ },
356
+ },
357
+ pantheon_delegation_read: {
358
+ description:
359
+ 'Block until a background delegation finishes, then return its verified report markdown and mark the job reconciled.',
360
+ args: readArgs,
361
+ execute: async (args, ctx) => {
362
+ if (!isDelegationEnabled(env)) {
363
+ throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
364
+ }
365
+ return managerFor(ctx.sessionID).read(args.id)
366
+ },
367
+ },
368
+ pantheon_delegation_list: {
369
+ description: 'List background delegations with [pantheon:<alias>] / [native] tags per line.',
370
+ args: listArgs,
371
+ execute: async (_args, ctx) => {
372
+ if (!isDelegationEnabled(env)) {
373
+ throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
374
+ }
375
+ const lines = await managerFor(ctx.sessionID).list()
376
+ return lines.length > 0 ? lines.join('\n') : 'No delegations.'
377
+ },
378
+ },
379
+ }
380
+ }
381
+
382
+ // ─── Plugin native branch (WS1, PR #94) ─────────────────────────────────
383
+ //
384
+ // Extracted verbatim from the `delegateMode === 'native'` branch in
385
+ // src/plugin.ts so the real mounting (model lowercasing, read-only set,
386
+ // conditional managerOptions, finalizeDelegation passthrough) is unit
387
+ // testable with fakes — importing plugin.ts itself would pull module-level
388
+ // singletons (board, timers, logger). The plugin calls this helper; the
389
+ // wiring test calls the same helper with the same shapes.
390
+
391
+ /** Raw materials the plugin owns for the native branch. */
392
+ export interface PluginNativeDelegationWiring {
393
+ board: BackgroundJobBoard
394
+ client: DelegationClient
395
+ isRootSession: (sessionID: string) => boolean
396
+ registerChildSession: (sessionID: string, parentID: string) => void
397
+ registerReadOnlySession: (
398
+ sessionID: string,
399
+ info: { agent: string; readOnlyFlag?: boolean },
400
+ ) => void
401
+ readOnlyAgents: ReadonlySet<string>
402
+ agentModels: Record<string, string | undefined>
403
+ stepCap: StepCapTracker
404
+ wallClockTimeoutMs?: number
405
+ finalizeDelegation: DelegationToolset['finalizeDelegation']
406
+ }
407
+
408
+ /** Native toolset + the legacy completion observer (never kill-switched). */
409
+ export type PluginNativeDelegation = NativeDelegateToolset & {
410
+ finalizeDelegation: DelegationToolset['finalizeDelegation']
411
+ }
412
+
413
+ export function buildPluginNativeDelegation(
414
+ wiring: PluginNativeDelegationWiring,
415
+ ): PluginNativeDelegation {
416
+ const {
417
+ board,
418
+ client,
419
+ isRootSession,
420
+ registerChildSession,
421
+ registerReadOnlySession,
422
+ readOnlyAgents,
423
+ agentModels,
424
+ stepCap,
425
+ wallClockTimeoutMs,
426
+ finalizeDelegation,
427
+ } = wiring
428
+ return {
429
+ ...createNativeDelegateTools({
430
+ board,
431
+ client,
432
+ isRootSession,
433
+ registerChildSession,
434
+ registerReadOnlySession,
435
+ isReadOnlyAgent: (agent) => readOnlyAgents.has(agent.toLowerCase()),
436
+ agentModel: (agent) => agentModels[agent.toLowerCase()],
437
+ stepCap,
438
+ ...(wallClockTimeoutMs !== undefined
439
+ ? { managerOptions: { timeoutMs: wallClockTimeoutMs } }
440
+ : {}),
441
+ }),
442
+ finalizeDelegation,
443
+ }
444
+ }
@@ -21,10 +21,11 @@
21
21
  * acknowledgment, not a completion), so every job gets exactly ONE terminal
22
22
  * transition — never a reconciled echo. Unknown sessions are ignored.
23
23
  *
24
- * `finalizeIdleChildrenWithoutMd` proactively finalizes children that are
25
- * idle on the board but have no MD report on disk — eliminates the phantom
26
- * "running" window during `pullOutput` (Fix 2 for the TUI stale-running bug).
27
- * Wired into a periodic scan via `startIdleChildScan` (default 30 s cadence).
24
+ * `finalizeIdleChildrenWithoutMd` proactively finalizes children confirmed
25
+ * idle by the V1 session-status API but with no MD report on disk — eliminates
26
+ * the phantom "running" window during `pullOutput` without treating a board
27
+ * record's `running` state as proof that the child is idle. Wired into a
28
+ * periodic scan via `startIdleChildScan` (default 30 s cadence).
28
29
  *
29
30
  * Pure TypeScript — zero runtime dependencies beyond the board types.
30
31
  *
@@ -118,6 +119,11 @@ export interface IdleChildScanDeps {
118
119
  }
119
120
  /** Bound Phase 2 finalize — clears the timeout timer + writes the report. */
120
121
  finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
122
+ /**
123
+ * Confirm the child's native V1 session status. `undefined` means the
124
+ * status is unavailable and MUST NOT be treated as idle.
125
+ */
126
+ isIdle?: (childSessionID: string) => Promise<boolean | undefined>
121
127
  /**
122
128
  * Check whether an MD report exists for the given board job.
123
129
  * Receives the full job record so the caller can construct the report
@@ -130,30 +136,41 @@ export interface IdleChildScanDeps {
130
136
  }
131
137
 
132
138
  /**
133
- * Scan running board jobs for children whose sessions are idle but have no MD
134
- * report on disk, and proactively finalize them. This eliminates the phantom
135
- * "running" window that occurs between `session.idle` firing and the
136
- * `pullOutput`→`writeDelegationReport` cycle completing.
139
+ * Scan running board jobs for children whose V1 session status is confirmed
140
+ * idle but have no MD report on disk, and proactively finalize them. This
141
+ * eliminates the phantom "running" window that occurs between `session.idle`
142
+ * firing and the `pullOutput`→`writeDelegationReport` cycle completing.
137
143
  *
138
144
  * Called from the periodic idle-child scan (`startIdleChildScan`, default 30 s).
139
145
  * The scan is O(N) where N = number of running jobs (typically < 10).
140
146
  *
141
- * Each child is finalized exactly once — the board's same-terminal idempotency
142
- * ensures repeated scans are harmless. Errors are caught per-child so one
143
- * failure never blocks the rest.
147
+ * A missing status probe is an explicit unknown state, not an idle signal, so
148
+ * legacy embedders without the V1 API fail closed. Errors are caught
149
+ * per-child so one failure never blocks the rest.
144
150
  *
145
151
  * @returns Number of children finalized (for diagnostics).
146
152
  */
147
- export async function finalizeIdleChildrenWithoutMd(
148
- deps: IdleChildScanDeps,
149
- ): Promise<number> {
150
- const runningJobs = deps.board
151
- .list()
152
- .filter((j: BackgroundJobRecord) => j.state === 'running')
153
+ export async function finalizeIdleChildrenWithoutMd(deps: IdleChildScanDeps): Promise<number> {
154
+ const runningJobs = deps.board.list().filter((j: BackgroundJobRecord) => j.state === 'running')
153
155
 
154
156
  let finalized = 0
155
157
  for (const job of runningJobs) {
156
158
  try {
159
+ if (deps.isIdle === undefined) {
160
+ deps.logger?.warn?.(
161
+ `[delegation-notify] idle status unknown for ${job.taskID}; proactive finalize skipped`,
162
+ )
163
+ continue
164
+ }
165
+ const idle = await deps.isIdle(job.taskID)
166
+ if (idle !== true) {
167
+ if (idle === undefined) {
168
+ deps.logger?.warn?.(
169
+ `[delegation-notify] idle status unknown for ${job.taskID}; proactive finalize skipped`,
170
+ )
171
+ }
172
+ continue
173
+ }
157
174
  // If the child already has an MD report, finalize was already handled
158
175
  // by handleDelegationEvent — skip.
159
176
  if (deps.hasReport(job)) continue
@@ -161,9 +178,7 @@ export async function finalizeIdleChildrenWithoutMd(
161
178
  await deps.finalize(job.taskID, { state: 'completed' })
162
179
  finalized++
163
180
  } catch (err) {
164
- deps.logger?.warn?.(
165
- `[delegation-notify] proactive finalize failed for ${job.taskID}: ${err}`,
166
- )
181
+ deps.logger?.warn?.(`[delegation-notify] proactive finalize failed for ${job.taskID}: ${err}`)
167
182
  }
168
183
  }
169
184
  return finalized