pantheon-opencode 1.3.5-beta.57.2e03676 → 1.3.5

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  <p align="center">
5
5
  <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-v1.3.4-blue" alt="Version"></a>
6
6
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License"></a>
7
- <a href="src/agents/README.md"><img src="https://img.shields.io/badge/agents-14-purple" alt="Agents"></a>
7
+ <a href="docs/agents/README.md"><img src="https://img.shields.io/badge/agents-14-purple" alt="Agents"></a>
8
8
  <a href="src/skills/README.md"><img src="https://img.shields.io/badge/skills-21-orange" alt="Skills"></a>
9
9
  <a href="commands/"><img src="https://img.shields.io/badge/commands-11-red" alt="Commands"></a>
10
10
  <a href="https://github.com/ils15/pantheon/actions"><img src="https://img.shields.io/github/actions/workflow/status/ils15/pantheon/ci.yml?branch=main&label=CI" alt="CI"></a>
@@ -22,7 +22,7 @@ Supports **OpenCode** — multi-agent orchestration for your editor.
22
22
 
23
23
  | Resource | Link |
24
24
  |----------|------|
25
- | 📖 **Agent Reference** | [src/agents/README.md](src/agents/README.md) — all 14 agents |
25
+ | 📖 **Agent Reference** | [docs/agents/README.md](docs/agents/README.md) — all 14 agents |
26
26
  | 📖 **Skills Reference** | [src/skills/README.md](src/skills/README.md) — all 21 skills |
27
27
  | 🚀 **Installation Guide** | [docs/INSTALLATION.md](docs/INSTALLATION.md) |
28
28
  | 🔌 **MCP Server Guide** | [docs/mcp-recommendations.md](docs/mcp-recommendations.md) — recommended MCP servers for each project type |
@@ -324,7 +324,7 @@ child model's provider before dispatching (single source of truth:
324
324
  enforces at startup). If an **auto-resolved** model (`options.agentModels` or
325
325
  the active preset) points to a provider that requires an API key
326
326
  (`apiKeyEnv`) and the env var is unset, the delegate falls back to
327
- `opencode/deepseek-v4-flash-free` (validated the same way). If the fallback is
327
+ `opencode-go/deepseek-v4-flash` (validated the same way). If the fallback is
328
328
  also unusable, the tool returns a clear error **text** (never throws) naming
329
329
  the missing env var — and registers **no job** on the board. An **explicit**
330
330
  `model` passed by the caller is always respected (warned, not overridden).
@@ -518,7 +518,7 @@ Domain Specialist
518
518
  └── Gaia — remote sensing: LULC analysis, scientific literature
519
519
  ```
520
520
 
521
- > See [src/agents/README.md](src/agents/README.md) for the complete reference — each agent's
521
+ > See [docs/agents/README.md](docs/agents/README.md) for the complete reference — each agent's
522
522
  > tools, model assignment, behavioral rules, and invocation patterns.
523
523
 
524
524
  ### Architecture Diagram
@@ -1097,7 +1097,7 @@ Pantheon draws from the broader multi-agent landscape while diverging in key way
1097
1097
  | [CHANGELOG.md](CHANGELOG.md) | Release history |
1098
1098
  | [docs/INSTALLATION.md](docs/INSTALLATION.md) | Installation guide |
1099
1099
  | [docs/platforms/opencode.md](docs/platforms/opencode.md) | OpenCode setup guide |
1100
- | [src/agents/README.md](src/agents/README.md) | Agent directory |
1100
+ | [docs/agents/README.md](docs/agents/README.md) | Agent directory |
1101
1101
  | [src/skills/README.md](src/skills/README.md) | Skill directory |
1102
1102
  | [docs/mcp-tools.md](docs/mcp-tools.md) | Canonical MCP tool registry |
1103
1103
  | [docs/mcp-user-guide.md](docs/mcp-user-guide.md) | Adding custom MCP servers |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.3.5-beta.57.2e03676",
3
+ "version": "1.3.5",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -51,6 +51,7 @@
51
51
  "skills-lock.json",
52
52
  "scripts/*.sh",
53
53
  "src/agents/*.md",
54
+ "!src/agents/README.md",
54
55
  "bin/*.mjs",
55
56
  "AGENTS.md",
56
57
  "opencode.json",
@@ -187,6 +187,26 @@ function showHelp() {
187
187
  // Helper utilities
188
188
  // ---------------------------------------------------------------------------
189
189
 
190
+ /** @type {Set<string>} agent-defining frontmatter fields */
191
+ const AGENT_FRONTMATTER_FIELDS = new Set(['name', 'description', 'mode'])
192
+
193
+ /**
194
+ * Return true if the file content has YAML frontmatter containing at least
195
+ * one agent-defining field (name, description, or mode). Non-agent .md files
196
+ * such as README.md are excluded.
197
+ * @param {string} content
198
+ * @returns {boolean}
199
+ */
200
+ export function isValidAgentFile(content) {
201
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
202
+ if (!match) return false
203
+ const fm = match[1]
204
+ for (const field of AGENT_FRONTMATTER_FIELDS) {
205
+ if (new RegExp(`(?:^|\\r?\\n)${field}\\s*:`).test(fm)) return true
206
+ }
207
+ return false
208
+ }
209
+
190
210
  /**
191
211
  * Collect canonical agent names from agents/*.md
192
212
  * @returns {string[]} sorted list of agent names (without extension)
@@ -195,7 +215,9 @@ function getCanonicalAgentNames() {
195
215
  if (!existsSync(AGENTS_DIR)) return []
196
216
  return readdirSync(AGENTS_DIR)
197
217
  .filter((f) => f.endsWith('.md'))
198
- .map((f) => f.replace(/\.agent\.md$/, ''))
218
+ .map((f) => ({ name: f, path: join(AGENTS_DIR, f) }))
219
+ .filter(({ path: p }) => isValidAgentFile(readFileSync(p, 'utf8')))
220
+ .map(({ name }) => name.replace(/\.agent\.md$/, ''))
199
221
  .sort()
200
222
  }
201
223
 
@@ -1125,7 +1147,15 @@ export function deriveInstalledAgentFiles(configs) {
1125
1147
  continue
1126
1148
  }
1127
1149
  for (const entry of entries) {
1128
- if (entry.isFile() && entry.name.endsWith('.md')) files.push(join(directory, entry.name))
1150
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
1151
+ const filePath = join(directory, entry.name)
1152
+ try {
1153
+ if (isValidAgentFile(readFileSync(filePath, 'utf8'))) {
1154
+ files.push(filePath)
1155
+ }
1156
+ } catch {
1157
+ // unreadable — skip
1158
+ }
1129
1159
  }
1130
1160
  }
1131
1161
  return [...new Set(files)].sort()
@@ -76,7 +76,7 @@ export function readCanonicalAgents() {
76
76
  if (!existsSync(AGENTS_DIR)) return []
77
77
 
78
78
  const files = readdirSync(AGENTS_DIR)
79
- .filter((f) => f.endsWith('.agent.md') || f.endsWith('.md'))
79
+ .filter((f) => (f.endsWith('.agent.md') || f.endsWith('.md')) && f.toLowerCase() !== 'readme.md')
80
80
  .sort()
81
81
 
82
82
  const agents = []
@@ -558,7 +558,9 @@ export async function installOpenCode(
558
558
  const agentsDir = join(ROOT, 'src', 'agents')
559
559
  if (!existsSync(agentsDir)) return {}
560
560
  const sources = {}
561
- const files = readdirSync(agentsDir).filter((f) => f.endsWith('.agent.md') || f.endsWith('.md'))
561
+ const files = readdirSync(agentsDir).filter(
562
+ (f) => (f.endsWith('.agent.md') || f.endsWith('.md')) && f.toLowerCase() !== 'readme.md',
563
+ )
562
564
  for (const f of files) {
563
565
  const name = f.replace(/\.(agent\.)?md$/, '')
564
566
  sources[name] = `${agentPrefix}/${name}.md`
@@ -570,7 +572,9 @@ export async function installOpenCode(
570
572
  const agentsDir = join(ROOT, 'src', 'agents')
571
573
  if (!existsSync(agentsDir)) return {}
572
574
  const config = {}
573
- const files = readdirSync(agentsDir).filter((f) => f.endsWith('.agent.md') || f.endsWith('.md'))
575
+ const files = readdirSync(agentsDir).filter(
576
+ (f) => (f.endsWith('.agent.md') || f.endsWith('.md')) && f.toLowerCase() !== 'readme.md',
577
+ )
574
578
  for (const f of files) {
575
579
  const name = f.replace(/\.(agent\.)?md$/, '')
576
580
  const content = readFileSync(join(agentsDir, f), 'utf8')
@@ -33,7 +33,7 @@ export const PLATFORM_DIR = join(ROOT, 'platform')
33
33
  export function getAgentNames() {
34
34
  if (!existsSync(AGENTS_DIR)) return []
35
35
  return readdirSync(AGENTS_DIR)
36
- .filter((f) => f.endsWith('.agent.md') || f.endsWith('.md'))
36
+ .filter((f) => (f.endsWith('.agent.md') || f.endsWith('.md')) && f.toLowerCase() !== 'readme.md')
37
37
  .map((f) => f.replace(/\.(agent\.)?md$/, ''))
38
38
  .sort()
39
39
  }
@@ -277,9 +277,11 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
277
277
  // Build set of expected destination filenames (after rename)
278
278
  const dstNames = new Set()
279
279
  for (const entry of entries) {
280
+ if (entry.toLowerCase() === 'readme.md') continue
280
281
  const srcFile = join(srcDir, entry)
281
282
  if (!existsSync(srcFile)) continue
282
283
  const dstName = renameMap ? (renameMap(entry) ?? entry) : entry
284
+ if (dstName.toLowerCase() === 'readme.md') continue
283
285
  dstNames.add(dstName)
284
286
 
285
287
  const dstFile = join(dstDir, dstName)
@@ -2,6 +2,9 @@
2
2
  name: apollo
3
3
  description: "Read-only investigation scout — 3-10 parallel searches across codebase, external docs, and GitHub. Called by: athena, zeus, hermes, aphrodite, demeter. No edits, no commands."
4
4
  mode: subagent
5
+ visible: false
6
+ tools:
7
+ task: false
5
8
  reasoning_effort: low
6
9
  permission:
7
10
  read: allow
@@ -2,6 +2,9 @@
2
2
  name: gaia
3
3
  description: "Remote sensing domain specialist — satellite image processing, spectral analysis, SAR, change detection, time series, ML/DL classification. Read-only analysis of geospatial data."
4
4
  mode: subagent
5
+ visible: false
6
+ tools:
7
+ task: false
5
8
  reasoning_effort: low
6
9
  permission:
7
10
  read: allow
@@ -37,7 +37,7 @@ skills:
37
37
 
38
38
  ## Golden Rule
39
39
 
40
- **Coordenador APENAS.** Leu arquivo → delegue. Nunca implemente, nunca edite, nunca debugue.
40
+ **Coordenador APENAS. Zeus NUNCA lê arquivos de código-fonte.** Toda leitura de `src/`, `tests/`, `scripts/` vai para @apollo. Leia apenas: config, docs, memory bank, delegação.
41
41
 
42
42
 
43
43
  ## 🔒 Fusion-Style Enforcement
@@ -324,6 +324,10 @@ out = await guard.maybeRetry(result, async () => { ...task() de novo... })
324
324
  Regra: retry 1x APENAS em `empty-mode1`/`empty-mode2`. Resultado com conteudo
325
325
  nunca redispara. Apos 1 retry vazio → escalate (mesma regra do TODO Enforcer).
326
326
 
327
+ > **Waves DEVEM usar `zeusDelegateWithRetry`** (`src/pantheon/zeus-delegate-with-retry.ts`) — encapsula `delegate → waitForTerminal → classify → retry 1x → escalate` com cap duro 1; se ainda vazio throw `ZeusEscalationError` (escale: tente outro agente, simplifique, manual).
328
+ > Uso alto-nível: `import { zeusDelegateWithRetry } from './pantheon/zeus-delegate-with-retry.ts'` → `await zeusDelegateWithRetry({ board, client, sessionID, agent, prompt })`.
329
+ > Uso baixo-nível: `createZeusRetryHelper().executeWithRetry(first, () => secondRead())` — classifica empty e decide retry/escalate (mesmo cap 1).
330
+
327
331
  ### /cost — pantheon_cost tool (WIRED no plugin)
328
332
  `pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY (node:sqlite;
329
333
  fallback `scripts/cost.mjs`) e devolve tabela markdown de custo + tokens por
@@ -169,6 +169,70 @@ export const DEFAULT_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
169
169
  'hashline_edit',
170
170
  ])
171
171
 
172
+ // ─── Zeus Read Guard ──────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Path patterns that Zeus must NOT read directly. Zeus must delegate
176
+ * codebase reads to @apollo to avoid context bloat and enforce separation
177
+ * of concerns.
178
+ */
179
+ export const ZEUS_READ_DENY_PATTERNS: ReadonlyArray<RegExp> = [
180
+ /^src\//,
181
+ /^tests?\//,
182
+ /^scripts?\//,
183
+ ]
184
+
185
+ /**
186
+ * Path exceptions that Zeus MAY read even if they match a deny pattern.
187
+ * Markdown files, .pantheon/ configs, and memories/ are operational context
188
+ * that Zeus legitimately needs.
189
+ */
190
+ export const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
191
+ /\.md$/,
192
+ /\.pantheon\//,
193
+ /memories\//,
194
+ ]
195
+
196
+ /**
197
+ * Zeus read guard. When the active agent is Zeus, deny read/glob/grep calls
198
+ * that target source code, tests, or scripts — Zeus must delegate those to
199
+ * @apollo. Allowed paths (markdown, .pantheon/, memories/) are exempt.
200
+ *
201
+ * Non-Zeus sessions are never affected.
202
+ *
203
+ * @throws {Error} when Zeus attempts a denied read
204
+ */
205
+ export function zeusReadGuard(
206
+ tool: string,
207
+ args: unknown,
208
+ agent: string | undefined,
209
+ ): void {
210
+ if (agent !== 'zeus') return
211
+ if (tool !== 'read' && tool !== 'glob' && tool !== 'grep') return
212
+
213
+ const record = args as Record<string, unknown> | undefined
214
+ const filePath =
215
+ typeof record?.filePath === 'string'
216
+ ? record.filePath
217
+ : typeof record?.pattern === 'string'
218
+ ? record.pattern
219
+ : undefined
220
+
221
+ if (!filePath) return
222
+
223
+ for (const pattern of ZEUS_READ_DENY_PATTERNS) {
224
+ if (pattern.test(filePath)) {
225
+ // Check allowed-path exceptions before denying
226
+ for (const allowed of ALLOWED_PATHS) {
227
+ if (allowed.test(filePath)) return
228
+ }
229
+ throw new Error(
230
+ `Zeus cannot read ${filePath} — delegate to @apollo`,
231
+ )
232
+ }
233
+ }
234
+ }
235
+
172
236
  // ─── Registry ──────────────────────────────────────────────────────────
173
237
 
174
238
  /**
@@ -365,15 +365,18 @@ export interface ChildSessionModelRef {
365
365
  }
366
366
 
367
367
  /**
368
- * Known-good fallback model (P1, 2026-08-11 — release 1.3.4): when an
368
+ * Known-good fallback model (P1, 2026-08-11 — release 1.3.4, updated
369
+ * 2026-08-21 for sandbox without PANTHEON_OPENCODE_API_KEY): when an
369
370
  * AUTO-RESOLVED model (routing.yml agent entry / active preset) points at a
370
371
  * provider whose API key is not configured, the child is dispatched on the
371
- * native `opencode` provider instead — verified to work in the field without
372
- * an external provider key. Validated with the same providerKeyConfigured
373
- * gate as the resolved model; an explicit caller-supplied `model` always wins
374
- * over this fallback.
372
+ * `opencode-go` provider instead — `opencode-go/deepseek-v4-flash`
373
+ * via https://opencode.ai/zen/go/v1 works without an external API key
374
+ * (Go subscription / opencode auth, sandbox default). Validated with the
375
+ * same providerKeyConfigured gate as the resolved model, with an
376
+ * opencode-go exception for the sandbox; an explicit caller-supplied
377
+ * `model` always wins over this fallback.
375
378
  */
376
- const FALLBACK_MODEL = 'opencode/deepseek-v4-flash-free'
379
+ const FALLBACK_MODEL = 'opencode-go/deepseek-v4-flash'
377
380
 
378
381
  /**
379
382
  * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
@@ -484,7 +487,7 @@ function resolveUsableChildModel(
484
487
 
485
488
  // Auto-resolved (agentModels / preset) → try the known-good fallback.
486
489
  const fallback = splitModelRef(FALLBACK_MODEL)
487
- if (fallback !== undefined && missingProviderKeyEnv(fallback.providerID, { env }) === undefined) {
490
+ if (fallback !== undefined && (fallback.providerID === 'opencode-go' || missingProviderKeyEnv(fallback.providerID, { env }) === undefined)) {
488
491
  warn(
489
492
  `[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
490
493
  `(unset) — falling back to ${FALLBACK_MODEL}`,
@@ -653,7 +656,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
653
656
  // preset) is used when its provider key is configured; an explicit
654
657
  // caller model is always respected (warned); an auto-resolved model
655
658
  // whose provider key is missing falls back to the known-good
656
- // opencode/deepseek-v4-flash-free; if nothing usable remains the
659
+ // opencode-go/deepseek-v4-flash; if nothing usable remains the
657
660
  // failure is returned as TEXT below — no session, no board job.
658
661
  const resolved = resolveUsableChildModel(options, args.agent, args.model)
659
662
  if (resolved.error !== undefined) {
@@ -20,6 +20,7 @@ import type { BackgroundJobBoard } from './background-job-board.ts'
20
20
  import type { ToolContextLike } from './delegation.ts'
21
21
  import { GoalStore } from './goal-store.ts'
22
22
  import { createPantheonLogger } from './logger.ts'
23
+ import { safeSessionPath } from './session-guard.ts'
23
24
  import type { TodoEnforcerMessage } from './todo-enforcer.ts'
24
25
 
25
26
  // Silence-by-default TUI policy (pantheon-hooks L42-58): warn → hooks.log,
@@ -188,8 +189,13 @@ export class GoalLoop {
188
189
  this.lastContinuationAt.set(sessionID, now)
189
190
  const inherited = await this.inheritAgentOrModel(sessionID)
190
191
  const prompt = GOAL_CONTINUATION_PROMPT.replace('{objective}', goal.objective)
192
+ const promptPath = safeSessionPath(sessionID)
193
+ if (!promptPath) {
194
+ this.warn(`goal continuation skipped: invalid sessionID "${sessionID}"`)
195
+ return
196
+ }
191
197
  await this.client.session.promptAsync({
192
- path: { id: sessionID },
198
+ path: promptPath.path,
193
199
  body: {
194
200
  ...(inherited.agent !== undefined ? { agent: inherited.agent } : {}),
195
201
  ...(inherited.model !== undefined ? { model: inherited.model } : {}),
@@ -206,7 +212,12 @@ export class GoalLoop {
206
212
  * continuation runs in the same context as the session's last turn.
207
213
  */
208
214
  private async inheritAgentOrModel(sessionID: string): Promise<InheritedContext> {
209
- const messages = await this.client.session.messages({ path: { id: sessionID } })
215
+ const path = safeSessionPath(sessionID)
216
+ if (!path) {
217
+ this.warn(`inherit skipped: invalid sessionID "${sessionID}"`)
218
+ return {}
219
+ }
220
+ const messages = await this.client.session.messages(path)
210
221
  for (const msg of [...messages].reverse()) {
211
222
  const info = msg.info
212
223
  if (info === undefined || info.role !== 'assistant') continue
@@ -402,6 +402,7 @@ function providerDefKeyConfigured(provider, env) {
402
402
  * @returns {string|undefined} missing env var name, or undefined when usable
403
403
  */
404
404
  export function missingProviderKeyEnv(providerID, { env = process.env, routingPath } = {}) {
405
+ if (providerID === 'opencode-go') return undefined
405
406
  const defs = loadPresetDefs(routingPath)
406
407
  for (const def of Object.values(defs)) {
407
408
  const provider = def?.providers?.[providerID]
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Session Guard — shared server-side validation for session IDs.
3
+ *
4
+ * Opencode session IDs always start with "ses". The TUI previously forwarded
5
+ * the literal template placeholder "{sessionID}" (URL-encoded as "%7BsessionID%7D")
6
+ * into `session.children`, causing ~1.5 errors/s from the 1s safety poll.
7
+ * This module is the single chokepoint: every `client.session.*({path:{id}})`;
8
+ * call site MUST go through `safeSessionPath`/`buildChildrenPath`, which
9
+ * reject placeholders before they reach the server (fail-open: warn + return,
10
+ * never throw, never send the placeholder).
11
+ *
12
+ * Mirrors the TUI's `src/plugins/tui/src/index.tsx` contract verbatim so both
13
+ * sides agree on what "valid" means. The TUI file remains self-contained
14
+ * (dist/tui.tsx is a raw copy, no relative imports), so its local copy is kept
15
+ * in sync — this module is the shared source for `src/pantheon/*` and
16
+ * `src/plugin.ts`.
17
+ *
18
+ * @module session-guard
19
+ */
20
+
21
+ /**
22
+ * Server-aligned session id validity: opencode rejects anything not starting
23
+ * with "ses" (SchemaError). Mirrors that exact contract — nothing stricter,
24
+ * nothing looser — so "{sessionID}", "%7BsessionID%7D", "", "wrk_…" never
25
+ * reach a path.
26
+ */
27
+ export function isValidSessionId(id: unknown): id is string {
28
+ return typeof id === 'string' && id.startsWith('ses')
29
+ }
30
+
31
+ /**
32
+ * The single choke point for every `session.*({path:{id}})` call.
33
+ * Returns `{path:{id}}` ONLY for a server-valid id; otherwise null so the
34
+ * caller skips the call entirely (fail-open).
35
+ */
36
+ export function safeSessionPath(id: unknown): { path: { id: string } } | null {
37
+ if (!isValidSessionId(id)) return null
38
+ return { path: { id } }
39
+ }
40
+
41
+ /**
42
+ * Build the `session.children` path ONLY from a validated session id.
43
+ * Delegates to {@link safeSessionPath} — the single choke point. Returns
44
+ * null for null/invalid ids so the caller skips the fetch.
45
+ */
46
+ export function buildChildrenPath(
47
+ id: string | null | undefined,
48
+ ): { path: { id: string } } | null {
49
+ return safeSessionPath(id)
50
+ }
@@ -49,6 +49,7 @@
49
49
 
50
50
  import type { BackgroundJobBoard } from './background-job-board.ts'
51
51
  import { createPantheonLogger } from './logger.ts'
52
+ import { buildChildrenPath, safeSessionPath } from './session-guard.ts'
52
53
 
53
54
  // Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
54
55
  // fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
@@ -233,8 +234,13 @@ export class TodoEnforcer {
233
234
  */
234
235
  async listPendingTodos(sessionID: string): Promise<TodoLike[]> {
235
236
  if (!this.options.enabled) return []
237
+ const path = safeSessionPath(sessionID)
238
+ if (!path) {
239
+ this.warn(`todo list skipped: invalid sessionID "${sessionID}"`)
240
+ return []
241
+ }
236
242
  try {
237
- const todos = await this.client.session.todo({ path: { id: sessionID } })
243
+ const todos = await this.client.session.todo(path)
238
244
  return todos.filter((todo) => todo.status !== 'completed' && todo.status !== 'cancelled')
239
245
  } catch (err: unknown) {
240
246
  const reason = err instanceof Error ? err.message : String(err)
@@ -280,7 +286,12 @@ export class TodoEnforcer {
280
286
  // still waiting on it, so skip.
281
287
  if ((await this.activeChildren(sessionID, now)) > 0) return
282
288
 
283
- const todos = await this.client.session.todo({ path: { id: sessionID } })
289
+ const path = safeSessionPath(sessionID)
290
+ if (!path) {
291
+ this.warn(`todo check skipped: invalid sessionID "${sessionID}"`)
292
+ return
293
+ }
294
+ const todos = await this.client.session.todo(path)
284
295
  const incomplete = todos.filter(
285
296
  (todo) => todo.status !== 'completed' && todo.status !== 'cancelled',
286
297
  ).length
@@ -328,8 +339,13 @@ export class TodoEnforcer {
328
339
  // (throwing) injection still applies the cooldown — no hot retry loop.
329
340
  this.lastInjectionAt.set(sessionID, now)
330
341
  const inherited = await this.inheritAgentOrModel(sessionID)
342
+ const promptPath = safeSessionPath(sessionID)
343
+ if (!promptPath) {
344
+ this.warn(`todo injection skipped: invalid sessionID "${sessionID}"`)
345
+ return
346
+ }
331
347
  await this.client.session.promptAsync({
332
- path: { id: sessionID },
348
+ path: promptPath.path,
333
349
  body: {
334
350
  ...(inherited.agent !== undefined ? { agent: inherited.agent } : {}),
335
351
  ...(inherited.model !== undefined ? { model: inherited.model } : {}),
@@ -368,8 +384,13 @@ export class TodoEnforcer {
368
384
  * version-sensitive runtimes.
369
385
  */
370
386
  private async activeChildren(sessionID: string, now: number): Promise<number> {
387
+ const path = safeSessionPath(sessionID)
388
+ if (!path) {
389
+ this.warn(`children check skipped: invalid sessionID "${sessionID}"`)
390
+ return 0
391
+ }
371
392
  try {
372
- const children = await this.client.session.children({ path: { id: sessionID } })
393
+ const children = await this.client.session.children(path)
373
394
  return children.filter((child) => this.isChildActive(child, now)).length
374
395
  } catch (err: unknown) {
375
396
  const reason = err instanceof Error ? err.message : String(err)
@@ -398,7 +419,12 @@ export class TodoEnforcer {
398
419
  * SDK then applies the session default.
399
420
  */
400
421
  private async inheritAgentOrModel(sessionID: string): Promise<InheritedContext> {
401
- const messages = await this.client.session.messages({ path: { id: sessionID } })
422
+ const path = safeSessionPath(sessionID)
423
+ if (!path) {
424
+ this.warn(`inherit skipped: invalid sessionID "${sessionID}"`)
425
+ return {}
426
+ }
427
+ const messages = await this.client.session.messages(path)
402
428
  for (const msg of [...messages].reverse()) {
403
429
  const info = msg.info
404
430
  if (info === undefined || info.role !== 'assistant') continue
@@ -48,6 +48,7 @@
48
48
  */
49
49
 
50
50
  import { createPantheonLogger } from './logger.ts'
51
+ import { safeSessionPath } from './session-guard.ts'
51
52
  import type { TodoLike } from './todo-enforcer.ts'
52
53
 
53
54
  // Silence-by-default TUI policy (pantheon-hooks L42-58): console echo is
@@ -173,8 +174,13 @@ export class TodoPreserver {
173
174
  */
174
175
  async capture(sessionID: string): Promise<void> {
175
176
  this.pruneExpired()
177
+ const path = safeSessionPath(sessionID)
178
+ if (!path) {
179
+ this.warn(`todo snapshot capture skipped: invalid sessionID "${sessionID}"`)
180
+ return
181
+ }
176
182
  try {
177
- const todos = await this.client.session.todo({ path: { id: sessionID } })
183
+ const todos = await this.client.session.todo(path)
178
184
  if (todos.length === 0) {
179
185
  // Nothing to preserve → drop any stale entry so no restore happens.
180
186
  this.snapshots.delete(sessionID)
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'
7
7
  import type { Hooks, PluginInput } from '@opencode-ai/plugin'
8
8
  import type { FilePart, Part, TextPart, UserMessage } from '@opencode-ai/sdk'
9
9
  import type { ResolvedPreset } from './presets.mjs'
10
- import { hasVision, resolveActivePreset, visionBrokenOnGateway } from './presets.mjs'
10
+ import { CAPABILITY_TABLE, hasVision, resolveActivePreset, visionBrokenOnGateway } from './presets.mjs'
11
11
 
12
12
  // ─── Constants ────────────────────────────────────────────────────────────
13
13
 
@@ -121,9 +121,30 @@ type NativeVisionEndpoint = { modelID: string; baseURL: string }
121
121
 
122
122
  export type VisionMode = 'native' | 'tool' | 'auto'
123
123
 
124
+ /**
125
+ * Check whether a provider is text-only by examining CAPABILITY_TABLE.
126
+ * A provider is text-only if ALL provider-qualified entries (prefix starting
127
+ * with `providerID/`) have `vision: false`. If no provider-qualified entries
128
+ * exist, the provider is unknown → returns false (fail-open).
129
+ */
130
+ export function providerIsTextOnly(providerID: string): boolean {
131
+ const providerEntries = CAPABILITY_TABLE.filter((entry) =>
132
+ entry.prefix.startsWith(`${providerID}/`),
133
+ )
134
+ if (providerEntries.length === 0) return false
135
+ return providerEntries.every((entry) => !entry.vision)
136
+ }
137
+
124
138
  /** Return whether OpenCode's current model is known to accept image input. */
125
- export function modelAcceptsImages(model: ModelInfo | undefined): boolean {
126
- if (!model) return true
139
+ export function modelAcceptsImages(
140
+ model: ModelInfo | undefined,
141
+ providerID?: string,
142
+ ): boolean {
143
+ if (!model) {
144
+ // Fail-closed for known text-only providers
145
+ if (providerID && providerIsTextOnly(providerID)) return false
146
+ return true
147
+ }
127
148
  try {
128
149
  const id = `${model.providerID}/${model.modelID}`
129
150
  // Provider/gateway-aware: a model can be vision-capable per models.dev
@@ -1230,7 +1251,7 @@ export function createVisionHandler(input: PluginInput) {
1230
1251
  const message: UserMessage | undefined = output?.message
1231
1252
  if (!message || message.role !== 'user') return
1232
1253
  const model = hookInput?.model ?? message.model
1233
- if (modelAcceptsImages(model)) {
1254
+ if (modelAcceptsImages(model, model?.providerID)) {
1234
1255
  nativeVisionSessions.add(hookInput.sessionID)
1235
1256
  return
1236
1257
  }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Zeus Delegate With Retry (Wave 4, empty-mode fix) — helper for Zeus waves.
3
+ *
4
+ * Opencode 1.18.x cannot intercept `task()` completion via hooks, so the
5
+ * `dispatch-guard` lib is NOT wired in the plugin. Zeus waves MUST use this
6
+ * helper manually: it encapsulates
7
+ *
8
+ * delegate → waitForTerminal → classify via createDispatchGuard
9
+ * → maybeRetry 1x on empty-mode1/mode2 → escalate
10
+ *
11
+ * Retry cap is HARD at 1 (never 2x). The guard is stateful per helper
12
+ * instance — one retry budget total. Fail-open on missing APIs is NOT
13
+ * applicable here: a failed delegate throws/log+escalate so the wave can
14
+ * decide (a) retry with different agent, (b) simplify, (c) manual.
15
+ *
16
+ * Two usage shapes:
17
+ * 1. High-level: `zeusDelegateWithRetry({ board, client, sessionID, agent, prompt })`
18
+ * — creates child session, registers board, promptAsync, waitForTerminal,
19
+ * reads report, classifies, retries once if empty, escalates if still empty.
20
+ * 2. Low-level: `createZeusRetryHelper()` — wraps a generic
21
+ * `() => Promise<DispatchResultLike>` pair via `executeWithRetry`.
22
+ *
23
+ * The high-level helper is the one Zeus waves should import:
24
+ *
25
+ * ```ts
26
+ * import { zeusDelegateWithRetry } from './pantheon/zeus-delegate-with-retry.ts'
27
+ * const { report, alias, taskID, retried, classification } =
28
+ * await zeusDelegateWithRetry({ board, client, sessionID, agent: 'apollo', prompt })
29
+ * ```
30
+ *
31
+ * If the result is empty after one retry, the helper THROWS with an
32
+ * `EscalationError` — the wave must catch and escalate to the user (see
33
+ * `src/agents/zeus.md` "Waves with Retry").
34
+ *
35
+ * Pure TypeScript — no direct SDK import. Board/client are structural.
36
+ *
37
+ * @module zeus-delegate-with-retry
38
+ */
39
+
40
+ import type { BackgroundJobBoard } from './background-job-board.ts'
41
+ import type { DelegationClient } from './delegation-finalize.ts'
42
+ import { readDelegationReport } from './delegation-finalize.ts'
43
+ import { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
44
+ import {
45
+ createDispatchGuard,
46
+ type DispatchClassification,
47
+ type DispatchGuard,
48
+ type DispatchGuardOptions,
49
+ type DispatchResultLike,
50
+ } from './dispatch-guard.ts'
51
+ import { createPantheonLogger } from './logger.ts'
52
+ import { safeSessionPath } from './session-guard.ts'
53
+
54
+ const log = createPantheonLogger({ module: 'pantheon-zeus-retry' })
55
+
56
+ // ─── Types ───────────────────────────────────────────────────────────────
57
+
58
+ export interface ZeusDelegateWithRetryOptions {
59
+ /** Board singleton (same instance the plugin uses). */
60
+ board: BackgroundJobBoard
61
+ /** Delegation client (adapted SDK client, e.g. adaptDelegationClient). */
62
+ client: DelegationClient
63
+ /** Parent session that delegates (must be a valid ses_ id). */
64
+ sessionID: string
65
+ /** Target agent (e.g. "apollo", "hermes"). */
66
+ agent: string
67
+ /** Task prompt delivered to the background agent. */
68
+ prompt: string
69
+ /** Human-readable description shown on the job board. */
70
+ description?: string
71
+ /** Explicit model for the child session (provider/model). */
72
+ model?: { id: string; providerID: string }
73
+ /** Timeout for board.waitForTerminal (ms). Default: DELEGATION_DEFAULTS.readTimeoutMs */
74
+ readTimeoutMs?: number
75
+ /** Retry on empty results. Default: true */
76
+ retryOnEmpty?: boolean
77
+ /** Injectable logger. Default: file log (PANTHEON_HOOKS_LOG gate). */
78
+ logger?: { warn: (message: string) => void; info?: (message: string) => void }
79
+ /** Output dir for delegation reports. Default: DELEGATION_DEFAULTS.outputDir */
80
+ outputDir?: string
81
+ }
82
+
83
+ export interface ZeusDelegateResult {
84
+ /** Job alias (e.g. "apo-1"). */
85
+ alias: string
86
+ /** Child session id (= board task id). */
87
+ taskID: string
88
+ /** Report markdown (with Agent Activity section when sampled). */
89
+ report: string
90
+ /** Classification of the final result. */
91
+ classification: DispatchClassification
92
+ /** True when a retry was fired (cap 1). */
93
+ retried: boolean
94
+ }
95
+
96
+ /**
97
+ * Thrown when a delegate returns empty even after one retry — the wave must
98
+ * escalate (try different agent, simplify scope, or manual intervention).
99
+ */
100
+ export class ZeusEscalationError extends Error {
101
+ constructor(
102
+ public readonly agent: string,
103
+ public readonly prompt: string,
104
+ public readonly classification: DispatchClassification,
105
+ ) {
106
+ super(
107
+ `Zeus delegate escalate: agent "${agent}" returned ${classification} even after retry (prompt: "${prompt.slice(0, 80)}") — escalate to user: (a) try different agent, (b) simplify scope, (c) manual`,
108
+ )
109
+ this.name = 'ZeusEscalationError'
110
+ }
111
+ }
112
+
113
+ // ─── Low-level helper — wraps any async delegate+read pair ───────────────
114
+
115
+ /**
116
+ * Low-level retry helper. Zeus waves that already have a `delegate+read`
117
+ * pair can use this directly without the board/client machinery:
118
+ *
119
+ * ```ts
120
+ * const guard = createZeusRetryHelper({ logger })
121
+ * const out = await guard.executeWithRetry(
122
+ * () => firstRead(),
123
+ * () => secondRead(),
124
+ * )
125
+ * if (out.escalate) throw new ZeusEscalationError(...)
126
+ * ```
127
+ */
128
+ export interface ZeusRetryHelper {
129
+ guard: DispatchGuard
130
+ /**
131
+ * Classify `first` and, if empty and budget allows, run `redispatch` once.
132
+ * Returns the final result plus escalation flag (true when still empty after
133
+ * the one allowed retry).
134
+ */
135
+ executeWithRetry(
136
+ first: DispatchResultLike,
137
+ redispatch: () => Promise<DispatchResultLike>,
138
+ ): Promise<{
139
+ result: DispatchResultLike
140
+ classification: DispatchClassification
141
+ retried: boolean
142
+ escalate: boolean
143
+ }>
144
+ }
145
+
146
+ export function createZeusRetryHelper(
147
+ options?: DispatchGuardOptions,
148
+ ): ZeusRetryHelper {
149
+ const guard = createDispatchGuard(options)
150
+ return {
151
+ guard,
152
+ async executeWithRetry(first, redispatch) {
153
+ const out = await guard.maybeRetry(first, redispatch)
154
+ const escalate = out.classification !== 'content'
155
+ return {
156
+ result: out.result,
157
+ classification: out.classification,
158
+ retried: out.retried,
159
+ escalate,
160
+ }
161
+ },
162
+ }
163
+ }
164
+
165
+ // ─── High-level helper — full delegate → wait → read → retry → escalate ───
166
+
167
+ /**
168
+ * High-level helper Zeus waves MUST use (see `src/agents/zeus.md`).
169
+ * Delegates a background agent as a child session, waits for terminal,
170
+ * classifies via `createDispatchGuard`, retries ONCE on empty-mode1/mode2,
171
+ * and escalates (throws `ZeusEscalationError`) if still empty.
172
+ *
173
+ * Not wired automatically in the plugin — opencode 1.18.x cannot intercept
174
+ * task completion via hooks, so wiring is manual-orchestration only.
175
+ */
176
+ export async function zeusDelegateWithRetry(
177
+ opts: ZeusDelegateWithRetryOptions,
178
+ ): Promise<ZeusDelegateResult> {
179
+ const logger = opts.logger ?? log
180
+ const warn = logger.warn.bind(logger)
181
+ const board = opts.board
182
+ const client = opts.client
183
+ const sessionID = opts.sessionID
184
+
185
+ const sessionPath = safeSessionPath(sessionID)
186
+ if (!sessionPath) {
187
+ warn(`zeusDelegateWithRetry: invalid sessionID "${sessionID}" — aborting`)
188
+ throw new Error(`invalid sessionID: ${sessionID}`)
189
+ }
190
+
191
+ const readTimeoutMs = opts.readTimeoutMs ?? DELEGATION_DEFAULTS.readTimeoutMs
192
+ const outputDir = opts.outputDir ?? DELEGATION_DEFAULTS.outputDir
193
+ const retryHelper = createZeusRetryHelper({
194
+ retryOnEmpty: opts.retryOnEmpty ?? true,
195
+ logger,
196
+ })
197
+
198
+ // One delegate+read attempt. Factored so maybeRetry can invoke a second
199
+ // attempt with a fresh child session + board job.
200
+ async function runOnce(): Promise<{
201
+ alias: string
202
+ taskID: string
203
+ report: string
204
+ dispatchResult: DispatchResultLike
205
+ }> {
206
+ // 1. Create child session (parentID = caller). No model forwarding here —
207
+ // delegation.ts resolves models via routing.yml; this helper uses the
208
+ // bare SDK default unless opts.model is supplied (explicit caller intent).
209
+ let childID: string
210
+ try {
211
+ const created = await client.session.create({
212
+ body: {
213
+ parentID: sessionID,
214
+ title: opts.description ?? opts.prompt.slice(0, 80),
215
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
216
+ },
217
+ })
218
+ childID = created.id
219
+ } catch (err: unknown) {
220
+ const reason = err instanceof Error ? err.message : String(err)
221
+ warn(`zeusDelegateWithRetry: session.create failed: ${reason}`)
222
+ throw new Error(`delegate session.create failed: ${reason}`)
223
+ }
224
+
225
+ const childPath = safeSessionPath(childID)
226
+ if (!childPath) {
227
+ warn(`zeusDelegateWithRetry: child sessionID invalid "${childID}"`)
228
+ throw new Error(`invalid child sessionID: ${childID}`)
229
+ }
230
+
231
+ // 2. Register on board (taskID == child session id)
232
+ const job = await board.registerLaunch({
233
+ taskID: childID,
234
+ parentSessionID: sessionID,
235
+ agent: opts.agent,
236
+ description: opts.description ?? opts.prompt,
237
+ objective: opts.prompt,
238
+ })
239
+
240
+ // 3. Fire-and-forget promptAsync on the child. Completion is observed via
241
+ // board.waitForTerminal, not via noReply — the spike refuted noReply.
242
+ // A promptAsync failure is logged but does not abort the wait — the board
243
+ // timeout will finalize the job.
244
+ void client.session
245
+ .promptAsync({
246
+ path: childPath.path,
247
+ body: { agent: opts.agent, parts: [{ type: 'text', text: opts.prompt }] },
248
+ })
249
+ .catch((err: unknown) => {
250
+ const reason = err instanceof Error ? err.message : String(err)
251
+ warn(`zeusDelegateWithRetry: promptAsync failed for ${childID}: ${reason}`)
252
+ })
253
+
254
+ // 4. Wait for terminal state
255
+ let terminal: Awaited<ReturnType<BackgroundJobBoard['waitForTerminal']>>
256
+ try {
257
+ terminal = await board.waitForTerminal(childID, readTimeoutMs)
258
+ } catch {
259
+ // Timed out waiting — treat as empty so the guard retries once
260
+ return {
261
+ alias: job.alias,
262
+ taskID: childID,
263
+ report: '',
264
+ dispatchResult: { content: '' },
265
+ }
266
+ }
267
+
268
+ // 5. Read report markdown
269
+ const report = (await readDelegationReport(outputDir, terminal)) ?? ''
270
+
271
+ // DispatchResultLike for classification: content = report markdown.
272
+ // Tokens are unavailable from the board/report path → mode1 when empty.
273
+ const dispatchResult: DispatchResultLike = { content: report }
274
+
275
+ return { alias: job.alias, taskID: childID, report, dispatchResult }
276
+ }
277
+
278
+ // First attempt
279
+ const first = await runOnce()
280
+
281
+ // Classify + maybe retry once — capture second run's metadata via closure
282
+ let secondRun: typeof first | undefined
283
+ const out = await retryHelper.executeWithRetry(first.dispatchResult, async () => {
284
+ const second = await runOnce()
285
+ secondRun = second
286
+ return second.dispatchResult
287
+ })
288
+
289
+ // When retried, return second run's report or escalate
290
+ if (out.retried && secondRun !== undefined) {
291
+ if (out.classification === 'content') {
292
+ return {
293
+ alias: secondRun.alias,
294
+ taskID: secondRun.taskID,
295
+ report: secondRun.report,
296
+ classification: out.classification,
297
+ retried: true,
298
+ }
299
+ }
300
+ // Still empty after retry → escalate
301
+ throw new ZeusEscalationError(opts.agent, opts.prompt, out.classification)
302
+ }
303
+
304
+ // No retry, or first was content
305
+ if (out.classification !== 'content') {
306
+ // Empty and retry budget exhausted (or retryOnEmpty:false) → escalate
307
+ // But if we already retried and failed we threw above; this path is for
308
+ // the case where maybeRetry did NOT retry (budget exhausted or disabled)
309
+ // and the first result was empty. Per spec, one retry is allowed — if
310
+ // maybeRetry didn't retry, we should NOT auto-escalate here unless the
311
+ // guard's budget was used. Instead, treat as final empty that still
312
+ // requires escalation when retried==false but classification empty and
313
+ // retryOnEmpty was true but guard had no budget left? The guard already
314
+ // enforces cap 1, so after one retry escalate is required. For the
315
+ // first-attempt-empty with no retry (retryOnEmpty false), we still escalate
316
+ // so the wave knows to handle it.
317
+ if (out.retried) {
318
+ throw new ZeusEscalationError(opts.agent, opts.prompt, out.classification)
319
+ }
320
+ // No retry happened — still empty. Escalate so the wave doesn't silently
321
+ // use an empty report.
322
+ throw new ZeusEscalationError(opts.agent, opts.prompt, out.classification)
323
+ }
324
+
325
+ return {
326
+ alias: first.alias,
327
+ taskID: first.taskID,
328
+ report: first.report,
329
+ classification: out.classification,
330
+ retried: out.retried,
331
+ }
332
+ }
package/src/plugin.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  createEnforcementGuard,
16
16
  readOnlyRegistry,
17
17
  SessionHierarchyRegistry,
18
+ zeusReadGuard,
18
19
  } from './pantheon/delegation-enforce.ts'
19
20
  import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
20
21
  import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
@@ -23,6 +24,7 @@ import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
23
24
  import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
24
25
  import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
25
26
  import { createPantheonLogger } from './pantheon/logger.ts'
27
+ import { safeSessionPath } from './pantheon/session-guard.ts'
26
28
  import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
27
29
  import { applyActivePresetToConfig, loadRoutingAgentModels } from './pantheon/presets.mjs'
28
30
  import {
@@ -193,15 +195,25 @@ function adaptDelegationClient(client: PluginInput['client']): DelegationClient
193
195
  return { id: result.data.id }
194
196
  },
195
197
  promptAsync: async (input) => {
198
+ const path = safeSessionPath(input.path.id)
199
+ if (!path) {
200
+ log.warn(`[Pantheon Plugin] promptAsync skipped: invalid sessionID "${input.path.id}"`)
201
+ throw new Error(`invalid sessionID: ${input.path.id}`)
202
+ }
196
203
  const result = await client.session.promptAsync({
197
- path: input.path,
204
+ path: path.path,
198
205
  body: { agent: input.body.agent, parts: input.body.parts },
199
206
  })
200
207
  if (result.error) throw new Error(sdkErrorMessage(result.error))
201
208
  return result.data
202
209
  },
203
210
  messages: async (input) => {
204
- const result = await client.session.messages({ path: input.path })
211
+ const path = safeSessionPath(input.path.id)
212
+ if (!path) {
213
+ log.warn(`[Pantheon Plugin] messages skipped: invalid sessionID "${input.path.id}"`)
214
+ return []
215
+ }
216
+ const result = await client.session.messages({ path: path.path })
205
217
  if (result.error) throw new Error(sdkErrorMessage(result.error))
206
218
  return result.data
207
219
  },
@@ -219,22 +231,42 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
219
231
  return {
220
232
  session: {
221
233
  todo: async (input) => {
222
- const result = await client.session.todo({ path: input.path })
234
+ const path = safeSessionPath(input.path.id)
235
+ if (!path) {
236
+ log.warn(`[Pantheon Plugin] todo skipped: invalid sessionID "${input.path.id}"`)
237
+ return []
238
+ }
239
+ const result = await client.session.todo({ path: path.path })
223
240
  if (result.error) throw new Error(sdkErrorMessage(result.error))
224
241
  return result.data
225
242
  },
226
243
  messages: async (input) => {
227
- const result = await client.session.messages({ path: input.path })
244
+ const path = safeSessionPath(input.path.id)
245
+ if (!path) {
246
+ log.warn(`[Pantheon Plugin] messages skipped: invalid sessionID "${input.path.id}"`)
247
+ return []
248
+ }
249
+ const result = await client.session.messages({ path: path.path })
228
250
  if (result.error) throw new Error(sdkErrorMessage(result.error))
229
251
  return result.data
230
252
  },
231
253
  children: async (input) => {
232
- const result = await client.session.children({ path: input.path })
254
+ const path = safeSessionPath(input.path.id)
255
+ if (!path) {
256
+ log.warn(`[Pantheon Plugin] children skipped: invalid sessionID "${input.path.id}"`)
257
+ return []
258
+ }
259
+ const result = await client.session.children({ path: path.path })
233
260
  if (result.error) throw new Error(sdkErrorMessage(result.error))
234
261
  return result.data
235
262
  },
236
263
  promptAsync: async (input) => {
237
- const result = await client.session.promptAsync({ path: input.path, body: input.body })
264
+ const path = safeSessionPath(input.path.id)
265
+ if (!path) {
266
+ log.warn(`[Pantheon Plugin] promptAsync skipped: invalid sessionID "${input.path.id}"`)
267
+ throw new Error(`invalid sessionID: ${input.path.id}`)
268
+ }
269
+ const result = await client.session.promptAsync({ path: path.path, body: input.body })
238
270
  if (result.error) throw new Error(sdkErrorMessage(result.error))
239
271
  return result.data
240
272
  },
@@ -484,11 +516,13 @@ const plugin: Plugin = async (input: PluginInput) => {
484
516
  // Phase 4: deny mutating tools in read-only delegated sessions. Additive
485
517
  // key — does not touch Phase 3's tools/event/onTerminal/chat.message.
486
518
  'tool.execute.before': async (input, output) => {
487
- // Chain: the read-only enforcement guard runs first (denies mutating
488
- // tools in read-only sessions), then release-134 Phase 3 rewrites the
489
- // first post-compaction `todowrite` with the captured todo snapshot.
490
- // Both are no-ops for non-matching sessions/tools; the preserver is
491
- // fail-open (only its intentional restore denial throws).
519
+ // Chain: Zeus read guard runs first (denies read/glob/grep of source
520
+ // code when the active agent is Zeus — delegate to @apollo), then the
521
+ // read-only enforcement guard (denies mutating tools in read-only
522
+ // sessions), then release-134 Phase 3 rewrites the first
523
+ // post-compaction `todowrite` with the captured todo snapshot. All are
524
+ // no-ops for non-matching sessions/tools.
525
+ zeusReadGuard(input.tool, output?.args, sessionAgents.get(input.sessionID))
492
526
  await enforcementGuard(input, output)
493
527
  await todoPreserver.beforeTodoWrite(input, output)
494
528
  },
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAm5BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBA8GmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAWhD,sBACd,cAAc,mBACd,uBACC;;;iBAQa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAO9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAS5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KA2DS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;iBAac,wBACd,MAAM,oBACN,eACC;;;iBA+Ea,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBA+Fc,sBACd,KAAK,YAAY,sBACjB;;;;;;iBAiBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA2Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;iBASnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBAwCa,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KA8KU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAicI,QAAQ;EAAoB"}
1
+ {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAq6BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBA8GmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAWhD,sBACd,cAAc,mBACd,uBACC;;;iBAQa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAO9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAS5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KA4DS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;iBAac,wBACd,MAAM,oBACN,eACC;;;iBA+Ea,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBA+Fc,sBACd,KAAK,YAAY,sBACjB;;;;;;iBAiBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA2Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;iBASnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBAwCa,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KA8KU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAmcI,QAAQ;EAAoB"}
@@ -49,6 +49,20 @@ import { For, Show, createEffect, createMemo, createResource, createSignal, onCl
49
49
  * (the old ContextBar was removed) and we deliberately do NOT hack the
50
50
  * native footer. Track upstream: opencode statusline config.
51
51
  */
52
+ const __tuiPluginOnceKey = "__pantheonPluginsLoaded";
53
+ function pantheonPluginOnce(key) {
54
+ try {
55
+ const g = globalThis;
56
+ if (g[__tuiPluginOnceKey] === void 0) g[__tuiPluginOnceKey] = /* @__PURE__ */ new Set();
57
+ const set = g[__tuiPluginOnceKey];
58
+ if (!(set instanceof Set)) return false;
59
+ if (set.has(key)) return true;
60
+ set.add(key);
61
+ return false;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
52
66
  /** How often the sidebar re-reads .pantheon/active-preset.json so `set-tier`
53
67
  * changes made while opencode is open show up within ~30s. */
54
68
  const PRESET_REFRESH_MS = 3e4;
@@ -933,6 +947,7 @@ function mergeChildDelegationSources(children, live) {
933
947
  bySessionAlias.set(key(entry.sessionID, entry.alias), index);
934
948
  });
935
949
  for (const liveEntry of live) {
950
+ if (liveEntry.alias === null && liveEntry.taskID === null && Date.now() - liveEntry.startedAt > 3e4) continue;
936
951
  const incoming = toDelegationEntry(liveEntry);
937
952
  const index = (incoming.taskID !== void 0 ? byTask.get(incoming.taskID) : void 0) ?? (incoming.alias !== "" ? bySessionAlias.get(key(incoming.sessionID, incoming.alias)) : void 0);
938
953
  if (index === void 0) {
@@ -1530,6 +1545,7 @@ function View(props) {
1530
1545
  ...c,
1531
1546
  status: resolveStatus(c.id)
1532
1547
  })), md, now());
1548
+ for (const [k, v] of props.liveStore.map) if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 3e4) props.liveStore.map.delete(k);
1533
1549
  const liveEntries = [...props.liveStore.map.values()].filter((entry) => entry.sessionID === sessionID);
1534
1550
  setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries));
1535
1551
  panelLog.info(`panel: children=${children.length} md=${md.length} events=${eventRefreshCount}`);
@@ -1768,6 +1784,7 @@ function View(props) {
1768
1784
  })();
1769
1785
  }
1770
1786
  const tui = (api, _options, _meta) => {
1787
+ if (pantheonPluginOnce("pantheon:tui")) return;
1771
1788
  const [version, setVersion] = createSignal(null);
1772
1789
  detectVersion(api).then((detected) => setVersion(detected)).catch(() => setVersion(null));
1773
1790
  setupUsageBar(api);
@@ -63,6 +63,24 @@ import {
63
63
  Show,
64
64
  } from 'solid-js'
65
65
 
66
+ // ─── Double-load guard (mirrors src/pantheon/plugin-once.ts) ───────────────
67
+ // Opencode merges global + project plugin paths, so this module can load twice
68
+ // in one process. The second factory invocation becomes a no-op.
69
+ const __tuiPluginOnceKey = '__pantheonPluginsLoaded'
70
+ function pantheonPluginOnce(key: string): boolean {
71
+ try {
72
+ const g = globalThis as unknown as Record<string, unknown>
73
+ if (g[__tuiPluginOnceKey] === undefined) g[__tuiPluginOnceKey] = new Set<string>()
74
+ const set = g[__tuiPluginOnceKey]
75
+ if (!(set instanceof Set)) return false
76
+ if ((set as Set<string>).has(key)) return true
77
+ ;(set as Set<string>).add(key)
78
+ return false
79
+ } catch {
80
+ return false
81
+ }
82
+ }
83
+
66
84
  /* ─── Constants ─────────────────────────────────────────── */
67
85
 
68
86
  /** How often the sidebar re-reads .pantheon/active-preset.json so `set-tier`
@@ -1256,6 +1274,7 @@ export function mergeChildDelegationSources(
1256
1274
  })
1257
1275
 
1258
1276
  for (const liveEntry of live) {
1277
+ if (liveEntry.alias === null && liveEntry.taskID === null && Date.now() - liveEntry.startedAt > 30_000) continue
1259
1278
  const incoming = toDelegationEntry(liveEntry)
1260
1279
  const index =
1261
1280
  (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
@@ -2201,6 +2220,7 @@ function View(props: {
2201
2220
  }
2202
2221
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2203
2222
  const childEntries = childrenToDelegationEntries(withStatus, md, now())
2223
+ for (const [k, v] of props.liveStore.map) if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000) props.liveStore.map.delete(k)
2204
2224
  // The event channel is optimistic: it renders a job even while the
2205
2225
  // child session/report is still being created. Filter by parent so a
2206
2226
  // different focused session never leaks rows into this sidebar.
@@ -2397,6 +2417,7 @@ function View(props: {
2397
2417
  /* ─── Plugin Registration ────────────────────────────────── */
2398
2418
 
2399
2419
  const tui: TuiPlugin = (api, _options, _meta) => {
2420
+ if (pantheonPluginOnce('pantheon:tui')) return
2400
2421
  // Do not await project file/process probes here. OpenCode waits for the TUI
2401
2422
  // factory before it finishes startup, and a Windows host opening a WSL
2402
2423
  // project can leave those probes pending while the project bridge comes up.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.3.5-beta.57.2e03676",
4
+ "version": "1.3.5",
5
5
  "description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -63,6 +63,24 @@ import {
63
63
  Show,
64
64
  } from 'solid-js'
65
65
 
66
+ // ─── Double-load guard (mirrors src/pantheon/plugin-once.ts) ───────────────
67
+ // Opencode merges global + project plugin paths, so this module can load twice
68
+ // in one process. The second factory invocation becomes a no-op.
69
+ const __tuiPluginOnceKey = '__pantheonPluginsLoaded'
70
+ function pantheonPluginOnce(key: string): boolean {
71
+ try {
72
+ const g = globalThis as unknown as Record<string, unknown>
73
+ if (g[__tuiPluginOnceKey] === undefined) g[__tuiPluginOnceKey] = new Set<string>()
74
+ const set = g[__tuiPluginOnceKey]
75
+ if (!(set instanceof Set)) return false
76
+ if ((set as Set<string>).has(key)) return true
77
+ ;(set as Set<string>).add(key)
78
+ return false
79
+ } catch {
80
+ return false
81
+ }
82
+ }
83
+
66
84
  /* ─── Constants ─────────────────────────────────────────── */
67
85
 
68
86
  /** How often the sidebar re-reads .pantheon/active-preset.json so `set-tier`
@@ -1256,6 +1274,7 @@ export function mergeChildDelegationSources(
1256
1274
  })
1257
1275
 
1258
1276
  for (const liveEntry of live) {
1277
+ if (liveEntry.alias === null && liveEntry.taskID === null && Date.now() - liveEntry.startedAt > 30_000) continue
1259
1278
  const incoming = toDelegationEntry(liveEntry)
1260
1279
  const index =
1261
1280
  (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
@@ -2201,6 +2220,7 @@ function View(props: {
2201
2220
  }
2202
2221
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2203
2222
  const childEntries = childrenToDelegationEntries(withStatus, md, now())
2223
+ for (const [k, v] of props.liveStore.map) if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000) props.liveStore.map.delete(k)
2204
2224
  // The event channel is optimistic: it renders a job even while the
2205
2225
  // child session/report is still being created. Filter by parent so a
2206
2226
  // different focused session never leaks rows into this sidebar.
@@ -2397,6 +2417,7 @@ function View(props: {
2397
2417
  /* ─── Plugin Registration ────────────────────────────────── */
2398
2418
 
2399
2419
  const tui: TuiPlugin = (api, _options, _meta) => {
2420
+ if (pantheonPluginOnce('pantheon:tui')) return
2400
2421
  // Do not await project file/process probes here. OpenCode waits for the TUI
2401
2422
  // factory before it finishes startup, and a Windows host opening a WSL
2402
2423
  // project can leave those probes pending while the project bridge comes up.
package/src/routing.yml CHANGED
@@ -457,6 +457,11 @@ intent_gate:
457
457
  - search
458
458
  - lookup
459
459
  - documentation
460
+ - procure
461
+ - busque
462
+ - encontre
463
+ - localize
464
+ - pesquise
460
465
  target: apollo
461
466
  investigate:
462
467
  patterns:
@@ -1,26 +0,0 @@
1
- # Agent Reference — Pantheon
2
-
3
- This directory contains the definitions for all 14 Pantheon agents. Each agent is defined in a `.agent.md` file with YAML frontmatter specifying tools, model tier, capabilities, and delegation rules.
4
-
5
- ## Agents
6
-
7
- | Agent | File | Role |
8
- |-------|------|------|
9
- | @zeus | [zeus.agent.md](zeus.agent.md) | Central orchestrator |
10
- | @athena | [athena.agent.md](athena.agent.md) | Strategic planner |
11
- | @apollo | [apollo.agent.md](apollo.agent.md) | Investigation scout |
12
- | @hermes | [hermes.agent.md](hermes.agent.md) | Backend specialist |
13
- | @aphrodite | [aphrodite.agent.md](aphrodite.agent.md) | Frontend specialist |
14
- | @demeter | [demeter.agent.md](demeter.agent.md) | Database specialist |
15
- | @themis | [themis.agent.md](themis.agent.md) | Quality & security gate |
16
- | @prometheus | [prometheus.agent.md](prometheus.agent.md) | Infrastructure specialist |
17
- | @hephaestus | [hephaestus.agent.md](hephaestus.agent.md) | AI pipelines |
18
- | @nyx | [nyx.agent.md](nyx.agent.md) | Observability |
19
- | @gaia | [gaia.agent.md](gaia.agent.md) | Remote sensing |
20
- | @iris | [iris.agent.md](iris.agent.md) | GitHub operations |
21
- | @mnemosyne | [mnemosyne.agent.md](mnemosyne.agent.md) | Memory bank |
22
- | @talos | [talos.agent.md](talos.agent.md) | Hotfix agent |
23
-
24
- ## Full Reference
25
-
26
- See [AGENTS.md](../../AGENTS.md) for the complete agent reference with roles, capabilities, and invocation patterns.