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