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.
- package/.pantheon/code-mode/manifest.json +21 -0
- package/CHANGELOG.md +835 -0
- package/README.md +62 -0
- package/README.pt-BR.md +18 -1
- package/package.json +2 -1
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/code_mode_server.py +305 -9
- package/scripts/install/opencode.mjs +36 -0
- package/scripts/probe-pantheon-cost.mjs +10 -1
- package/scripts/sync-tui.mjs +78 -34
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +305 -9
- package/src/pantheon/background-job-board.ts +14 -0
- package/src/pantheon/cost-command.ts +62 -42
- package/src/pantheon/delegate-manager.ts +109 -12
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +18 -110
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +10 -1
- package/src/plugin.ts +19 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
|
@@ -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
|
-
* -
|
|
8
|
-
* -
|
|
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(
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
+
}
|