pantheon-opencode 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -187,6 +187,17 @@ opencode
187
187
  > **Minimal:** `--headless --no-mcp` installs only agents (~2s).
188
188
  > **Full:** `--headless` also creates Python venv + MCP servers (memory, persistence, KV, vision).
189
189
 
190
+ ### Doctor validation statuses
191
+
192
+ The doctor reports each check as **PASS**, **WARN**, **ERROR**, or **SKIP**. WARN is
193
+ advisory and exits 0; ERROR is blocking and exits 2. The `global` and `sandbox`
194
+ profiles require the core `pantheon-memory` and `pantheon-resources` MCPs, while
195
+ `lite` treats MCPs and optional helpers (`platform`, `gh_grep`, Context7, and
196
+ `validate-agent-frontmatter.py`) as informational/skipped. Optional warnings are
197
+ never used to hide missing required MCPs. When ERROR and WARN coexist, the final
198
+ summary explicitly reports the blocking error and exit code 2; it never reports a
199
+ positive “no blocking errors” result.
200
+
190
201
  ---
191
202
 
192
203
  ### Approval Gates
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -18,16 +18,14 @@
18
18
  * node scripts/doctor.mjs --verbose
19
19
  * node scripts/doctor.mjs --help
20
20
  *
21
- * Exit codes:
22
- * 0 = all green
23
- * 1 = warnings only
24
- * 2 = errors (needs attention)
21
+ * Result classification: PASS, WARN (advisory), ERROR (blocking), or SKIP.
22
+ * Exit codes: 0 = no blocking errors (warnings are allowed), 2 = errors.
25
23
  */
26
24
 
27
25
  import { spawn as spawnAsync, spawnSync } from 'node:child_process'
28
26
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
29
27
  import { homedir } from 'node:os'
30
- import { basename, dirname, join } from 'node:path'
28
+ import { basename, dirname, join, resolve } from 'node:path'
31
29
  import { fileURLToPath } from 'node:url'
32
30
 
33
31
  // ---------------------------------------------------------------------------
@@ -67,6 +65,7 @@ function parseArgs(argv) {
67
65
  fix: false,
68
66
  verbose: false,
69
67
  help: false,
68
+ profile: process.env.PANTHEON_PROFILE ?? 'auto',
70
69
  }
71
70
 
72
71
  for (let i = 2; i < argv.length; i++) {
@@ -83,6 +82,9 @@ function parseArgs(argv) {
83
82
  case '--verbose':
84
83
  args.verbose = true
85
84
  break
85
+ case '--profile':
86
+ args.profile = argv[++i] ?? 'auto'
87
+ break
86
88
  case '--help':
87
89
  case '-h':
88
90
  args.help = true
@@ -97,6 +99,10 @@ function parseArgs(argv) {
97
99
  args.target = process.cwd()
98
100
  }
99
101
 
102
+ if (args.profile === 'auto') {
103
+ args.profile = args.target.includes('pantheon-sandbox') ? 'sandbox' : 'global'
104
+ }
105
+
100
106
  // Resolve to absolute
101
107
  if (!args.target.startsWith('/')) {
102
108
  args.target = join(process.cwd(), args.target)
@@ -129,7 +135,6 @@ function pass(msg) {
129
135
  }
130
136
  function warn(msg) {
131
137
  emit('warn', msg)
132
- if (exitCode < 1) exitCode = 1
133
138
  }
134
139
  function error(msg) {
135
140
  emit('error', msg)
@@ -153,6 +158,7 @@ function showHelp() {
153
158
  node scripts/doctor.mjs auto-detect, cwd
154
159
  node scripts/doctor.mjs --target /path/to/project target project
155
160
  node scripts/doctor.mjs --platform opencode specific platform
161
+ node scripts/doctor.mjs --profile global|lite|sandbox profile policy
156
162
  node scripts/doctor.mjs --fix attempt auto-fixes
157
163
  node scripts/doctor.mjs --verbose detailed output
158
164
  node scripts/doctor.mjs --help show this help
@@ -168,9 +174,10 @@ function showHelp() {
168
174
  F. Runtime Layer — venv python + pinned pip dependencies
169
175
 
170
176
  Exit codes:
171
- 0 — all checks pass
172
- 1 — warnings
173
- 2 — errors
177
+ 0 — no blocking errors (warnings are advisory)
178
+ 2 — blocking errors
179
+
180
+ Profiles: global (core MCPs required), lite (MCPs optional), sandbox (core MCPs required)
174
181
 
175
182
  Report bugs: https://github.com/ils15/pantheon/issues
176
183
  `)
@@ -239,7 +246,7 @@ function checkAgentFiles(args) {
239
246
 
240
247
  // Determine which platforms to check
241
248
  if (!existsSync(PLATFORM_DIR)) {
242
- warn('Platform component not distributed in this package — skipping platform agent check')
249
+ info(`Platform component not distributed in this package — platform check skipped (${args.profile} profile)`)
243
250
  return
244
251
  }
245
252
  const platformDirs = readdirSync(PLATFORM_DIR, { withFileTypes: true })
@@ -248,7 +255,7 @@ function checkAgentFiles(args) {
248
255
  .filter((name) => !args.platform || name === args.platform)
249
256
 
250
257
  if (platformDirs.length === 0) {
251
- warn(`No platform directories found in platform/`)
258
+ info(`No platform directories found in platform/ — platform check skipped (${args.profile} profile)`)
252
259
  return
253
260
  }
254
261
 
@@ -354,9 +361,14 @@ function checkMcpConfig(args) {
354
361
  section('B. MCP Configuration')
355
362
 
356
363
  const configs = collectMcpConfigs(args)
364
+ const requiredMcpNames = args.profile === 'lite' ? [] : ['pantheon-memory', 'pantheon-resources']
357
365
 
358
366
  if (configs.length === 0) {
359
- warn('No opencode.json found in target or user config')
367
+ if (requiredMcpNames.length > 0) {
368
+ error(`No opencode.json found; required MCPs missing: ${requiredMcpNames.join(', ')}`)
369
+ } else {
370
+ info(`No opencode.json found for ${args.profile} profile — MCP check skipped`)
371
+ }
360
372
  return
361
373
  }
362
374
 
@@ -365,11 +377,12 @@ function checkMcpConfig(args) {
365
377
 
366
378
  const mcp = cfg.data.mcp
367
379
  if (!mcp || typeof mcp !== 'object') {
368
- warn(`${cfg.label}: no "mcp" section`)
380
+ if (requiredMcpNames.length > 0) error(`${cfg.label}: required MCPs missing — no "mcp" section`)
381
+ else info(`${cfg.label}: no "mcp" section (${args.profile} profile — optional)`)
369
382
  continue
370
383
  }
371
384
 
372
- // 1. gh_grep MCP check
385
+ // 1. gh_grep MCP check (optional in every supported profile)
373
386
  const ghGrep = mcp.gh_grep
374
387
  if (ghGrep) {
375
388
  if (ghGrep.type === 'remote' && ghGrep.url === 'https://mcp.grep.app') {
@@ -383,7 +396,7 @@ function checkMcpConfig(args) {
383
396
  warn(`${cfg.label}: gh_grep MCP may need review — ${detail}`)
384
397
  }
385
398
  } else {
386
- warn(`${cfg.label}: gh_grep MCP not configured`)
399
+ info(`${cfg.label}: optional gh_grep MCP not configured (${args.profile} profile)`)
387
400
  }
388
401
 
389
402
  // 2. Context7 MCP check
@@ -406,7 +419,7 @@ function checkMcpConfig(args) {
406
419
  warn(`${cfg.label}: Context7 MCP issues — ${details.join(', ')}`)
407
420
  }
408
421
  } else {
409
- warn(`${cfg.label}: Context7 MCP not configured`)
422
+ info(`${cfg.label}: optional Context7 MCP not configured (${args.profile} profile)`)
410
423
  }
411
424
 
412
425
  // 3. Check for deprecated "tools" field format
@@ -450,6 +463,14 @@ function checkMcpConfig(args) {
450
463
  }
451
464
  }
452
465
  }
466
+
467
+ if (requiredMcpNames.length > 0) {
468
+ const configured = new Set(configs.flatMap((cfg) => Object.keys(cfg.data.mcp ?? {})))
469
+ for (const required of requiredMcpNames) {
470
+ if (configured.has(required)) pass(`Required MCP "${required}" configured in an available config`)
471
+ else error(`Required MCP "${required}" not configured in any available config`)
472
+ }
473
+ }
453
474
  }
454
475
 
455
476
  // ---------------------------------------------------------------------------
@@ -558,7 +579,11 @@ async function checkMcpRuntimeSmoke(args) {
558
579
  }
559
580
 
560
581
  if (local.length === 0) {
561
- warn('No local MCP servers found in any config — nothing to smoke-test')
582
+ if (args.profile === 'lite') {
583
+ info('No local MCP servers found in lite profile — smoke test skipped')
584
+ } else {
585
+ error('No local MCP servers found in any config — required runtime MCPs are missing')
586
+ }
562
587
  return
563
588
  }
564
589
 
@@ -620,7 +645,7 @@ function checkPermissionMismatches(args) {
620
645
  // Locate the validation script
621
646
  const validator = join(ROOT, 'scripts', 'validate-agent-frontmatter.py')
622
647
  if (!existsSync(validator)) {
623
- warn('validate-agent-frontmatter.py not found — cannot check permissions')
648
+ info(`validate-agent-frontmatter.py not found — optional helper skipped (${args.profile} profile)`)
624
649
  return
625
650
  }
626
651
 
@@ -871,14 +896,18 @@ function checkRequirementLine(line, installed) {
871
896
  }
872
897
  }
873
898
 
874
- function checkVenvLayer() {
899
+ function checkVenvLayer(args) {
875
900
  section('F. Runtime Layer — Venv, Python & Dependencies')
876
901
 
877
902
  const configDir = join(homedir(), '.config', 'opencode')
878
903
  const venvPython = join(configDir, '.venv', 'bin', 'python3')
879
904
 
880
905
  if (!existsSync(venvPython)) {
881
- error(`Venv python not found: ${venvPython} (run \`pantheon-opencode init\` to create it)`)
906
+ if (args.profile === 'lite') {
907
+ info(`Venv python not found — runtime layer skipped for lite profile`)
908
+ } else {
909
+ error(`Venv python not found: ${venvPython} (run \`pantheon-opencode init\` to create it)`)
910
+ }
882
911
  return
883
912
  }
884
913
  pass(`Venv python exists: ${venvPython}`)
@@ -924,6 +953,30 @@ function checkVenvLayer() {
924
953
  // Summary
925
954
  // ---------------------------------------------------------------------------
926
955
 
956
+ /**
957
+ * Return the final doctor status message without masking blocking errors.
958
+ * @param {{ error: number; warn: number }} summaryCounts
959
+ * @param {number} summaryExitCode
960
+ * @returns {string}
961
+ */
962
+ export function summaryMessage(summaryCounts, summaryExitCode) {
963
+ if (summaryCounts.error > 0 || summaryExitCode !== 0) {
964
+ const errorText = summaryCounts.error > 0
965
+ ? `${summaryCounts.error} blocking error(s) found`
966
+ : 'Blocking check failure'
967
+ const warningText = summaryCounts.warn > 0
968
+ ? `; ${summaryCounts.warn} warning(s) remain advisory`
969
+ : ''
970
+ return `❌ ${errorText}${warningText} — exit code ${summaryExitCode}`
971
+ }
972
+
973
+ if (summaryCounts.warn > 0) {
974
+ return `⚠️ Checks passed with ${summaryCounts.warn} warning(s) — warnings are advisory`
975
+ }
976
+
977
+ return '✅ All checks passed!'
978
+ }
979
+
927
980
  function printSummary(targetPath) {
928
981
  console.log(`\n${'='.repeat(60)}`)
929
982
  console.log(' Summary')
@@ -934,13 +987,7 @@ function printSummary(targetPath) {
934
987
  console.log(` ${ICON.info} ${counts.info} info`)
935
988
  console.log('')
936
989
 
937
- if (exitCode === 0) {
938
- console.log(' ✅ All checks passed!')
939
- } else if (exitCode === 1) {
940
- console.log(' ⚠️ Warnings found — review above (some may be auto-fixable with --fix)')
941
- } else {
942
- console.log(' ❌ Errors found — needs attention')
943
- }
990
+ console.log(` ${summaryMessage(counts, exitCode)}`)
944
991
 
945
992
  console.log(`\n Target: ${process.argv.includes('--target') ? targetPath : 'current directory'}`)
946
993
  console.log('')
@@ -994,7 +1041,9 @@ async function main() {
994
1041
  process.exit(exitCode)
995
1042
  }
996
1043
 
997
- main().catch((err) => {
998
- console.error(`❌ Doctor crashed: ${err.stack ?? err.message}`)
999
- process.exit(2)
1000
- })
1044
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1045
+ main().catch((err) => {
1046
+ console.error(`❌ Doctor crashed: ${err.stack ?? err.message}`)
1047
+ process.exit(2)
1048
+ })
1049
+ }
@@ -0,0 +1,18 @@
1
+ /** Status/exit policy shared by installation validation and its tests. */
2
+ export const VALIDATION_STATUS = Object.freeze({ PASS: 'PASS', WARN: 'WARN', ERROR: 'ERROR', SKIP: 'SKIP' })
3
+
4
+ export function classifyDoctorExit(exitStatus, output = '') {
5
+ if (exitStatus === 0) return VALIDATION_STATUS.PASS
6
+ if (exitStatus === 1 && !/(?:❌\s*)?[1-9]\d*\s+errors?/i.test(output)) {
7
+ return VALIDATION_STATUS.WARN
8
+ }
9
+ return VALIDATION_STATUS.ERROR
10
+ }
11
+
12
+ export function classifyTuiExit(exitStatus) {
13
+ return exitStatus === 0 ? VALIDATION_STATUS.PASS : VALIDATION_STATUS.ERROR
14
+ }
15
+
16
+ export function validationExitCode(doctorStatus, tuiStatus) {
17
+ return doctorStatus === VALIDATION_STATUS.ERROR || tuiStatus === VALIDATION_STATUS.ERROR ? 1 : 0
18
+ }
@@ -98,21 +98,37 @@ export function runHook(
98
98
  const scriptPath = path.join(opts.cwd ?? resolveHooksDir(), script)
99
99
  const timeout = opts.timeout ?? HOOK_TIMEOUT_MS
100
100
 
101
+ let serializedPayload: string
102
+ try {
103
+ serializedPayload = JSON.stringify(payload)
104
+ } catch (err) {
105
+ // Fail closed: a scanner hook must not run without the payload it is
106
+ // supposed to inspect, and serialization errors must never reject.
107
+ return Promise.resolve({
108
+ code: 1,
109
+ stdout: '',
110
+ stderr: `payload serialization failed: ${err instanceof Error ? err.message : String(err)}`,
111
+ signal: null,
112
+ timedOut: false,
113
+ })
114
+ }
115
+
101
116
  return new Promise<HookResult>((resolve) => {
102
117
  const stdoutBuf: { value: string } = { value: '' }
103
118
  const stderrBuf: { value: string } = { value: '' }
104
119
  let settled = false
120
+ let timedOut = false
121
+ let timeoutHandle: NodeJS.Timeout | undefined
105
122
 
106
123
  const settle = (result: HookResult) => {
107
124
  if (!settled) {
108
125
  settled = true
126
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
109
127
  resolve(result)
110
128
  }
111
129
  }
112
130
 
113
131
  const spawnOpts = {
114
- timeout,
115
- killSignal: 'SIGKILL' as const,
116
132
  env: hookEnv(payload, opts.env),
117
133
  // stdio:'pipe' is equivalent to ['pipe','pipe','pipe'] — it guarantees
118
134
  // the child's stdin is OUR pipe, NEVER the opencode TUI's terminal
@@ -120,6 +136,10 @@ export function runHook(
120
136
  // the opencode core hook timeout).
121
137
  stdio: 'pipe' as const,
122
138
  windowsHide: true,
139
+ // A process group lets the timeout kill shell descendants too. Without
140
+ // this, a shell hook can leave `sleep` holding stdout/stderr open after
141
+ // the shell itself receives SIGKILL, so the Promise would not settle.
142
+ ...(process.platform !== 'win32' ? { detached: true } : {}),
123
143
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
124
144
  }
125
145
 
@@ -172,15 +192,35 @@ export function runHook(
172
192
  stdout: stdoutBuf.value,
173
193
  stderr: stderrBuf.value,
174
194
  signal,
175
- timedOut: code === null && signal === 'SIGKILL',
195
+ timedOut,
176
196
  })
177
197
  })
178
198
 
199
+ timeoutHandle = setTimeout(() => {
200
+ timedOut = true
201
+ try {
202
+ // Negative PIDs target the process group created by `detached`.
203
+ if (process.platform !== 'win32' && child.pid !== undefined) {
204
+ process.kill(-child.pid, 'SIGKILL')
205
+ } else {
206
+ child.kill('SIGKILL')
207
+ }
208
+ } catch {
209
+ // The child may have exited between the timer and kill call. The
210
+ // close/error handlers still produce the structured result.
211
+ try {
212
+ child.kill('SIGKILL')
213
+ } catch {
214
+ // Hooks must never leak a kill failure to the plugin host.
215
+ }
216
+ }
217
+ }, timeout)
218
+
179
219
  // Deliver the Claude Code protocol JSON on stdin. stdin.end() runs
180
220
  // UNCONDITIONALLY — even for an empty payload {} or a script that never
181
221
  // reads stdin — so the child can never block waiting for input and hit
182
222
  // the runtime hook timeout (reported as "Hook X timed out after 30000ms").
183
- child.stdin.write(JSON.stringify(payload))
223
+ child.stdin.write(serializedPayload)
184
224
  child.stdin.end()
185
225
  })
186
226
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
- "version": "1.0.0",
3
+ "version": "1.3.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pantheon-tui",
9
- "version": "1.0.0",
9
+ "version": "1.3.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@opencode-ai/plugin": ">=1.14.0",
@@ -1526,9 +1526,9 @@
1526
1526
  }
1527
1527
  },
1528
1528
  "node_modules/brace-expansion": {
1529
- "version": "2.1.2",
1530
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
1531
- "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
1529
+ "version": "2.1.4",
1530
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
1531
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
1532
1532
  "license": "MIT",
1533
1533
  "dependencies": {
1534
1534
  "balanced-match": "^1.0.0"
@@ -2703,6 +2703,20 @@
2703
2703
  "license": "0BSD",
2704
2704
  "optional": true
2705
2705
  },
2706
+ "node_modules/typescript": {
2707
+ "version": "5.9.3",
2708
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2709
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2710
+ "license": "Apache-2.0",
2711
+ "peer": true,
2712
+ "bin": {
2713
+ "tsc": "bin/tsc",
2714
+ "tsserver": "bin/tsserver"
2715
+ },
2716
+ "engines": {
2717
+ "node": ">=14.17"
2718
+ }
2719
+ },
2706
2720
  "node_modules/unconfig-core": {
2707
2721
  "version": "7.5.0",
2708
2722
  "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz",
@@ -2773,6 +2787,21 @@
2773
2787
  "url": "https://github.com/sponsors/sxzz"
2774
2788
  }
2775
2789
  },
2790
+ "node_modules/web-tree-sitter": {
2791
+ "version": "0.25.10",
2792
+ "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz",
2793
+ "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==",
2794
+ "license": "MIT",
2795
+ "peer": true,
2796
+ "peerDependencies": {
2797
+ "@types/emscripten": "^1.40.0"
2798
+ },
2799
+ "peerDependenciesMeta": {
2800
+ "@types/emscripten": {
2801
+ "optional": true
2802
+ }
2803
+ }
2804
+ },
2776
2805
  "node_modules/which": {
2777
2806
  "version": "2.0.2",
2778
2807
  "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.3.0",
4
+ "version": "1.3.1",
5
5
  "description": "Pantheon TUI plugin — sidebar (agents, sessions, commands, config, memory, active preset) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
6
6
  "license": "MIT",
7
7
  "exports": {