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

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.
@@ -4,11 +4,8 @@
4
4
  *
5
5
  * Reads the opencode session database READ-ONLY (sum tokens by agent and
6
6
  * phase over the last N days → markdown table). Zero new dependencies:
7
- * - primary path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
8
- * - CLI fallback: spawn scripts/cost.mjs with its explicit flag-based
9
- * interface when the direct import is unavailable;
10
- * - missing/unreadable db → FRIENDLY error string, never a crash (tools
11
- * return errors as TEXT).
7
+ * - only path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
8
+ * - missing/unreadable db → a diagnostic contract status as TEXT.
12
9
  *
13
10
  * dbPath resolution: explicit option > env PANTHEON_COST_DB > the version-aware
14
11
  * default selected by PANTHEON_OPENCODE_VERSION (v1 or v2). An unset version
@@ -18,16 +15,13 @@
18
15
  *
19
16
  * @module cost-command
20
17
  */
21
- import { execFile } from 'node:child_process'
22
18
  import { existsSync } from 'node:fs'
23
19
  import { homedir } from 'node:os'
24
20
  import { join } from 'node:path'
25
- import { promisify } from 'node:util'
26
21
  import { z } from 'zod'
27
22
 
28
23
  import type { ToolContextLike } from './delegation.ts'
29
-
30
- const execFileAsync = promisify(execFile)
24
+ import type { NativeTaskStatus } from './native-task-status.ts'
31
25
 
32
26
  /** One aggregated token row (per agent and phase). */
33
27
  export interface CostRow {
@@ -41,10 +35,21 @@ export interface CostRow {
41
35
  export interface CostCommandOptions {
42
36
  /** Explicit db path (testability / unusual installs). Default: resolved candidates. */
43
37
  dbPath?: string
38
+ /** Injectable loader for tests; production always loads node:sqlite. */
39
+ sqliteLoader?: () => Promise<SqliteModule>
44
40
  }
45
41
 
46
42
  export type OpenCodeVersion = 'v1' | 'v2'
47
43
 
44
+ interface SqliteDatabase {
45
+ prepare(sql: string): { get(): unknown; all(...args: unknown[]): unknown[] }
46
+ close(): void
47
+ }
48
+
49
+ export interface SqliteModule {
50
+ DatabaseSync: new (path: string, options: { readOnly: boolean }) => SqliteDatabase
51
+ }
52
+
48
53
  /** One cost tool: description + zod args shape + execute (delegation.ts shape). */
49
54
  export interface CostTool<Args extends z.ZodRawShape = typeof costArgs> {
50
55
  description: string
@@ -125,8 +130,11 @@ function assertCompatibleSchema(db: {
125
130
  }
126
131
 
127
132
  /** Aggregate tokens by agent and phase via node:sqlite (read-only). */
128
- async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRow[]> {
129
- const sqlite = await import('node:sqlite')
133
+ async function queryWithNodeSqlite(
134
+ dbPath: string,
135
+ days: number,
136
+ sqlite: SqliteModule,
137
+ ): Promise<CostRow[]> {
130
138
  const db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
131
139
  try {
132
140
  assertCompatibleSchema(db)
@@ -142,25 +150,6 @@ async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRo
142
150
  }
143
151
  }
144
152
 
145
- /** CLI fallback: delegate the same aggregation to scripts/cost.mjs. */
146
- async function queryWithScript(
147
- scriptPath: string,
148
- dbPath: string,
149
- days: number,
150
- ): Promise<CostRow[]> {
151
- const { stdout } = await execFileAsync(
152
- process.execPath,
153
- [scriptPath, '--db-path', dbPath, '--days', String(days)],
154
- {
155
- timeout: 15_000,
156
- encoding: 'utf8',
157
- },
158
- )
159
- const parsed = JSON.parse(stdout) as { ok: boolean; rows?: CostRow[]; error?: string }
160
- if (!parsed.ok) throw new Error(parsed.error ?? 'cost.mjs failed')
161
- return parsed.rows ?? []
162
- }
163
-
164
153
  /** Parse message.data JSON rows and aggregate tokens by assistant agent/phase. */
165
154
  function aggregateMessages(dataValues: readonly string[]): CostRow[] {
166
155
  const byAgentAndPhase = new Map<string, CostRow>()
@@ -232,7 +221,42 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
232
221
  }
233
222
 
234
223
  export function createCostCommand(options?: CostCommandOptions): CostCommand {
235
- const scriptPath = new URL('../../scripts/cost.mjs', import.meta.url).pathname
224
+ const loadSqlite =
225
+ options?.sqliteLoader ?? (async () => (await import('node:sqlite')) as SqliteModule)
226
+
227
+ const failure = (status: NativeTaskStatus, detail: string): string =>
228
+ `status: ${status}\npantheon_cost failed: ${detail}`
229
+
230
+ const diagnostic = (err: unknown): string => {
231
+ if (err instanceof Error) {
232
+ const stderr = (err as Error & { stderr?: unknown }).stderr
233
+ const suffix =
234
+ typeof stderr === 'string' && stderr.trim() !== '' ? `; stderr: ${stderr.trim()}` : ''
235
+ return `${err.message}${suffix}`
236
+ }
237
+ return String(err)
238
+ }
239
+
240
+ const statusForError = (err: unknown): NativeTaskStatus => {
241
+ const detail = diagnostic(err).toLowerCase()
242
+ if (
243
+ detail.includes('not found') ||
244
+ detail.includes('unknown builtin') ||
245
+ detail.includes('no such built-in') ||
246
+ detail.includes('cannot find module')
247
+ ) {
248
+ return 'UNSUPPORTED'
249
+ }
250
+ if (
251
+ detail.includes('not a database') ||
252
+ detail.includes('malformed') ||
253
+ detail.includes('corrupt') ||
254
+ detail.includes('incompatible opencode.db schema')
255
+ ) {
256
+ return 'CORRUPT_DATA'
257
+ }
258
+ return 'UNAVAILABLE'
259
+ }
236
260
 
237
261
  const execute = async (args: { days?: number }, _ctx: ToolContextLike): Promise<string> => {
238
262
  const days = args?.days ?? 7
@@ -240,20 +264,16 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
240
264
  const dbPath = findExistingDb(options?.dbPath)
241
265
  if (dbPath === undefined) {
242
266
  const expected = resolveCostDbPath(options)
243
- return (
244
- `pantheon_cost failed: opencode.db not found (expected at ${expected}). ` +
245
- `No token history is available until opencode has stored session data.`
267
+ return failure(
268
+ 'UNAVAILABLE',
269
+ `opencode.db not found (expected at ${expected}). No token history is available until opencode has stored session data.`,
246
270
  )
247
271
  }
248
- const rows = await queryWithNodeSqlite(dbPath, days).catch(async (err: unknown) => {
249
- if (err instanceof Error && err.message.startsWith('incompatible opencode.db schema'))
250
- throw err
251
- return queryWithScript(scriptPath, dbPath, days)
252
- })
253
- return renderMarkdown(rows, days)
272
+ const sqlite = await loadSqlite()
273
+ const rows = await queryWithNodeSqlite(dbPath, days, sqlite)
274
+ return `status: OK\n${renderMarkdown(rows, days)}`
254
275
  } catch (err: unknown) {
255
- const reason = err instanceof Error ? err.message : String(err)
256
- return `pantheon_cost failed: ${reason}`
276
+ return failure(statusForError(err), diagnostic(err))
257
277
  }
258
278
  }
259
279
 
@@ -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
+ }