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,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegate Manager (WS1, PR #94) — thin manager over the native task() call.
|
|
3
|
+
*
|
|
4
|
+
* Relaunch of the 1.4.2 delegation as a THIN manager: launch / monitor /
|
|
5
|
+
* reconcile over an injected `task()`-shaped function. No monolithic
|
|
6
|
+
* scheduler: no timers of its own beyond wait/timeout bounds, no idle-event
|
|
7
|
+
* plumbing, no hidden queues. Completion is recorded ONLY after the result
|
|
8
|
+
* is verified (non-empty content); anything else becomes `error`.
|
|
9
|
+
*
|
|
10
|
+
* - Minimal records: every launch returns `{ id, agent, state, line }`
|
|
11
|
+
* (id, agent, state, one short line). Full reports only via `read()`.
|
|
12
|
+
* - Auto-reconcile + signal delete on the spot after every terminal state.
|
|
13
|
+
* - Entry cap + aggressive prune of completed/reconciled jobs.
|
|
14
|
+
* - Concurrency limit (read live from the board) + foreground fallback.
|
|
15
|
+
* - Model failover across an ordered model list.
|
|
16
|
+
* - Step budgets reuse StepCapTracker (routing.yml max_steps, already
|
|
17
|
+
* extended beyond zeus) + summarize-and-stop cut.
|
|
18
|
+
* - Kill-switch: `PANTHEON_DELEGATION=off` disables launch/read/list.
|
|
19
|
+
*
|
|
20
|
+
* The V1 fire-and-forget path (delegation.ts) is untouched; the plugin wires
|
|
21
|
+
* this manager in front of it behind `PANTHEON_DELEGATE_MODE`.
|
|
22
|
+
*
|
|
23
|
+
* @module delegate-manager
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
27
|
+
import {
|
|
28
|
+
buildStopInstruction,
|
|
29
|
+
cappedSummary,
|
|
30
|
+
DEFAULT_MAX_STEPS,
|
|
31
|
+
type StepCapTracker,
|
|
32
|
+
} from './step-cap.ts'
|
|
33
|
+
|
|
34
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** Input shape for the native task() call (injected — fake in tests). */
|
|
37
|
+
export interface NativeTaskInput {
|
|
38
|
+
agent: string
|
|
39
|
+
prompt: string
|
|
40
|
+
model?: string
|
|
41
|
+
signal?: AbortSignal
|
|
42
|
+
/** Board taskID assigned by the manager (== child session id on V1 hosts). */
|
|
43
|
+
taskID: string
|
|
44
|
+
parentSessionID: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Result shape for the native task() call. */
|
|
48
|
+
export interface NativeTaskResult {
|
|
49
|
+
content?: string | null
|
|
50
|
+
tokensInput?: number
|
|
51
|
+
tokensOutput?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** task()-shaped function: run one agent turn and return its result. */
|
|
55
|
+
export type NativeTaskFn = (input: NativeTaskInput) => Promise<NativeTaskResult>
|
|
56
|
+
|
|
57
|
+
/** Minimal launch receipt: id, agent, state, one short line. */
|
|
58
|
+
export interface ManagerReceipt {
|
|
59
|
+
id: string
|
|
60
|
+
agent: string
|
|
61
|
+
state: 'reconciled' | 'error'
|
|
62
|
+
line: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LaunchArgs {
|
|
66
|
+
agent: string
|
|
67
|
+
prompt: string
|
|
68
|
+
description?: string
|
|
69
|
+
model?: string
|
|
70
|
+
/**
|
|
71
|
+
* Board taskID to register. The V1 session adapter supplies the child
|
|
72
|
+
* session id here so the idle hook and the manager share one record.
|
|
73
|
+
* Defaults to a generated `native-<ts>-<seq>` id.
|
|
74
|
+
*/
|
|
75
|
+
taskID?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DelegateManagerOptions {
|
|
79
|
+
board: BackgroundJobBoard
|
|
80
|
+
task: NativeTaskFn
|
|
81
|
+
parentSessionID: string
|
|
82
|
+
/** Env mapping (default: process.env). Kill-switch reads PANTHEON_DELEGATION. */
|
|
83
|
+
env?: Record<string, string | undefined>
|
|
84
|
+
/** Ordered model list for failover (default: single attempt, inherited model). */
|
|
85
|
+
models?: string[]
|
|
86
|
+
/** Per-attempt wall-clock budget in ms (default: 120_000). */
|
|
87
|
+
timeoutMs?: number
|
|
88
|
+
/** read() wait budget in ms (default: 60_000). */
|
|
89
|
+
readTimeoutMs?: number
|
|
90
|
+
/** Run inline without a board record when concurrency is full (default: true). */
|
|
91
|
+
foregroundFallback?: boolean
|
|
92
|
+
/** Hard cap on board entries; oldest terminal jobs evicted first (default: 50). */
|
|
93
|
+
maxEntries?: number
|
|
94
|
+
/** Aggressive prune: completed/reconciled jobs kept (default: 10). */
|
|
95
|
+
keepCompleted?: number
|
|
96
|
+
/** Per-agent step budgets (routing.yml max_steps). */
|
|
97
|
+
stepCap?: StepCapTracker
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DelegateManager {
|
|
101
|
+
launch(args: LaunchArgs): Promise<ManagerReceipt>
|
|
102
|
+
read(idOrAlias: string): Promise<string>
|
|
103
|
+
list(): Promise<string[]>
|
|
104
|
+
recover(): Promise<void>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Kill-switch ───────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** False only when `PANTHEON_DELEGATION=off` (case-insensitive). */
|
|
110
|
+
export function isDelegationEnabled(
|
|
111
|
+
env: Record<string, string | undefined> = process.env,
|
|
112
|
+
): boolean {
|
|
113
|
+
return (env.PANTHEON_DELEGATION ?? '').trim().toLowerCase() !== 'off'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
const LINE_MAX = 160
|
|
119
|
+
|
|
120
|
+
function firstLine(text: string, max = 80): string {
|
|
121
|
+
const line =
|
|
122
|
+
(text ?? '')
|
|
123
|
+
.split('\n')
|
|
124
|
+
.map((l) => l.trim())
|
|
125
|
+
.find((l) => l !== '') ?? ''
|
|
126
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function shortState(state: BackgroundJobRecord['state']): string {
|
|
130
|
+
switch (state) {
|
|
131
|
+
case 'completed':
|
|
132
|
+
return 'OK'
|
|
133
|
+
case 'error':
|
|
134
|
+
return 'ERR'
|
|
135
|
+
case 'cancelled':
|
|
136
|
+
return 'CAN'
|
|
137
|
+
case 'running':
|
|
138
|
+
return 'RUN'
|
|
139
|
+
default:
|
|
140
|
+
return 'REC'
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function boardLine(job: BackgroundJobRecord): string {
|
|
145
|
+
const desc = firstLine(job.description, 80)
|
|
146
|
+
return `[pantheon:${job.alias}] ${job.agent} — ${desc} — ${shortState(job.state)}`.slice(
|
|
147
|
+
0,
|
|
148
|
+
LINE_MAX,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
153
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
154
|
+
const guard = new Promise<never>((_, reject) => {
|
|
155
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
|
|
156
|
+
timer.unref?.()
|
|
157
|
+
})
|
|
158
|
+
return Promise.race([promise, guard]).finally(() => {
|
|
159
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Factory ───────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export function createDelegateManager(options: DelegateManagerOptions): DelegateManager {
|
|
166
|
+
const {
|
|
167
|
+
board,
|
|
168
|
+
task,
|
|
169
|
+
parentSessionID,
|
|
170
|
+
env = process.env,
|
|
171
|
+
models = [],
|
|
172
|
+
timeoutMs = 120_000,
|
|
173
|
+
readTimeoutMs = 60_000,
|
|
174
|
+
foregroundFallback = true,
|
|
175
|
+
maxEntries = 50,
|
|
176
|
+
keepCompleted = 10,
|
|
177
|
+
stepCap,
|
|
178
|
+
} = options
|
|
179
|
+
|
|
180
|
+
// Synchronous in-flight reservations per agent: launch() must decide
|
|
181
|
+
// background vs foreground BEFORE its first await, otherwise concurrent
|
|
182
|
+
// launches without interleaved awaits would all see an empty board.
|
|
183
|
+
const pendingByAgent = new Map<string, number>()
|
|
184
|
+
let seq = 0
|
|
185
|
+
const foregroundRecent: { agent: string; line: string; at: number }[] = []
|
|
186
|
+
|
|
187
|
+
function pending(agent: string): number {
|
|
188
|
+
return pendingByAgent.get(agent.toLowerCase()) ?? 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function guardEnabled(): void {
|
|
192
|
+
if (!isDelegationEnabled(env)) {
|
|
193
|
+
throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function runTask(
|
|
198
|
+
agent: string,
|
|
199
|
+
prompt: string,
|
|
200
|
+
explicitModel: string | undefined,
|
|
201
|
+
taskID: string,
|
|
202
|
+
): Promise<{ ok: true; result: NativeTaskResult } | { ok: false; error: string }> {
|
|
203
|
+
const attempts: (string | undefined)[] =
|
|
204
|
+
explicitModel !== undefined ? [explicitModel] : models.length > 0 ? models : [undefined]
|
|
205
|
+
let lastError = 'no model attempts configured'
|
|
206
|
+
for (const model of attempts) {
|
|
207
|
+
try {
|
|
208
|
+
const controller = new AbortController()
|
|
209
|
+
const result = await withTimeout(
|
|
210
|
+
task({
|
|
211
|
+
agent,
|
|
212
|
+
prompt,
|
|
213
|
+
signal: controller.signal,
|
|
214
|
+
taskID,
|
|
215
|
+
parentSessionID,
|
|
216
|
+
...(model !== undefined ? { model } : {}),
|
|
217
|
+
}),
|
|
218
|
+
timeoutMs,
|
|
219
|
+
`task(${agent})`,
|
|
220
|
+
)
|
|
221
|
+
return { ok: true, result }
|
|
222
|
+
} catch (err) {
|
|
223
|
+
lastError = err instanceof Error ? err.message : String(err)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { ok: false, error: lastError }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function settle(
|
|
230
|
+
taskID: string,
|
|
231
|
+
alias: string,
|
|
232
|
+
verifyError?: string,
|
|
233
|
+
): Promise<ManagerReceipt> {
|
|
234
|
+
const job = board.get(taskID)
|
|
235
|
+
if (!job) throw new Error(`job ${taskID} vanished after terminal state`)
|
|
236
|
+
// The receipt reports the VERIFIED outcome, not the raw terminal state:
|
|
237
|
+
// a `completed` the manager could not verify stays `error` for the
|
|
238
|
+
// caller ("complete ONLY after verified result"). Reconcile is only the
|
|
239
|
+
// acknowledgment that the result was consumed.
|
|
240
|
+
const terminalState = job.state
|
|
241
|
+
const failed = verifyError !== undefined || terminalState === 'error'
|
|
242
|
+
// Auto-reconcile + delete the signal file on the spot.
|
|
243
|
+
if (job.state !== 'reconciled') await board.markReconciled(taskID)
|
|
244
|
+
await board.deleteSignal(alias)
|
|
245
|
+
// Teto de entradas + prune agressivo de completed.
|
|
246
|
+
await board.pruneCompleted(keepCompleted)
|
|
247
|
+
await board.enforceEntryCap(maxEntries)
|
|
248
|
+
const settled = board.get(taskID)
|
|
249
|
+
const line = failed
|
|
250
|
+
? `[pantheon:${alias}] ${job.agent} — ERROR: ${firstLine(verifyError ?? settled?.lastStatusError ?? job.lastStatusError ?? 'failed', 90)}`.slice(
|
|
251
|
+
0,
|
|
252
|
+
LINE_MAX,
|
|
253
|
+
)
|
|
254
|
+
: boardLine(settled ?? job)
|
|
255
|
+
return { id: taskID, agent: job.agent, state: failed ? 'error' : 'reconciled', line }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Apply a terminal transition best-effort: a racing hook may have gotten there first. */
|
|
259
|
+
async function applyTerminal(
|
|
260
|
+
taskID: string,
|
|
261
|
+
state: 'completed' | 'error',
|
|
262
|
+
fields: { resultSummary?: string; error?: string },
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
try {
|
|
265
|
+
await board.updateStatus({ taskID, state, ...fields })
|
|
266
|
+
} catch (err) {
|
|
267
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
268
|
+
if (!msg.startsWith('Invalid state transition')) throw err
|
|
269
|
+
// Lost the race to another terminal writer (e.g. the V1 idle hook) —
|
|
270
|
+
// the record stands; verification still governs the receipt.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function launchBackground(args: LaunchArgs, prompt: string): Promise<ManagerReceipt> {
|
|
275
|
+
const key = args.agent.toLowerCase()
|
|
276
|
+
pendingByAgent.set(key, pending(args.agent) + 1)
|
|
277
|
+
seq += 1
|
|
278
|
+
const taskID = args.taskID ?? `native-${Date.now()}-${seq}`
|
|
279
|
+
try {
|
|
280
|
+
const record = await board.registerLaunch({
|
|
281
|
+
taskID,
|
|
282
|
+
parentSessionID,
|
|
283
|
+
agent: args.agent,
|
|
284
|
+
description: args.description ?? firstLine(args.prompt, 120),
|
|
285
|
+
})
|
|
286
|
+
const outcome = await runTask(args.agent, prompt, args.model, taskID)
|
|
287
|
+
if (!outcome.ok) {
|
|
288
|
+
await applyTerminal(taskID, 'error', { error: outcome.error })
|
|
289
|
+
return await settle(taskID, record.alias, outcome.error)
|
|
290
|
+
}
|
|
291
|
+
if (typeof outcome.result.content !== 'string' || outcome.result.content.trim() === '') {
|
|
292
|
+
const verifyError = 'task result empty — not verified, never completed'
|
|
293
|
+
await applyTerminal(taskID, 'error', { error: verifyError })
|
|
294
|
+
return await settle(taskID, record.alias, verifyError)
|
|
295
|
+
}
|
|
296
|
+
await applyTerminal(taskID, 'completed', {
|
|
297
|
+
resultSummary: firstLine(outcome.result.content, 500),
|
|
298
|
+
})
|
|
299
|
+
return await settle(taskID, record.alias)
|
|
300
|
+
} finally {
|
|
301
|
+
pendingByAgent.set(key, Math.max(0, pending(args.agent) - 1))
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function launchForeground(args: LaunchArgs, prompt: string): Promise<ManagerReceipt> {
|
|
306
|
+
seq += 1
|
|
307
|
+
const id = args.taskID ?? `fg-${Date.now()}-${seq}`
|
|
308
|
+
const outcome = await runTask(args.agent, prompt, args.model, id)
|
|
309
|
+
const ok =
|
|
310
|
+
outcome.ok &&
|
|
311
|
+
typeof outcome.result.content === 'string' &&
|
|
312
|
+
outcome.result.content.trim() !== ''
|
|
313
|
+
const detail = ok
|
|
314
|
+
? firstLine((outcome as { ok: true; result: NativeTaskResult }).result.content ?? '', 90)
|
|
315
|
+
: `ERROR: ${(outcome as { ok: false; error: string }).error ?? 'empty result — not verified'}`
|
|
316
|
+
const line = `[native] ${args.agent} — ${detail} — ${ok ? 'OK' : 'ERR'} (foreground)`.slice(
|
|
317
|
+
0,
|
|
318
|
+
LINE_MAX,
|
|
319
|
+
)
|
|
320
|
+
foregroundRecent.push({ agent: args.agent, line, at: Date.now() })
|
|
321
|
+
if (foregroundRecent.length > 20) foregroundRecent.splice(0, foregroundRecent.length - 20)
|
|
322
|
+
return { id, agent: args.agent, state: ok ? 'reconciled' : 'error', line }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
async launch(args: LaunchArgs): Promise<ManagerReceipt> {
|
|
327
|
+
guardEnabled()
|
|
328
|
+
// R4 step budgets (routing.yml max_steps, every agent): capped agents
|
|
329
|
+
// skip dispatch with a stop summary — no session, no board job.
|
|
330
|
+
let prompt = args.prompt
|
|
331
|
+
if (stepCap !== undefined) {
|
|
332
|
+
const maxSteps = stepCap.maxStepsFor(args.agent)
|
|
333
|
+
if (stepCap.isCapped(args.agent)) {
|
|
334
|
+
const line = cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS).slice(0, LINE_MAX)
|
|
335
|
+
return { id: `cap-${Date.now()}`, agent: args.agent, state: 'reconciled', line }
|
|
336
|
+
}
|
|
337
|
+
const rec = stepCap.recordStep(args.agent)
|
|
338
|
+
if (rec.capped && rec.maxSteps !== undefined) {
|
|
339
|
+
prompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Synchronous routing decision: background when a slot is free,
|
|
343
|
+
// foreground fallback when full, clean rejection otherwise.
|
|
344
|
+
const free =
|
|
345
|
+
board.canDispatch(args.agent) && pending(args.agent) < board.maxConcurrentPerAgent
|
|
346
|
+
if (free) return launchBackground(args, prompt)
|
|
347
|
+
if (foregroundFallback) return launchForeground(args, prompt)
|
|
348
|
+
throw new Error(
|
|
349
|
+
`pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}"`,
|
|
350
|
+
)
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
async read(idOrAlias: string): Promise<string> {
|
|
354
|
+
guardEnabled()
|
|
355
|
+
const job = board.resolve(parentSessionID, idOrAlias) ?? board.get(idOrAlias)
|
|
356
|
+
if (!job) throw new Error(`delegation not found: ${idOrAlias}`)
|
|
357
|
+
const terminal = await withTimeout(
|
|
358
|
+
board.waitForTerminal(job.taskID, readTimeoutMs),
|
|
359
|
+
readTimeoutMs + 5_000,
|
|
360
|
+
`read(${idOrAlias})`,
|
|
361
|
+
)
|
|
362
|
+
if (terminal.state !== 'reconciled') await board.markReconciled(terminal.taskID)
|
|
363
|
+
await board.deleteSignal(terminal.alias)
|
|
364
|
+
if (terminal.state === 'error' && !terminal.resultSummary) {
|
|
365
|
+
return `# Delegation Report — ${terminal.alias}\n\nERROR: ${terminal.lastStatusError ?? 'failed'}\n`
|
|
366
|
+
}
|
|
367
|
+
return (
|
|
368
|
+
`# Delegation Report — ${terminal.alias}\n\n` +
|
|
369
|
+
`${terminal.resultSummary ?? terminal.lastStatusError ?? terminal.state}\n`
|
|
370
|
+
)
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
async list(): Promise<string[]> {
|
|
374
|
+
guardEnabled()
|
|
375
|
+
const lines = board.list(parentSessionID).map((job) => boardLine(job))
|
|
376
|
+
for (const fg of foregroundRecent) lines.push(fg.line)
|
|
377
|
+
return lines
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
async recover(): Promise<void> {
|
|
381
|
+
await board.recoverRunningJobs()
|
|
382
|
+
},
|
|
383
|
+
}
|
|
384
|
+
}
|