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

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 (44) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +871 -0
  3. package/README.md +101 -1
  4. package/README.pt-BR.md +55 -1
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +14 -10
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +1 -1
  9. package/scripts/code_mode_server.py +339 -28
  10. package/scripts/doctor.mjs +178 -9
  11. package/scripts/install/cli-ui.mjs +17 -11
  12. package/scripts/install/config-migration.mjs +4 -6
  13. package/scripts/install/opencode.mjs +116 -35
  14. package/scripts/install/shared.mjs +43 -2
  15. package/scripts/install/strings.mjs +129 -0
  16. package/scripts/install-mcp.mjs +1 -0
  17. package/scripts/probe-pantheon-cost.mjs +10 -1
  18. package/scripts/release-beta-version.mjs +2 -7
  19. package/scripts/sync-artifacts.mjs +169 -0
  20. package/scripts/sync-tui.mjs +124 -34
  21. package/src/agents/zeus.md +2 -2
  22. package/src/mcp/code_mode_server.py +339 -28
  23. package/src/pantheon/auto-wake.ts +1 -5
  24. package/src/pantheon/background-job-board.ts +14 -0
  25. package/src/pantheon/cost-command.ts +62 -42
  26. package/src/pantheon/delegate-manager.ts +109 -12
  27. package/src/pantheon/native-probe.ts +234 -0
  28. package/src/pantheon/native-task-status.ts +139 -0
  29. package/src/pantheon/shared-board.ts +37 -0
  30. package/src/pantheon/task-result-guard.ts +107 -0
  31. package/src/pantheon/token-opt.ts +18 -110
  32. package/src/pantheon/tool-ceiling.ts +159 -0
  33. package/src/pantheon/v2-events.ts +10 -1
  34. package/src/plugin.ts +25 -9
  35. package/src/plugins/pantheon-hooks.ts +51 -0
  36. package/src/plugins/tui/dist/server.d.ts +1 -1
  37. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  38. package/src/plugins/tui/dist/server.js +1 -1
  39. package/src/plugins/tui/dist/server.js.map +1 -1
  40. package/src/plugins/tui/dist/tui.d.ts +1 -1
  41. package/src/plugins/tui/dist/tui.js +1 -1
  42. package/src/plugins/tui/dist/tui.js.map +1 -1
  43. package/src/plugins/tui/package-lock.json +2 -2
  44. package/src/plugins/tui/package.json +1 -1
@@ -31,6 +31,8 @@ import {
31
31
  type DelegationClient,
32
32
  readDelegationReport,
33
33
  } from './delegation-finalize.ts'
34
+ import { createNativeProbe, type NativeProbeFn, type ProbeClient } from './native-probe.ts'
35
+ import { classifyNativeResult, type NativeTaskStatus } from './native-task-status.ts'
34
36
  import {
35
37
  buildStopInstruction,
36
38
  cappedSummary,
@@ -66,6 +68,7 @@ export interface ManagerReceipt {
66
68
  id: string
67
69
  agent: string
68
70
  state: 'reconciled' | 'error'
71
+ status: NativeTaskStatus
69
72
  line: string
70
73
  }
71
74
 
@@ -102,10 +105,15 @@ export interface DelegateManagerOptions {
102
105
  keepCompleted?: number
103
106
  /** Per-agent step budgets (routing.yml max_steps). */
104
107
  stepCap?: StepCapTracker
108
+ /** Lazy capability probe, invoked once before the first native dispatch. */
109
+ nativeProbe?: NativeProbeFn
110
+ /** V1 client used by the lazy capability probe. */
111
+ probeClient?: ProbeClient
105
112
  }
106
113
 
107
114
  export interface DelegateManager {
108
115
  launch(args: LaunchArgs): Promise<ManagerReceipt>
116
+ ensureNativeAvailable(): Promise<NativeTaskStatus | undefined>
109
117
  read(idOrAlias: string): Promise<string>
110
118
  list(): Promise<string[]>
111
119
  recover(): Promise<void>
@@ -131,9 +139,29 @@ export function resolveDelegateMode(
131
139
 
132
140
  const LINE_MAX = 160
133
141
 
142
+ /** Remove terminal escapes and control characters from untrusted child output. */
143
+ export function sanitizeReceiptText(text: string): string {
144
+ let clean = ''
145
+ let skippingAnsi = false
146
+ for (const char of text) {
147
+ const code = char.charCodeAt(0)
148
+ if (skippingAnsi) {
149
+ if (code === 0x5b) continue
150
+ if (code >= 0x40 && code <= 0x7e) skippingAnsi = false
151
+ continue
152
+ }
153
+ if (code === 0x1b) {
154
+ skippingAnsi = true
155
+ continue
156
+ }
157
+ if (char === '\n' || char === '\t' || (code >= 0x20 && code <= 0x7e)) clean += char
158
+ }
159
+ return clean
160
+ }
161
+
134
162
  function firstLine(text: string, max = 80): string {
135
163
  const line =
136
- (text ?? '')
164
+ sanitizeReceiptText(text ?? '')
137
165
  .split('\n')
138
166
  .map((l) => l.trim())
139
167
  .find((l) => l !== '') ?? ''
@@ -182,21 +210,30 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
182
210
  task,
183
211
  parentSessionID,
184
212
  env = process.env,
185
- models = [],
213
+ models: modelsOpt = [],
186
214
  timeoutMs = 120_000,
187
215
  readTimeoutMs = 60_000,
188
- foregroundFallback = true,
216
+ foregroundFallback: fgFallbackOpt = true,
189
217
  maxEntries = 50,
190
218
  keepCompleted = 10,
191
219
  stepCap,
220
+ nativeProbe,
221
+ probeClient,
192
222
  } = options
193
223
 
224
+ // Strict native mode: foregroundFallback and models are overridden.
225
+ // The user-facing options are ignored when PANTHEON_DELEGATE_MODE=native.
226
+ const isNativeMode = resolveDelegateMode(env) === 'native'
227
+ const foregroundFallback = isNativeMode ? false : fgFallbackOpt
228
+ const models = isNativeMode ? [] : modelsOpt
229
+
194
230
  // Synchronous in-flight reservations per agent: launch() must decide
195
231
  // background vs foreground BEFORE its first await, otherwise concurrent
196
232
  // launches without interleaved awaits would all see an empty board.
197
233
  const pendingByAgent = new Map<string, number>()
198
234
  let seq = 0
199
235
  const foregroundRecent: { agent: string; line: string; at: number }[] = []
236
+ let probeResult: Awaited<ReturnType<NativeProbeFn>> | null = null
200
237
 
201
238
  function pending(agent: string): number {
202
239
  return pendingByAgent.get(agent.toLowerCase()) ?? 0
@@ -208,6 +245,12 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
208
245
  }
209
246
  }
210
247
 
248
+ async function ensureNativeAvailable(): Promise<NativeTaskStatus | undefined> {
249
+ if (!isNativeMode || nativeProbe === undefined || probeClient === undefined) return undefined
250
+ probeResult ??= await nativeProbe({ client: probeClient, parentSessionID })
251
+ return probeResult.status === 'AVAILABLE' ? undefined : probeResult.status
252
+ }
253
+
211
254
  async function runTask(
212
255
  agent: string,
213
256
  prompt: string,
@@ -253,6 +296,7 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
253
296
  // acknowledgment that the result was consumed.
254
297
  const terminalState = job.state
255
298
  const failed = verifyError !== undefined || terminalState === 'error'
299
+ const status = classifyNativeResult(job, verifyError)
256
300
  // Auto-reconcile + delete the signal file on the spot.
257
301
  if (job.state !== 'reconciled') await board.markReconciled(taskID)
258
302
  await board.deleteSignal(alias)
@@ -266,7 +310,7 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
266
310
  LINE_MAX,
267
311
  )
268
312
  : boardLine(settled ?? job)
269
- return { id: taskID, agent: job.agent, state: failed ? 'error' : 'reconciled', line }
313
+ return { id: taskID, agent: job.agent, state: failed ? 'error' : 'reconciled', status, line }
270
314
  }
271
315
 
272
316
  /** Apply a terminal transition best-effort: a racing hook may have gotten there first. */
@@ -333,7 +377,33 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
333
377
  )
334
378
  foregroundRecent.push({ agent: args.agent, line, at: Date.now() })
335
379
  if (foregroundRecent.length > 20) foregroundRecent.splice(0, foregroundRecent.length - 20)
336
- return { id, agent: args.agent, state: ok ? 'reconciled' : 'error', line }
380
+ const status = classifyNativeResult(
381
+ {
382
+ taskID: id,
383
+ parentSessionID,
384
+ agent: args.agent,
385
+ description: args.description ?? firstLine(args.prompt, 120),
386
+ state: ok ? 'completed' : 'error',
387
+ timedOut: false,
388
+ alias: id,
389
+ launchedAt: Date.now(),
390
+ updatedAt: Date.now(),
391
+ totalErrors: 0,
392
+ timeoutCount: 0,
393
+ terminalUnreconciled: false,
394
+ contextFiles: [],
395
+ ...(ok
396
+ ? {
397
+ resultSummary: firstLine(
398
+ (outcome as { ok: true; result: NativeTaskResult }).result.content ?? '',
399
+ 500,
400
+ ),
401
+ }
402
+ : {}),
403
+ },
404
+ ok ? undefined : (outcome as { ok: false; error?: string }).error,
405
+ )
406
+ return { id, agent: args.agent, state: ok ? 'reconciled' : 'error', status, line }
337
407
  }
338
408
 
339
409
  return {
@@ -346,13 +416,31 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
346
416
  const maxSteps = stepCap.maxStepsFor(args.agent)
347
417
  if (stepCap.isCapped(args.agent)) {
348
418
  const line = cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS).slice(0, LINE_MAX)
349
- return { id: `cap-${Date.now()}`, agent: args.agent, state: 'reconciled', line }
419
+ return {
420
+ id: `cap-${Date.now()}`,
421
+ agent: args.agent,
422
+ state: 'reconciled',
423
+ status: 'ESCALATE',
424
+ line,
425
+ }
350
426
  }
351
427
  const rec = stepCap.recordStep(args.agent)
352
428
  if (rec.capped && rec.maxSteps !== undefined) {
353
429
  prompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
354
430
  }
355
431
  }
432
+ if (isNativeMode) {
433
+ const unavailableStatus = await ensureNativeAvailable()
434
+ if (unavailableStatus !== undefined) {
435
+ return {
436
+ id: `probe-${parentSessionID}`,
437
+ agent: args.agent,
438
+ state: 'error',
439
+ status: unavailableStatus,
440
+ line: `[native] ${args.agent} — native API ${unavailableStatus.toLowerCase()} — ERR`,
441
+ }
442
+ }
443
+ }
356
444
  // Synchronous routing decision: background when a slot is free,
357
445
  // foreground fallback when full, clean rejection otherwise.
358
446
  const free =
@@ -363,6 +451,7 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
363
451
  `pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}"`,
364
452
  )
365
453
  },
454
+ ensureNativeAvailable,
366
455
 
367
456
  async read(idOrAlias: string): Promise<string> {
368
457
  guardEnabled()
@@ -376,11 +465,11 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
376
465
  if (terminal.state !== 'reconciled') await board.markReconciled(terminal.taskID)
377
466
  await board.deleteSignal(terminal.alias)
378
467
  if (terminal.state === 'error' && !terminal.resultSummary) {
379
- return `# Delegation Report — ${terminal.alias}\n\nERROR: ${terminal.lastStatusError ?? 'failed'}\n`
468
+ return `# Delegation Report — ${terminal.alias}\n\nERROR: ${sanitizeReceiptText(terminal.lastStatusError ?? 'failed')}\n`
380
469
  }
381
470
  return (
382
471
  `# Delegation Report — ${terminal.alias}\n\n` +
383
- `${terminal.resultSummary ?? terminal.lastStatusError ?? terminal.state}\n`
472
+ `${sanitizeReceiptText(terminal.resultSummary ?? terminal.lastStatusError ?? terminal.state)}\n`
384
473
  )
385
474
  },
386
475
 
@@ -524,7 +613,7 @@ export interface NativeDelegateToolset {
524
613
  }
525
614
  function shortFirstLine(text: string, max = 60): string {
526
615
  const line =
527
- text
616
+ sanitizeReceiptText(text)
528
617
  .split('\n')
529
618
  .map((item) => item.trim())
530
619
  .find(Boolean) ?? ''
@@ -555,6 +644,7 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
555
644
  const managerFor = (parentSessionID: string): DelegateManager => {
556
645
  const existing = managers.get(parentSessionID)
557
646
  if (existing !== undefined) return existing
647
+ const nativeProbe = createNativeProbe()
558
648
  const manager = createDelegateManager({
559
649
  ...managerOptions,
560
650
  board,
@@ -562,6 +652,8 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
562
652
  parentSessionID,
563
653
  env,
564
654
  ...(stepCap === undefined ? {} : { stepCap }),
655
+ nativeProbe,
656
+ probeClient: client,
565
657
  })
566
658
  managers.set(parentSessionID, manager)
567
659
  return manager
@@ -577,6 +669,11 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
577
669
  throw new Error(
578
670
  `pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
579
671
  )
672
+ const manager = managerFor(ctx.sessionID)
673
+ const unavailableStatus = await manager.ensureNativeAvailable()
674
+ if (unavailableStatus !== undefined) {
675
+ return `status: ${unavailableStatus}\n[native] ${args.agent} — native API ${unavailableStatus.toLowerCase()} — ERR`
676
+ }
580
677
  const model = args.model ?? agentModel?.(args.agent)
581
678
  let childID: string
582
679
  try {
@@ -590,7 +687,7 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
590
687
  })
591
688
  childID = created.id
592
689
  } catch (error) {
593
- return `pantheon_delegate failed: session.create rejected: ${error instanceof Error ? error.message : String(error)}`
690
+ return `pantheon_delegate failed: session.create rejected: ${sanitizeReceiptText(error instanceof Error ? error.message : String(error))}`
594
691
  }
595
692
  registerChildSession?.(childID, ctx.sessionID)
596
693
  if (args.read_only === true || isReadOnlyAgent?.(args.agent) === true)
@@ -598,14 +695,14 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
598
695
  agent: args.agent,
599
696
  ...(args.read_only === undefined ? {} : { readOnlyFlag: args.read_only }),
600
697
  })
601
- const receipt = await managerFor(ctx.sessionID).launch({
698
+ const receipt = await manager.launch({
602
699
  agent: args.agent,
603
700
  prompt: args.prompt,
604
701
  ...(args.description === undefined ? {} : { description: args.description }),
605
702
  ...(args.model === undefined ? {} : { model: args.model }),
606
703
  taskID: childID,
607
704
  })
608
- return receipt.line
705
+ return `status: ${receipt.status}\n${receipt.line}`
609
706
  },
610
707
  },
611
708
  pantheon_delegation_read: {
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Native Probe — lazy capability detection for native task() delegation.
3
+ *
4
+ * The probe is invoked on the FIRST native delegation of a session (not at
5
+ * startup). It creates a child session with a minimal "ping" prompt, waits
6
+ * for idle, and classifies the output.
7
+ *
8
+ * Results are cached per probe instance (singleton guard):
9
+ * - UNAVAILABLE is TERMINAL — no retry.
10
+ * - AVAILABLE is also cached — no re-probe.
11
+ *
12
+ * Uses the OpenCode V1 session API (session.create + promptAsync) injected via
13
+ * the parent context.
14
+ *
15
+ * @module native-probe
16
+ */
17
+
18
+ import { createPantheonLogger } from './logger.ts'
19
+
20
+ const log = createPantheonLogger({ module: 'native-probe' })
21
+
22
+ // ─── Types ─────────────────────────────────────────────────────────────
23
+
24
+ /** Possible outcomes of a native capability probe. */
25
+ export type NativeProbeResult = {
26
+ status: 'AVAILABLE' | 'UNAVAILABLE' | 'UNSUPPORTED'
27
+ }
28
+
29
+ /** Minimal V1 client shape needed by the probe (session.create + promptAsync). */
30
+ export interface ProbeClient {
31
+ session: {
32
+ create: (input: { body: { parentID: string } }) => Promise<{ id: string }>
33
+ promptAsync: (input: {
34
+ path: { id: string }
35
+ body: { agent: string; parts: Array<{ type: 'text'; text: string }> }
36
+ }) => Promise<unknown>
37
+ }
38
+ }
39
+
40
+ /** Context injected into the probe function. */
41
+ export interface NativeProbeContext {
42
+ client: ProbeClient
43
+ parentSessionID: string
44
+ }
45
+
46
+ /** Options for a single probe invocation. */
47
+ export interface NativeProbeOptions {
48
+ /** Timeout in milliseconds. Default: 30000. */
49
+ timeoutMs?: number
50
+ }
51
+
52
+ /** The probe function signature. */
53
+ export type NativeProbeFn = (
54
+ ctx: NativeProbeContext,
55
+ options?: NativeProbeOptions,
56
+ ) => Promise<NativeProbeResult>
57
+
58
+ // ─── Helpers ───────────────────────────────────────────────────────────
59
+
60
+ /** Default timeout for the probe. */
61
+ const DEFAULT_TIMEOUT_MS = 30_000
62
+
63
+ /** Sentinel value indicating a timeout occurred. */
64
+ const TIMEOUT_SENTINEL = Symbol('timeout')
65
+
66
+ /**
67
+ * Wrap a promise with a timeout.
68
+ * Returns the value on success, throws the original error on rejection,
69
+ * or returns TIMEOUT_SENTINEL if the timeout fires first.
70
+ */
71
+ async function withTimeout<T>(
72
+ promise: Promise<T>,
73
+ ms: number,
74
+ ): Promise<T | typeof TIMEOUT_SENTINEL> {
75
+ return new Promise<T | typeof TIMEOUT_SENTINEL>((resolve, reject) => {
76
+ const timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), ms)
77
+ void promise.then(
78
+ (value) => {
79
+ clearTimeout(timer)
80
+ resolve(value)
81
+ },
82
+ (err: unknown) => {
83
+ clearTimeout(timer)
84
+ reject(err)
85
+ },
86
+ )
87
+ })
88
+ }
89
+
90
+ /**
91
+ * Extract text content from a promptAsync response.
92
+ * Returns empty string if no recognizable content found.
93
+ */
94
+ function extractOutput(result: unknown): string {
95
+ if (result == null) return ''
96
+ if (typeof result === 'string') return result
97
+ if (typeof result === 'object') {
98
+ const obj = result as Record<string, unknown>
99
+ // Check common shapes: { output }, { content }, { text }
100
+ if (typeof obj.output === 'string') return obj.output
101
+ if (typeof obj.content === 'string') return obj.content
102
+ if (typeof obj.text === 'string') return obj.text
103
+ }
104
+ // Non-empty object → treat as content (accepted message, etc.)
105
+ if (typeof result === 'object' && result !== null) {
106
+ return 'acknowledged'
107
+ }
108
+ return ''
109
+ }
110
+
111
+ function isUnsupportedError(message: string): boolean {
112
+ const lower = message.toLowerCase()
113
+ return (
114
+ lower.includes('not available') ||
115
+ lower.includes('not supported') ||
116
+ lower.includes('unsupported') ||
117
+ lower.includes('method not found')
118
+ )
119
+ }
120
+
121
+ // ─── Implementation ────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Create a native probe function with per-instance caching.
125
+ *
126
+ * The probe is LAZY: it does not run at startup. Call it on the first
127
+ * native delegation of the session. The result is cached — subsequent
128
+ * calls return the cached value without re-probing.
129
+ *
130
+ * @returns A probe function that caches results per instance.
131
+ */
132
+ export function createNativeProbe(): NativeProbeFn {
133
+ let cachedResult: NativeProbeResult | null = null
134
+ let inFlight: Promise<NativeProbeResult> | null = null
135
+
136
+ return async (
137
+ ctx: NativeProbeContext,
138
+ options?: NativeProbeOptions,
139
+ ): Promise<NativeProbeResult> => {
140
+ // Return cached result if available (singleton guard)
141
+ if (cachedResult !== null) {
142
+ return cachedResult
143
+ }
144
+ // Concurrent first dispatches share one probe instead of creating multiple
145
+ // child sessions. The promise is cleared only after a terminal result.
146
+ if (inFlight !== null) return inFlight
147
+
148
+ inFlight = probe(ctx, options)
149
+ try {
150
+ return await inFlight
151
+ } finally {
152
+ inFlight = null
153
+ }
154
+ }
155
+
156
+ async function probe(
157
+ ctx: NativeProbeContext,
158
+ options?: NativeProbeOptions,
159
+ ): Promise<NativeProbeResult> {
160
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS
161
+
162
+ // ── Phase 1: create child session ──────────────────────────────
163
+ let childID: string
164
+ try {
165
+ const createResult = await withTimeout(
166
+ ctx.client.session.create({ body: { parentID: ctx.parentSessionID } }),
167
+ timeoutMs,
168
+ )
169
+ if (createResult === TIMEOUT_SENTINEL) {
170
+ // Timeout during session.create → UNAVAILABLE
171
+ log.warn('[Native Probe] session.create timed out after', timeoutMs, 'ms')
172
+ const result: NativeProbeResult = { status: 'UNAVAILABLE' }
173
+ cachedResult = result
174
+ return result
175
+ }
176
+ childID = createResult.id
177
+ } catch (err: unknown) {
178
+ const msg = err instanceof Error ? err.message : String(err)
179
+ log.error('[Native Probe] session.create failed:', msg)
180
+ // Check for unsupported API patterns
181
+ if (isUnsupportedError(msg)) {
182
+ const result: NativeProbeResult = { status: 'UNSUPPORTED' }
183
+ cachedResult = result
184
+ return result
185
+ }
186
+ // Other creation failures → UNAVAILABLE
187
+ const result: NativeProbeResult = { status: 'UNAVAILABLE' }
188
+ cachedResult = result
189
+ return result
190
+ }
191
+
192
+ // ── Phase 2: send probe prompt ─────────────────────────────────
193
+ let promptResult: unknown
194
+ try {
195
+ const raw = await withTimeout(
196
+ ctx.client.session.promptAsync({
197
+ path: { id: childID },
198
+ body: { agent: 'probe', parts: [{ type: 'text', text: 'Reply with PONG.' }] },
199
+ }),
200
+ timeoutMs,
201
+ )
202
+ if (raw === TIMEOUT_SENTINEL) {
203
+ // Timeout during promptAsync → UNAVAILABLE
204
+ log.warn('[Native Probe] promptAsync timed out after', timeoutMs, 'ms')
205
+ const result: NativeProbeResult = { status: 'UNAVAILABLE' }
206
+ cachedResult = result
207
+ return result
208
+ }
209
+ promptResult = raw
210
+ } catch (err: unknown) {
211
+ const msg = err instanceof Error ? err.message : String(err)
212
+ log.error('[Native Probe] promptAsync failed:', msg)
213
+ if (isUnsupportedError(msg)) {
214
+ const result: NativeProbeResult = { status: 'UNSUPPORTED' }
215
+ cachedResult = result
216
+ return result
217
+ }
218
+ // promptAsync error → UNAVAILABLE
219
+ const result: NativeProbeResult = { status: 'UNAVAILABLE' }
220
+ cachedResult = result
221
+ return result
222
+ }
223
+
224
+ // ── Phase 3: classify output ───────────────────────────────────
225
+ const output = extractOutput(promptResult)
226
+ const hasContent = output.trim() !== ''
227
+
228
+ const result: NativeProbeResult = {
229
+ status: hasContent ? 'AVAILABLE' : 'UNAVAILABLE',
230
+ }
231
+ cachedResult = result
232
+ return result
233
+ }
234
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Native Task Status — classifies BackgroundJobRecord results from native
3
+ * task() delegation into actionable status codes.
4
+ *
5
+ * Pure module — zero external dependencies. Consumed by native-probe.ts
6
+ * and the delegation manager to decide retry/skip/escalate strategy.
7
+ *
8
+ * @module native-task-status
9
+ */
10
+
11
+ import type { BackgroundJobRecord } from './background-job-board.ts'
12
+
13
+ // ─── Types ─────────────────────────────────────────────────────────────
14
+
15
+ /** Actionable status for a native task() delegation result. */
16
+ export type NativeTaskStatus =
17
+ | 'OK'
18
+ | 'UNSUPPORTED'
19
+ | 'UNAVAILABLE'
20
+ | 'INVALID_INPUT'
21
+ | 'INVALID_STATE'
22
+ | 'CONFLICT'
23
+ | 'CORRUPT_DATA'
24
+ | 'TIMEOUT'
25
+ | 'ESCALATE'
26
+
27
+ // ─── Helpers ───────────────────────────────────────────────────────────
28
+
29
+ /** Check if a string contains any of the given substrings (case-insensitive). */
30
+ function containsAny(text: string | undefined, patterns: readonly string[]): boolean {
31
+ if (text == null) return false
32
+ const lower = text.toLowerCase()
33
+ return patterns.some((p) => lower.includes(p.toLowerCase()))
34
+ }
35
+
36
+ /** Check if a job's output is empty or whitespace-only. */
37
+ function isOutputEmpty(job: BackgroundJobRecord): boolean {
38
+ const summary = job.resultSummary
39
+ if (summary == null) return true
40
+ if (typeof summary !== 'string') return true
41
+ return summary.trim() === ''
42
+ }
43
+
44
+ // ─── Implementation ────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Classify a BackgroundJobRecord result into a NativeTaskStatus.
48
+ *
49
+ * Priority order (first match wins):
50
+ * 1. verifyError and lastStatusError patterns → UNSUPPORTED, CONFLICT,
51
+ * INVALID_INPUT, INVALID_STATE, CORRUPT_DATA, ESCALATE
52
+ * 2. state === startup_failed or timedOut → TIMEOUT
53
+ * 3. state === completed + non-empty output → OK
54
+ * 4. state === completed + empty output → UNAVAILABLE (silent failure / admission rejection)
55
+ * 5. state === error → UNAVAILABLE (safe default for provider/infra errors)
56
+ * 6. Fallback → UNAVAILABLE
57
+ */
58
+ export function classifyNativeResult(
59
+ job: BackgroundJobRecord,
60
+ verifyError?: string,
61
+ ): NativeTaskStatus {
62
+ const statusError = [verifyError, job.lastStatusError]
63
+ .filter((value): value is string => value != null && value.length > 0)
64
+ .join(' ')
65
+
66
+ // A missing native task API is terminal and must not be retried or sent
67
+ // through a fallback model. Provider/transport failures remain unavailable.
68
+ if (
69
+ containsAny(statusError, [
70
+ 'api unavailable',
71
+ 'api not available',
72
+ 'api missing',
73
+ 'method not found',
74
+ 'unknown method',
75
+ 'not supported',
76
+ 'unsupported',
77
+ 'does not support',
78
+ 'task() is not available',
79
+ ])
80
+ ) {
81
+ return 'UNSUPPORTED'
82
+ }
83
+
84
+ // ── Phase 1: verifyError explicit signals (highest priority) ───────
85
+
86
+ if (verifyError != null && verifyError.length > 0) {
87
+ // CONFLICT: canDispatch=false or concurrency exceeded
88
+ if (containsAny(verifyError, ['canDispatch', 'concurrency', 'conflict'])) {
89
+ return 'CONFLICT'
90
+ }
91
+
92
+ // INVALID_INPUT: agent not recognized / invalid
93
+ if (containsAny(verifyError, ['invalid agent', 'agent not found', 'unknown agent'])) {
94
+ return 'INVALID_INPUT'
95
+ }
96
+
97
+ // INVALID_STATE: child already in terminal state
98
+ if (containsAny(verifyError, ['already terminal', 'already in terminal', 'invalid state'])) {
99
+ return 'INVALID_STATE'
100
+ }
101
+
102
+ // CORRUPT_DATA: output can't be parsed / corrupted
103
+ if (containsAny(verifyError, ['corrupt', 'parse failed', 'malformed', 'invalid format'])) {
104
+ return 'CORRUPT_DATA'
105
+ }
106
+
107
+ // ESCALATE: unrecoverable failure
108
+ if (containsAny(verifyError, ['unrecoverable', 'fatal', 'max retries exceeded'])) {
109
+ return 'ESCALATE'
110
+ }
111
+ }
112
+
113
+ // ── Phase 2: state-based classification ────────────────────────────
114
+
115
+ // TIMEOUT: startup failure or timed out
116
+ if (job.state === 'startup_failed' || job.state === 'startup_unknown' || job.timedOut) {
117
+ return 'TIMEOUT'
118
+ }
119
+
120
+ // ── Phase 3: output-based classification ───────────────────────────
121
+
122
+ if (job.state === 'completed') {
123
+ if (!isOutputEmpty(job)) {
124
+ return 'OK'
125
+ }
126
+ // completed but empty → silent failure (BackendAdmissionRejected pattern)
127
+ return 'UNAVAILABLE'
128
+ }
129
+
130
+ // ── Phase 4: error state fallback ──────────────────────────────────
131
+
132
+ if (job.state === 'error') {
133
+ return 'UNAVAILABLE'
134
+ }
135
+
136
+ // ── Fallback ───────────────────────────────────────────────────────
137
+
138
+ return 'UNAVAILABLE'
139
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Process-wide BackgroundJobBoard singleton shared by every Pantheon plugin
3
+ * surface (src/plugin.ts V1, src/plugins/pantheon-hooks.ts).
4
+ *
5
+ * WHY globalThis and not a module-level `const`: opencode loads each plugin
6
+ * TWICE from two different filesystem paths (the installed npm package AND
7
+ * the repo source — see src/pantheon/plugin-once.ts). Each load is a separate
8
+ * module instance, so a plain ESM singleton would yield TWO boards: the
9
+ * native-task mirror in pantheon-hooks would register jobs on one instance
10
+ * while the finalize path (session.idle + idle scan in plugin.ts) listens on
11
+ * the other, and the job would never finalize. A globalThis-anchored value is
12
+ * process-global by construction, deduping both module copies.
13
+ *
14
+ * The persistence file and signal dir are RELATIVE paths — the board operates
15
+ * against the opencode project root (the cwd of the plugin process), matching
16
+ * the pre-existing plugin.ts wiring exactly.
17
+ */
18
+ import { BackgroundJobBoard } from './background-job-board.ts'
19
+ import { FilePersistenceAdapter } from './file-persistence.ts'
20
+
21
+ const BOARD_KEY = '__pantheonSharedBoard'
22
+
23
+ /** Construction inputs mirror the original plugin.ts module-scope board. */
24
+ const BOARD_OPTIONS = {
25
+ maxConcurrentPerAgent: 3,
26
+ signalDir: '.pantheon/deepwork/board-signals',
27
+ } as const
28
+
29
+ export function getSharedBoard(): BackgroundJobBoard {
30
+ const host = globalThis as { [BOARD_KEY]?: BackgroundJobBoard }
31
+ const existing = host[BOARD_KEY]
32
+ if (existing instanceof BackgroundJobBoard) return existing
33
+ const board = new BackgroundJobBoard(BOARD_OPTIONS)
34
+ board.setPersistence(new FilePersistenceAdapter('.pantheon/board/state.json'))
35
+ host[BOARD_KEY] = board
36
+ return board
37
+ }