pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11

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.
Files changed (133) hide show
  1. package/AGENTS.global.md +206 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +350 -0
  4. package/README.md +561 -52
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +70 -4
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +24 -17
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +428 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/backup.mjs +475 -0
  22. package/scripts/install/cli-ui.mjs +6 -1
  23. package/scripts/install/config-migration.mjs +317 -4
  24. package/scripts/install/exit-codes.mjs +26 -0
  25. package/scripts/install/health-check.mjs +21 -19
  26. package/scripts/install/interactive.mjs +16 -2
  27. package/scripts/install/migrate.mjs +41 -22
  28. package/scripts/install/opencode-version.mjs +246 -4
  29. package/scripts/install/opencode.mjs +1175 -215
  30. package/scripts/install/plan.mjs +287 -0
  31. package/scripts/install/plugin.mjs +62 -14
  32. package/scripts/install/shared.mjs +190 -48
  33. package/scripts/install/strings.mjs +20 -0
  34. package/scripts/install/venv.mjs +13 -3
  35. package/scripts/install/vision-prompt.mjs +171 -0
  36. package/scripts/install-mcp.mjs +28 -9
  37. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  38. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  39. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  40. package/scripts/probe-context-rehydrate.mjs +8 -2
  41. package/scripts/probe-pantheon-cost.mjs +71 -15
  42. package/scripts/prune.mjs +9 -3
  43. package/scripts/restore.mjs +222 -0
  44. package/scripts/run-node-tests.mjs +189 -0
  45. package/scripts/sync-artifacts.mjs +26 -13
  46. package/scripts/sync-tui.mjs +24 -3
  47. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  48. package/scripts/validate-routing.mjs +3 -3
  49. package/scripts/validate-sandbox-install.mjs +138 -0
  50. package/scripts/versioning.mjs +13 -1
  51. package/scripts/vision-status.mjs +682 -0
  52. package/skills-lock.json +8 -8
  53. package/src/agents/aphrodite.md +20 -37
  54. package/src/agents/apollo.md +7 -3
  55. package/src/agents/athena.md +63 -35
  56. package/src/agents/demeter.md +8 -28
  57. package/src/agents/gaia.md +1 -1
  58. package/src/agents/hephaestus.md +10 -21
  59. package/src/agents/hermes.md +15 -38
  60. package/src/agents/iris.md +7 -1
  61. package/src/agents/mnemosyne.md +18 -54
  62. package/src/agents/nyx.md +1 -1
  63. package/src/agents/prometheus.md +16 -20
  64. package/src/agents/talos.md +10 -2
  65. package/src/agents/themis.md +5 -5
  66. package/src/agents/zeus.md +99 -172
  67. package/src/instructions/agent-return-format.instructions.md +34 -17
  68. package/src/instructions/backend-standards.instructions.md +2 -0
  69. package/src/instructions/frontend-standards.instructions.md +3 -1
  70. package/src/instructions/memory-protocol.instructions.md +12 -110
  71. package/src/instructions/test-economy.instructions.md +21 -0
  72. package/src/instructions/zeus-anti-stall.instructions.md +20 -67
  73. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  74. package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
  75. package/src/instructions/zeus-memory-operations.instructions.md +30 -0
  76. package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
  77. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  78. package/src/mcp/eval_store.py +2 -2
  79. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  80. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  81. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  82. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  83. package/src/mcp/requirements-mcp.txt +6 -4
  84. package/src/mcp/requirements-vision.txt +2 -2
  85. package/src/pantheon/cost-command.ts +403 -102
  86. package/src/pantheon/delegation-enforce.ts +10 -1
  87. package/src/pantheon/model-agents.ts +19 -0
  88. package/src/pantheon/model-command.ts +218 -56
  89. package/src/pantheon/presets.d.mts +0 -8
  90. package/src/pantheon/presets.mjs +5 -32
  91. package/src/pantheon/v2-bounded-read.ts +640 -0
  92. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  93. package/src/pantheon/v2-execute-after.ts +186 -0
  94. package/src/pantheon/v2-model-overrides.ts +275 -0
  95. package/src/pantheon/v2-tools.ts +418 -0
  96. package/src/pantheon/v2-unsupported.d.mts +11 -0
  97. package/src/pantheon/v2-unsupported.mjs +90 -0
  98. package/src/pantheon/v2-vision.ts +622 -0
  99. package/src/pantheon/vision-local.ts +531 -0
  100. package/src/pantheon/vision.ts +1160 -115
  101. package/src/plugin-v2.ts +1158 -219
  102. package/src/plugin.ts +12 -9
  103. package/src/plugins/tui/dist/server.d.ts +1 -2
  104. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  105. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  106. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  107. package/src/plugins/tui/dist/tui-v2.js +659 -0
  108. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  109. package/src/plugins/tui/dist/tui.d.ts +85 -82
  110. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  111. package/src/plugins/tui/dist/tui.js +72 -59
  112. package/src/plugins/tui/index-v2.ts +6 -0
  113. package/src/plugins/tui/package-lock.json +5516 -1353
  114. package/src/plugins/tui/package.json +22 -12
  115. package/src/plugins/tui/src/index-v2.tsx +734 -0
  116. package/src/plugins/tui/src/index.tsx +106 -85
  117. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  118. package/src/plugins/tui/tsconfig.build.json +5 -0
  119. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  120. package/src/plugins/tui/tsdown.config.ts +22 -2
  121. package/src/plugins/tui/tui-v2.ts +2 -0
  122. package/src/plugins/tui/tui.ts +3 -0
  123. package/src/routing.yml +11 -21
  124. package/src/skills/README.md +3 -3
  125. package/src/skills/agent-coordination/SKILL.md +7 -2
  126. package/src/skills/artifact-management/SKILL.md +12 -5
  127. package/src/skills/auto-continue/SKILL.md +52 -35
  128. package/src/skills/code-review-checklist/SKILL.md +5 -5
  129. package/src/skills/context-compression/SKILL.md +26 -879
  130. package/src/skills/session-goal/SKILL.md +14 -5
  131. package/src/skills/spec-driven-development/SKILL.md +3 -1
  132. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  133. package/opencode.json +0 -58
@@ -0,0 +1,214 @@
1
+ /** V2 caller/target policy and one-shot host tool-origin correlation. */
2
+
3
+ export const PANTHEON_AGENTS: ReadonlySet<string> = new Set([
4
+ 'zeus',
5
+ 'athena',
6
+ 'apollo',
7
+ 'hermes',
8
+ 'aphrodite',
9
+ 'demeter',
10
+ 'themis',
11
+ 'prometheus',
12
+ 'hephaestus',
13
+ 'nyx',
14
+ 'gaia',
15
+ 'iris',
16
+ 'mnemosyne',
17
+ 'talos',
18
+ ])
19
+
20
+ export interface V2PermissionEvent {
21
+ sessionID?: unknown
22
+ action?: unknown
23
+ resources?: unknown
24
+ /** Optional event identity is cross-checked against the host tool event. */
25
+ agent?: unknown
26
+ source?: unknown
27
+ /** Mutable decision fields exposed by OpenCode V2's evaluate hook. */
28
+ effect?: unknown
29
+ message?: unknown
30
+ }
31
+
32
+ export interface V2SubagentOrigin {
33
+ sessionID: string
34
+ messageID: string
35
+ callID: string
36
+ caller: string
37
+ target: string
38
+ }
39
+
40
+ interface StoredV2SubagentOrigin extends V2SubagentOrigin {
41
+ capturedAt: number
42
+ }
43
+
44
+ const ORIGIN_TTL_MS = 60_000
45
+ const MAX_PENDING_ORIGINS = 256
46
+
47
+ function originKey(sessionID: string, messageID: string, callID: string): string {
48
+ return JSON.stringify([sessionID, messageID, callID])
49
+ }
50
+
51
+ function nonEmptyString(value: unknown): string | undefined {
52
+ if (typeof value !== 'string') return undefined
53
+ const trimmed = value.trim()
54
+ return trimmed.length === 0 ? undefined : trimmed
55
+ }
56
+
57
+ /**
58
+ * One-shot correlation between the host's tool.execute.before event and the
59
+ * permission evaluation emitted from inside that same native tool call.
60
+ * Entries are short-lived and bounded; missing, stale, or uncorrelated origin
61
+ * data can only make admission fail closed.
62
+ */
63
+ export class V2SubagentOriginRegistry {
64
+ private readonly entries = new Map<string, StoredV2SubagentOrigin>()
65
+
66
+ capture(rawEvent: unknown, now = Date.now()): V2SubagentOrigin | undefined {
67
+ this.prune(now)
68
+ if (rawEvent === null || typeof rawEvent !== 'object' || Array.isArray(rawEvent)) {
69
+ return undefined
70
+ }
71
+ const event = rawEvent as {
72
+ tool?: unknown
73
+ sessionID?: unknown
74
+ messageID?: unknown
75
+ id?: unknown
76
+ agent?: unknown
77
+ input?: unknown
78
+ }
79
+ if (event.tool !== 'subagent' || event.input === null || typeof event.input !== 'object') {
80
+ return undefined
81
+ }
82
+ const input = event.input as { agent?: unknown }
83
+ const sessionID = nonEmptyString(event.sessionID)
84
+ const messageID = nonEmptyString(event.messageID)
85
+ const callID = nonEmptyString(event.id)
86
+ const caller = agentName(event.agent)
87
+ const target = agentName(input.agent)
88
+ if (
89
+ sessionID === undefined ||
90
+ messageID === undefined ||
91
+ callID === undefined ||
92
+ caller === undefined ||
93
+ target === undefined ||
94
+ !PANTHEON_AGENTS.has(target) ||
95
+ this.entries.size >= MAX_PENDING_ORIGINS
96
+ ) {
97
+ return undefined
98
+ }
99
+
100
+ const origin = { sessionID, messageID, callID, caller, target }
101
+ this.entries.set(originKey(sessionID, messageID, callID), { ...origin, capturedAt: now })
102
+ return origin
103
+ }
104
+
105
+ /** Remove and return the matching origin, so one tool event authorizes once. */
106
+ consume(event: V2PermissionEvent, now = Date.now()): V2SubagentOrigin | undefined {
107
+ this.prune(now)
108
+ const source = event.source as { type?: unknown; messageID?: unknown; id?: unknown } | undefined
109
+ if (source?.type !== 'tool') return undefined
110
+ const sessionID = nonEmptyString(event.sessionID)
111
+ const messageID = nonEmptyString(source.messageID)
112
+ const callID = nonEmptyString(source.id)
113
+ if (sessionID === undefined || messageID === undefined || callID === undefined) return undefined
114
+ const key = originKey(sessionID, messageID, callID)
115
+ const origin = this.entries.get(key)
116
+ this.entries.delete(key)
117
+ if (origin === undefined) return undefined
118
+ const { capturedAt: _capturedAt, ...result } = origin
119
+ return result
120
+ }
121
+
122
+ /** Cleanup when a subagent tool exits without reaching permission.evaluate. */
123
+ forget(rawEvent: unknown): void {
124
+ if (rawEvent === null || typeof rawEvent !== 'object' || Array.isArray(rawEvent)) return
125
+ const event = rawEvent as {
126
+ tool?: unknown
127
+ sessionID?: unknown
128
+ messageID?: unknown
129
+ id?: unknown
130
+ }
131
+ if (event.tool !== 'subagent') return
132
+ const sessionID = nonEmptyString(event.sessionID)
133
+ const messageID = nonEmptyString(event.messageID)
134
+ const callID = nonEmptyString(event.id)
135
+ if (sessionID === undefined || messageID === undefined || callID === undefined) return
136
+ this.entries.delete(originKey(sessionID, messageID, callID))
137
+ }
138
+
139
+ private prune(now: number): void {
140
+ for (const [key, origin] of this.entries) {
141
+ if (now - origin.capturedAt > ORIGIN_TTL_MS) this.entries.delete(key)
142
+ }
143
+ }
144
+ }
145
+
146
+ export interface AuthoritativeSession {
147
+ id?: unknown
148
+ parentID?: unknown
149
+ }
150
+
151
+ export type V2DelegationDecision = 'allow' | 'deny' | 'passthrough' | 'preserve-deny'
152
+
153
+ function agentName(value: unknown): string | undefined {
154
+ if (typeof value !== 'string') return undefined
155
+ const valueNormalized = value.trim().toLowerCase()
156
+ return valueNormalized.length === 0 ? undefined : valueNormalized
157
+ }
158
+
159
+ /** Extract the target from the host's resource string or `{ target }` entry. */
160
+ export function v2ResourceTarget(resource: unknown): string | undefined {
161
+ if (typeof resource === 'string') return agentName(resource)
162
+ if (resource === null || typeof resource !== 'object' || Array.isArray(resource)) return undefined
163
+ return agentName((resource as { target?: unknown }).target)
164
+ }
165
+
166
+ /**
167
+ * Decide whether a permission evaluation is a Pantheon-managed delegation.
168
+ * Native/custom-to-native/custom calls are left entirely to host permissions.
169
+ */
170
+ export function evaluateV2Delegation(
171
+ event: V2PermissionEvent,
172
+ session: AuthoritativeSession | undefined,
173
+ existingStatus?: unknown,
174
+ origin?: V2SubagentOrigin,
175
+ ): V2DelegationDecision {
176
+ if (existingStatus === 'deny') return 'preserve-deny'
177
+
178
+ if (!Array.isArray(event.resources) || event.resources.length === 0) return 'deny'
179
+ const rawTargets = event.resources.map(v2ResourceTarget)
180
+ if (rawTargets.some((target) => target === undefined)) return 'deny'
181
+ const targets = rawTargets.filter((target): target is string => target !== undefined)
182
+ const pantheonTargets = targets.filter((target) => PANTHEON_AGENTS.has(target))
183
+ if (pantheonTargets.length === 0) return 'passthrough'
184
+ if (event.action !== 'subagent' || targets.length !== 1 || origin === undefined) return 'deny'
185
+ const sessionID = typeof event.sessionID === 'string' ? event.sessionID : undefined
186
+ const caller = agentName(origin.caller)
187
+ if (
188
+ sessionID === undefined ||
189
+ session === undefined ||
190
+ session.id !== sessionID ||
191
+ caller === undefined ||
192
+ origin.sessionID !== sessionID ||
193
+ origin.target !== pantheonTargets[0]
194
+ ) {
195
+ return 'deny'
196
+ }
197
+ const source = event.source as { type?: unknown; messageID?: unknown; id?: unknown } | undefined
198
+ if (
199
+ source?.type !== 'tool' ||
200
+ source.messageID !== origin.messageID ||
201
+ source.id !== origin.callID
202
+ ) {
203
+ return 'deny'
204
+ }
205
+ const eventCaller = agentName(event.agent)
206
+ if (eventCaller !== undefined && eventCaller !== caller) return 'deny'
207
+ if (session.parentID !== undefined && session.parentID !== null) {
208
+ return 'deny'
209
+ }
210
+
211
+ const callerMayTarget = (target: string): boolean =>
212
+ caller === 'zeus' || ((caller === 'athena' || caller === 'hermes') && target === 'apollo')
213
+ return pantheonTargets.every(callerMayTarget) ? 'allow' : 'deny'
214
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * V2 `execute.after` parity adapter — reproduces the V1 post-tool chain on the
3
+ * V2 tool-event surface.
4
+ *
5
+ * V1 (`src/plugin.ts`) wires a single `tool.execute.after` handler that runs,
6
+ * in order:
7
+ * 1. task-result-guard — an empty native `task()` result becomes an explicit
8
+ * error so the parent LLM cannot read silence as success;
9
+ * 2. context-sandbox — oversized read/grep/glob/webfetch output is truncated
10
+ * with a marker + metadata;
11
+ * 3. read-enhancer — a `read` result gains per-line hashline tags
12
+ * (`N#TAG|content`) so `hashline_edit` refs resolve.
13
+ *
14
+ * On a V2-only install (`opencode.json` `plugins` declares only `plugin-v2`),
15
+ * `src/plugin.ts` is NOT loaded, so that chain does not exist. `plugin-v2.ts`
16
+ * registered an `execute.after` hook but with an empty body, so the chain was
17
+ * dead on V2. This module rebuilds it against the REAL V2 event shape.
18
+ *
19
+ * The V2 event (opencode 2.0.x `ToolHooks["execute.after"]`) is ONE object:
20
+ *
21
+ * { tool, sessionID, agent, messageID, id, input } & (
22
+ * { status: 'completed'; result: Tool.Result } |
23
+ * { status: 'error'; error: Tool.Error } )
24
+ *
25
+ * where `Tool.Result = { output?, content?, metadata? }`. This adapter only
26
+ * acts on `status === 'completed'`; an ERROR event is left untouched — V1's own
27
+ * behaviour on a tool error was never verified, so this module does not claim
28
+ * after-on-error semantics.
29
+ *
30
+ * Fail-safe: every path is wrapped so an unexpected shape can never break the
31
+ * tool result the host is about to surface.
32
+ *
33
+ * @module pantheon/v2-execute-after
34
+ */
35
+
36
+ import {
37
+ type ContextSandboxConfig,
38
+ createContextSandbox,
39
+ resolveSandboxConfig,
40
+ DEFAULT_CONFIG as SANDBOX_DEFAULT_CONFIG,
41
+ } from './context-sandbox.ts'
42
+ import { createReadEnhancer } from './hashline/read-enhancer.ts'
43
+ import { createTaskResultGuard } from './task-result-guard.ts'
44
+
45
+ /** The `Tool.Result` fields this adapter reads and writes back. */
46
+ export interface V2ToolResult {
47
+ output?: unknown
48
+ content?: unknown
49
+ metadata?: unknown
50
+ }
51
+
52
+ /** The completed, non-error `execute.after` event as the 2.0.x host dispatches it. */
53
+ export interface V2ExecuteAfterEvent {
54
+ tool?: unknown
55
+ sessionID?: unknown
56
+ id?: unknown
57
+ input?: unknown
58
+ status?: unknown
59
+ result?: unknown
60
+ }
61
+
62
+ /** Options for {@link createV2ExecuteAfter}. */
63
+ export interface V2ExecuteAfterOptions {
64
+ /**
65
+ * Sandbox config, normally derived from `ctx.options.context_sandbox` via
66
+ * {@link resolveSandboxConfig}. Defaults to the shared defaults.
67
+ */
68
+ sandbox?: ContextSandboxConfig
69
+ }
70
+
71
+ /** Which `Tool.Result` field carries the (mutable) text this adapter enhances. */
72
+ type TextField = 'output' | 'content' | 'content-array'
73
+
74
+ /**
75
+ * Extract the mutable text from a `Tool.Result`.
76
+ *
77
+ * Preference order mirrors what a session renders: a declared string `output`
78
+ * first, then a string `content`, then the concatenation of `content` text
79
+ * parts. A `content` array containing non-text (file) parts is deliberately NOT
80
+ * enhanced — replacing it would corrupt the structured payload, so it is
81
+ * reported as unextractable and the whole event becomes a no-op.
82
+ *
83
+ * @returns `{ text, field }` or `null` when there is nothing to enhance.
84
+ */
85
+ function extractText(result: V2ToolResult): { text: string; field: TextField } | null {
86
+ if (typeof result.output === 'string') return { text: result.output, field: 'output' }
87
+ if (typeof result.content === 'string') return { text: result.content, field: 'content' }
88
+ if (Array.isArray(result.content)) {
89
+ const parts = result.content
90
+ const allText = parts.every(
91
+ (part) => typeof part === 'string' || (part as { type?: unknown })?.type === 'text',
92
+ )
93
+ if (!allText || parts.length === 0) return null
94
+ const text = parts
95
+ .map((part) => (typeof part === 'string' ? part : ((part as { text?: string }).text ?? '')))
96
+ .join('\n')
97
+ return { text, field: 'content-array' }
98
+ }
99
+ return null
100
+ }
101
+
102
+ /**
103
+ * Write enhanced text back into the SAME field it came from, preserving the
104
+ * result's other fields.
105
+ */
106
+ function writeText(result: V2ToolResult, field: TextField, text: string): void {
107
+ if (field === 'output') {
108
+ result.output = text
109
+ } else if (field === 'content') {
110
+ result.content = text
111
+ } else {
112
+ result.content = [{ type: 'text', text }]
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Build the V2 `execute.after` handler that runs the V1 parity chain.
118
+ *
119
+ * @param options - see {@link V2ExecuteAfterOptions}
120
+ * @returns a handler suitable for `ctx.tool.hook('execute.after', …)`
121
+ */
122
+ export function createV2ExecuteAfter(
123
+ options: V2ExecuteAfterOptions = {},
124
+ ): (event: unknown) => Promise<void> {
125
+ const taskResultGuard = createTaskResultGuard()
126
+ const sandbox = createContextSandbox(options.sandbox ?? SANDBOX_DEFAULT_CONFIG)
127
+ const readEnhancer = createReadEnhancer()
128
+
129
+ return async function v2ExecuteAfter(event: unknown): Promise<void> {
130
+ try {
131
+ if (event === null || typeof event !== 'object' || Array.isArray(event)) return
132
+ const payload = event as V2ExecuteAfterEvent
133
+ // Only a completed result has a result to augment; an error event is left
134
+ // untouched (no after-on-error semantics are claimed).
135
+ if (payload.status !== 'completed') return
136
+
137
+ const result = payload.result
138
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) return
139
+
140
+ const tool = typeof payload.tool === 'string' ? payload.tool : ''
141
+ const sessionID = typeof payload.sessionID === 'string' ? payload.sessionID : ''
142
+ const callID = typeof payload.id === 'string' ? payload.id : ''
143
+
144
+ const extracted = extractText(result as V2ToolResult)
145
+ // A `task` result may carry no text at all — that IS the empty-result
146
+ // failure the guard exists to surface, so synthesize an empty string and
147
+ // let the guard replace it. Other tools with no text are a no-op.
148
+ if (extracted === null && tool !== 'task') return
149
+ const original = extracted === null ? { text: '', field: 'output' as TextField } : extracted
150
+
151
+ const input = { tool, sessionID, callID, args: payload.input }
152
+ const output: {
153
+ output: string
154
+ metadata?: Record<string, unknown>
155
+ } = { output: original.text }
156
+
157
+ await taskResultGuard(input, output)
158
+ await sandbox(input, output)
159
+ await readEnhancer(input, output)
160
+
161
+ if (output.output !== original.text) {
162
+ writeText(result as V2ToolResult, original.field, output.output)
163
+ }
164
+ // Surface sandbox metadata onto the result (merge, never replace). It is
165
+ // only present when the sandbox actually truncated something.
166
+ if (output.metadata && Object.keys(output.metadata).length > 0) {
167
+ ;(result as V2ToolResult).metadata = {
168
+ ...((result as V2ToolResult).metadata as Record<string, unknown> | undefined),
169
+ ...output.metadata,
170
+ }
171
+ }
172
+ } catch {
173
+ // Fail-safe: an unexpected shape must never break the tool result.
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Resolve a sandbox config from a V2 plugin's `ctx.options.context_sandbox`.
180
+ * Exposed so `plugin-v2.ts` resolves it the SAME way V1's config hook does,
181
+ * rather than silently constructing defaults.
182
+ */
183
+ export function resolveV2SandboxConfig(options: unknown): ContextSandboxConfig {
184
+ const raw = (options as { context_sandbox?: unknown } | null | undefined)?.context_sandbox
185
+ return resolveSandboxConfig(raw)
186
+ }
@@ -0,0 +1,275 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { KNOWN_AGENTS } from './model-agents.ts'
4
+ import { capabilityEntry, normalizeCapability } from './presets.mjs'
5
+
6
+ export interface V2ModelOverride {
7
+ model: string
8
+ variant?: 'low' | 'medium' | 'high'
9
+ }
10
+
11
+ export interface V2ModelConfiguration {
12
+ overrides: Record<string, V2ModelOverride>
13
+ legacyPresetMarker: { source: 'env' | 'file' } | null
14
+ warnings: string[]
15
+ }
16
+
17
+ export interface V2AgentDraftLike {
18
+ list(): readonly { id: string }[]
19
+ update(id: string, update: (agent: { id: string; model?: unknown }) => void): void
20
+ }
21
+
22
+ interface AgentModelLike {
23
+ id: string
24
+ model?: unknown
25
+ }
26
+
27
+ const MODEL_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:+-]*$/
28
+ const VARIANTS = new Set(['low', 'medium', 'high'])
29
+ const KNOWN_AGENTS_SET = new Set<string>(KNOWN_AGENTS)
30
+
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
33
+ }
34
+
35
+ function isVariant(value: unknown): value is NonNullable<V2ModelOverride['variant']> {
36
+ return typeof value === 'string' && VARIANTS.has(value)
37
+ }
38
+
39
+ function isSupportedModelChoice(model: unknown, variant: unknown): model is string {
40
+ if (typeof model !== 'string' || !MODEL_REF_PATTERN.test(model)) return false
41
+ if (variant !== undefined && !isVariant(variant)) return false
42
+ try {
43
+ capabilityEntry(model)
44
+ if (variant === undefined) return true
45
+ const normalized = normalizeCapability(model, variant)
46
+ return !normalized.clamped && normalized.variant === variant
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
52
+ interface PresetFileSystem {
53
+ openSync(path: string, flags: number): number
54
+ fstatSync(fileDescriptor: number): { isFile(): boolean }
55
+ readFileSync(fileDescriptor: number, encoding: 'utf8'): string
56
+ closeSync(fileDescriptor: number): void
57
+ }
58
+
59
+ class SecurePresetReadUnavailableError extends Error {
60
+ constructor() {
61
+ super('Secure preset-file opening is unavailable')
62
+ }
63
+ }
64
+
65
+ class NonRegularPresetFileError extends Error {
66
+ constructor() {
67
+ super('Preset file is not a regular file')
68
+ }
69
+ }
70
+
71
+ /** Open and read a regular preset file without following links or blocking on special files. */
72
+ export function readV2PresetFile(
73
+ candidate: string,
74
+ fileSystem: PresetFileSystem = { openSync, fstatSync, readFileSync, closeSync },
75
+ ): string {
76
+ const noFollowFlag = constants.O_NOFOLLOW
77
+ const nonBlockFlag = constants.O_NONBLOCK
78
+ if (
79
+ noFollowFlag === undefined ||
80
+ noFollowFlag === 0 ||
81
+ nonBlockFlag === undefined ||
82
+ nonBlockFlag === 0
83
+ ) {
84
+ throw new SecurePresetReadUnavailableError()
85
+ }
86
+
87
+ let fileDescriptor: number | undefined
88
+ try {
89
+ fileDescriptor = fileSystem.openSync(
90
+ candidate,
91
+ constants.O_RDONLY | noFollowFlag | nonBlockFlag,
92
+ )
93
+ if (!fileSystem.fstatSync(fileDescriptor).isFile()) {
94
+ throw new NonRegularPresetFileError()
95
+ }
96
+ return fileSystem.readFileSync(fileDescriptor, 'utf8')
97
+ } finally {
98
+ if (fileDescriptor !== undefined) fileSystem.closeSync(fileDescriptor)
99
+ }
100
+ }
101
+
102
+ function parseOverrides(
103
+ value: Record<string, unknown>,
104
+ warnings: string[],
105
+ ): Record<string, V2ModelOverride> {
106
+ const parsed: Record<string, V2ModelOverride> = {}
107
+ for (const [agent, raw] of Object.entries(value)) {
108
+ if (!KNOWN_AGENTS_SET.has(agent)) {
109
+ warnings.push(
110
+ 'Override entry ignored for an unsupported agent; use one of the canonical Pantheon agent IDs.',
111
+ )
112
+ continue
113
+ }
114
+ const entry = isRecord(raw) ? raw : undefined
115
+ const hasVariant = entry !== undefined && Object.hasOwn(entry, 'variant')
116
+ const variant = hasVariant ? entry?.variant : entry?.reasoning_effort
117
+ const model = entry?.model
118
+ if (!entry || (hasVariant && !isVariant(variant)) || !isSupportedModelChoice(model, variant)) {
119
+ warnings.push(
120
+ 'Override entry ignored because its model or variant is unsupported; choose a supported model and reasoning variant.',
121
+ )
122
+ continue
123
+ }
124
+ const override: V2ModelOverride = { model }
125
+ if (isVariant(variant)) override.variant = variant
126
+ parsed[agent] = override
127
+ }
128
+ return parsed
129
+ }
130
+
131
+ function defaultCandidates(): string[] {
132
+ return [join(process.cwd(), '.pantheon', 'active-preset.json')]
133
+ }
134
+
135
+ /** Read explicit per-agent project overrides only; global files are ignored in V2. */
136
+ export function readV2ModelConfiguration(
137
+ options: { candidates?: string[]; env?: NodeJS.ProcessEnv } = {},
138
+ ): V2ModelConfiguration {
139
+ const env = options.env ?? process.env
140
+ const envName = env.PANTHEON_MODEL_PRESET?.trim()
141
+ let fileMarkerPresent = false
142
+ const configurations: Array<Record<string, V2ModelOverride>> = []
143
+ const warnings: string[] = []
144
+ let readFailure = false
145
+
146
+ // `model-command` passes project before global candidates. Keep this reader
147
+ // project-only even if a caller supplies the legacy candidate list.
148
+ const candidates = options.candidates ?? defaultCandidates()
149
+ for (const candidate of candidates.slice(0, 1)) {
150
+ let rawContent: string
151
+ try {
152
+ rawContent = readV2PresetFile(candidate)
153
+ } catch (error: unknown) {
154
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
155
+ if (error instanceof SecurePresetReadUnavailableError) {
156
+ warnings.push(
157
+ 'V2 project model configuration cannot be read securely on this platform; manual overrides were not applied.',
158
+ )
159
+ readFailure = true
160
+ break
161
+ }
162
+ if (
163
+ error instanceof NonRegularPresetFileError ||
164
+ (error instanceof Error && 'code' in error && error.code === 'ELOOP')
165
+ ) {
166
+ warnings.push(
167
+ 'V2 project model configuration is not a regular file or is a symlink; check active-preset.json. Manual overrides were not applied.',
168
+ )
169
+ readFailure = true
170
+ break
171
+ }
172
+ warnings.push(
173
+ 'V2 project model configuration could not be read; check active-preset.json file access. Manual overrides were not applied.',
174
+ )
175
+ readFailure = true
176
+ break
177
+ }
178
+
179
+ let raw: unknown
180
+ try {
181
+ raw = JSON.parse(rawContent)
182
+ } catch {
183
+ warnings.push(
184
+ 'V2 project model configuration contains invalid JSON; correct active-preset.json. Manual overrides were not applied.',
185
+ )
186
+ readFailure = true
187
+ break
188
+ }
189
+ if (!isRecord(raw)) {
190
+ warnings.push(
191
+ 'V2 project model configuration has an invalid shape; correct active-preset.json. Manual overrides were not applied.',
192
+ )
193
+ readFailure = true
194
+ break
195
+ }
196
+
197
+ if (Object.hasOwn(raw, 'preset')) fileMarkerPresent = true
198
+ let parsedOverrides: Record<string, V2ModelOverride> = {}
199
+ if (Object.hasOwn(raw, 'overrides')) {
200
+ if (!isRecord(raw.overrides)) {
201
+ warnings.push(
202
+ 'V2 project model configuration has an invalid `overrides` object; expected an object in active-preset.json. The project file contributed no manual overrides.',
203
+ )
204
+ } else if (Object.hasOwn(raw.overrides, 'agents')) {
205
+ if (!isRecord(raw.overrides.agents)) {
206
+ warnings.push(
207
+ 'V2 project model configuration has invalid `overrides.agents`; expected an object in active-preset.json. The project file contributed no manual overrides.',
208
+ )
209
+ } else {
210
+ parsedOverrides = parseOverrides(raw.overrides.agents, warnings)
211
+ }
212
+ }
213
+ }
214
+ configurations.push(parsedOverrides)
215
+ }
216
+
217
+ const envMarkerPresent = typeof envName === 'string' && envName.length > 0
218
+ const markerSource: 'env' | 'file' | null = envMarkerPresent
219
+ ? 'env'
220
+ : fileMarkerPresent
221
+ ? 'file'
222
+ : null
223
+
224
+ // A project-file read/parse failure fails closed. Malformed entries warn but
225
+ // leave other valid project entries available.
226
+ const overrides: Record<string, V2ModelOverride> = {}
227
+ if (!readFailure) {
228
+ Object.assign(overrides, configurations[0] ?? {})
229
+ }
230
+ return {
231
+ overrides,
232
+ legacyPresetMarker: markerSource === null ? null : { source: markerSource },
233
+ warnings,
234
+ }
235
+ }
236
+
237
+ /** Apply one explicit model override while leaving all other agent settings intact. */
238
+ export function applyV2ModelOverride(
239
+ agent: AgentModelLike,
240
+ override: V2ModelOverride | undefined,
241
+ ): void {
242
+ if (
243
+ !KNOWN_AGENTS_SET.has(agent.id) ||
244
+ !override ||
245
+ !isSupportedModelChoice(override.model, override.variant)
246
+ ) {
247
+ return
248
+ }
249
+ const separator = override.model.indexOf('/')
250
+ const previous = isRecord(agent.model) ? agent.model : {}
251
+ const model: Record<string, unknown> = {
252
+ ...previous,
253
+ providerID: override.model.slice(0, separator),
254
+ id: override.model.slice(separator + 1),
255
+ }
256
+ if (override.variant !== undefined) {
257
+ model.variant = override.variant
258
+ } else {
259
+ delete model.variant
260
+ }
261
+ agent.model = model
262
+ }
263
+
264
+ /** Apply explicit overrides to matching agents and leave unmatched host defaults untouched. */
265
+ export function applyV2ModelOverrides(
266
+ draft: V2AgentDraftLike,
267
+ overrides: Record<string, V2ModelOverride>,
268
+ ): void {
269
+ for (const agent of draft.list()) {
270
+ if (!KNOWN_AGENTS_SET.has(agent.id)) continue
271
+ const override = overrides[agent.id]
272
+ if (!override) continue
273
+ draft.update(agent.id, (current) => applyV2ModelOverride(current, override))
274
+ }
275
+ }