pantheon-opencode 1.3.2 → 1.3.3
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/README.md +50 -0
- package/opencode.json +4 -1
- package/package.json +4 -2
- package/scripts/cost.mjs +69 -0
- package/scripts/install/opencode.mjs +27 -9
- package/src/agents/zeus.md +76 -0
- package/src/pantheon/background-job-board.ts +95 -6
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +110 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +270 -0
- package/src/pantheon/delegation-notify.ts +238 -0
- package/src/pantheon/delegation.ts +388 -0
- package/src/pantheon/dispatch-guard.ts +102 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +297 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +259 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/todo-enforcer.ts +369 -0
- package/src/plugin.ts +272 -6
- package/src/plugins/pantheon-hooks.ts +17 -8
- package/src/plugins/tui/package.json +1 -1
- package/src/routing.yml +33 -2
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation Notify (Phase 3) — completion notifications for background jobs.
|
|
3
|
+
*
|
|
4
|
+
* The spike refuted `promptAsync({noReply:true})` as a push mechanism — nothing
|
|
5
|
+
* is delivered to the parent session. The working channel is a QUEUE + flush:
|
|
6
|
+
*
|
|
7
|
+
* 1. When a board job reaches a terminal state (`board.onTerminal`), the
|
|
8
|
+
* plugin calls `notifyParent(job)` → a `<task-notification>` block is
|
|
9
|
+
* queued in-memory for `job.parentSessionID` (FIFO, bounded per parent).
|
|
10
|
+
* 2. The next `chat.message` hook fire for that parent session calls
|
|
11
|
+
* `flushQueue(parentSessionID, output)`, which prepends the pending
|
|
12
|
+
* notifications onto the first text part of the parent's user message —
|
|
13
|
+
* the same graceful-degradation channel pantheon-hooks uses for its
|
|
14
|
+
* <system-reminder> fallback buffer (TUI toasts are broken on 1.18.13).
|
|
15
|
+
*
|
|
16
|
+
* If the parent never sends another message the notification stays queued —
|
|
17
|
+
* the graceful-degradation path. There is deliberately NO client push.
|
|
18
|
+
*
|
|
19
|
+
* `handleDelegationEvent` wires the `event` hook: `session.idle`/`session.error`
|
|
20
|
+
* on a child session that is a board job → `finalizeDelegation` (Phase 2),
|
|
21
|
+
* which transitions the board and fires `onTerminal` → notify. That makes the
|
|
22
|
+
* onTerminal listener the SINGLE notification point — finalize never notifies
|
|
23
|
+
* directly, so there is no double-notify. Unknown sessions are ignored.
|
|
24
|
+
*
|
|
25
|
+
* Pure TypeScript — zero runtime dependencies beyond the board types.
|
|
26
|
+
*
|
|
27
|
+
* Follow-up (Themis review, Phase 5): `extractErrorMessage` handles the two
|
|
28
|
+
* observed `session.error` error shapes (string, { message }). The opencode SDK
|
|
29
|
+
* MAY expose other shapes (e.g. Error-like with non-string `message`) — if a
|
|
30
|
+
* real session.error ever carries one, the extraction degrades to the generic
|
|
31
|
+
* "session error" label. No code change: there is no evidence any other shape
|
|
32
|
+
* occurs in practice; revisit if a session.error shows an unexpected payload.
|
|
33
|
+
*
|
|
34
|
+
* @module delegation-notify
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { BackgroundJobRecord } from './background-job-board.ts'
|
|
38
|
+
import type { FinalizeInput } from './delegation-finalize.ts'
|
|
39
|
+
|
|
40
|
+
export type { FinalizeInput } from './delegation-finalize.ts'
|
|
41
|
+
|
|
42
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** One queued notification: text + enqueue timestamp. */
|
|
45
|
+
export interface QueuedNotification {
|
|
46
|
+
text: string
|
|
47
|
+
at: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Structural view of the event payload the `event` hook receives. */
|
|
51
|
+
export interface DelegationEventLike {
|
|
52
|
+
type?: string
|
|
53
|
+
properties?: Record<string, unknown>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Dependencies threaded to handleDelegationEvent (board lookup + finalize). */
|
|
57
|
+
export interface DelegationEventDeps {
|
|
58
|
+
/** Look up a board job by session ID (child session IDs are task IDs). */
|
|
59
|
+
board: { get(taskID: string): BackgroundJobRecord | undefined }
|
|
60
|
+
/** Bound Phase 2 finalize — clears the timeout timer + writes the report. */
|
|
61
|
+
finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Notification text ─────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** Cap for description/summary text embedded in a notification. */
|
|
67
|
+
const NOTIFICATION_TEXT_MAX = 120
|
|
68
|
+
|
|
69
|
+
function truncate(text: string, max: number): string {
|
|
70
|
+
const trimmed = text.trim()
|
|
71
|
+
return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the self-contained `<task-notification>` block for a terminal job.
|
|
76
|
+
* Carries the task ID, alias, agent, state, timeout flag, description and
|
|
77
|
+
* result summary so the parent model can act on the completion without extra
|
|
78
|
+
* tool calls.
|
|
79
|
+
*/
|
|
80
|
+
export function buildTaskNotification(job: BackgroundJobRecord): string {
|
|
81
|
+
const stateLabel =
|
|
82
|
+
job.state === 'completed' ? 'completed' : job.state === 'error' ? 'failed' : job.state
|
|
83
|
+
const timeout = job.timedOut ? ' (timed out)' : ''
|
|
84
|
+
// The `Result:` marker makes the child output origin explicit in the parent
|
|
85
|
+
// context — the summary text came from the CHILD's output, not the parent's
|
|
86
|
+
// own context (Themis mitigation, Phase 5).
|
|
87
|
+
const summary = job.resultSummary
|
|
88
|
+
? ` — Result: ${truncate(job.resultSummary, NOTIFICATION_TEXT_MAX)}`
|
|
89
|
+
: ''
|
|
90
|
+
return [
|
|
91
|
+
'<task-notification>',
|
|
92
|
+
`<task id="${job.taskID}" alias="${job.alias}" agent="${job.agent}" state="${job.state}">`,
|
|
93
|
+
`<summary>Delegation [${job.alias}] (${job.agent}) ${stateLabel}${timeout}: ${truncate(job.description, NOTIFICATION_TEXT_MAX)}${summary}</summary>`,
|
|
94
|
+
`<read>Read the full report with pantheon_delegation_read({ id: "${job.alias}" }).</read>`,
|
|
95
|
+
'</task>',
|
|
96
|
+
'</task-notification>',
|
|
97
|
+
].join('\n')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Notifier (queue + flush) ──────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** Per-parent queue bound — a full queue drops new entries, never grows unbounded. */
|
|
103
|
+
const MAX_PER_PARENT = 10
|
|
104
|
+
|
|
105
|
+
function isTextPartLike(value: unknown): value is { type: 'text'; text: string } {
|
|
106
|
+
if (!value || typeof value !== 'object') return false
|
|
107
|
+
const part = value as { type?: unknown; text?: unknown }
|
|
108
|
+
return part.type === 'text' && typeof part.text === 'string'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* In-memory completion notification queue, keyed by parent session ID.
|
|
113
|
+
* FIFO per parent, bounded (MAX_PER_PARENT — anti-spam philosophy, matching
|
|
114
|
+
* pantheon-hooks' CHAT_REMINDER_MAX). Delivered by `flushQueue` into the
|
|
115
|
+
* parent's next chat.message; delivered notifications are marked sent.
|
|
116
|
+
*/
|
|
117
|
+
export class DelegationNotifier {
|
|
118
|
+
private readonly queue = new Map<string, QueuedNotification[]>()
|
|
119
|
+
private readonly sent = new Map<string, number>()
|
|
120
|
+
private readonly warn: (message: string) => void
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param logger Optional injected logger (testable). Defaults to
|
|
124
|
+
* `console.warn` with the `[pantheon-delegation]` prefix, matching the
|
|
125
|
+
* plugin's structured-log style.
|
|
126
|
+
*/
|
|
127
|
+
constructor(logger?: { warn: (message: string) => void }) {
|
|
128
|
+
this.warn =
|
|
129
|
+
logger?.warn ?? ((message: string) => console.warn(`[pantheon-delegation] ${message}`))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Notifications awaiting delivery for a parent session. */
|
|
133
|
+
pendingCount(parentSessionID: string): number {
|
|
134
|
+
return this.queue.get(parentSessionID)?.length ?? 0
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether any notifications await delivery for a parent session. */
|
|
138
|
+
hasPending(parentSessionID: string): boolean {
|
|
139
|
+
return this.pendingCount(parentSessionID) > 0
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Notifications already injected into a parent's chat.message output. */
|
|
143
|
+
sentCount(parentSessionID: string): number {
|
|
144
|
+
return this.sent.get(parentSessionID) ?? 0
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Queue a completion notification for a parent session. The channel: there
|
|
149
|
+
* is NO client push — the notification is delivered by the next
|
|
150
|
+
* `flushQueue` for that parent. Returns false when the queue is full.
|
|
151
|
+
*/
|
|
152
|
+
queueNotification(parentSessionID: string, text: string): boolean {
|
|
153
|
+
const entries = this.queue.get(parentSessionID) ?? []
|
|
154
|
+
if (entries.length >= MAX_PER_PARENT) {
|
|
155
|
+
this.warn(
|
|
156
|
+
`notification dropped for parent session ${parentSessionID}: ` +
|
|
157
|
+
`queue full (${MAX_PER_PARENT} pending)`,
|
|
158
|
+
)
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
entries.push({ text, at: Date.now() })
|
|
162
|
+
this.queue.set(parentSessionID, entries)
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Build + queue the notification for a terminal job. False when full. */
|
|
167
|
+
notifyParent(job: BackgroundJobRecord): boolean {
|
|
168
|
+
return this.queueNotification(job.parentSessionID, buildTaskNotification(job))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Inject all pending notifications for a parent into a `chat.message` output
|
|
173
|
+
* (prepend onto the first text part — matching the pantheon-hooks reminder
|
|
174
|
+
* injection pattern; with no text part the block is unshifted as a new one).
|
|
175
|
+
* Delivered entries are marked sent. Returns how many were delivered (0 when
|
|
176
|
+
* nothing was pending).
|
|
177
|
+
*/
|
|
178
|
+
flushQueue(parentSessionID: string, output: { parts: unknown[] }): number {
|
|
179
|
+
const entries = this.queue.get(parentSessionID)
|
|
180
|
+
if (entries === undefined || entries.length === 0) return 0
|
|
181
|
+
this.queue.delete(parentSessionID)
|
|
182
|
+
|
|
183
|
+
const block = entries.map((entry) => entry.text).join('\n')
|
|
184
|
+
const firstText = output.parts.find(isTextPartLike)
|
|
185
|
+
if (firstText !== undefined) {
|
|
186
|
+
firstText.text = `${block}\n\n${firstText.text}`
|
|
187
|
+
} else {
|
|
188
|
+
output.parts.unshift({ type: 'text', text: block })
|
|
189
|
+
}
|
|
190
|
+
this.sent.set(parentSessionID, (this.sent.get(parentSessionID) ?? 0) + entries.length)
|
|
191
|
+
return entries.length
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Event → finalize wiring ───────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
function extractErrorMessage(error: unknown): string {
|
|
198
|
+
if (typeof error === 'string' && error.trim() !== '') return error
|
|
199
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
200
|
+
const message = (error as { message: unknown }).message
|
|
201
|
+
if (typeof message === 'string' && message.trim() !== '') return message
|
|
202
|
+
}
|
|
203
|
+
return 'session error'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Handle a lifecycle event for background delegations:
|
|
208
|
+
* - `session.idle` on a board job → finalize as completed;
|
|
209
|
+
* - `session.error` on a board job → finalize as error (message extracted);
|
|
210
|
+
* - any other event, a missing session ID, or an unknown session → no-op.
|
|
211
|
+
*
|
|
212
|
+
* Finalize transitions the board, which fires `onTerminal` — the single point
|
|
213
|
+
* that queues the completion notification (no double-notify). The board's
|
|
214
|
+
* same-terminal idempotency makes repeated idles harmless (finalize rewrites
|
|
215
|
+
* the report but never transitions a terminal job to a different state).
|
|
216
|
+
*
|
|
217
|
+
* @returns True when the event named a board job (handled).
|
|
218
|
+
*/
|
|
219
|
+
export async function handleDelegationEvent(
|
|
220
|
+
ev: DelegationEventLike,
|
|
221
|
+
deps: DelegationEventDeps,
|
|
222
|
+
): Promise<boolean> {
|
|
223
|
+
const type = ev?.type
|
|
224
|
+
if (type !== 'session.idle' && type !== 'session.error') return false
|
|
225
|
+
const sessionID = ev.properties?.sessionID
|
|
226
|
+
if (typeof sessionID !== 'string' || sessionID === '') return false
|
|
227
|
+
if (deps.board.get(sessionID) === undefined) return false
|
|
228
|
+
|
|
229
|
+
if (type === 'session.idle') {
|
|
230
|
+
await deps.finalize(sessionID, { state: 'completed' })
|
|
231
|
+
} else {
|
|
232
|
+
await deps.finalize(sessionID, {
|
|
233
|
+
state: 'error',
|
|
234
|
+
error: extractErrorMessage(ev.properties?.error),
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation Core (Phase 2) — the three background-delegation tools plus the
|
|
3
|
+
* finalize lifecycle hook, built on the BackgroundJobBoard.
|
|
4
|
+
*
|
|
5
|
+
* Tools (STRUCTURAL shape — matches what opencode expects: `{description,
|
|
6
|
+
* args (zod shape), execute(args, ctx)}`; zod is imported directly — the
|
|
7
|
+
* `@opencode-ai/plugin` package is a devDep and is NEVER runtime-imported):
|
|
8
|
+
* - `pantheon_delegate({prompt, agent, description?})` — create a child
|
|
9
|
+
* session (parentID = caller), register it on the board, arm a timeout,
|
|
10
|
+
* and fire-and-forget `session.promptAsync` on the child (NO noReply —
|
|
11
|
+
* the spike refuted noReply as a push mechanism). Completion is observed
|
|
12
|
+
* via `session.idle` on the child and finalized through
|
|
13
|
+
* `finalizeDelegation` (exposed on the toolset for the Phase 3 event hook).
|
|
14
|
+
* - `pantheon_delegation_read({id})` — block via `board.waitForTerminal`,
|
|
15
|
+
* return the report markdown, mark the job reconciled.
|
|
16
|
+
* - `pantheon_delegation_list()` — job list with `[unread]` for terminal-
|
|
17
|
+
* unreconciled jobs.
|
|
18
|
+
*
|
|
19
|
+
* Depth guard (root-session detection): the ToolContext carries NO parentID
|
|
20
|
+
* (spike), so root detection is configurable. Resolution order:
|
|
21
|
+
* 1. `options.isRootSession(sessionID)` custom predicate;
|
|
22
|
+
* 2. `options.rootSessions` explicit allowlist;
|
|
23
|
+
* 3. default: any session WE created via `session.create` is a sub-session;
|
|
24
|
+
* everything else is treated as root. Phase 3 should pass the root
|
|
25
|
+
* registry derived from `session.created` events (Session.parentID
|
|
26
|
+
* undefined ⇒ root) for authoritative enforcement.
|
|
27
|
+
*
|
|
28
|
+
* Read-only enforcement is Phase 4 — `read_only` is exposed on the delegate
|
|
29
|
+
* args and the child session is registered in the read-only registry when the
|
|
30
|
+
* delegate is read-only (explicit flag or agent ∈ readOnlyAgents), which the
|
|
31
|
+
* plugin's `tool.execute.before` guard enforces.
|
|
32
|
+
*
|
|
33
|
+
* @module delegation
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { z } from 'zod'
|
|
37
|
+
|
|
38
|
+
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
39
|
+
import { readOnlyRegistry } from './delegation-enforce.ts'
|
|
40
|
+
import {
|
|
41
|
+
DELEGATION_DEFAULTS,
|
|
42
|
+
type DelegationClient,
|
|
43
|
+
type DelegationDeps,
|
|
44
|
+
type DelegationOptions,
|
|
45
|
+
type FinalizeInput,
|
|
46
|
+
finalizeDelegation as finalizeDelegationReport,
|
|
47
|
+
readDelegationReport,
|
|
48
|
+
} from './delegation-finalize.ts'
|
|
49
|
+
import { resolveActivePreset } from './presets.mjs'
|
|
50
|
+
|
|
51
|
+
export type {
|
|
52
|
+
DelegationClient,
|
|
53
|
+
DelegationClientSession,
|
|
54
|
+
DelegationMessageBundle,
|
|
55
|
+
DelegationOptions,
|
|
56
|
+
FinalizeInput,
|
|
57
|
+
} from './delegation-finalize.ts'
|
|
58
|
+
export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
|
|
59
|
+
|
|
60
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/** Structural view of the tool context opencode passes to execute(). */
|
|
63
|
+
export interface ToolContextLike {
|
|
64
|
+
sessionID: string
|
|
65
|
+
directory?: string
|
|
66
|
+
worktree?: string
|
|
67
|
+
agent?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A delegation tool: description + zod args shape + execute. */
|
|
71
|
+
export interface DelegationTool<Args extends z.ZodRawShape = z.ZodRawShape> {
|
|
72
|
+
description: string
|
|
73
|
+
args: Args
|
|
74
|
+
execute(args: z.infer<z.ZodObject<Args>>, ctx: ToolContextLike): Promise<string>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The full toolset returned by createDelegationTools(). */
|
|
78
|
+
export interface DelegationToolset {
|
|
79
|
+
pantheon_delegate: DelegationTool<typeof delegateArgs>
|
|
80
|
+
pantheon_delegation_read: DelegationTool<typeof readArgs>
|
|
81
|
+
pantheon_delegation_list: DelegationTool<typeof listArgs>
|
|
82
|
+
/** Lifecycle hook for the Phase 3 event wiring (session.idle on the child). */
|
|
83
|
+
finalizeDelegation: (
|
|
84
|
+
childSessionID: string,
|
|
85
|
+
opts: FinalizeInput,
|
|
86
|
+
) => Promise<BackgroundJobRecord | undefined>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Input for createDelegationTools(). */
|
|
90
|
+
export interface CreateDelegationToolsInput {
|
|
91
|
+
board: BackgroundJobBoard
|
|
92
|
+
client: DelegationClient
|
|
93
|
+
options?: DelegationOptions
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Args schemas ──────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const delegateArgs = {
|
|
99
|
+
prompt: z.string().min(1).describe('Task prompt delivered to the background agent.'),
|
|
100
|
+
agent: z.string().min(1).describe('Agent name, e.g. "apollo" or "hermes".'),
|
|
101
|
+
description: z.string().optional().describe('Human-readable description shown on the job board.'),
|
|
102
|
+
read_only: z.boolean().optional().describe('Advisory flag for Phase 4 read-only enforcement.'),
|
|
103
|
+
model: z
|
|
104
|
+
.string()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe(
|
|
107
|
+
'Explicit model for the child session (provider/model, e.g. "opencode/deepseek-v4-flash-free").',
|
|
108
|
+
),
|
|
109
|
+
} satisfies z.ZodRawShape
|
|
110
|
+
|
|
111
|
+
const readArgs = {
|
|
112
|
+
id: z.string().min(1).describe('Job alias (e.g. "apo-1") or task ID to read.'),
|
|
113
|
+
} satisfies z.ZodRawShape
|
|
114
|
+
|
|
115
|
+
const listArgs = {} satisfies z.ZodRawShape
|
|
116
|
+
|
|
117
|
+
// ─── State labels ──────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
120
|
+
switch (state) {
|
|
121
|
+
case 'running':
|
|
122
|
+
return 'RUNNING'
|
|
123
|
+
case 'completed':
|
|
124
|
+
return 'OK'
|
|
125
|
+
case 'error':
|
|
126
|
+
return 'ERR'
|
|
127
|
+
case 'cancelled':
|
|
128
|
+
return 'CANCELLED'
|
|
129
|
+
case 'reconciled':
|
|
130
|
+
return 'RECONCILED'
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Model resolution ──────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** Session model ref accepted by the opencode server's session.create. */
|
|
137
|
+
export interface ChildSessionModelRef {
|
|
138
|
+
id: string
|
|
139
|
+
providerID: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
|
|
144
|
+
* into the `{ id, providerID }` ref the session.create body expects.
|
|
145
|
+
* Returns undefined for malformed IDs (no slash, empty segments).
|
|
146
|
+
*/
|
|
147
|
+
function splitModelRef(model: string): ChildSessionModelRef | undefined {
|
|
148
|
+
const slash = model.indexOf('/')
|
|
149
|
+
if (slash <= 0 || slash === model.length - 1) return undefined
|
|
150
|
+
return { providerID: model.slice(0, slash), id: model.slice(slash + 1) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the child session model with priority:
|
|
155
|
+
* 1. explicit `model` option passed to the delegate tool;
|
|
156
|
+
* 2. `options.agentModels[agent]` (routing.yml agent entry, case-insensitive);
|
|
157
|
+
* 3. the active preset's agent entry via resolveActivePreset();
|
|
158
|
+
* 4. fallback: undefined — the child is created without a model (the
|
|
159
|
+
* caller warns) so opencode's default applies.
|
|
160
|
+
*/
|
|
161
|
+
function resolveChildModel(
|
|
162
|
+
options: DelegationOptions,
|
|
163
|
+
agent: string,
|
|
164
|
+
explicitModel?: string,
|
|
165
|
+
): ChildSessionModelRef | undefined {
|
|
166
|
+
// (a) explicit model option on the delegate tool call
|
|
167
|
+
if (explicitModel !== undefined && explicitModel !== '') {
|
|
168
|
+
const ref = splitModelRef(explicitModel)
|
|
169
|
+
if (ref !== undefined) return ref
|
|
170
|
+
}
|
|
171
|
+
const key = agent.toLowerCase()
|
|
172
|
+
// (b) routing.yml agent entry wired through options.agentModels
|
|
173
|
+
const mapped = options.agentModels?.[key]
|
|
174
|
+
if (mapped !== undefined && mapped !== '') {
|
|
175
|
+
const ref = splitModelRef(mapped)
|
|
176
|
+
if (ref !== undefined) return ref
|
|
177
|
+
}
|
|
178
|
+
// (c) active preset agent entry (resolveActivePreset reads routing.yml
|
|
179
|
+
// presets + .pantheon/active-preset.json). Guarded — a broken routing.yml
|
|
180
|
+
// must never kill the delegate.
|
|
181
|
+
try {
|
|
182
|
+
const presetModel = resolveActivePreset({
|
|
183
|
+
...(options.presetEnv !== undefined ? { env: options.presetEnv } : {}),
|
|
184
|
+
...(options.logger !== undefined ? { logger: options.logger } : {}),
|
|
185
|
+
})?.agents?.[key]?.model
|
|
186
|
+
if (presetModel !== undefined && presetModel !== '') {
|
|
187
|
+
const ref = splitModelRef(presetModel)
|
|
188
|
+
if (ref !== undefined) return ref
|
|
189
|
+
}
|
|
190
|
+
} catch (err: unknown) {
|
|
191
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
192
|
+
options.logger?.warn?.(`[pantheon-delegate] active preset resolution failed: ${reason}`)
|
|
193
|
+
}
|
|
194
|
+
return undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Factory ───────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build the delegation toolset. Keeps per-instance state: the set of child
|
|
201
|
+
* sessions WE created (depth-guard default) and the per-job timeout timers
|
|
202
|
+
* (cleared on finalize, `.unref()`'d so they never hold the process open).
|
|
203
|
+
*/
|
|
204
|
+
export function createDelegationTools(input: CreateDelegationToolsInput): DelegationToolset {
|
|
205
|
+
const { board, client } = input
|
|
206
|
+
const options: DelegationOptions = input.options ?? {}
|
|
207
|
+
const deps: DelegationDeps = { board, client, options }
|
|
208
|
+
const outputDir = options.outputDir ?? DELEGATION_DEFAULTS.outputDir
|
|
209
|
+
const timeoutMs = options.timeoutMs ?? DELEGATION_DEFAULTS.timeoutMs
|
|
210
|
+
const readTimeoutMs = options.readTimeoutMs ?? DELEGATION_DEFAULTS.readTimeoutMs
|
|
211
|
+
|
|
212
|
+
const knownChildren = new Set<string>()
|
|
213
|
+
const timers = new Map<string, NodeJS.Timeout>()
|
|
214
|
+
|
|
215
|
+
function isRootSession(sessionID: string): boolean {
|
|
216
|
+
if (options.isRootSession !== undefined) return options.isRootSession(sessionID)
|
|
217
|
+
if (options.rootSessions !== undefined) return options.rootSessions.has(sessionID)
|
|
218
|
+
return !knownChildren.has(sessionID)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function clearTimer(childSessionID: string): void {
|
|
222
|
+
const timer = timers.get(childSessionID)
|
|
223
|
+
if (timer !== undefined) {
|
|
224
|
+
clearTimeout(timer)
|
|
225
|
+
timers.delete(childSessionID)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Bound finalize: clear the job timer, then run the report + transition. */
|
|
230
|
+
async function finalize(
|
|
231
|
+
childSessionID: string,
|
|
232
|
+
opts: FinalizeInput,
|
|
233
|
+
): Promise<BackgroundJobRecord | undefined> {
|
|
234
|
+
clearTimer(childSessionID)
|
|
235
|
+
return finalizeDelegationReport(deps, childSessionID, opts)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
|
|
239
|
+
description:
|
|
240
|
+
'Dispatch a background agent as a child session and register it on the job board. ' +
|
|
241
|
+
'Returns the readable alias (e.g. "apo-1"); read the result with ' +
|
|
242
|
+
'pantheon_delegation_read. Only root sessions may delegate.',
|
|
243
|
+
args: delegateArgs,
|
|
244
|
+
execute: async (args, ctx) => {
|
|
245
|
+
if (!isRootSession(ctx.sessionID)) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
if (!board.canDispatch(args.agent)) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}" ` +
|
|
253
|
+
`(${board.getRunningCount(args.agent)} running)`,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// session.create failure → return a clear error as TEXT (tools return
|
|
258
|
+
// errors as text, not thrown) and register NO job on the board — an
|
|
259
|
+
// unhandled rejection here would otherwise lose the failure entirely.
|
|
260
|
+
// The child model is resolved from (a) the explicit `model` option,
|
|
261
|
+
// (b) options.agentModels (routing.yml), (c) the active preset — else
|
|
262
|
+
// left unset so opencode's default applies (warned below).
|
|
263
|
+
const childModel = resolveChildModel(options, args.agent, args.model)
|
|
264
|
+
if (childModel === undefined) {
|
|
265
|
+
options.logger?.warn?.(
|
|
266
|
+
`[pantheon-delegate] no model resolved for agent "${args.agent}" — ` +
|
|
267
|
+
"child session will use opencode's default model (may require API keys)",
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
let created: { id: string }
|
|
271
|
+
try {
|
|
272
|
+
created = await client.session.create({
|
|
273
|
+
body: {
|
|
274
|
+
parentID: ctx.sessionID,
|
|
275
|
+
title: args.description ?? args.prompt.slice(0, 80),
|
|
276
|
+
...(childModel !== undefined ? { model: childModel } : {}),
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
} catch (err: unknown) {
|
|
280
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
281
|
+
return `pantheon_delegate failed to create child session: ${reason}`
|
|
282
|
+
}
|
|
283
|
+
const childSessionID = created.id
|
|
284
|
+
knownChildren.add(childSessionID)
|
|
285
|
+
|
|
286
|
+
// Phase 4 read-only enforcement: register the child session in the
|
|
287
|
+
// read-only registry when the delegate is read-only — explicit
|
|
288
|
+
// `read_only: true` on the call, or the agent ∈ readOnlyAgents
|
|
289
|
+
// (case-insensitive). The plugin's tool.execute.before guard then
|
|
290
|
+
// denies edit/write/bash/task inside that session.
|
|
291
|
+
const readOnly =
|
|
292
|
+
args.read_only === true || (options.readOnlyAgents?.has(args.agent.toLowerCase()) ?? false)
|
|
293
|
+
if (readOnly) {
|
|
294
|
+
const entry: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
|
|
295
|
+
if (args.read_only !== undefined) entry.readOnlyFlag = args.read_only
|
|
296
|
+
readOnlyRegistry.register(childSessionID, entry)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const job = await board.registerLaunch({
|
|
300
|
+
taskID: childSessionID,
|
|
301
|
+
parentSessionID: ctx.sessionID,
|
|
302
|
+
agent: args.agent,
|
|
303
|
+
description: args.description ?? args.prompt,
|
|
304
|
+
objective: args.prompt,
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// Timeout manager: cleared on finalize, unref'd so the timer never
|
|
308
|
+
// keeps the process alive on its own.
|
|
309
|
+
const timer = setTimeout(() => {
|
|
310
|
+
timers.delete(childSessionID)
|
|
311
|
+
void finalize(childSessionID, {
|
|
312
|
+
state: 'error',
|
|
313
|
+
error: `Delegation [${job.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
|
|
314
|
+
timedOut: true,
|
|
315
|
+
}).catch((err: unknown) =>
|
|
316
|
+
console.error('[pantheon-delegate] timeout finalize failed:', err),
|
|
317
|
+
)
|
|
318
|
+
}, timeoutMs)
|
|
319
|
+
timer.unref()
|
|
320
|
+
timers.set(childSessionID, timer)
|
|
321
|
+
|
|
322
|
+
// Fire-and-forget — completion is observed via session.idle on the
|
|
323
|
+
// child (spike: noReply does NOT deliver anything to the parent).
|
|
324
|
+
void client.session
|
|
325
|
+
.promptAsync({
|
|
326
|
+
path: { id: childSessionID },
|
|
327
|
+
body: { agent: args.agent, parts: [{ type: 'text', text: args.prompt }] },
|
|
328
|
+
})
|
|
329
|
+
.catch((err: unknown) => console.error('[pantheon-delegate] promptAsync failed:', err))
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
`Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
|
|
333
|
+
`Read the result with pantheon_delegation_read({ id: "${job.alias}" }).`
|
|
334
|
+
)
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const pantheon_delegation_read: DelegationTool<typeof readArgs> = {
|
|
339
|
+
description:
|
|
340
|
+
'Block until a background delegation finishes (completed/error/cancelled), then return its ' +
|
|
341
|
+
'report markdown and mark the job reconciled. Resolves by alias or task ID.',
|
|
342
|
+
args: readArgs,
|
|
343
|
+
execute: async (args, ctx) => {
|
|
344
|
+
const job = board.resolve(ctx.sessionID, args.id)
|
|
345
|
+
if (!job) {
|
|
346
|
+
return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let terminal: BackgroundJobRecord
|
|
350
|
+
try {
|
|
351
|
+
terminal = await board.waitForTerminal(job.taskID, readTimeoutMs)
|
|
352
|
+
} catch {
|
|
353
|
+
return `Timed out after ${readTimeoutMs}ms waiting for delegation "${args.id}" ([${job.alias}]).`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const md = await readDelegationReport(outputDir, terminal)
|
|
357
|
+
if (md === undefined) {
|
|
358
|
+
return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await board.markReconciled(job.taskID)
|
|
362
|
+
return md
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const pantheon_delegation_list: DelegationTool<typeof listArgs> = {
|
|
367
|
+
description:
|
|
368
|
+
'List background delegations for the current session, with [unread] for finished jobs.',
|
|
369
|
+
args: listArgs,
|
|
370
|
+
execute: async (_args, ctx) => {
|
|
371
|
+
const jobs = board.list(ctx.sessionID)
|
|
372
|
+
if (jobs.length === 0) return 'No background delegations for this session.'
|
|
373
|
+
|
|
374
|
+
const lines = jobs.map((j) => {
|
|
375
|
+
const unread = j.terminalUnreconciled ? ' [unread]' : ''
|
|
376
|
+
return ` [${j.alias}] ${j.agent} — ${j.description} — ${stateLabel(j.state)}${unread}`
|
|
377
|
+
})
|
|
378
|
+
return `Background Delegations (${jobs.length}):\n${lines.join('\n')}`
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
pantheon_delegate,
|
|
384
|
+
pantheon_delegation_read,
|
|
385
|
+
pantheon_delegation_list,
|
|
386
|
+
finalizeDelegation: finalize,
|
|
387
|
+
}
|
|
388
|
+
}
|