pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.10

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 (82) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +1026 -0
  3. package/README.md +171 -16
  4. package/README.pt-BR.md +99 -13
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +23 -14
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +65 -0
  9. package/scripts/code_mode_server.py +431 -55
  10. package/scripts/cost.mjs +30 -14
  11. package/scripts/doctor.mjs +178 -9
  12. package/scripts/install/cli-ui.mjs +17 -11
  13. package/scripts/install/config-migration.mjs +4 -6
  14. package/scripts/install/opencode.mjs +137 -35
  15. package/scripts/install/plugin.mjs +3 -33
  16. package/scripts/install/shared.mjs +43 -2
  17. package/scripts/install/strings.mjs +129 -0
  18. package/scripts/install-mcp.mjs +1 -0
  19. package/scripts/manifest-inventory.mjs +410 -0
  20. package/scripts/mcp_persistence_server.py +537 -128
  21. package/scripts/package-evidence.mjs +242 -0
  22. package/scripts/probe-context-rehydrate.mjs +240 -0
  23. package/scripts/probe-pantheon-cost.mjs +313 -0
  24. package/scripts/release-beta-version.mjs +56 -60
  25. package/scripts/sync-artifacts.mjs +169 -0
  26. package/scripts/sync-tui.mjs +121 -53
  27. package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
  28. package/scripts/validation-policy.mjs +7 -7
  29. package/scripts/version-check.mjs +113 -108
  30. package/scripts/versioning.mjs +230 -86
  31. package/src/agents/mnemosyne.md +1 -1
  32. package/src/agents/zeus.md +2 -2
  33. package/src/mcp/code_mode_server.py +431 -55
  34. package/src/mcp/mcp_persistence_server.py +537 -128
  35. package/src/pantheon/auto-wake.ts +1 -5
  36. package/src/pantheon/background-job-board.ts +43 -1
  37. package/src/pantheon/compaction-handoff.ts +304 -0
  38. package/src/pantheon/cost-command.ts +345 -62
  39. package/src/pantheon/delegate-manager.ts +390 -8
  40. package/src/pantheon/delegation-finalize.ts +32 -0
  41. package/src/pantheon/delegation.ts +235 -18
  42. package/src/pantheon/file-persistence-salvage.ts +64 -0
  43. package/src/pantheon/file-persistence.ts +194 -45
  44. package/src/pantheon/native-probe.ts +234 -0
  45. package/src/pantheon/native-task-status.ts +139 -0
  46. package/src/pantheon/provider-capabilities.ts +221 -0
  47. package/src/pantheon/provider-stream-tracker.ts +182 -0
  48. package/src/pantheon/shared-board.ts +37 -0
  49. package/src/pantheon/task-result-guard.ts +107 -0
  50. package/src/pantheon/token-opt.ts +146 -12
  51. package/src/pantheon/tool-ceiling.ts +159 -0
  52. package/src/pantheon/v2-events.ts +18 -1
  53. package/src/plugin-v2.ts +2 -14
  54. package/src/plugin.ts +28 -17
  55. package/src/plugins/pantheon-hooks.ts +51 -0
  56. package/src/plugins/tui/dist/server.d.ts +1 -1
  57. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  58. package/src/plugins/tui/dist/server.js +1 -1
  59. package/src/plugins/tui/dist/server.js.map +1 -1
  60. package/src/plugins/tui/dist/tui.d.ts +116 -27
  61. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  62. package/src/plugins/tui/dist/tui.js +414 -150
  63. package/src/plugins/tui/dist/tui.js.map +1 -1
  64. package/src/plugins/tui/dist/tui.tsx +447 -84
  65. package/src/plugins/tui/package-lock.json +2 -2
  66. package/src/plugins/tui/package.json +1 -1
  67. package/src/plugins/tui/src/index.tsx +447 -84
  68. package/.pantheon/code-mode/.gitkeep +0 -0
  69. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
  70. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
  71. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
  72. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
  73. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
  74. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
  75. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
  76. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
  77. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
  78. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
  79. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
  80. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
  81. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
  82. package/src/pantheon/delegate-task-adapter.ts +0 -444
@@ -1,91 +1,238 @@
1
1
  /**
2
2
  * File-based persistence adapter for the BackgroundJobBoard.
3
3
  *
4
- * Stores all jobs in a single `state.json` file using atomic writes
5
- * (write to `state.json.tmp` then rename) to prevent partial writes
6
- * from crashes.
4
+ * Stores all jobs in a single `state.json` file. Writes are crash-safe:
5
+ * the payload is written to a UNIQUE temp file, fsynced, then renamed over
6
+ * the destination. Writes to the same path are serialized through an
7
+ * in-process queue so two logical writers (plugin × native) cannot
8
+ * interleave and corrupt the JSON or race the rename.
9
+ *
10
+ * Reads are corruption-tolerant: a malformed `state.json` is moved aside to
11
+ * `<state>.corrupt-<ts>`, a clear log is emitted, and whatever complete
12
+ * records can be salvaged from the array are returned — never a throw, so
13
+ * callers (delete/prune/recover) cannot get stuck in a retry loop.
7
14
  *
8
15
  * @module file-persistence
9
16
  */
10
17
 
11
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
12
- import { dirname } from 'node:path'
18
+ import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
19
+ import { dirname, resolve } from 'node:path'
13
20
 
14
21
  import type { BackgroundJobRecord, PersistenceAdapter } from './background-job-board.ts'
22
+ import { salvageRecords } from './file-persistence-salvage.ts'
23
+ import { createPantheonLogger } from './logger.ts'
24
+
25
+ export { salvageRecords } from './file-persistence-salvage.ts'
26
+
27
+ const log = createPantheonLogger({ module: 'FilePersistence' })
15
28
 
16
29
  /** Default path for the state file. */
17
30
  const DEFAULT_STATE_PATH = '.pantheon/board/state.json'
18
31
 
32
+ /** Max rename attempts after an ENOENT before giving up (dir/tmp recreated each try). */
33
+ const RENAME_MAX_ATTEMPTS = 3
34
+
35
+ /** Minimal file handle surface used to fsync the temp file before rename. */
36
+ export interface PersistenceFileHandle {
37
+ writeFile(data: string, encoding: 'utf-8'): Promise<void>
38
+ sync(): Promise<void>
39
+ close(): Promise<void>
40
+ }
41
+
42
+ /**
43
+ * Filesystem operations used by the adapter. Injectable so tests can fault
44
+ * inject a failing `rename` (ENOENT) deterministically.
45
+ */
46
+ export interface PersistenceFsOps {
47
+ mkdir(dir: string): Promise<void>
48
+ readFile(path: string, encoding: 'utf-8'): Promise<string>
49
+ rename(from: string, to: string): Promise<void>
50
+ openForWrite(path: string): Promise<PersistenceFileHandle>
51
+ unlink(path: string): Promise<void>
52
+ }
53
+
54
+ /** Real Node.js filesystem implementation (the default). */
55
+ const defaultFsOps: PersistenceFsOps = {
56
+ mkdir: async (dir) => {
57
+ await mkdir(dir, { recursive: true })
58
+ },
59
+ readFile: (path, encoding) => readFile(path, encoding),
60
+ rename: (from, to) => rename(from, to),
61
+ openForWrite: (path) => open(path, 'w'),
62
+ unlink: (path) => unlink(path),
63
+ }
64
+
65
+ function isEnoent(err: unknown): boolean {
66
+ return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT'
67
+ }
68
+
69
+ /**
70
+ * In-process write queues keyed by absolute state path. Shared across
71
+ * adapter INSTANCES so a plugin adapter and a native adapter pointing at the
72
+ * same file serialize their read-modify-write cycles.
73
+ */
74
+ const writeQueues = new Map<string, Promise<unknown>>()
75
+
76
+ function enqueueWrite<T>(key: string, task: () => Promise<T>): Promise<T> {
77
+ const previous = writeQueues.get(key) ?? Promise.resolve()
78
+ const run = previous.then(task, task)
79
+ writeQueues.set(
80
+ key,
81
+ run.then(
82
+ () => undefined,
83
+ () => undefined,
84
+ ),
85
+ )
86
+ return run
87
+ }
88
+
19
89
  /**
20
90
  * File-based persistence adapter.
21
91
  *
22
- * All jobs are stored in a single JSON array in `statePath`.
23
- * Writes are atomic: content is written to a `.tmp` file, then renamed.
24
- *
25
- * The board's in-memory `this.jobs` Map is the source of truth —
26
- * this file is a crash-recovery snapshot. `deleteJob()` removes the
27
- * record from the snapshot so pruned/reconciled jobs don't resurface
28
- * after a restart.
92
+ * All jobs are stored in a single JSON array in `statePath`. Writes are
93
+ * atomic and serialized (unique tmp + fsync + rename, with ENOENT retry).
94
+ * Corrupt state is backed up and salvaged instead of throwing.
29
95
  */
30
96
  export class FilePersistenceAdapter implements PersistenceAdapter {
31
97
  private readonly statePath: string
32
98
  private readonly dir: string
99
+ private readonly ops: PersistenceFsOps
100
+ private readonly lockKey: string
101
+ private tmpCounter = 0
33
102
 
34
103
  /**
35
104
  * @param statePath Path to the state JSON file (default: `.pantheon/board/state.json`)
105
+ * @param ops Filesystem operations (defaults to real Node.js fs; injectable for tests)
36
106
  */
37
- constructor(statePath?: string) {
107
+ constructor(statePath?: string, ops: PersistenceFsOps = defaultFsOps) {
38
108
  this.statePath = statePath ?? DEFAULT_STATE_PATH
39
109
  this.dir = dirname(this.statePath)
110
+ this.ops = ops
111
+ this.lockKey = resolve(this.statePath)
40
112
  }
41
113
 
42
114
  /** Ensure the parent directory exists. */
43
115
  private async ensureDir(): Promise<void> {
44
- await mkdir(this.dir, { recursive: true })
116
+ await this.ops.mkdir(this.dir)
45
117
  }
46
118
 
47
- /** Path to the atomic-write temp file. */
48
- private get tmpPath(): string {
49
- return this.statePath + '.tmp'
119
+ /** Unique temp path per write — prevents two writers racing the same tmp. */
120
+ private makeTmpPath(): string {
121
+ this.tmpCounter += 1
122
+ const unique = `${process.pid}.${this.tmpCounter}.${Math.random().toString(36).slice(2, 8)}`
123
+ return `${this.statePath}.${unique}.tmp`
50
124
  }
51
125
 
52
126
  /**
53
127
  * Read all jobs from the state file.
54
- * Returns `null` if the file doesn't exist (first run / no jobs yet).
128
+ *
129
+ * Return contract distinguishes three failure modes:
130
+ * - absent file (ENOENT) → `[]` (first run / no jobs yet);
131
+ * - corrupt content → quarantine + `salvageRecords()` (best-effort recovery);
132
+ * - non-ENOENT IO error (e.g. EIO) → `null`, signalling the read failed.
133
+ *
134
+ * Callers doing read-modify-write MUST abort on `null` so a failed read
135
+ * cannot overwrite the on-disk state with a partial snapshot.
55
136
  */
56
137
  private async readState(): Promise<BackgroundJobRecord[] | null> {
138
+ let content: string
57
139
  try {
58
- const content = await readFile(this.statePath, 'utf-8')
59
- return JSON.parse(content) as BackgroundJobRecord[]
140
+ content = await this.ops.readFile(this.statePath, 'utf-8')
60
141
  } catch (err: unknown) {
61
- if (
62
- err instanceof Error &&
63
- 'code' in err &&
64
- (err as NodeJS.ErrnoException).code === 'ENOENT'
65
- ) {
66
- return null
142
+ if (isEnoent(err)) return []
143
+ log.error(`[FilePersistence] Failed to read ${this.statePath}:`, err)
144
+ return null
145
+ }
146
+
147
+ try {
148
+ const parsed: unknown = JSON.parse(content)
149
+ if (!Array.isArray(parsed)) {
150
+ throw new Error('state.json is not a JSON array')
67
151
  }
68
- throw err
152
+ return parsed as BackgroundJobRecord[]
153
+ } catch (err: unknown) {
154
+ await this.quarantineCorrupt(err)
155
+ const salvaged = salvageRecords(content)
156
+ log.warn(
157
+ `[FilePersistence] Corrupt ${this.statePath} — recovered ${salvaged.length} record(s)`,
158
+ )
159
+ return salvaged
160
+ }
161
+ }
162
+
163
+ /** Move a corrupt state.json aside so the next write starts from a clean file. */
164
+ private async quarantineCorrupt(cause: unknown): Promise<void> {
165
+ const backupPath = `${this.statePath}.corrupt-${Date.now()}`
166
+ try {
167
+ await this.ops.rename(this.statePath, backupPath)
168
+ log.error(`[FilePersistence] Corrupt ${this.statePath} backed up to ${backupPath}:`, cause)
169
+ } catch (err: unknown) {
170
+ log.error(
171
+ `[FilePersistence] Could not back up corrupt ${this.statePath} — continuing with salvaged state:`,
172
+ err,
173
+ )
69
174
  }
70
175
  }
71
176
 
72
- /** Write the full state array atomically (tmp + rename). */
177
+ /** Write the full state array: unique tmp + fsync + rename (with ENOENT retry). */
73
178
  private async writeState(records: BackgroundJobRecord[]): Promise<void> {
74
- await this.ensureDir()
75
179
  const content = JSON.stringify(records, null, 2)
76
- await writeFile(this.tmpPath, content, 'utf-8')
77
- await rename(this.tmpPath, this.statePath)
180
+ const tmpPath = this.makeTmpPath()
181
+ let lastError: unknown
182
+
183
+ for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
184
+ try {
185
+ await this.ensureDir()
186
+ const handle = await this.ops.openForWrite(tmpPath)
187
+ try {
188
+ await handle.writeFile(content, 'utf-8')
189
+ await handle.sync()
190
+ } finally {
191
+ await handle.close()
192
+ }
193
+ await this.ops.rename(tmpPath, this.statePath)
194
+ return
195
+ } catch (err: unknown) {
196
+ lastError = err
197
+ if (!isEnoent(err)) break
198
+ log.warn(
199
+ `[FilePersistence] rename ENOENT for ${this.statePath} ` +
200
+ `(attempt ${attempt}/${RENAME_MAX_ATTEMPTS}) — recreating dir and retrying`,
201
+ )
202
+ await this.safeUnlink(tmpPath)
203
+ }
204
+ }
205
+
206
+ await this.safeUnlink(tmpPath)
207
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
78
208
  }
79
209
 
80
- async saveJob(record: BackgroundJobRecord): Promise<void> {
81
- const records = (await this.readState()) ?? []
82
- const idx = records.findIndex((r) => r.taskID === record.taskID)
83
- if (idx >= 0) {
84
- records[idx] = record
85
- } else {
86
- records.push(record)
210
+ private async safeUnlink(path: string): Promise<void> {
211
+ try {
212
+ await this.ops.unlink(path)
213
+ } catch {
214
+ // Best-effort temp cleanup — absent file is fine.
87
215
  }
88
- await this.writeState(records)
216
+ }
217
+
218
+ async saveJob(record: BackgroundJobRecord): Promise<void> {
219
+ await enqueueWrite(this.lockKey, async () => {
220
+ const records = await this.readState()
221
+ if (records === null) {
222
+ log.error(
223
+ `[FilePersistence] Aborting saveJob(${record.taskID}): could not read ` +
224
+ `${this.statePath} — refusing to overwrite state with a partial snapshot`,
225
+ )
226
+ return
227
+ }
228
+ const idx = records.findIndex((r) => r.taskID === record.taskID)
229
+ if (idx >= 0) {
230
+ records[idx] = record
231
+ } else {
232
+ records.push(record)
233
+ }
234
+ await this.writeState(records)
235
+ })
89
236
  }
90
237
 
91
238
  async loadAllJobs(): Promise<BackgroundJobRecord[]> {
@@ -93,11 +240,13 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
93
240
  }
94
241
 
95
242
  async deleteJob(taskID: string): Promise<void> {
96
- const records = await this.readState()
97
- if (!records) return
98
- const idx = records.findIndex((r) => r.taskID === taskID)
99
- if (idx < 0) return
100
- records.splice(idx, 1)
101
- await this.writeState(records)
243
+ await enqueueWrite(this.lockKey, async () => {
244
+ const records = await this.readState()
245
+ if (!records) return
246
+ const idx = records.findIndex((r) => r.taskID === taskID)
247
+ if (idx < 0) return
248
+ records.splice(idx, 1)
249
+ await this.writeState(records)
250
+ })
102
251
  }
103
252
  }
@@ -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
+ }