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 +5 -5
- package/package.json +2 -1
- package/scripts/doctor.mjs +32 -2
- package/scripts/install/agents-md.mjs +1 -1
- package/scripts/install/opencode.mjs +6 -2
- package/scripts/install/shared.mjs +3 -1
- package/src/agents/apollo.md +3 -0
- package/src/agents/gaia.md +3 -0
- package/src/agents/zeus.md +5 -1
- package/src/pantheon/delegation-enforce.ts +64 -0
- package/src/pantheon/delegation.ts +11 -8
- package/src/pantheon/goal-loop.ts +13 -2
- package/src/pantheon/presets.mjs +1 -0
- package/src/pantheon/session-guard.ts +50 -0
- package/src/pantheon/todo-enforcer.ts +31 -5
- package/src/pantheon/todo-preserve.ts +7 -1
- package/src/pantheon/vision.ts +25 -4
- package/src/pantheon/zeus-delegate-with-retry.ts +332 -0
- package/src/plugin.ts +45 -11
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +17 -0
- package/src/plugins/tui/dist/tui.tsx +21 -0
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +21 -0
- package/src/routing.yml +5 -0
- package/src/agents/README.md +0 -26
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="
|
|
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** | [
|
|
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
|
|
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 [
|
|
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
|
-
| [
|
|
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
|
|
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",
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
|
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()
|
|
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(
|
|
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(
|
|
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)
|
package/src/agents/apollo.md
CHANGED
|
@@ -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
|
package/src/agents/gaia.md
CHANGED
|
@@ -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
|
package/src/agents/zeus.md
CHANGED
|
@@ -37,7 +37,7 @@ skills:
|
|
|
37
37
|
|
|
38
38
|
## Golden Rule
|
|
39
39
|
|
|
40
|
-
**Coordenador APENAS.**
|
|
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
|
|
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
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
package/src/pantheon/presets.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
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(
|
|
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)
|
package/src/pantheon/vision.ts
CHANGED
|
@@ -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(
|
|
126
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
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":";;
|
|
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
|
|
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
package/src/agents/README.md
DELETED
|
@@ -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.
|