pantheon-opencode 1.4.3 → 1.5.0-beta.10
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/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/manifest.json +21 -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/CHANGELOG.md +1026 -0
- package/README.md +292 -1080
- package/README.pt-BR.md +308 -0
- package/bin/pantheon-init.mjs +207 -31
- package/opencode.json +1 -0
- package/package.json +34 -14
- package/plugin.json +15 -0
- package/pyproject.toml +65 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +451 -44
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +306 -129
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +23 -10
- package/scripts/install/config-migration.mjs +399 -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 +396 -121
- package/scripts/install/plugin.mjs +89 -49
- package/scripts/install/shared.mjs +55 -159
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +45 -232
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +798 -60
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +135 -42
- package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +27 -16
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +451 -44
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +798 -60
- 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/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +110 -2
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +356 -69
- package/src/pantheon/delegate-manager.ts +766 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +406 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1337 -0
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +689 -0
- package/src/pantheon/tool-ceiling.ts +159 -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 +148 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +261 -51
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/tui.d.ts +116 -24
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +416 -149
- package/src/plugins/tui/dist/tui.tsx +456 -87
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +462 -95
- package/src/skills/README.md +0 -2
- package/src/pantheon/tool-context.ts +0 -41
package/src/plugin.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
1
3
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
2
4
|
import type { PluginConfig } from 'opencode'
|
|
3
|
-
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
4
5
|
import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
|
|
5
6
|
import {
|
|
6
7
|
type ContextSandboxConfig,
|
|
@@ -10,6 +11,15 @@ import {
|
|
|
10
11
|
DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
|
|
11
12
|
} from './pantheon/context-sandbox.ts'
|
|
12
13
|
import { createCostCommand } from './pantheon/cost-command.ts'
|
|
14
|
+
import { buildPluginNativeDelegation, resolveDelegateMode } from './pantheon/delegate-manager.ts'
|
|
15
|
+
import {
|
|
16
|
+
collectRootSessionIDs,
|
|
17
|
+
createDelegationTools,
|
|
18
|
+
type DelegationClient,
|
|
19
|
+
resolveDelegationBudgets,
|
|
20
|
+
resolveDelegationTimeoutMs,
|
|
21
|
+
startIdleChildScan,
|
|
22
|
+
} from './pantheon/delegation.ts'
|
|
13
23
|
import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
|
|
14
24
|
import {
|
|
15
25
|
createEnforcementGuard,
|
|
@@ -17,7 +27,8 @@ import {
|
|
|
17
27
|
SessionHierarchyRegistry,
|
|
18
28
|
zeusReadGuard,
|
|
19
29
|
} from './pantheon/delegation-enforce.ts'
|
|
20
|
-
import {
|
|
30
|
+
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
31
|
+
import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
|
|
21
32
|
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
22
33
|
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
23
34
|
import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
|
|
@@ -25,8 +36,16 @@ import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
|
|
|
25
36
|
import { createPantheonLogger } from './pantheon/logger.ts'
|
|
26
37
|
import { createModelCommand } from './pantheon/model-command.ts'
|
|
27
38
|
import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
|
|
28
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
applyActivePresetToConfig,
|
|
41
|
+
loadRoutingAgentModels,
|
|
42
|
+
loadRoutingMaxSteps,
|
|
43
|
+
loadRoutingPermissionTask,
|
|
44
|
+
} from './pantheon/presets.mjs'
|
|
29
45
|
import { safeSessionPath } from './pantheon/session-guard.ts'
|
|
46
|
+
import { getSharedBoard } from './pantheon/shared-board.ts'
|
|
47
|
+
import { StepCapTracker } from './pantheon/step-cap.ts'
|
|
48
|
+
import { createTaskResultGuard } from './pantheon/task-result-guard.ts'
|
|
30
49
|
import {
|
|
31
50
|
TODO_ENFORCER_DEFAULTS,
|
|
32
51
|
TodoEnforcer,
|
|
@@ -34,6 +53,7 @@ import {
|
|
|
34
53
|
todoEnforcerEnabledFromEnv,
|
|
35
54
|
} from './pantheon/todo-enforcer.ts'
|
|
36
55
|
import { TodoPreserver } from './pantheon/todo-preserve.ts'
|
|
56
|
+
import { handleToolCeilingEvent, type ToolCeilingResult } from './pantheon/tool-ceiling.ts'
|
|
37
57
|
import { checkTuiVersionStaleness } from './pantheon/tui-version-check.ts'
|
|
38
58
|
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
39
59
|
|
|
@@ -45,12 +65,23 @@ import { activePresetCandidates, createVisionHandler } from './pantheon/vision.t
|
|
|
45
65
|
// .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
|
|
46
66
|
const log = createPantheonLogger({ module: 'pantheon-plugin' })
|
|
47
67
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
// R4: per-agent step caps from routing.yml `agents.<name>.max_steps`
|
|
69
|
+
// (fail-open → {} = no agent capped). R1 retry_policy/cooldown are NOT
|
|
70
|
+
// wired here — the plugin has no retry path (opencode cannot intercept task
|
|
71
|
+
// completion); zeus waves load them via presets.mjs loaders and pass them
|
|
72
|
+
// to zeusDelegateWithRetry explicitly.
|
|
73
|
+
const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
|
|
74
|
+
|
|
75
|
+
// O5: permission.task glob rules from routing.yml `permission.task`
|
|
76
|
+
// (fail-open → undefined = existing runtime matrix only).
|
|
77
|
+
const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
|
|
78
|
+
|
|
79
|
+
// beta.5: the board is a PROCESS-wide singleton (getSharedBoard) shared with
|
|
80
|
+
// src/plugins/pantheon-hooks.ts, whose native-task mirror registers children
|
|
81
|
+
// here; the finalize path below (session.idle + idle scan) must observe the
|
|
82
|
+
// SAME instance. globalThis anchoring dedupes the npm+repo double load.
|
|
83
|
+
const board = getSharedBoard()
|
|
84
|
+
let terminalToastClient: ToastClient = {}
|
|
54
85
|
board
|
|
55
86
|
.recoverRunningJobs()
|
|
56
87
|
.catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
|
|
@@ -60,13 +91,17 @@ board
|
|
|
60
91
|
// file-only log line (console echo opt-in via PANTHEON_HOOKS_LOG). There is
|
|
61
92
|
// deliberately NO chat delivery — the user policy is zero delegation
|
|
62
93
|
// notifications in the transcript. Completion visibility lives in the board
|
|
63
|
-
// `[unread]` marker
|
|
94
|
+
// `[unread]` marker (pantheon_delegation_list), pantheon_delegation_read,
|
|
95
|
+
// TUI toasts (pantheon-hooks, PANTHEON_TOASTS gate) and compaction
|
|
96
|
+
// carry-forward.
|
|
64
97
|
board.onTerminal((taskID: string) => {
|
|
65
98
|
const job = board.get(taskID)
|
|
66
99
|
if (!job) return
|
|
67
100
|
log.info(
|
|
68
101
|
`[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
|
|
69
102
|
)
|
|
103
|
+
// Deliberately direct-to-TUI: onTerminal must not inject chat/transcript text.
|
|
104
|
+
void showDelegationTerminalToast(terminalToastClient, job)
|
|
70
105
|
})
|
|
71
106
|
|
|
72
107
|
// Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
|
|
@@ -84,15 +119,23 @@ setInterval(
|
|
|
84
119
|
|
|
85
120
|
// ─── Phase 4: Read-Only Enforcement + Compaction Context ───────────────
|
|
86
121
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
122
|
+
// WS1 (PR #94): root-session predicate shared by the Phase 4 enforcement
|
|
123
|
+
// guard and the native delegate toolset — both must agree on what a root is.
|
|
124
|
+
// (Hoisted function: `rootSessions`/`sessionHierarchy` below are resolved at
|
|
125
|
+
// call time, after module init.)
|
|
126
|
+
function isRootSession(sessionID: string): boolean {
|
|
127
|
+
return rootSessions.has(sessionID) || sessionHierarchy.isRoot(sessionID)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The read-only registry is populated by createDelegationTools (delegation.ts)
|
|
131
|
+
// when a delegate is read-only (explicit flag or agent ∈ readOnlyAgents).
|
|
132
|
+
// The guard denies edit/write/bash/task inside those sessions by throwing
|
|
133
|
+
// from tool.execute.before; unknown sessions are allowed (safe default).
|
|
91
134
|
const enforcementGuard = createEnforcementGuard({
|
|
92
135
|
getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
|
|
93
136
|
options: {
|
|
94
137
|
getSessionAgent: (sessionID) => sessionAgents.get(sessionID),
|
|
95
|
-
isRootSession
|
|
138
|
+
isRootSession,
|
|
96
139
|
isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
|
|
97
140
|
logger: log,
|
|
98
141
|
},
|
|
@@ -108,7 +151,7 @@ const commandNormalizer = createCommandNormalizer({ logger: log })
|
|
|
108
151
|
// Mirrors routing.yml background_delegation.max_compaction_items.
|
|
109
152
|
const COMPACTION_MAX_ITEMS = 10
|
|
110
153
|
|
|
111
|
-
// Root-session registry for the
|
|
154
|
+
// Root-session registry for the delegation depth guard: sessions created
|
|
112
155
|
// without a parentID are roots. Populated from TWO sources:
|
|
113
156
|
// 1. the `session.created` event hook (live roots — fires for sessions
|
|
114
157
|
// created while the plugin runs);
|
|
@@ -116,25 +159,26 @@ const COMPACTION_MAX_ITEMS = 10
|
|
|
116
159
|
// hook (compaction-134) — opencode does NOT replay session.created
|
|
117
160
|
// events for sessions that exist before plugin load, so without the
|
|
118
161
|
// seed a resumed root would never enter this set after a restart.
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
162
|
+
// delegation.ts additionally falls back to knownChildren ("any session WE
|
|
163
|
+
// created is a sub-session; unknown sessions are roots") so the depth guard
|
|
164
|
+
// works even when this set is incomplete.
|
|
122
165
|
const rootSessions = new Set<string>()
|
|
123
166
|
const sessionHierarchy = new SessionHierarchyRegistry()
|
|
124
167
|
const sessionAgents = new Map<string, string>()
|
|
168
|
+
const sessionToolCeilings = new Map<string, ToolCeilingResult>()
|
|
125
169
|
|
|
126
170
|
/**
|
|
127
171
|
* Seed resumed root sessions without making OpenCode startup depend on the
|
|
128
172
|
* session API responding. Some OpenCode environments do not have the API
|
|
129
173
|
* ready while plugin config hooks run; awaiting session.list() there leaves
|
|
130
|
-
* the TUI stuck before it can open. The
|
|
174
|
+
* the TUI stuck before it can open. The delegation layer already treats an
|
|
131
175
|
* unknown session as a root, so this enrichment is deliberately best-effort.
|
|
132
176
|
*/
|
|
133
177
|
function seedRootSessionsInBackground(input: PluginInput): void {
|
|
134
178
|
// Fail-open: environments without the session API (client.session absent or
|
|
135
179
|
// not yet wired while config hooks run) must NOT break startup. `session`
|
|
136
180
|
// is captured and the method invoked on it so `this` stays bound; the
|
|
137
|
-
//
|
|
181
|
+
// delegation layer already treats unknown sessions as roots, so skipping
|
|
138
182
|
// the seed is always safe.
|
|
139
183
|
const session = input.client.session
|
|
140
184
|
if (typeof session?.list !== 'function') return
|
|
@@ -144,15 +188,10 @@ function seedRootSessionsInBackground(input: PluginInput): void {
|
|
|
144
188
|
.then((result) => {
|
|
145
189
|
if (!result.error && Array.isArray(result.data)) {
|
|
146
190
|
sessionHierarchy.completeSeed(result.data)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
seeded += 1
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (seeded > 0) {
|
|
155
|
-
log.info(`[Pantheon Plugin] Seeded ${seeded} root session(s) from session.list`)
|
|
191
|
+
const seeded = collectRootSessionIDs(result.data)
|
|
192
|
+
if (seeded.size > 0) {
|
|
193
|
+
for (const id of seeded) rootSessions.add(id)
|
|
194
|
+
log.info(`[Pantheon Plugin] Seeded ${seeded.size} root session(s) from session.list`)
|
|
156
195
|
}
|
|
157
196
|
} else {
|
|
158
197
|
sessionHierarchy.failSeed()
|
|
@@ -170,6 +209,62 @@ function sdkErrorMessage(error: { data?: { message?: string } } | null | undefin
|
|
|
170
209
|
return typeof message === 'string' && message.trim() !== '' ? message : 'request failed'
|
|
171
210
|
}
|
|
172
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Adapt the opencode SDK client to the structural DelegationClient the
|
|
214
|
+
* delegation toolset expects: the SDK wraps every call in a
|
|
215
|
+
* `{data, error, request, response}` result while delegation.ts uses direct
|
|
216
|
+
* returns. Errors surface as throws so the toolset's own error handling
|
|
217
|
+
* (tool errors returned as TEXT) keeps working.
|
|
218
|
+
*/
|
|
219
|
+
function adaptDelegationClient(client: PluginInput['client']): DelegationClient {
|
|
220
|
+
return {
|
|
221
|
+
session: {
|
|
222
|
+
create: async (input) => {
|
|
223
|
+
const body: {
|
|
224
|
+
parentID?: string
|
|
225
|
+
title?: string
|
|
226
|
+
model?: { id: string; providerID: string }
|
|
227
|
+
} = { parentID: input.body.parentID }
|
|
228
|
+
if (input.body.title !== undefined) body.title = input.body.title
|
|
229
|
+
// Forward a model only when the active routing profile supplied one.
|
|
230
|
+
if (input.body.model !== undefined) body.model = input.body.model
|
|
231
|
+
const result = await client.session.create({ body })
|
|
232
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
233
|
+
return { id: result.data.id }
|
|
234
|
+
},
|
|
235
|
+
promptAsync: async (input) => {
|
|
236
|
+
const path = safeSessionPath(input.path.id)
|
|
237
|
+
if (!path) {
|
|
238
|
+
log.warn(`[Pantheon Plugin] promptAsync skipped: invalid sessionID "${input.path.id}"`)
|
|
239
|
+
throw new Error(`invalid sessionID: ${input.path.id}`)
|
|
240
|
+
}
|
|
241
|
+
const result = await client.session.promptAsync({
|
|
242
|
+
path: path.path,
|
|
243
|
+
body: {
|
|
244
|
+
agent: input.body.agent,
|
|
245
|
+
...(input.body.model !== undefined
|
|
246
|
+
? { model: { providerID: input.body.model.providerID, modelID: input.body.model.id } }
|
|
247
|
+
: {}),
|
|
248
|
+
parts: input.body.parts,
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
252
|
+
return result.data
|
|
253
|
+
},
|
|
254
|
+
messages: async (input) => {
|
|
255
|
+
const path = safeSessionPath(input.path.id)
|
|
256
|
+
if (!path) {
|
|
257
|
+
log.warn(`[Pantheon Plugin] messages skipped: invalid sessionID "${input.path.id}"`)
|
|
258
|
+
return []
|
|
259
|
+
}
|
|
260
|
+
const result = await client.session.messages({ path: path.path })
|
|
261
|
+
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
262
|
+
return result.data
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
173
268
|
/**
|
|
174
269
|
* Adapt the opencode SDK client to the structural TodoEnforcerClient the
|
|
175
270
|
* todo enforcer expects: unwraps the SDK `{data, error, request, response}`
|
|
@@ -250,6 +345,7 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
|
|
|
250
345
|
* Helpers live in src/pantheon/vision.ts and are imported from there directly.
|
|
251
346
|
*/
|
|
252
347
|
const plugin: Plugin = async (input: PluginInput) => {
|
|
348
|
+
terminalToastClient = input.client as unknown as ToastClient
|
|
253
349
|
// release-134: runtime guard against DOUBLE registration (duplicate toasts +
|
|
254
350
|
// duplicate task_ids in one process — 2026-08-12 live logs). opencode
|
|
255
351
|
// merges the global config (npm-package plugin paths) with the project
|
|
@@ -264,12 +360,100 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
264
360
|
// is older than the installed package. Runs once, fail-open, no auto-sync.
|
|
265
361
|
checkTuiVersionStaleness((msg) => log.warn(msg))
|
|
266
362
|
const vision = createVisionHandler(input)
|
|
363
|
+
// Phase 2/3: background delegation toolset + the bound finalize lifecycle
|
|
364
|
+
// hook. Completion is observed through the event hook below
|
|
365
|
+
// (session.idle / session.error on a child) → finalizeDelegation.
|
|
366
|
+
// Fase B3: runtime controls for the two read-only exceptions and the real
|
|
367
|
+
// per-child wall-clock timer. No task_budget field is injected into the
|
|
368
|
+
// plugin/frontmatter; these are enforced by delegation.ts itself.
|
|
369
|
+
const delegationBudgets = resolveDelegationBudgets()
|
|
370
|
+
const wallClockTimeoutMs = resolveDelegationTimeoutMs()
|
|
371
|
+
|
|
372
|
+
// WS1 (PR #94): delegation backend gate. Default = legacy (the V1
|
|
373
|
+
// fire-and-forget toolset, intact); PANTHEON_DELEGATE_MODE=native selects
|
|
374
|
+
// the thin manager over native task() semantics. Kill-switch
|
|
375
|
+
// PANTHEON_DELEGATION=off covers the native manager; finalizeDelegation stays on
|
|
376
|
+
// the legacy observer: it works on the shared board in both modes and must
|
|
377
|
+
// never be kill-switched, so children never orphan. The idle hook below is
|
|
378
|
+
// untouched.
|
|
379
|
+
const delegationClient = adaptDelegationClient(input.client)
|
|
380
|
+
// Only an explicitly active routing profile may provide child models.
|
|
381
|
+
// An empty map deliberately lets OpenCode inherit the parent model.
|
|
382
|
+
const routingAgentModels = loadRoutingAgentModels({
|
|
383
|
+
candidates: activePresetCandidates(),
|
|
384
|
+
logger: log,
|
|
385
|
+
})
|
|
386
|
+
const readOnlyAgents = new Set(['apollo', 'gaia'])
|
|
387
|
+
const legacyDelegation = createDelegationTools({
|
|
388
|
+
board,
|
|
389
|
+
client: delegationClient,
|
|
390
|
+
options: {
|
|
391
|
+
rootSessions,
|
|
392
|
+
isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
|
|
393
|
+
registerChildSession: (sessionID, parentID) => {
|
|
394
|
+
sessionHierarchy.register({ id: sessionID, parentID })
|
|
395
|
+
},
|
|
396
|
+
enforceRuntimeMatrix: true,
|
|
397
|
+
readOnlyAgents,
|
|
398
|
+
agentModels: routingAgentModels,
|
|
399
|
+
delegationBudgets,
|
|
400
|
+
stepCapTracker,
|
|
401
|
+
...(routingPermissionTask != null ? { permissionTask: routingPermissionTask } : {}),
|
|
402
|
+
...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
|
|
403
|
+
},
|
|
404
|
+
})
|
|
405
|
+
const delegateMode = resolveDelegateMode()
|
|
406
|
+
if (delegateMode === 'native') {
|
|
407
|
+
log.info('[Pantheon Plugin] Delegation mode: native (PANTHEON_DELEGATE_MODE=native)')
|
|
408
|
+
}
|
|
409
|
+
const delegation =
|
|
410
|
+
delegateMode === 'native'
|
|
411
|
+
? buildPluginNativeDelegation({
|
|
412
|
+
board,
|
|
413
|
+
client: delegationClient,
|
|
414
|
+
isRootSession,
|
|
415
|
+
registerChildSession: (sessionID, parentID) => {
|
|
416
|
+
sessionHierarchy.register({ id: sessionID, parentID })
|
|
417
|
+
},
|
|
418
|
+
registerReadOnlySession: (sessionID, info) => {
|
|
419
|
+
readOnlyRegistry.register(sessionID, info)
|
|
420
|
+
},
|
|
421
|
+
readOnlyAgents,
|
|
422
|
+
agentModels: routingAgentModels,
|
|
423
|
+
stepCap: stepCapTracker,
|
|
424
|
+
...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
|
|
425
|
+
finalizeDelegation: legacyDelegation.finalizeDelegation,
|
|
426
|
+
})
|
|
427
|
+
: legacyDelegation
|
|
428
|
+
|
|
429
|
+
// Proactive idle-child scan (Fix 2): finalize children that are idle on the
|
|
430
|
+
// board but have no MD report on disk — eliminates the phantom "running"
|
|
431
|
+
// window. Runs every 30s (configurable via PANTHEON_IDLE_SCAN_INTERVAL_MS),
|
|
432
|
+
// unref'd so it never holds the process open. Fail-open by design.
|
|
433
|
+
const idleScanIntervalMs = Number(process.env.PANTHEON_IDLE_SCAN_INTERVAL_MS) || undefined
|
|
434
|
+
startIdleChildScan(
|
|
435
|
+
{
|
|
436
|
+
board,
|
|
437
|
+
finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
|
|
438
|
+
hasReport: (job) => {
|
|
439
|
+
try {
|
|
440
|
+
const filePath = join('.pantheon/delegations', job.parentSessionID, `${job.alias}.md`)
|
|
441
|
+
return existsSync(filePath)
|
|
442
|
+
} catch {
|
|
443
|
+
return false
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
logger: log,
|
|
447
|
+
},
|
|
448
|
+
idleScanIntervalMs,
|
|
449
|
+
)
|
|
450
|
+
|
|
267
451
|
// Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
|
|
268
452
|
// Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
|
|
269
|
-
// access — COMPACTION_MAX_ITEMS pattern). The event hook routes
|
|
270
|
-
// session.idle events here
|
|
271
|
-
// (routing.yml is a doc
|
|
272
|
-
// PANTHEON_TOASTS pattern).
|
|
453
|
+
// access — COMPACTION_MAX_ITEMS pattern). The event hook routes non-board
|
|
454
|
+
// session.idle events here; board-child idles go to finalizeDelegation.
|
|
455
|
+
// Runtime kill-switch: PANTHEON_TODO_ENFORCER=off (routing.yml is a doc
|
|
456
|
+
// mirror only — the env var is the real switch, PANTHEON_TOASTS pattern).
|
|
273
457
|
const todoEnforcer = new TodoEnforcer({
|
|
274
458
|
client: adaptTodoEnforcerClient(input.client),
|
|
275
459
|
board,
|
|
@@ -321,13 +505,18 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
321
505
|
},
|
|
322
506
|
}
|
|
323
507
|
const sandboxHandler = createContextSandbox(sandboxConfig)
|
|
508
|
+
// Task Result Guard (2026-09-10): detect empty task() results from native
|
|
509
|
+
// opencode subagent calls and convert to explicit errors. Runs FIRST in the
|
|
510
|
+
// chain so the error message is visible even if later handlers skip.
|
|
511
|
+
const taskResultGuard = createTaskResultGuard()
|
|
324
512
|
const combinedAfter: typeof readEnhancer = async (input, output) => {
|
|
513
|
+
await taskResultGuard(input, output)
|
|
325
514
|
await sandboxHandler(input, output)
|
|
326
515
|
await readEnhancer(input, output)
|
|
327
516
|
}
|
|
328
517
|
|
|
329
|
-
// Wave 4 (PR #46): /cost — cost + token visibility
|
|
330
|
-
// opencode.db read-only
|
|
518
|
+
// Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
|
|
519
|
+
// opencode.db read-only via the single node:sqlite backend.
|
|
331
520
|
// Fully wired (unlike dispatch-guard, which is manual-orchestration-only
|
|
332
521
|
// because opencode 1.18.x cannot intercept task completion via hooks).
|
|
333
522
|
const costCommand = createCostCommand()
|
|
@@ -338,11 +527,11 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
338
527
|
// compaction-134 seed: opencode does NOT replay `session.created`
|
|
339
528
|
// events for sessions that exist before plugin load, so after a
|
|
340
529
|
// restart the resumed ROOT session never enters rootSessions and the
|
|
341
|
-
// depth guard would
|
|
342
|
-
// session.list() in the background — every session
|
|
343
|
-
// is a root. Fail-open: a failed/throttled list
|
|
344
|
-
//
|
|
345
|
-
// and config setup must never wait for the session API.
|
|
530
|
+
// depth guard would reject its pantheon_delegate calls. Seed the
|
|
531
|
+
// registry from session.list() in the background — every session
|
|
532
|
+
// WITHOUT a parentID is a root. Fail-open: a failed/throttled list
|
|
533
|
+
// leaves the set untouched (delegation.ts treats unknown sessions as
|
|
534
|
+
// roots), and config setup must never wait for the session API.
|
|
346
535
|
// Context sandbox config — opencode.json → context_sandbox: {enabled, limits}
|
|
347
536
|
try {
|
|
348
537
|
const raw = (config as unknown as Record<string, unknown>).context_sandbox
|
|
@@ -387,16 +576,18 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
387
576
|
log.warn('[plugin] preset application failed (see logs for details)')
|
|
388
577
|
}
|
|
389
578
|
},
|
|
390
|
-
// Phase 2:
|
|
391
|
-
// shape
|
|
392
|
-
// deterministic /pantheon-model, /cost and full-auto goal tools.
|
|
579
|
+
// Phase 2: background delegation tools (structural — matches the `tool`
|
|
580
|
+
// hook field shape; read_only sessions feed the Phase 4 registry).
|
|
393
581
|
tool: {
|
|
582
|
+
pantheon_delegate: delegation.pantheon_delegate,
|
|
583
|
+
pantheon_delegation_read: delegation.pantheon_delegation_read,
|
|
584
|
+
pantheon_delegation_list: delegation.pantheon_delegation_list,
|
|
394
585
|
hashline_edit: hashlineEdit,
|
|
395
586
|
// Wave 3 (PR #46): full-auto goal tools (single active goal/session).
|
|
396
587
|
pantheon_goal_create: goalTools.pantheon_goal_create,
|
|
397
588
|
pantheon_goal_update: goalTools.pantheon_goal_update,
|
|
398
589
|
pantheon_goal_get: goalTools.pantheon_goal_get,
|
|
399
|
-
// Wave 4 (PR #46): /cost — cost report
|
|
590
|
+
// Wave 4 (PR #46): /cost — delegation cost report from opencode.db.
|
|
400
591
|
pantheon_cost: costCommand.pantheon_cost,
|
|
401
592
|
// Deterministic /pantheon-model management of top-level model fields.
|
|
402
593
|
pantheon_model: modelCommand.pantheon_model,
|
|
@@ -430,13 +621,25 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
430
621
|
if (ev.type === 'session.compacted') {
|
|
431
622
|
await todoPreserver.onCompacted(ev.properties.sessionID)
|
|
432
623
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
//
|
|
437
|
-
//
|
|
624
|
+
if (ev.type === 'message.updated') {
|
|
625
|
+
await handleToolCeilingEvent(input.client, ev, sessionToolCeilings)
|
|
626
|
+
}
|
|
627
|
+
// Phase 3: observe completion on child sessions → finalizeDelegation.
|
|
628
|
+
// The board transition fires onTerminal → the file-only audit log (no
|
|
629
|
+
// chat delivery — see the onTerminal listener above). Unknown sessions
|
|
630
|
+
// are a no-op.
|
|
438
631
|
try {
|
|
439
|
-
|
|
632
|
+
const delegated = await handleDelegationEvent(ev, {
|
|
633
|
+
board,
|
|
634
|
+
finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
|
|
635
|
+
})
|
|
636
|
+
// Wave 1/3: non-board idle → idle dispatcher. Board children are
|
|
637
|
+
// handled by handleDelegationEvent above; everything else (roots,
|
|
638
|
+
// non-board sessions) is routed by priority: an active goal owns the
|
|
639
|
+
// idle (goal loop), otherwise the todo enforcer re-injects. Neither
|
|
640
|
+
// onIdle ever throws (internal failures are logged + swallowed), so
|
|
641
|
+
// the session is safe.
|
|
642
|
+
if (!delegated && ev.type === 'session.idle') {
|
|
440
643
|
await idleDispatcher.onIdle(ev.properties.sessionID)
|
|
441
644
|
}
|
|
442
645
|
} catch (err) {
|
|
@@ -486,6 +689,13 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
486
689
|
list: (sessionID: string) => todoEnforcer.listPendingTodos(sessionID),
|
|
487
690
|
},
|
|
488
691
|
})
|
|
692
|
+
const ceiling = sessionToolCeilings.get(_input.sessionID)
|
|
693
|
+
if (ceiling?.status === 'OK') {
|
|
694
|
+
output.context.push(
|
|
695
|
+
`<tool_ceiling remaining_context_tokens="${ceiling.ceiling}">` +
|
|
696
|
+
'Use the remaining host-reported context budget when selecting tools.</tool_ceiling>',
|
|
697
|
+
)
|
|
698
|
+
}
|
|
489
699
|
if (blocks.length > 0) {
|
|
490
700
|
output.context.push(...blocks)
|
|
491
701
|
}
|
|
@@ -177,6 +177,7 @@ import { appendFile, mkdir } from 'node:fs/promises'
|
|
|
177
177
|
import { join } from 'node:path'
|
|
178
178
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
179
179
|
import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
|
|
180
|
+
import { getSharedBoard } from '../pantheon/shared-board.ts'
|
|
180
181
|
import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
|
|
181
182
|
|
|
182
183
|
/** Tools that represent a subagent delegation (opencode `task` tool etc.). */
|
|
@@ -775,6 +776,51 @@ function delegationAgent(tool: string, args?: unknown): string {
|
|
|
775
776
|
return tool
|
|
776
777
|
}
|
|
777
778
|
|
|
779
|
+
/**
|
|
780
|
+
* beta.5: mirror a native background task dispatch onto the SHARED board
|
|
781
|
+
* (getSharedBoard) so the existing finalize path in plugin.ts (session.idle
|
|
782
|
+
* event + 30s idle scan) writes the terminal report and the TUI delegation
|
|
783
|
+
* panel tracks the child end-to-end. Fire-and-forget and silent: a mirror
|
|
784
|
+
* failure must never surface into the tool call or the transcript.
|
|
785
|
+
* registerLaunchIfAbsent keeps it idempotent against any other dispatcher
|
|
786
|
+
* (v2-events, plugin double-load, repeated after-hook firings).
|
|
787
|
+
*/
|
|
788
|
+
function mirrorNativeBackgroundDispatch(
|
|
789
|
+
input: { tool: string; sessionID: string },
|
|
790
|
+
args: unknown,
|
|
791
|
+
result: ToolExecuteAfterResult | null,
|
|
792
|
+
): void {
|
|
793
|
+
const taskID = extractTaskId(result)
|
|
794
|
+
if (taskID === '' || typeof input.sessionID !== 'string' || input.sessionID === '') return
|
|
795
|
+
const description = trim(delegationDescription(args) ?? `native ${input.tool} dispatch`, 200)
|
|
796
|
+
try {
|
|
797
|
+
getSharedBoard()
|
|
798
|
+
.registerLaunchIfAbsent({
|
|
799
|
+
taskID,
|
|
800
|
+
parentSessionID: input.sessionID,
|
|
801
|
+
agent: delegationAgent(input.tool, args),
|
|
802
|
+
description,
|
|
803
|
+
})
|
|
804
|
+
.catch(() => {
|
|
805
|
+
// Mirror is best-effort — never surface into the tool call.
|
|
806
|
+
})
|
|
807
|
+
} catch {
|
|
808
|
+
// Never let the mirror take down the tool call.
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Task description from task-tool args (description ?? prompt); undefined when absent. */
|
|
813
|
+
function delegationDescription(args: unknown): string | undefined {
|
|
814
|
+
try {
|
|
815
|
+
const a = (args ?? {}) as Record<string, unknown>
|
|
816
|
+
const raw = a.description ?? a.prompt
|
|
817
|
+
if (typeof raw === 'string' && raw.trim() !== '') return raw
|
|
818
|
+
} catch {
|
|
819
|
+
// Malformed args — fall through to the generic description.
|
|
820
|
+
}
|
|
821
|
+
return undefined
|
|
822
|
+
}
|
|
823
|
+
|
|
778
824
|
/** 🚀 delegation-started toast (variant 'info', 2500ms), deduped per agent. */
|
|
779
825
|
async function delegationStartedToast(ctx: HookContext, agent: string): Promise<void> {
|
|
780
826
|
await throttledToast(ctx, {
|
|
@@ -1311,6 +1357,11 @@ const plugin: Plugin = async ({ client, directory }) => {
|
|
|
1311
1357
|
// NOT a completion: do not mark the group done and do not fire ✅
|
|
1312
1358
|
// — that would corrupt olympians aggregation. Real completion of
|
|
1313
1359
|
// background tasks is unobservable on 1.18.13 (see header).
|
|
1360
|
+
// beta.5: mirror the native background child onto the SHARED
|
|
1361
|
+
// board so the existing finalize path (session.idle + idle scan
|
|
1362
|
+
// in plugin.ts) writes the terminal report and the TUI
|
|
1363
|
+
// delegation panel tracks the child end-to-end. Fire-and-forget.
|
|
1364
|
+
mirrorNativeBackgroundDispatch(input, args, result)
|
|
1314
1365
|
} else {
|
|
1315
1366
|
const handledByOlympians = handleOlympiansCompletion(ctx, agent)
|
|
1316
1367
|
if (!handledByOlympians) void delegationDoneToast(ctx, agent)
|