pantheon-opencode 1.5.0-beta.4 → 1.5.0-beta.6

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.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * strings.mjs — installer strings with locale auto-detection.
3
+ *
4
+ * LANG/LC_ALL/LC_MESSAGES starting with "pt" selects the PT-BR table;
5
+ * everything else (and the absence of a locale) selects EN. A plain string
6
+ * table — deliberately no i18n framework (YAGNI).
7
+ *
8
+ * Scope: the user journey (banner → install steps → health → summary →
9
+ * next steps). Deep diagnostics (per-file copy detail, doctor output) and
10
+ * the model-picker wizard remain single-language by design.
11
+ */
12
+
13
+ const TABLE = {
14
+ en: {
15
+ installing: 'Installing...',
16
+ dryRun: 'DRY RUN',
17
+ canceled: 'Installation canceled — nothing was broken; run init again anytime.',
18
+ canceledShort: 'Installation canceled.',
19
+ installFailed: (msg) => `❌ Installation failed: ${msg}`,
20
+ failHintNoMcp:
21
+ ' Run with --no-mcp to skip Python dependencies:\n npx pantheon-opencode init --no-mcp',
22
+ failHintForce:
23
+ ' Or retry with --force to recreate the venv:\n npx pantheon-opencode init --force',
24
+ runningHealthCheck: 'Running health check...',
25
+ installedTitle: (v) => `✅ Pantheon OpenCode v${v} installed!`,
26
+ nextSteps: 'Next steps:',
27
+ nextVerify: '1. Verify installation:\n npx pantheon-opencode doctor',
28
+ nextLaunch: '2. Launch OpenCode',
29
+ nextAgents: '3. Invoke agents with @agent-name in chat',
30
+ nextProject: '4. For project-local install:\n npx pantheon-opencode init --project',
31
+ // install steps
32
+ globalLayout:
33
+ 'Global config directory detected — using flat layout (agents/, skills/, commands/)',
34
+ installingAgents: 'Installing agents',
35
+ installingSkills: 'Installing skills',
36
+ installingInstructions: 'Installing instructions',
37
+ installingPrompts: 'Installing prompts',
38
+ installingCommands: 'Installing commands',
39
+ installingPlugins: 'Installing plugins',
40
+ settingUpRuntime: 'Setting up Python virtual environment',
41
+ healthSection: 'Health Check',
42
+ runningHealthChecks: 'Running health checks',
43
+ runtimeFailed: (msg) => `Python runtime setup failed: ${msg}`,
44
+ runtimeSkippedMcp:
45
+ 'MCP servers were NOT configured because the Python runtime is unavailable. ' +
46
+ 'Fix python3 (or free disk space) and re-run init to add them; everything else was installed.',
47
+ prereqMissing:
48
+ 'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
49
+ v2Migrating: 'Migrating config to V2 native format...',
50
+ // summary (printSummary)
51
+ summaryInstalled: (target) => `OpenCode installed in ${target}`,
52
+ summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
53
+ summaryComponents: (created, skipped) =>
54
+ ` Components:\t${created} installed, ${skipped} skipped`,
55
+ summaryErrors: (n) => ` ${n} error(s) found`,
56
+ summaryWarnings: (n) => ` ${n} warning(s) — run 'doctor' for details`,
57
+ summaryNext: ' Next steps:',
58
+ summaryNextAgents: ' • Configure your agents in opencode.json',
59
+ summaryNextMcp: ' • Add MCP servers in opencode.json (mcp section)',
60
+ summaryNextDoctor: " • Run 'opencode doctor' to verify the installation",
61
+ },
62
+ pt: {
63
+ installing: 'Instalando...',
64
+ dryRun: 'SIMULAÇÃO',
65
+ canceled: 'Instalação cancelada — nada foi quebrado; rode init de novo quando quiser.',
66
+ canceledShort: 'Instalação cancelada.',
67
+ installFailed: (msg) => `❌ Instalação falhou: ${msg}`,
68
+ failHintNoMcp:
69
+ ' Rode com --no-mcp para pular dependências Python:\n npx pantheon-opencode init --no-mcp',
70
+ failHintForce:
71
+ ' Ou tente de novo com --force para recriar a venv:\n npx pantheon-opencode init --force',
72
+ runningHealthCheck: 'Rodando verificação de saúde...',
73
+ installedTitle: (v) => `✅ Pantheon OpenCode v${v} instalado!`,
74
+ nextSteps: 'Próximos passos:',
75
+ nextVerify: '1. Verifique a instalação:\n npx pantheon-opencode doctor',
76
+ nextLaunch: '2. Abra o OpenCode',
77
+ nextAgents: '3. Invoc agentes com @nome-do-agente no chat',
78
+ nextProject: '4. Para instalar no projeto:\n npx pantheon-opencode init --project',
79
+ // install steps
80
+ globalLayout:
81
+ 'Diretório de config global detectado — layout plano (agents/, skills/, commands/)',
82
+ installingAgents: 'Instalando agentes',
83
+ installingSkills: 'Instalando skills',
84
+ installingInstructions: 'Instalando instruções',
85
+ installingPrompts: 'Instalando prompts',
86
+ installingCommands: 'Instalando comandos',
87
+ installingPlugins: 'Instalando plugins',
88
+ settingUpRuntime: 'Configurando ambiente virtual Python',
89
+ healthSection: 'Verificação de saúde',
90
+ runningHealthChecks: 'Rodando verificações de saúde',
91
+ runtimeFailed: (msg) => `Falha ao configurar o runtime Python: ${msg}`,
92
+ runtimeSkippedMcp:
93
+ 'Os servidores MCP NÃO foram configurados porque o runtime Python está indisponível. ' +
94
+ 'Corrija o python3 (ou libere espaço em disco) e rode init de novo para adicioná-los; todo o resto foi instalado.',
95
+ prereqMissing:
96
+ 'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
97
+ v2Migrating: 'Migrando config para o formato nativo V2...',
98
+ // summary (printSummary)
99
+ summaryInstalled: (target) => `OpenCode instalado em ${target}`,
100
+ summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
101
+ summaryComponents: (created, skipped) =>
102
+ ` Componentes:\t${created} instalados, ${skipped} pulados`,
103
+ summaryErrors: (n) => ` ${n} erro(s) encontrados`,
104
+ summaryWarnings: (n) => ` ${n} aviso(s) — rode 'doctor' para detalhes`,
105
+ summaryNext: ' Próximos passos:',
106
+ summaryNextAgents: ' • Configure seus agentes em opencode.json',
107
+ summaryNextMcp: ' • Adicione MCP servers em opencode.json (seção mcp)',
108
+ summaryNextDoctor: " • Rode 'opencode doctor' para verificar a instalação",
109
+ },
110
+ }
111
+
112
+ /** Pure locale resolution (exported for tests). */
113
+ export function detectLocale(env = process.env) {
114
+ const raw = env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG ?? ''
115
+ return /^pt/i.test(raw) ? 'pt' : 'en'
116
+ }
117
+
118
+ /** Pure table access (exported for tests and explicit-locale callers). */
119
+ export function stringsFor(locale) {
120
+ return TABLE[locale] ?? TABLE.en
121
+ }
122
+
123
+ let cached = null
124
+
125
+ /** Locale-resolved string table for the current process. */
126
+ export function strings(env = process.env) {
127
+ if (cached === null) cached = stringsFor(detectLocale(env))
128
+ return cached
129
+ }
@@ -221,6 +221,7 @@ export const MCPS = {
221
221
  type: 'local',
222
222
  command: VENV_PYTHON,
223
223
  args: [CODE_MODE_SERVER],
224
+ cwd: '.',
224
225
  },
225
226
  },
226
227
  env: [],
@@ -14,18 +14,13 @@
14
14
  * compatibility only; it is no longer generated.
15
15
  */
16
16
 
17
- import {
18
- SEMVER_PATTERN,
19
- validateInventory,
20
- writeInventoryVersion,
21
- } from './manifest-inventory.mjs'
17
+ import { SEMVER_PATTERN, validateInventory, writeInventoryVersion } from './manifest-inventory.mjs'
22
18
 
23
19
  const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
24
20
  // Current committed sequential beta line.
25
21
  const BETA_LINE = /^(\d+\.\d+\.\d+)-beta\.(\d+)$/
26
22
  // Legacy PR+SHA beta scheme, accepted for recovery only.
27
- const LEGACY_BETA =
28
- /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.[1-9]\d*\.[0-9a-f]{7}$/
23
+ const LEGACY_BETA = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.[1-9]\d*\.[0-9a-f]{7}$/
29
24
  const BETA_RELEASE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.[1-9]\d*$/
30
25
 
31
26
  export function nextStableVersion(latest, intent = 'patch') {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * sync-artifacts.mjs — postinstall freshness sync for copy-only artifacts.
3
+ *
4
+ * Guarantees that an `npm install -g pantheon-opencode` (or npx cache
5
+ * refresh) brings an EXISTING installation up to date for everything that is
6
+ * a pure byte-compare copy: agents + routing.yml, skills, AGENTS.md,
7
+ * commands, the runtime MCP scripts, the code-mode payload and tiers.json.
8
+ *
9
+ * Deliberately NOT done here (they belong to `pantheon-opencode init` /
10
+ * `update`, which the doctor points at when versions drift):
11
+ * - opencode.json merge (config keys, plugin registration, MCP entries)
12
+ * - venv/pip setup
13
+ * - tui.json registration changes
14
+ *
15
+ * The user's opencode.json already references the installed package root for
16
+ * plugins, so after a package update those references hit the NEW code
17
+ * automatically; this sync refreshes the COPIES that would otherwise stay
18
+ * stale until the next manual init.
19
+ */
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ import {
24
+ collectSkillNames,
25
+ copyFiles,
26
+ installSkills,
27
+ ROOT,
28
+ syncDir,
29
+ writeIfChanged,
30
+ } from './install/shared.mjs'
31
+
32
+ /** Canonical MCP script sources — mirrors installOpenCode phase 2.10. */
33
+ const MCP_SCRIPTS = {
34
+ 'mcp_resources_server.py': () => join(ROOT, 'src', 'mcp', 'mcp_resources_server.py'),
35
+ 'code_mode_server.py': () => join(ROOT, 'src', 'mcp', 'code_mode_server.py'),
36
+ 'memory_mcp_server.py': () => join(ROOT, 'src', 'mcp', 'memory_mcp_server.py'),
37
+ 'mcp_persistence_server.py': () => join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
38
+ '_pantheon_paths.py': () => join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
39
+ 'mcp_codemap_module.py': () => join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
40
+ 'pantheon_vision_server.py': () => join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
41
+ 'eval_store.py': () => join(ROOT, 'src', 'mcp', 'eval_store.py'),
42
+ 'scrub-secrets.py': () => join(ROOT, 'scripts', 'scrub-secrets.py'),
43
+ }
44
+
45
+ /**
46
+ * Sync all copy-only artifacts from the installed package into configDir
47
+ * (the GLOBAL OpenCode config dir). Byte-compare idempotent; never throws.
48
+ * @returns {{ created: number, skipped: number, errors: string[] }}
49
+ */
50
+ export function syncCopyArtifacts(configDir) {
51
+ const result = { created: 0, skipped: 0, errors: [] }
52
+ const bump = (r) => {
53
+ result.created += r.created
54
+ result.skipped += r.skipped
55
+ }
56
+
57
+ // ── agents + routing.yml ──
58
+ try {
59
+ const dstAgents = join(configDir, 'agents')
60
+ if (existsSync(join(ROOT, 'src', 'agents'))) {
61
+ mkdirSync(dstAgents, { recursive: true })
62
+ bump(copyFiles(join(ROOT, 'src', 'agents'), dstAgents, false))
63
+ const routing = join(ROOT, 'src', 'routing.yml')
64
+ if (existsSync(routing)) {
65
+ if (
66
+ writeIfChanged(join(configDir, 'routing.yml'), readFileSync(routing, 'utf8'), false) ===
67
+ 'created'
68
+ ) {
69
+ result.created++
70
+ } else result.skipped++
71
+ }
72
+ }
73
+ } catch (err) {
74
+ result.errors.push(`agents: ${err.message}`)
75
+ }
76
+
77
+ // ── skills ──
78
+ try {
79
+ const names = collectSkillNames()
80
+ if (names.length > 0) bump(installSkills(names, configDir, false, ''))
81
+ } catch (err) {
82
+ result.errors.push(`skills: ${err.message}`)
83
+ }
84
+
85
+ // ── instructions (AGENTS.md) ──
86
+ try {
87
+ const src = join(ROOT, 'AGENTS.md')
88
+ if (existsSync(src)) {
89
+ if (
90
+ writeIfChanged(join(configDir, 'AGENTS.md'), readFileSync(src, 'utf8'), false) === 'created'
91
+ ) {
92
+ result.created++
93
+ } else result.skipped++
94
+ }
95
+ } catch (err) {
96
+ result.errors.push(`instructions: ${err.message}`)
97
+ }
98
+
99
+ // ── commands (*.md) ──
100
+ try {
101
+ const srcCmds = join(ROOT, 'commands')
102
+ if (existsSync(srcCmds)) {
103
+ bump(syncDir(srcCmds, join(configDir, 'commands'), false, false, (f) => f.endsWith('.md')))
104
+ }
105
+ } catch (err) {
106
+ result.errors.push(`commands: ${err.message}`)
107
+ }
108
+
109
+ // ── runtime payload (MCP scripts, code-mode, tiers) ──
110
+ try {
111
+ const dstScripts = join(configDir, 'scripts')
112
+ mkdirSync(dstScripts, { recursive: true })
113
+ for (const [name, srcFn] of Object.entries(MCP_SCRIPTS)) {
114
+ const src = srcFn()
115
+ if (!existsSync(src)) continue
116
+ const content = readFileSync(src, 'utf8')
117
+ const dst = join(dstScripts, name)
118
+ if (writeIfChanged(dst, content, false) === 'created') {
119
+ statSync(dst)
120
+ result.created++
121
+ } else result.skipped++
122
+ }
123
+
124
+ const req = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
125
+ if (existsSync(req)) {
126
+ if (
127
+ writeIfChanged(
128
+ join(configDir, 'requirements-vision.txt'),
129
+ readFileSync(req, 'utf8'),
130
+ false,
131
+ ) === 'created'
132
+ ) {
133
+ result.created++
134
+ } else result.skipped++
135
+ }
136
+
137
+ const srcCodeMode = join(ROOT, '.pantheon', 'code-mode')
138
+ const dstCodeMode = join(configDir, '.pantheon', 'code-mode')
139
+ if (existsSync(srcCodeMode)) {
140
+ bump(syncDir(srcCodeMode, dstCodeMode, false, false))
141
+ }
142
+
143
+ const srcTiers = join(ROOT, '.pantheon', 'tiers.json')
144
+ const dstTiers = join(configDir, '.pantheon', 'tiers.json')
145
+ if (existsSync(srcTiers)) {
146
+ if (writeIfChanged(dstTiers, readFileSync(srcTiers, 'utf8'), false) === 'created')
147
+ result.created++
148
+ else result.skipped++
149
+ }
150
+ } catch (err) {
151
+ result.errors.push(`runtime: ${err.message}`)
152
+ }
153
+
154
+ return result
155
+ }
156
+
157
+ /** Drop stale entries in dst that no longer exist in src (agents/skills dirs). */
158
+ export function pruneMissing(srcDir, dstDir) {
159
+ if (!existsSync(srcDir) || !existsSync(dstDir)) return 0
160
+ const src = new Set(readdirSync(srcDir))
161
+ let removed = 0
162
+ for (const entry of readdirSync(dstDir)) {
163
+ if (!src.has(entry)) {
164
+ rmSync(join(dstDir, entry), { recursive: true, force: true })
165
+ removed++
166
+ }
167
+ }
168
+ return removed
169
+ }
@@ -138,7 +138,7 @@ export function sanitizeNpmEnv(env = process.env) {
138
138
 
139
139
  // ── Main ───────────────────────────────────────────────────────────────
140
140
 
141
- function main() {
141
+ async function main() {
142
142
  try {
143
143
  const configDir = resolveConfigDir()
144
144
  if (!configDir) {
@@ -148,8 +148,17 @@ function main() {
148
148
 
149
149
  const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
150
150
  if (!existsSync(tuiCopyDir)) {
151
- // TUI plugin not installed — silent exit
152
- process.exit(0)
151
+ // beta.5: no TUI install (plugins component omitted) — skip the TUI
152
+ // sync but STILL refresh the copy-only artifacts below.
153
+ const { syncCopyArtifacts } = await import('./sync-artifacts.mjs')
154
+ const synced = syncCopyArtifacts(configDir)
155
+ if (synced.errors.length > 0) {
156
+ for (const e of synced.errors) console.error(` ⚠️ artifact sync: ${e}`)
157
+ }
158
+ console.log(
159
+ ` Artifacts synced: ${synced.created} updated, ${synced.skipped} already current`,
160
+ )
161
+ return
153
162
  }
154
163
 
155
164
  // Source: TUI plugin inside the installed package
@@ -181,6 +190,38 @@ function main() {
181
190
  ` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
182
191
  )
183
192
  }
193
+
194
+ // ── beta.5 freshness guarantee: refresh ALL copy-only artifacts ──
195
+ // Agents, skills, AGENTS.md, commands, MCP scripts, code-mode payload.
196
+ // The plugin references in opencode.json already point INTO this package,
197
+ // so only the copies would go stale after a package update.
198
+ const { syncCopyArtifacts } = await import('./sync-artifacts.mjs')
199
+ const { readState, writeState } = await import('./install/state.mjs')
200
+ const pkgVersion = readVersion(join(ROOT, 'package.json')) ?? 'unknown'
201
+ const synced = syncCopyArtifacts(configDir)
202
+ if (synced.errors.length > 0) {
203
+ for (const e of synced.errors) console.error(` ⚠️ artifact sync: ${e}`)
204
+ }
205
+ console.log(` Artifacts synced: ${synced.created} updated, ${synced.skipped} already current`)
206
+
207
+ // Version marker so doctor can detect a stale installation and point the
208
+ // user at `pantheon-opencode update` for the full refresh (config merge,
209
+ // venv, MCP entries).
210
+ try {
211
+ const previous = readState(configDir)
212
+ writeState(configDir, {
213
+ pantheon_version: pkgVersion,
214
+ previous_version: previous?.pantheon_version ?? null,
215
+ })
216
+ if (previous?.pantheon_version && previous.pantheon_version !== pkgVersion) {
217
+ console.log(
218
+ ` ⚠️ Installation moved ${previous.pantheon_version} → ${pkgVersion}. ` +
219
+ 'Run `npx pantheon-opencode init` to refresh config/venv/MCP entries.',
220
+ )
221
+ }
222
+ } catch {
223
+ // Marker is best-effort — never fail the postinstall over it.
224
+ }
184
225
  } catch (err) {
185
226
  console.error(`❌ TUI sync failed: ${err.message}`)
186
227
  process.exitCode = 1
@@ -192,4 +233,9 @@ function main() {
192
233
  const isDirectRun =
193
234
  Boolean(process.argv[1]) && pathToFileURL(process.argv[1]).href === import.meta.url
194
235
 
195
- if (isDirectRun) main()
236
+ if (isDirectRun) {
237
+ main().catch((err) => {
238
+ console.error(`❌ TUI sync failed: ${err.message}`)
239
+ process.exitCode = 1
240
+ })
241
+ }
@@ -157,12 +157,9 @@ def _detect_prlimit() -> str | None:
157
157
 
158
158
 
159
159
  # ── Scripts Directory Resolution ─────────────────────────────────────────────
160
- # Priority:
161
- # 1. /.opencode/.pantheon/code-mode/ (project install)
162
- # 2. /.pantheon/code-mode/ (legacy fallback)
163
- # 3. /.pantheon/code-mode/ (global fallback)
164
- # 4. .pantheon/code-mode/ shipped inside the installed package (tarball
165
- # fallback — package.json `files` includes .pantheon/code-mode/**)
160
+ # Project directories are selected before global directories. Once a usable
161
+ # project directory is selected, manifest errors are deliberately fail-closed;
162
+ # execution must never silently cross the project/global trust boundary.
166
163
 
167
164
 
168
165
  def _is_within(path: Path, root: Path) -> bool:
@@ -229,25 +226,38 @@ def _packaged_scripts_dir() -> Path | None:
229
226
  return None
230
227
 
231
228
 
232
- def _resolve_scripts_dir(candidates: list[Path]) -> Path | None:
233
- """Select the first usable candidate, then try the packaged fallback."""
229
+ def _resolve_scripts_dir(candidates: list[Path], *, allow_packaged: bool = True) -> Path | None:
230
+ """Select the first usable candidate, optionally trying the package copy."""
234
231
  for candidate in candidates:
235
232
  if _has_usable_scripts(candidate):
236
233
  return candidate
237
- return _packaged_scripts_dir()
234
+ return _packaged_scripts_dir() if allow_packaged else None
235
+
236
+
237
+ def _resolve_code_mode_dir() -> Path:
238
+ """Resolve code-mode scripts project-first, with a global-only fallback.
239
+
240
+ A project directory is a trust boundary: if one is selected, callers use
241
+ its manifest even when that manifest is missing or corrupt. Global users
242
+ retain the packaged/global fallback when no usable project directory is
243
+ available.
244
+ """
245
+ project = pantheon_project()
246
+ if project is not None:
247
+ project_candidates = [
248
+ project / ".opencode" / ".pantheon" / "code-mode",
249
+ project / ".pantheon" / "code-mode",
250
+ ]
251
+ selected = _resolve_scripts_dir(project_candidates, allow_packaged=False)
252
+ if selected is not None:
253
+ return selected
254
+
255
+ global_dir = pantheon_home() / ".pantheon" / "code-mode"
256
+ return _resolve_scripts_dir([global_dir]) or global_dir
238
257
 
239
258
 
240
259
  _PANTHEON_HOME: Path = pantheon_home()
241
- _SCRIPTS_DIR_CANDIDATES: list[Path] = []
242
- _proj = pantheon_project()
243
- if _proj is not None:
244
- _SCRIPTS_DIR_CANDIDATES.append(_proj / ".opencode" / ".pantheon" / "code-mode")
245
- _SCRIPTS_DIR_CANDIDATES.append(_proj / ".pantheon" / "code-mode")
246
- _SCRIPTS_DIR_CANDIDATES.append(_PANTHEON_HOME / ".pantheon" / "code-mode")
247
-
248
- SCRIPTS_DIR: Path = _resolve_scripts_dir(_SCRIPTS_DIR_CANDIDATES) or (
249
- _PANTHEON_HOME / ".pantheon" / "code-mode"
250
- )
260
+ SCRIPTS_DIR: Path = _resolve_code_mode_dir()
251
261
 
252
262
  # ── FastMCP App ───────────────────────────────────────────────────────────────
253
263
  mcp = FastMCP(
@@ -371,6 +381,11 @@ def _load_manifest(scripts_dir: Path | None = None) -> dict[str, str]:
371
381
  "CORRUPT_DATA", "Code-mode manifest must be a JSON object."
372
382
  )
373
383
 
384
+ if data.get("version") != 1:
385
+ raise ManifestError(
386
+ "CORRUPT_DATA", "Code-mode manifest malformed: version must be 1."
387
+ )
388
+
374
389
  scripts = data.get("scripts")
375
390
  if not isinstance(scripts, dict):
376
391
  raise ManifestError(
@@ -384,6 +399,7 @@ def _load_manifest(scripts_dir: Path | None = None) -> dict[str, str]:
384
399
  not isinstance(name, str)
385
400
  or not isinstance(digest, str)
386
401
  or len(digest) != _SHA256_HEX_LEN
402
+ or any(char not in "0123456789abcdefABCDEF" for char in digest)
387
403
  ):
388
404
  raise ManifestError(
389
405
  "CORRUPT_DATA",
@@ -877,4 +893,3 @@ def _cli() -> int:
877
893
 
878
894
  if __name__ == "__main__":
879
895
  raise SystemExit(_cli())
880
-
@@ -78,11 +78,7 @@ export async function consumeWakeSignals(signalDir?: string): Promise<WakeSignal
78
78
 
79
79
  // Step 3: Only add to results after successful rename
80
80
  signals.push(data)
81
- } catch {
82
- // Rename failed (another consumer got there first) or file is corrupted.
83
- // Skip silently in either case.
84
- continue
85
- }
81
+ } catch {}
86
82
  }
87
83
 
88
84
  return signals
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Process-wide BackgroundJobBoard singleton shared by every Pantheon plugin
3
+ * surface (src/plugin.ts V1, src/plugins/pantheon-hooks.ts).
4
+ *
5
+ * WHY globalThis and not a module-level `const`: opencode loads each plugin
6
+ * TWICE from two different filesystem paths (the installed npm package AND
7
+ * the repo source — see src/pantheon/plugin-once.ts). Each load is a separate
8
+ * module instance, so a plain ESM singleton would yield TWO boards: the
9
+ * native-task mirror in pantheon-hooks would register jobs on one instance
10
+ * while the finalize path (session.idle + idle scan in plugin.ts) listens on
11
+ * the other, and the job would never finalize. A globalThis-anchored value is
12
+ * process-global by construction, deduping both module copies.
13
+ *
14
+ * The persistence file and signal dir are RELATIVE paths — the board operates
15
+ * against the opencode project root (the cwd of the plugin process), matching
16
+ * the pre-existing plugin.ts wiring exactly.
17
+ */
18
+ import { BackgroundJobBoard } from './background-job-board.ts'
19
+ import { FilePersistenceAdapter } from './file-persistence.ts'
20
+
21
+ const BOARD_KEY = '__pantheonSharedBoard'
22
+
23
+ /** Construction inputs mirror the original plugin.ts module-scope board. */
24
+ const BOARD_OPTIONS = {
25
+ maxConcurrentPerAgent: 3,
26
+ signalDir: '.pantheon/deepwork/board-signals',
27
+ } as const
28
+
29
+ export function getSharedBoard(): BackgroundJobBoard {
30
+ const host = globalThis as { [BOARD_KEY]?: BackgroundJobBoard }
31
+ const existing = host[BOARD_KEY]
32
+ if (existing instanceof BackgroundJobBoard) return existing
33
+ const board = new BackgroundJobBoard(BOARD_OPTIONS)
34
+ board.setPersistence(new FilePersistenceAdapter('.pantheon/board/state.json'))
35
+ host[BOARD_KEY] = board
36
+ return board
37
+ }
package/src/plugin.ts CHANGED
@@ -2,7 +2,6 @@ import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Plugin, PluginInput } from '@opencode-ai/plugin'
4
4
  import type { PluginConfig } from 'opencode'
5
- import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
6
5
  import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
7
6
  import {
8
7
  type ContextSandboxConfig,
@@ -30,7 +29,6 @@ import {
30
29
  } from './pantheon/delegation-enforce.ts'
31
30
  import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
32
31
  import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
33
- import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
34
32
  import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
35
33
  import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
36
34
  import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
@@ -45,6 +43,7 @@ import {
45
43
  loadRoutingPermissionTask,
46
44
  } from './pantheon/presets.mjs'
47
45
  import { safeSessionPath } from './pantheon/session-guard.ts'
46
+ import { getSharedBoard } from './pantheon/shared-board.ts'
48
47
  import { StepCapTracker } from './pantheon/step-cap.ts'
49
48
  import { createTaskResultGuard } from './pantheon/task-result-guard.ts'
50
49
  import {
@@ -77,12 +76,11 @@ const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
77
76
  // (fail-open → undefined = existing runtime matrix only).
78
77
  const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
79
78
 
80
- const board = new BackgroundJobBoard({
81
- maxConcurrentPerAgent: 3,
82
- signalDir: '.pantheon/deepwork/board-signals',
83
- })
84
- const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
85
- board.setPersistence(persistence)
79
+ // beta.5: the board is a PROCESS-wide singleton (getSharedBoard) shared with
80
+ // src/plugins/pantheon-hooks.ts, whose native-task mirror registers children
81
+ // here; the finalize path below (session.idle + idle scan) must observe the
82
+ // SAME instance. globalThis anchoring dedupes the npm+repo double load.
83
+ const board = getSharedBoard()
86
84
  let terminalToastClient: ToastClient = {}
87
85
  board
88
86
  .recoverRunningJobs()
@@ -177,6 +177,7 @@ import { appendFile, mkdir } from 'node:fs/promises'
177
177
  import { join } from 'node:path'
178
178
  import type { Plugin, PluginInput } from '@opencode-ai/plugin'
179
179
  import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
180
+ import { getSharedBoard } from '../pantheon/shared-board.ts'
180
181
  import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
181
182
 
182
183
  /** Tools that represent a subagent delegation (opencode `task` tool etc.). */
@@ -775,6 +776,51 @@ function delegationAgent(tool: string, args?: unknown): string {
775
776
  return tool
776
777
  }
777
778
 
779
+ /**
780
+ * beta.5: mirror a native background task dispatch onto the SHARED board
781
+ * (getSharedBoard) so the existing finalize path in plugin.ts (session.idle
782
+ * event + 30s idle scan) writes the terminal report and the TUI delegation
783
+ * panel tracks the child end-to-end. Fire-and-forget and silent: a mirror
784
+ * failure must never surface into the tool call or the transcript.
785
+ * registerLaunchIfAbsent keeps it idempotent against any other dispatcher
786
+ * (v2-events, plugin double-load, repeated after-hook firings).
787
+ */
788
+ function mirrorNativeBackgroundDispatch(
789
+ input: { tool: string; sessionID: string },
790
+ args: unknown,
791
+ result: ToolExecuteAfterResult | null,
792
+ ): void {
793
+ const taskID = extractTaskId(result)
794
+ if (taskID === '' || typeof input.sessionID !== 'string' || input.sessionID === '') return
795
+ const description = trim(delegationDescription(args) ?? `native ${input.tool} dispatch`, 200)
796
+ try {
797
+ getSharedBoard()
798
+ .registerLaunchIfAbsent({
799
+ taskID,
800
+ parentSessionID: input.sessionID,
801
+ agent: delegationAgent(input.tool, args),
802
+ description,
803
+ })
804
+ .catch(() => {
805
+ // Mirror is best-effort — never surface into the tool call.
806
+ })
807
+ } catch {
808
+ // Never let the mirror take down the tool call.
809
+ }
810
+ }
811
+
812
+ /** Task description from task-tool args (description ?? prompt); undefined when absent. */
813
+ function delegationDescription(args: unknown): string | undefined {
814
+ try {
815
+ const a = (args ?? {}) as Record<string, unknown>
816
+ const raw = a.description ?? a.prompt
817
+ if (typeof raw === 'string' && raw.trim() !== '') return raw
818
+ } catch {
819
+ // Malformed args — fall through to the generic description.
820
+ }
821
+ return undefined
822
+ }
823
+
778
824
  /** 🚀 delegation-started toast (variant 'info', 2500ms), deduped per agent. */
779
825
  async function delegationStartedToast(ctx: HookContext, agent: string): Promise<void> {
780
826
  await throttledToast(ctx, {
@@ -1311,6 +1357,11 @@ const plugin: Plugin = async ({ client, directory }) => {
1311
1357
  // NOT a completion: do not mark the group done and do not fire ✅
1312
1358
  // — that would corrupt olympians aggregation. Real completion of
1313
1359
  // background tasks is unobservable on 1.18.13 (see header).
1360
+ // beta.5: mirror the native background child onto the SHARED
1361
+ // board so the existing finalize path (session.idle + idle scan
1362
+ // in plugin.ts) writes the terminal report and the TUI
1363
+ // delegation panel tracks the child end-to-end. Fire-and-forget.
1364
+ mirrorNativeBackgroundDispatch(input, args, result)
1314
1365
  } else {
1315
1366
  const handledByOlympians = handleOlympiansCompletion(ctx, agent)
1316
1367
  if (!handledByOlympians) void delegationDoneToast(ctx, agent)
@@ -14,4 +14,4 @@
14
14
  declare function server(): {};
15
15
  //#endregion
16
16
  export { server as default };
17
- //# sourceMappingURL=server.d.ts.map
17
+ //# sourceMappingURL=server.d.ts.map