pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d
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/AGENTS.md +919 -0
- package/README.md +157 -15
- package/bin/pantheon-init.mjs +177 -120
- package/opencode.json +3 -6
- package/package.json +8 -4
- package/scripts/build-agents-md.mjs +195 -0
- package/scripts/cost.mjs +17 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +189 -38
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-beta-version.mjs +80 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +6 -2
- package/src/pantheon/background-job-board.ts +24 -38
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +57 -18
- package/src/pantheon/delegation-compaction.ts +143 -16
- package/src/pantheon/delegation-finalize.ts +8 -1
- package/src/pantheon/delegation-notify.ts +19 -157
- package/src/pantheon/delegation.ts +330 -28
- package/src/pantheon/dispatch-guard.ts +9 -4
- package/src/pantheon/goal-loop.ts +7 -4
- package/src/pantheon/hashline/tool.ts +2 -1
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +79 -27
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +177 -50
- package/src/plugins/pantheon-hooks.ts +35 -22
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +3 -1
|
@@ -19,11 +19,19 @@
|
|
|
19
19
|
* Depth guard (root-session detection): the ToolContext carries NO parentID
|
|
20
20
|
* (spike), so root detection is configurable. Resolution order:
|
|
21
21
|
* 1. `options.isRootSession(sessionID)` custom predicate;
|
|
22
|
-
* 2. `options.rootSessions` explicit allowlist
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* 2. `options.rootSessions` explicit allowlist — with a knownChildren
|
|
23
|
+
* fallback (compaction-134): a session is a sub-session ONLY if WE
|
|
24
|
+
* created it as a child (`session.create` via pantheon_delegate).
|
|
25
|
+
* Sessions in neither set are treated as root, so a resumed root can
|
|
26
|
+
* delegate after a restart even when the allowlist is incomplete
|
|
27
|
+
* (opencode does not replay `session.created` events for pre-existing
|
|
28
|
+
* sessions — the plugin seeds the registry from session.list() at
|
|
29
|
+
* startup, and this fallback covers a failed/incomplete seed);
|
|
30
|
+
* 3. default (no rootSessions): any session WE created via
|
|
31
|
+
* `session.create` is a sub-session; everything else is treated as
|
|
32
|
+
* root. Phase 3 should pass the root registry derived from
|
|
33
|
+
* `session.created` events (Session.parentID undefined ⇒ root) for
|
|
34
|
+
* authoritative enforcement.
|
|
27
35
|
*
|
|
28
36
|
* Read-only enforcement is Phase 4 — `read_only` is exposed on the delegate
|
|
29
37
|
* args and the child session is registered in the read-only registry when the
|
|
@@ -41,12 +49,14 @@ import {
|
|
|
41
49
|
DELEGATION_DEFAULTS,
|
|
42
50
|
type DelegationClient,
|
|
43
51
|
type DelegationDeps,
|
|
52
|
+
type DelegationMessageBundle,
|
|
44
53
|
type DelegationOptions,
|
|
45
54
|
type FinalizeInput,
|
|
46
55
|
finalizeDelegation as finalizeDelegationReport,
|
|
47
56
|
readDelegationReport,
|
|
48
57
|
} from './delegation-finalize.ts'
|
|
49
|
-
import {
|
|
58
|
+
import { createPantheonLogger } from './logger.ts'
|
|
59
|
+
import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
|
|
50
60
|
|
|
51
61
|
export type {
|
|
52
62
|
DelegationClient,
|
|
@@ -59,6 +69,11 @@ export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
|
|
|
59
69
|
|
|
60
70
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
61
71
|
|
|
72
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
|
|
73
|
+
// plugin renders into the opencode TUI — the "lixo". Errors go to
|
|
74
|
+
// .pantheon/logs/hooks.log; console echo is opt-in via PANTHEON_HOOKS_LOG=1.
|
|
75
|
+
const log = createPantheonLogger({ module: 'pantheon-delegate' })
|
|
76
|
+
|
|
62
77
|
/** Structural view of the tool context opencode passes to execute(). */
|
|
63
78
|
export interface ToolContextLike {
|
|
64
79
|
sessionID: string
|
|
@@ -131,6 +146,167 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
|
131
146
|
}
|
|
132
147
|
}
|
|
133
148
|
|
|
149
|
+
// ─── Agent activity sampling ────────────────────────────────────────────
|
|
150
|
+
//
|
|
151
|
+
// Visibility for `pantheon_delegation_read` / `pantheon_delegation_list`
|
|
152
|
+
// (issue: the read blocks silently while the child works). While a read waits
|
|
153
|
+
// on `waitForTerminal` it periodically samples the CHILD session's messages
|
|
154
|
+
// via the same `client.session.messages` the finalize path uses, and the
|
|
155
|
+
// collected lines are appended to the report as `## Agent Activity`.
|
|
156
|
+
//
|
|
157
|
+
// Fail-open by design: a missing/throwing `session.messages` (or empty
|
|
158
|
+
// response) degrades to the current behavior — report without a section —
|
|
159
|
+
// and NEVER breaks the read. No streaming/SSE, no persistent timers: the
|
|
160
|
+
// poll lives inside the read's existing wait and is cleared when it settles.
|
|
161
|
+
|
|
162
|
+
/** Cap for one activity line (~200 chars, spec). */
|
|
163
|
+
const ACTIVITY_LINE_MAX = 200
|
|
164
|
+
/** How often the read re-samples child activity while waiting (ms). */
|
|
165
|
+
const ACTIVITY_POLL_MS = 2000
|
|
166
|
+
/** Keep only the latest N readable entries per sample. */
|
|
167
|
+
const ACTIVITY_LINES = 3
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Mutable collector shared between the wait loop and the read result.
|
|
171
|
+
* `sampled` distinguishes "messages unavailable/never succeeded" (fail-open
|
|
172
|
+
* → NO activity section, current behavior) from "messages OK but nothing
|
|
173
|
+
* readable" (→ `_no activity captured_` section).
|
|
174
|
+
*/
|
|
175
|
+
interface ActivityCollector {
|
|
176
|
+
/** Readable activity lines captured so far (latest window). */
|
|
177
|
+
lines: string[]
|
|
178
|
+
/** True once session.messages returned a bundle list at least once. */
|
|
179
|
+
sampled: boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Collapse whitespace and cap a single line at ~200 chars. */
|
|
183
|
+
function truncateActivityLine(text: string, max = ACTIVITY_LINE_MAX): string {
|
|
184
|
+
const clean = text.replace(/\s+/g, ' ').trim()
|
|
185
|
+
if (clean.length <= max) return clean
|
|
186
|
+
return `${clean.slice(0, max - 1)}…`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Readable tool args from a tool part (SDK ToolPart.metadata.input), or ''. */
|
|
190
|
+
function toolArgsFromPart(part: { metadata?: { input?: unknown } }): string {
|
|
191
|
+
const meta = part.metadata
|
|
192
|
+
const raw = meta !== undefined && 'input' in meta ? meta.input : meta
|
|
193
|
+
if (raw === undefined) return ''
|
|
194
|
+
try {
|
|
195
|
+
const s = typeof raw === 'string' ? raw : JSON.stringify(raw)
|
|
196
|
+
return s === '' || s === '{}' ? '' : s
|
|
197
|
+
} catch {
|
|
198
|
+
return ''
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One readable activity line per message bundle — tool calls first (highest
|
|
204
|
+
* signal), then the first non-empty text part. Image/attachment/reasoning
|
|
205
|
+
* parts are skipped (only `text` and `tool` parts are user-visible).
|
|
206
|
+
*/
|
|
207
|
+
function activityLineFromBundle(bundle: DelegationMessageBundle): string | undefined {
|
|
208
|
+
const role = bundle.info?.role
|
|
209
|
+
const parts = bundle.parts ?? []
|
|
210
|
+
for (const part of parts) {
|
|
211
|
+
if (part.type === 'tool' && typeof part.tool === 'string' && part.tool !== '') {
|
|
212
|
+
const args = toolArgsFromPart(part)
|
|
213
|
+
return truncateActivityLine(`tool: ${part.tool}${args !== '' ? ` ${args}` : ''}`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const part of parts) {
|
|
217
|
+
if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
|
|
218
|
+
const label = role === 'user' ? 'user' : 'assistant'
|
|
219
|
+
return truncateActivityLine(`${label}: ${part.text.trim()}`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Sample the child's latest readable activity into `collector` (replace
|
|
227
|
+
* window). Fail-open: unavailable/throwing messages leaves the collector
|
|
228
|
+
* untouched (previous lines, or none → `_no activity captured_`), while a
|
|
229
|
+
* successful (even empty) response marks the collector as sampled.
|
|
230
|
+
*/
|
|
231
|
+
async function sampleChildActivity(
|
|
232
|
+
client: DelegationClient,
|
|
233
|
+
childSessionID: string,
|
|
234
|
+
collector: ActivityCollector,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
if (typeof client.session.messages !== 'function') return
|
|
237
|
+
try {
|
|
238
|
+
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
239
|
+
if (!Array.isArray(bundles)) return
|
|
240
|
+
collector.sampled = true
|
|
241
|
+
if (bundles.length === 0) return
|
|
242
|
+
const fresh = bundles
|
|
243
|
+
.map((b) => activityLineFromBundle(b))
|
|
244
|
+
.filter((l): l is string => l !== undefined)
|
|
245
|
+
.slice(-ACTIVITY_LINES)
|
|
246
|
+
if (fresh.length > 0) {
|
|
247
|
+
collector.lines.length = 0
|
|
248
|
+
collector.lines.push(...fresh)
|
|
249
|
+
}
|
|
250
|
+
} catch (err: unknown) {
|
|
251
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
252
|
+
log.warn(`[pantheon-delegate] activity sample failed (fail-open): ${reason}`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Last readable activity line for a child, or undefined when unavailable. */
|
|
257
|
+
async function lastChildActivity(
|
|
258
|
+
client: DelegationClient,
|
|
259
|
+
childSessionID: string,
|
|
260
|
+
): Promise<string | undefined> {
|
|
261
|
+
if (typeof client.session.messages !== 'function') return undefined
|
|
262
|
+
try {
|
|
263
|
+
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
264
|
+
if (!Array.isArray(bundles)) return undefined
|
|
265
|
+
for (let i = bundles.length - 1; i >= 0; i--) {
|
|
266
|
+
const bundle = bundles[i]
|
|
267
|
+
if (bundle === undefined) continue
|
|
268
|
+
const line = activityLineFromBundle(bundle)
|
|
269
|
+
if (line !== undefined) return line
|
|
270
|
+
}
|
|
271
|
+
return undefined
|
|
272
|
+
} catch (err: unknown) {
|
|
273
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
274
|
+
log.warn(`[pantheon-delegate] last activity fetch failed (fail-open): ${reason}`)
|
|
275
|
+
return undefined
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Wait for a terminal board state while sampling the child's activity every
|
|
281
|
+
* ACTIVITY_POLL_MS (first sample fires immediately — a quick finalize can
|
|
282
|
+
* still capture something). The interval is cleared when the wait settles;
|
|
283
|
+
* collection never delays the terminal resolution.
|
|
284
|
+
*/
|
|
285
|
+
async function waitForTerminalWithActivity(
|
|
286
|
+
board: BackgroundJobBoard,
|
|
287
|
+
client: DelegationClient,
|
|
288
|
+
taskID: string,
|
|
289
|
+
timeoutMs: number,
|
|
290
|
+
collector: ActivityCollector,
|
|
291
|
+
): Promise<BackgroundJobRecord> {
|
|
292
|
+
void sampleChildActivity(client, taskID, collector)
|
|
293
|
+
const timer = setInterval(() => {
|
|
294
|
+
void sampleChildActivity(client, taskID, collector)
|
|
295
|
+
}, ACTIVITY_POLL_MS)
|
|
296
|
+
timer.unref()
|
|
297
|
+
try {
|
|
298
|
+
return await board.waitForTerminal(taskID, timeoutMs)
|
|
299
|
+
} finally {
|
|
300
|
+
clearInterval(timer)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Render the trailing activity section appended to the read result. */
|
|
305
|
+
export function formatActivitySection(lines: string[]): string {
|
|
306
|
+
if (lines.length === 0) return '## Agent Activity\n\n_no activity captured_'
|
|
307
|
+
return `## Agent Activity\n\n${lines.map((l) => `- ${l}`).join('\n')}`
|
|
308
|
+
}
|
|
309
|
+
|
|
134
310
|
// ─── Model resolution ──────────────────────────────────────────────────
|
|
135
311
|
|
|
136
312
|
/** Session model ref accepted by the opencode server's session.create. */
|
|
@@ -139,6 +315,17 @@ export interface ChildSessionModelRef {
|
|
|
139
315
|
providerID: string
|
|
140
316
|
}
|
|
141
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Known-good fallback model (P1, 2026-08-11 — release 1.3.4): when an
|
|
320
|
+
* AUTO-RESOLVED model (routing.yml agent entry / active preset) points at a
|
|
321
|
+
* provider whose API key is not configured, the child is dispatched on the
|
|
322
|
+
* native `opencode` provider instead — verified to work in the field without
|
|
323
|
+
* an external provider key. Validated with the same providerKeyConfigured
|
|
324
|
+
* gate as the resolved model; an explicit caller-supplied `model` always wins
|
|
325
|
+
* over this fallback.
|
|
326
|
+
*/
|
|
327
|
+
const FALLBACK_MODEL = 'opencode/deepseek-v4-flash-free'
|
|
328
|
+
|
|
142
329
|
/**
|
|
143
330
|
* Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
|
|
144
331
|
* into the `{ id, providerID }` ref the session.create body expects.
|
|
@@ -194,8 +381,96 @@ function resolveChildModel(
|
|
|
194
381
|
return undefined
|
|
195
382
|
}
|
|
196
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Resolve a USABLE child model for a delegate dispatch, gating the resolved
|
|
386
|
+
* provider's API key (P1, 2026-08-11 — release 1.3.4):
|
|
387
|
+
*
|
|
388
|
+
* 1. resolve the model with the existing precedence (explicit `model` >
|
|
389
|
+
* options.agentModels > active preset) — see resolveChildModel.
|
|
390
|
+
* 2. provider key gate: the resolved provider requires its apiKeyEnv env
|
|
391
|
+
* var (routing.yml preset defs — same source applyPreset enforces at
|
|
392
|
+
* startup). No gate (native providers) or key present → usable as-is.
|
|
393
|
+
* 3. key MISSING + EXPLICIT caller model → respected anyway (user intent),
|
|
394
|
+
* warned.
|
|
395
|
+
* 4. key MISSING + AUTO-resolved model → fall back to FALLBACK_MODEL
|
|
396
|
+
* (validated the same way). Fallback also unusable → returns an `error`
|
|
397
|
+
* TEXT (the caller returns it from the tool — never throws) and the
|
|
398
|
+
* caller registers NO job on the board.
|
|
399
|
+
*
|
|
400
|
+
* The no-model case (nothing resolved) keeps the pre-existing behavior:
|
|
401
|
+
* warn and let the child use opencode's default.
|
|
402
|
+
*
|
|
403
|
+
* @returns `{ model, error }` — at most one of `model` / `error` is set;
|
|
404
|
+
* both may be unset (no model resolved, warn emitted).
|
|
405
|
+
*/
|
|
406
|
+
function resolveUsableChildModel(
|
|
407
|
+
options: DelegationOptions,
|
|
408
|
+
agent: string,
|
|
409
|
+
explicitModel: string | undefined,
|
|
410
|
+
): { model: ChildSessionModelRef | undefined; error: string | undefined } {
|
|
411
|
+
const env = options.presetEnv ?? process.env
|
|
412
|
+
const explicit = explicitModel !== undefined && explicitModel !== ''
|
|
413
|
+
const warn = (msg: string) => options.logger?.warn?.(msg)
|
|
414
|
+
|
|
415
|
+
const model = resolveChildModel(options, agent, explicitModel)
|
|
416
|
+
if (model === undefined) {
|
|
417
|
+
warn(
|
|
418
|
+
`[pantheon-delegate] no model resolved for agent "${agent}" — ` +
|
|
419
|
+
"child session will use opencode's default model (may require API keys)",
|
|
420
|
+
)
|
|
421
|
+
return { model: undefined, error: undefined }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const missingVar = missingProviderKeyEnv(model.providerID, { env })
|
|
425
|
+
if (missingVar === undefined) return { model, error: undefined }
|
|
426
|
+
|
|
427
|
+
if (explicit) {
|
|
428
|
+
// Rule of precedence: the caller's explicit model is respected — warn only.
|
|
429
|
+
warn(
|
|
430
|
+
`[pantheon-delegate] model "${explicitModel}" provider "${model.providerID}" requires ` +
|
|
431
|
+
`API key ${missingVar} (unset) — respecting the explicit model anyway`,
|
|
432
|
+
)
|
|
433
|
+
return { model, error: undefined }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Auto-resolved (agentModels / preset) → try the known-good fallback.
|
|
437
|
+
const fallback = splitModelRef(FALLBACK_MODEL)
|
|
438
|
+
if (fallback !== undefined && missingProviderKeyEnv(fallback.providerID, { env }) === undefined) {
|
|
439
|
+
warn(
|
|
440
|
+
`[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
|
|
441
|
+
`(unset) — falling back to ${FALLBACK_MODEL}`,
|
|
442
|
+
)
|
|
443
|
+
return { model: fallback, error: undefined }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const message =
|
|
447
|
+
`pantheon_delegate: no usable model for agent "${agent}" — provider "${model.providerID}" ` +
|
|
448
|
+
`requires API key (set ${missingVar} or PANTHEON_MODEL_PRESET)`
|
|
449
|
+
warn(`[pantheon-delegate] ${message}`)
|
|
450
|
+
return { model: undefined, error: message }
|
|
451
|
+
}
|
|
452
|
+
|
|
197
453
|
// ─── Factory ───────────────────────────────────────────────────────────
|
|
198
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Pure helper: collect the IDs of root sessions (parentID === undefined)
|
|
457
|
+
* from a session list. Used to SEED the depth-guard allowlist at startup —
|
|
458
|
+
* opencode does not replay `session.created` events for sessions that exist
|
|
459
|
+
* before plugin load, so a resumed root would never enter `rootSessions`
|
|
460
|
+
* and the depth guard would reject its pantheon_delegate calls after a
|
|
461
|
+
* restart. Fail-open contract lives at the call site (plugin.ts): a failed
|
|
462
|
+
* `session.list()` leaves the registry untouched, never crashes startup.
|
|
463
|
+
*/
|
|
464
|
+
export function collectRootSessionIDs(
|
|
465
|
+
sessions: ReadonlyArray<{ id: string; parentID?: string }>,
|
|
466
|
+
): Set<string> {
|
|
467
|
+
const roots = new Set<string>()
|
|
468
|
+
for (const session of sessions) {
|
|
469
|
+
if (session.parentID === undefined) roots.add(session.id)
|
|
470
|
+
}
|
|
471
|
+
return roots
|
|
472
|
+
}
|
|
473
|
+
|
|
199
474
|
/**
|
|
200
475
|
* Build the delegation toolset. Keeps per-instance state: the set of child
|
|
201
476
|
* sessions WE created (depth-guard default) and the per-job timeout timers
|
|
@@ -214,7 +489,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
214
489
|
|
|
215
490
|
function isRootSession(sessionID: string): boolean {
|
|
216
491
|
if (options.isRootSession !== undefined) return options.isRootSession(sessionID)
|
|
217
|
-
if (options.rootSessions !== undefined)
|
|
492
|
+
if (options.rootSessions !== undefined) {
|
|
493
|
+
// compaction-134 (fix layer 2): the allowlist alone is NOT
|
|
494
|
+
// authoritative. opencode does not replay `session.created` events for
|
|
495
|
+
// pre-existing sessions, so after a restart the resumed root never
|
|
496
|
+
// entered rootSessions (the plugin seeds it from session.list() at
|
|
497
|
+
// startup, but that seed is fail-open). A session is a sub-session
|
|
498
|
+
// ONLY if WE created it as a child (knownChildren — filled on every
|
|
499
|
+
// pantheon_delegate dispatch). Everything unknown is treated as root:
|
|
500
|
+
// resumed/unknown sessions can delegate, while the real depth guard
|
|
501
|
+
// (a child of a delegate cannot re-delegate) is preserved.
|
|
502
|
+
return options.rootSessions.has(sessionID) || !knownChildren.has(sessionID)
|
|
503
|
+
}
|
|
218
504
|
return !knownChildren.has(sessionID)
|
|
219
505
|
}
|
|
220
506
|
|
|
@@ -257,16 +543,16 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
257
543
|
// session.create failure → return a clear error as TEXT (tools return
|
|
258
544
|
// errors as text, not thrown) and register NO job on the board — an
|
|
259
545
|
// unhandled rejection here would otherwise lose the failure entirely.
|
|
260
|
-
// The child model is resolved
|
|
261
|
-
// (
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
546
|
+
// The child model is resolved with a provider API-key gate (P1): the
|
|
547
|
+
// resolved model (explicit `model` > options.agentModels > active
|
|
548
|
+
// preset) is used when its provider key is configured; an explicit
|
|
549
|
+
// caller model is always respected (warned); an auto-resolved model
|
|
550
|
+
// whose provider key is missing falls back to the known-good
|
|
551
|
+
// opencode/deepseek-v4-flash-free; if nothing usable remains the
|
|
552
|
+
// failure is returned as TEXT below — no session, no board job.
|
|
553
|
+
const resolved = resolveUsableChildModel(options, args.agent, args.model)
|
|
554
|
+
if (resolved.error !== undefined) return resolved.error
|
|
555
|
+
const childModel = resolved.model
|
|
270
556
|
let created: { id: string }
|
|
271
557
|
try {
|
|
272
558
|
created = await client.session.create({
|
|
@@ -312,9 +598,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
312
598
|
state: 'error',
|
|
313
599
|
error: `Delegation [${job.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
|
|
314
600
|
timedOut: true,
|
|
315
|
-
}).catch((err: unknown) =>
|
|
316
|
-
console.error('[pantheon-delegate] timeout finalize failed:', err),
|
|
317
|
-
)
|
|
601
|
+
}).catch((err: unknown) => log.error('[pantheon-delegate] timeout finalize failed:', err))
|
|
318
602
|
}, timeoutMs)
|
|
319
603
|
timer.unref()
|
|
320
604
|
timers.set(childSessionID, timer)
|
|
@@ -326,7 +610,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
326
610
|
path: { id: childSessionID },
|
|
327
611
|
body: { agent: args.agent, parts: [{ type: 'text', text: args.prompt }] },
|
|
328
612
|
})
|
|
329
|
-
.catch((err: unknown) =>
|
|
613
|
+
.catch((err: unknown) => log.error('[pantheon-delegate] promptAsync failed:', err))
|
|
330
614
|
|
|
331
615
|
return (
|
|
332
616
|
`Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
|
|
@@ -338,7 +622,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
338
622
|
const pantheon_delegation_read: DelegationTool<typeof readArgs> = {
|
|
339
623
|
description:
|
|
340
624
|
'Block until a background delegation finishes (completed/error/cancelled), then return its ' +
|
|
341
|
-
'report markdown and mark the job reconciled.
|
|
625
|
+
'report markdown (with a trailing agent-activity section) and mark the job reconciled. ' +
|
|
626
|
+
'Resolves by alias or task ID.',
|
|
342
627
|
args: readArgs,
|
|
343
628
|
execute: async (args, ctx) => {
|
|
344
629
|
const job = board.resolve(ctx.sessionID, args.id)
|
|
@@ -346,9 +631,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
346
631
|
return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
|
|
347
632
|
}
|
|
348
633
|
|
|
634
|
+
// Sample the child's messages while waiting so the caller sees what the
|
|
635
|
+
// agent is doing — fail-open: no messages support → report as before.
|
|
636
|
+
const collector: ActivityCollector = { lines: [], sampled: false }
|
|
349
637
|
let terminal: BackgroundJobRecord
|
|
350
638
|
try {
|
|
351
|
-
terminal = await
|
|
639
|
+
terminal = await waitForTerminalWithActivity(
|
|
640
|
+
board,
|
|
641
|
+
client,
|
|
642
|
+
job.taskID,
|
|
643
|
+
readTimeoutMs,
|
|
644
|
+
collector,
|
|
645
|
+
)
|
|
352
646
|
} catch {
|
|
353
647
|
return `Timed out after ${readTimeoutMs}ms waiting for delegation "${args.id}" ([${job.alias}]).`
|
|
354
648
|
}
|
|
@@ -359,7 +653,9 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
359
653
|
}
|
|
360
654
|
|
|
361
655
|
await board.markReconciled(job.taskID)
|
|
362
|
-
|
|
656
|
+
// Fail-open: if session.messages never succeeded, keep the report as-is.
|
|
657
|
+
if (!collector.sampled && collector.lines.length === 0) return md
|
|
658
|
+
return `${md.replace(/\n+$/, '')}\n\n${formatActivitySection(collector.lines)}`
|
|
363
659
|
},
|
|
364
660
|
}
|
|
365
661
|
|
|
@@ -371,10 +667,16 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
371
667
|
const jobs = board.list(ctx.sessionID)
|
|
372
668
|
if (jobs.length === 0) return 'No background delegations for this session.'
|
|
373
669
|
|
|
374
|
-
const lines =
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
670
|
+
const lines = await Promise.all(
|
|
671
|
+
jobs.map(async (j) => {
|
|
672
|
+
const unread = j.terminalUnreconciled ? ' [unread]' : ''
|
|
673
|
+
const base = ` [${j.alias}] ${j.agent} — ${j.description} — ${stateLabel(j.state)}${unread}`
|
|
674
|
+
// Running jobs get a live `last activity:` line (fail-open fetch).
|
|
675
|
+
if (j.state !== 'running') return base
|
|
676
|
+
const last = await lastChildActivity(client, j.taskID)
|
|
677
|
+
return last === undefined ? base : `${base}\n last activity: ${last}`
|
|
678
|
+
}),
|
|
679
|
+
)
|
|
378
680
|
return `Background Delegations (${jobs.length}):\n${lines.join('\n')}`
|
|
379
681
|
},
|
|
380
682
|
}
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* @module dispatch-guard
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { createPantheonLogger } from './logger.ts'
|
|
23
|
+
|
|
24
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
|
|
25
|
+
// fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
|
|
26
|
+
// PANTHEON_HOOKS_LOG=1. `logger` injection stays for tests.
|
|
27
|
+
const log = createPantheonLogger({ module: 'pantheon-dispatch-guard' })
|
|
28
|
+
|
|
22
29
|
/** A dispatch result — the minimal shape needed to classify emptiness. */
|
|
23
30
|
export interface DispatchResultLike {
|
|
24
31
|
/** Text content of the result (task_result / report markdown). */
|
|
@@ -35,7 +42,7 @@ export type DispatchClassification = 'content' | 'empty-mode1' | 'empty-mode2'
|
|
|
35
42
|
export interface DispatchGuardOptions {
|
|
36
43
|
/** Enable auto-retry on empty results. Default: true. */
|
|
37
44
|
retryOnEmpty?: boolean
|
|
38
|
-
/** Injectable logger (defaults to
|
|
45
|
+
/** Injectable logger (defaults to the Pantheon file log, env-gated echo). */
|
|
39
46
|
logger?: { warn: (message: string) => void }
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -72,9 +79,7 @@ function hasTokens(result: DispatchResultLike): boolean {
|
|
|
72
79
|
|
|
73
80
|
export function createDispatchGuard(options?: DispatchGuardOptions): DispatchGuard {
|
|
74
81
|
const retryOnEmpty = options?.retryOnEmpty ?? true
|
|
75
|
-
const warn =
|
|
76
|
-
options?.logger?.warn ??
|
|
77
|
-
((message: string) => console.warn(`[pantheon-dispatch-guard] ${message}`))
|
|
82
|
+
const warn = options?.logger?.warn ?? ((message: string) => log.warn(message))
|
|
78
83
|
// Per-instance retry budget — the "cap 1" enforcement point.
|
|
79
84
|
let retriesUsed = 0
|
|
80
85
|
|
|
@@ -19,16 +19,20 @@ import { z } from 'zod'
|
|
|
19
19
|
import type { BackgroundJobBoard } from './background-job-board.ts'
|
|
20
20
|
import type { ToolContextLike } from './delegation.ts'
|
|
21
21
|
import { GoalStore } from './goal-store.ts'
|
|
22
|
+
import { createPantheonLogger } from './logger.ts'
|
|
22
23
|
import type { TodoEnforcerMessage } from './todo-enforcer.ts'
|
|
23
24
|
|
|
25
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): warn → hooks.log,
|
|
26
|
+
// console echo opt-in via PANTHEON_HOOKS_LOG=1. `deps.logger` stays for tests.
|
|
27
|
+
const log = createPantheonLogger({ module: 'pantheon-goal' })
|
|
28
|
+
|
|
24
29
|
export type { Goal, GoalStatus } from './goal-store.ts'
|
|
25
30
|
export { GoalStore }
|
|
26
31
|
|
|
27
32
|
// ─── Constants ─────────────────────────────────────────────────────────
|
|
28
33
|
|
|
29
34
|
/** Continuation text injected into idle sessions. `{objective}` is substituted. */
|
|
30
|
-
export const GOAL_CONTINUATION_PROMPT =
|
|
31
|
-
"Active goal: {objective}. Continue working toward it. Do not stop until you update the goal to done. If you believe it's complete, call pantheon_goal_update with status done."
|
|
35
|
+
export const GOAL_CONTINUATION_PROMPT = 'Continue goal: {objective}'
|
|
32
36
|
|
|
33
37
|
/** Defaults matching routing.yml `full_auto`. */
|
|
34
38
|
export const GOAL_LOOP_DEFAULTS = {
|
|
@@ -141,8 +145,7 @@ export class GoalLoop {
|
|
|
141
145
|
maxContinuations: opts.maxContinuations ?? GOAL_LOOP_DEFAULTS.maxContinuations,
|
|
142
146
|
now: opts.now ?? Date.now,
|
|
143
147
|
}
|
|
144
|
-
this.warn =
|
|
145
|
-
deps.logger?.warn ?? ((message: string) => console.warn(`[pantheon-goal] ${message}`))
|
|
148
|
+
this.warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
|
|
146
149
|
}
|
|
147
150
|
|
|
148
151
|
/** True when the session has an active (non-done) goal. Used by the idle dispatcher. */
|
|
@@ -105,7 +105,8 @@ function resolveFile(
|
|
|
105
105
|
if (isAbsolute(file)) return { ok: true, file: resolve(file) }
|
|
106
106
|
const base = resolve(ctx.worktree ?? ctx.directory ?? process.cwd())
|
|
107
107
|
const resolved = resolve(base, file)
|
|
108
|
-
// Prefix check with a path.sep boundary:
|
|
108
|
+
// Prefix check with a path.sep boundary: base + sep + name must not
|
|
109
|
+
// match a sibling prefix (e.g. `/data/x` vs `/data2/x`).
|
|
109
110
|
if (resolved !== base && !resolved.startsWith(base + sep)) {
|
|
110
111
|
return {
|
|
111
112
|
ok: false,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pantheon plugin logger — silence-by-default TUI policy (shared with
|
|
3
|
+
* pantheon-hooks, see src/plugins/pantheon-hooks.ts L42-58).
|
|
4
|
+
*
|
|
5
|
+
* WHY: `console.log`/`console.error` in an opencode plugin writes to the
|
|
6
|
+
* process stdout/stderr, which the TUI renders DIRECTLY into the terminal —
|
|
7
|
+
* the user-visible "lixo" (e.g. `[Pantheon Plugin] Board terminal: ...` in
|
|
8
|
+
* the TUI footer). Every Pantheon module therefore routes its logs through
|
|
9
|
+
* this helper:
|
|
10
|
+
*
|
|
11
|
+
* 1. ALWAYS append one ISO-stamped, module-prefixed line to the
|
|
12
|
+
* project-local `.pantheon/logs/hooks.log` (same file pantheon-hooks
|
|
13
|
+
* uses, so the plugin trail stays auditable on disk);
|
|
14
|
+
* 2. echo to the console ONLY when `PANTHEON_HOOKS_LOG=1` (or any truthy
|
|
15
|
+
* value — same env var pantheon-hooks uses; read at creation time).
|
|
16
|
+
*
|
|
17
|
+
* All channels are best-effort and fire-and-forget: a logging failure must
|
|
18
|
+
* never break the plugin, and an unhandled rejection could itself pollute
|
|
19
|
+
* the TUI, so writes are swallowed.
|
|
20
|
+
*
|
|
21
|
+
* Pure TypeScript — zero external deps beyond Node.js builtins (fs/path).
|
|
22
|
+
*
|
|
23
|
+
* @module logger
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
27
|
+
import { dirname, join } from 'node:path'
|
|
28
|
+
|
|
29
|
+
export interface PantheonLoggerOptions {
|
|
30
|
+
/** Module tag prefixed to every line (e.g. 'pantheon-plugin'). */
|
|
31
|
+
module: string
|
|
32
|
+
/**
|
|
33
|
+
* Env gate for the console echo — truthy means echo in addition to the
|
|
34
|
+
* log file. Defaults to `process.env.PANTHEON_HOOKS_LOG` (read at
|
|
35
|
+
* creation), matching the pantheon-hooks policy. Injectable for tests.
|
|
36
|
+
*/
|
|
37
|
+
env?: string | undefined
|
|
38
|
+
/**
|
|
39
|
+
* Project directory — the log lands at `<logDir>/.pantheon/logs/hooks.log`.
|
|
40
|
+
* Defaults to `process.cwd()` (opencode runs plugins with cwd = project,
|
|
41
|
+
* consistent with the plugin's other relative `.pantheon/...` paths).
|
|
42
|
+
*/
|
|
43
|
+
logDir?: string | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PantheonLogger {
|
|
47
|
+
info(message: string, ...args: unknown[]): void
|
|
48
|
+
warn(message: string, ...args: unknown[]): void
|
|
49
|
+
error(message: string, ...args: unknown[]): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LOG_LEVELS = ['info', 'warn', 'error'] as const
|
|
53
|
+
type LogLevel = (typeof LOG_LEVELS)[number]
|
|
54
|
+
|
|
55
|
+
/** Console method per level — info maps to console.log (the only console touch). */
|
|
56
|
+
const CONSOLE_METHODS: Record<LogLevel, 'log' | 'warn' | 'error'> = {
|
|
57
|
+
info: 'log',
|
|
58
|
+
warn: 'warn',
|
|
59
|
+
error: 'error',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Serialize a log arg — Errors to stack/message, never "[object Object]". */
|
|
63
|
+
function formatArg(arg: unknown): string {
|
|
64
|
+
if (arg instanceof Error) return arg.stack ?? arg.message
|
|
65
|
+
if (typeof arg === 'string') return arg
|
|
66
|
+
try {
|
|
67
|
+
return JSON.stringify(arg)
|
|
68
|
+
} catch {
|
|
69
|
+
return String(arg)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Append one ISO-stamped line to the hooks.log. Multi-line messages are
|
|
75
|
+
* split so EVERY line carries the stamp (pantheon-hooks appendHookLog
|
|
76
|
+
* pattern). Throws only on IO failure — callers swallow.
|
|
77
|
+
*/
|
|
78
|
+
async function appendToLog(logPath: string, prefix: string, args: unknown[]): Promise<void> {
|
|
79
|
+
const body = [prefix, ...args.map(formatArg)].join(' ')
|
|
80
|
+
const stamp = new Date().toISOString()
|
|
81
|
+
const lines = body
|
|
82
|
+
.split('\n')
|
|
83
|
+
.map((p) => p.trim())
|
|
84
|
+
.filter((p) => p !== '')
|
|
85
|
+
.map((p) => `[${stamp}] ${p}`)
|
|
86
|
+
if (lines.length === 0) return
|
|
87
|
+
await mkdir(dirname(logPath), { recursive: true })
|
|
88
|
+
await appendFile(logPath, `${lines.join('\n')}\n`, 'utf8')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create a module logger. Every level writes to the log file ALWAYS and
|
|
93
|
+
* echoes to the console only when the env gate is truthy. Fire-and-forget:
|
|
94
|
+
* returns void, never throws.
|
|
95
|
+
*/
|
|
96
|
+
export function createPantheonLogger(options: PantheonLoggerOptions): PantheonLogger {
|
|
97
|
+
const echo = (options.env ?? process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
|
|
98
|
+
const logPath = join(options.logDir ?? process.cwd(), '.pantheon', 'logs', 'hooks.log')
|
|
99
|
+
|
|
100
|
+
const write = (level: LogLevel, message: string, args: unknown[]): void => {
|
|
101
|
+
void appendToLog(logPath, `[${options.module}] ${message}`, args).catch(() => {
|
|
102
|
+
// Best-effort file log — never break the plugin over logging.
|
|
103
|
+
})
|
|
104
|
+
if (echo) {
|
|
105
|
+
// Opt-in debug echo (PANTHEON_HOOKS_LOG=1). Off by default: console
|
|
106
|
+
// output in a plugin renders into the opencode TUI (the pollution bug).
|
|
107
|
+
const consoleMethod = CONSOLE_METHODS[level]
|
|
108
|
+
console[consoleMethod](`[${options.module}] ${message}`, ...args)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
info: (message, ...args) => write('info', message, args),
|
|
114
|
+
warn: (message, ...args) => write('warn', message, args),
|
|
115
|
+
error: (message, ...args) => write('error', message, args),
|
|
116
|
+
}
|
|
117
|
+
}
|