pantheon-opencode 1.5.0-beta.3 → 1.5.0-beta.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.
Files changed (44) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +871 -0
  3. package/README.md +101 -1
  4. package/README.pt-BR.md +55 -1
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +14 -10
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +1 -1
  9. package/scripts/code_mode_server.py +339 -28
  10. package/scripts/doctor.mjs +178 -9
  11. package/scripts/install/cli-ui.mjs +17 -11
  12. package/scripts/install/config-migration.mjs +4 -6
  13. package/scripts/install/opencode.mjs +116 -35
  14. package/scripts/install/shared.mjs +43 -2
  15. package/scripts/install/strings.mjs +129 -0
  16. package/scripts/install-mcp.mjs +1 -0
  17. package/scripts/probe-pantheon-cost.mjs +10 -1
  18. package/scripts/release-beta-version.mjs +2 -7
  19. package/scripts/sync-artifacts.mjs +169 -0
  20. package/scripts/sync-tui.mjs +124 -34
  21. package/src/agents/zeus.md +2 -2
  22. package/src/mcp/code_mode_server.py +339 -28
  23. package/src/pantheon/auto-wake.ts +1 -5
  24. package/src/pantheon/background-job-board.ts +14 -0
  25. package/src/pantheon/cost-command.ts +62 -42
  26. package/src/pantheon/delegate-manager.ts +109 -12
  27. package/src/pantheon/native-probe.ts +234 -0
  28. package/src/pantheon/native-task-status.ts +139 -0
  29. package/src/pantheon/shared-board.ts +37 -0
  30. package/src/pantheon/task-result-guard.ts +107 -0
  31. package/src/pantheon/token-opt.ts +18 -110
  32. package/src/pantheon/tool-ceiling.ts +159 -0
  33. package/src/pantheon/v2-events.ts +10 -1
  34. package/src/plugin.ts +25 -9
  35. package/src/plugins/pantheon-hooks.ts +51 -0
  36. package/src/plugins/tui/dist/server.d.ts +1 -1
  37. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  38. package/src/plugins/tui/dist/server.js +1 -1
  39. package/src/plugins/tui/dist/server.js.map +1 -1
  40. package/src/plugins/tui/dist/tui.d.ts +1 -1
  41. package/src/plugins/tui/dist/tui.js +1 -1
  42. package/src/plugins/tui/dist/tui.js.map +1 -1
  43. package/src/plugins/tui/package-lock.json +2 -2
  44. package/src/plugins/tui/package.json +1 -1
@@ -16,6 +16,8 @@ Or via MCP client (stdio transport):
16
16
  from __future__ import annotations
17
17
 
18
18
  import asyncio
19
+ import hashlib
20
+ import json
19
21
  import logging
20
22
  import os
21
23
  import shutil
@@ -38,6 +40,30 @@ SCRIPT_TIMEOUT: int = 30
38
40
  # executor for longer than 5 minutes.
39
41
  MAX_SCRIPT_TIMEOUT: int = 300
40
42
 
43
+ # ── Common status contract ────────────────────────────────────────────────────
44
+ # Mirrors the nine-code `NativeTaskStatus` contract introduced in B3-05. Every
45
+ # code-mode result is labelled with one of these statuses so callers can apply
46
+ # the same retry/skip/escalate strategy across the whole platform.
47
+ CONTRACT_STATUSES: frozenset[str] = frozenset(
48
+ {
49
+ "OK",
50
+ "UNSUPPORTED",
51
+ "UNAVAILABLE",
52
+ "INVALID_INPUT",
53
+ "INVALID_STATE",
54
+ "CONFLICT",
55
+ "CORRUPT_DATA",
56
+ "TIMEOUT",
57
+ "ESCALATE",
58
+ }
59
+ )
60
+
61
+ # Explicit installation mode: a script only runs when it is listed in this
62
+ # manifest with a matching SHA-256. No manifest → nothing executes.
63
+ MANIFEST_FILENAME: str = "manifest.json"
64
+ MANIFEST_VERSION: int = 1
65
+ _SHA256_HEX_LEN: int = 64
66
+
41
67
  # ── Env Allowlist ──────────────────────────────────────────────────────────────
42
68
  # Only these variables are passed to script subprocesses. Everything else
43
69
  # (secrets, cloud credentials, etc.) is stripped to limit blast radius.
@@ -131,12 +157,9 @@ def _detect_prlimit() -> str | None:
131
157
 
132
158
 
133
159
  # ── Scripts Directory Resolution ─────────────────────────────────────────────
134
- # Priority:
135
- # 1. /.opencode/.pantheon/code-mode/ (project install)
136
- # 2. /.pantheon/code-mode/ (legacy fallback)
137
- # 3. /.pantheon/code-mode/ (global fallback)
138
- # 4. .pantheon/code-mode/ shipped inside the installed package (tarball
139
- # 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.
140
163
 
141
164
 
142
165
  def _is_within(path: Path, root: Path) -> bool:
@@ -203,25 +226,38 @@ def _packaged_scripts_dir() -> Path | None:
203
226
  return None
204
227
 
205
228
 
206
- def _resolve_scripts_dir(candidates: list[Path]) -> Path | None:
207
- """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."""
208
231
  for candidate in candidates:
209
232
  if _has_usable_scripts(candidate):
210
233
  return candidate
211
- 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
212
257
 
213
258
 
214
259
  _PANTHEON_HOME: Path = pantheon_home()
215
- _SCRIPTS_DIR_CANDIDATES: list[Path] = []
216
- _proj = pantheon_project()
217
- if _proj is not None:
218
- _SCRIPTS_DIR_CANDIDATES.append(_proj / ".opencode" / ".pantheon" / "code-mode")
219
- _SCRIPTS_DIR_CANDIDATES.append(_proj / ".pantheon" / "code-mode")
220
- _SCRIPTS_DIR_CANDIDATES.append(_PANTHEON_HOME / ".pantheon" / "code-mode")
221
-
222
- SCRIPTS_DIR: Path = _resolve_scripts_dir(_SCRIPTS_DIR_CANDIDATES) or (
223
- _PANTHEON_HOME / ".pantheon" / "code-mode"
224
- )
260
+ SCRIPTS_DIR: Path = _resolve_code_mode_dir()
225
261
 
226
262
  # ── FastMCP App ───────────────────────────────────────────────────────────────
227
263
  mcp = FastMCP(
@@ -233,12 +269,19 @@ mcp = FastMCP(
233
269
  # ── Helpers ───────────────────────────────────────────────────────────────────
234
270
 
235
271
 
236
- def _validate_script_name(script_name: str) -> Path:
272
+ def _validate_script_name(script_name: str, scripts_dir: Path | None = None) -> Path:
237
273
  """Validate a script name and return its resolved path.
238
274
 
239
- Raises ValueError if the name is invalid, traverses paths, or
240
- has a disallowed extension.
275
+ Args:
276
+ script_name: Bare file name of a script in the code-mode directory.
277
+ scripts_dir: Directory to resolve against. Defaults to ``SCRIPTS_DIR``.
278
+
279
+ Raises:
280
+ ValueError: If the name is invalid, traverses paths, or has a
281
+ disallowed extension.
241
282
  """
283
+ scripts_dir = scripts_dir or SCRIPTS_DIR
284
+
242
285
  if not script_name:
243
286
  raise ValueError("Script name cannot be empty")
244
287
 
@@ -253,12 +296,12 @@ def _validate_script_name(script_name: str) -> Path:
253
296
  allowed = ", ".join(sorted(ALLOWED_EXTENSIONS))
254
297
  raise ValueError(f"Extension '{ext}' not allowed. Allowed: {allowed}")
255
298
 
256
- if SCRIPTS_DIR.is_symlink():
299
+ if scripts_dir.is_symlink():
257
300
  raise ValueError(f"Invalid script name: '{script_name}'")
258
301
 
259
302
  try:
260
- scripts_root = SCRIPTS_DIR.resolve(strict=True)
261
- script_path = (SCRIPTS_DIR / name).resolve(strict=True)
303
+ scripts_root = scripts_dir.resolve(strict=True)
304
+ script_path = (scripts_dir / name).resolve(strict=True)
262
305
  except (FileNotFoundError, OSError, RuntimeError):
263
306
  raise ValueError(f"Script '{script_name}' not found") from None
264
307
 
@@ -272,6 +315,219 @@ def _validate_script_name(script_name: str) -> Path:
272
315
  return script_path
273
316
 
274
317
 
318
+ # ── Installation Mode: Manifest + SHA-256 ─────────────────────────────────────
319
+ # The manifest is the explicit opt-in. A script is executable only when its
320
+ # bare name is present in `manifest.json` AND the SHA-256 of the on-disk file
321
+ # matches the recorded digest. Any deviation fails closed with a contract
322
+ # status instead of executing.
323
+
324
+
325
+ class ManifestError(Exception):
326
+ """Raised when the code-mode manifest cannot authorise execution."""
327
+
328
+ def __init__(self, status: str, message: str) -> None:
329
+ super().__init__(message)
330
+ self.status = status
331
+ self.message = message
332
+
333
+
334
+ def _manifest_path(scripts_dir: Path | None = None) -> Path:
335
+ """Return the manifest path for *scripts_dir* (defaults to SCRIPTS_DIR)."""
336
+ return (scripts_dir or SCRIPTS_DIR) / MANIFEST_FILENAME
337
+
338
+
339
+ def _sha256_file(path: Path) -> str:
340
+ """Return the hex SHA-256 digest of *path* (streamed, binary-safe)."""
341
+ digest = hashlib.sha256()
342
+ with path.open("rb") as handle:
343
+ for chunk in iter(lambda: handle.read(65536), b""):
344
+ digest.update(chunk)
345
+ return digest.hexdigest()
346
+
347
+
348
+ def _load_manifest(scripts_dir: Path | None = None) -> dict[str, str]:
349
+ """Load and validate the manifest, returning ``{script_name: sha256}``.
350
+
351
+ Raises:
352
+ ManifestError: With ``INVALID_STATE`` when the manifest is absent (no
353
+ explicit opt-in), or ``CORRUPT_DATA`` when it is unreadable or
354
+ malformed.
355
+ """
356
+ manifest_file = _manifest_path(scripts_dir)
357
+ if not manifest_file.is_file():
358
+ raise ManifestError(
359
+ "INVALID_STATE",
360
+ f"Code-mode manifest not found at {manifest_file}. No script executes "
361
+ "without explicit opt-in — run approve_code_script first.",
362
+ )
363
+
364
+ try:
365
+ raw = manifest_file.read_text(encoding="utf-8")
366
+ except OSError as exc:
367
+ raise ManifestError(
368
+ "CORRUPT_DATA", f"Code-mode manifest unreadable: {exc}"
369
+ ) from None
370
+
371
+ try:
372
+ data = json.loads(raw)
373
+ except (json.JSONDecodeError, ValueError):
374
+ raise ManifestError(
375
+ "CORRUPT_DATA",
376
+ f"Code-mode manifest is malformed JSON: {manifest_file}",
377
+ ) from None
378
+
379
+ if not isinstance(data, dict):
380
+ raise ManifestError(
381
+ "CORRUPT_DATA", "Code-mode manifest must be a JSON object."
382
+ )
383
+
384
+ if data.get("version") != 1:
385
+ raise ManifestError(
386
+ "CORRUPT_DATA", "Code-mode manifest malformed: version must be 1."
387
+ )
388
+
389
+ scripts = data.get("scripts")
390
+ if not isinstance(scripts, dict):
391
+ raise ManifestError(
392
+ "CORRUPT_DATA",
393
+ "Code-mode manifest is malformed: missing 'scripts' object.",
394
+ )
395
+
396
+ approved: dict[str, str] = {}
397
+ for name, digest in scripts.items():
398
+ if (
399
+ not isinstance(name, str)
400
+ or not isinstance(digest, str)
401
+ or len(digest) != _SHA256_HEX_LEN
402
+ or any(char not in "0123456789abcdefABCDEF" for char in digest)
403
+ ):
404
+ raise ManifestError(
405
+ "CORRUPT_DATA",
406
+ f"Code-mode manifest entry for '{name}' is malformed "
407
+ "(expected a 64-character SHA-256 hex digest).",
408
+ )
409
+ approved[name] = digest.lower()
410
+ return approved
411
+
412
+
413
+ def _verify_approved(script_path: Path, scripts_dir: Path | None = None) -> None:
414
+ """Authorise *script_path* against the manifest.
415
+
416
+ Raises:
417
+ ManifestError: ``CONFLICT`` when the script is not listed and
418
+ ``CORRUPT_DATA`` when its hash does not match the recorded digest.
419
+ """
420
+ approved = _load_manifest(scripts_dir)
421
+ name = script_path.name
422
+ if name not in approved:
423
+ raise ManifestError(
424
+ "CONFLICT",
425
+ f"Script '{name}' is not approved in the code-mode manifest. "
426
+ "Run approve_code_script to opt in.",
427
+ )
428
+ actual = _sha256_file(script_path)
429
+ if actual != approved[name]:
430
+ raise ManifestError(
431
+ "CORRUPT_DATA",
432
+ f"Script '{name}' hash mismatch (expected {approved[name]}, got "
433
+ f"{actual}). Re-approve the script after verifying the change.",
434
+ )
435
+
436
+
437
+ def _approve_script(
438
+ script_name: str, scripts_dir: Path | None = None
439
+ ) -> tuple[str, str]:
440
+ """Add or refresh *script_name* in the manifest with its SHA-256.
441
+
442
+ Returns:
443
+ A ``(status, message)`` tuple; the status is a contract code and is
444
+ ``OK`` on success.
445
+ """
446
+ target_dir = scripts_dir or SCRIPTS_DIR
447
+ try:
448
+ script_path = _validate_script_name(script_name, target_dir)
449
+ except ValueError as exc:
450
+ return "INVALID_INPUT", str(exc)
451
+
452
+ try:
453
+ approved = _load_manifest(target_dir)
454
+ except ManifestError as exc:
455
+ if exc.status == "INVALID_STATE":
456
+ approved = {}
457
+ else:
458
+ return exc.status, exc.message
459
+
460
+ approved[script_path.name] = _sha256_file(script_path)
461
+ payload = {
462
+ "version": MANIFEST_VERSION,
463
+ "scripts": dict(sorted(approved.items())),
464
+ }
465
+ try:
466
+ _manifest_path(target_dir).write_text(
467
+ json.dumps(payload, indent=2) + "\n", encoding="utf-8"
468
+ )
469
+ except OSError as exc:
470
+ return "UNAVAILABLE", f"Failed to write code-mode manifest: {exc}"
471
+
472
+ digest = approved[script_path.name]
473
+ return "OK", f"Approved '{script_path.name}' with SHA-256 {digest}"
474
+
475
+
476
+ def _generate_manifest(scripts_dir: Path | None = None) -> int:
477
+ """Write a manifest covering every valid script in *scripts_dir*.
478
+
479
+ Used by the installer to (re)seed the explicit opt-in for bundled scripts.
480
+
481
+ Returns:
482
+ The number of scripts recorded.
483
+ """
484
+ target_dir = scripts_dir or SCRIPTS_DIR
485
+ try:
486
+ resolved_dir = target_dir.resolve(strict=True)
487
+ except (OSError, RuntimeError):
488
+ return 0
489
+
490
+ approved: dict[str, str] = {}
491
+ if resolved_dir.is_dir() and not resolved_dir.is_symlink():
492
+ for candidate in sorted(resolved_dir.iterdir()):
493
+ if candidate.name.startswith("."):
494
+ continue
495
+ if candidate.suffix.lower() not in ALLOWED_EXTENSIONS:
496
+ continue
497
+ try:
498
+ resolved = candidate.resolve(strict=True)
499
+ except (OSError, RuntimeError):
500
+ continue
501
+ if (
502
+ resolved.is_file()
503
+ and resolved.parent == resolved_dir
504
+ and _is_within(resolved, resolved_dir)
505
+ ):
506
+ approved[candidate.name] = _sha256_file(resolved)
507
+
508
+ payload = {
509
+ "version": MANIFEST_VERSION,
510
+ "scripts": dict(sorted(approved.items())),
511
+ }
512
+ try:
513
+ _manifest_path(target_dir).write_text(
514
+ json.dumps(payload, indent=2) + "\n", encoding="utf-8"
515
+ )
516
+ except OSError:
517
+ return 0
518
+ return len(approved)
519
+
520
+
521
+ def _contract_result(
522
+ status: str, message: str, json_output: bool
523
+ ) -> str | dict[str, Any]:
524
+ """Build a contract-labelled error result (text or structured JSON)."""
525
+ if json_output:
526
+ return {"status": status, "error": message}
527
+ return f"[{status}] {message}"
528
+
529
+
530
+
275
531
  def _format_output(
276
532
  stdout: str,
277
533
  stderr: str,
@@ -395,10 +651,12 @@ def _build_result(
395
651
  metadata: dict[str, Any],
396
652
  json_output: bool,
397
653
  timeout_s: int,
654
+ status: str = "OK",
398
655
  ) -> str | dict[str, Any]:
399
656
  """Build the tool result in plain-text or structured JSON form."""
400
657
  if json_output:
401
658
  return {
659
+ "status": status,
402
660
  "stdout": stdout,
403
661
  "stderr": stderr,
404
662
  "exit_code": exit_code,
@@ -484,12 +742,17 @@ async def execute_code_script(
484
742
  try:
485
743
  script_path = _validate_script_name(script_name)
486
744
  except ValueError as e:
487
- return str(e)
745
+ return _contract_result("INVALID_INPUT", str(e), json_output)
488
746
 
489
747
  metadata = _parse_frontmatter(script_path)
490
748
  denied = _validate_args(script_path, args)
491
749
  if denied is not None:
492
- return denied
750
+ return _contract_result("INVALID_INPUT", denied, json_output)
751
+
752
+ try:
753
+ _verify_approved(script_path)
754
+ except ManifestError as e:
755
+ return _contract_result(e.status, e.message, json_output)
493
756
 
494
757
  timeout_s = _script_timeout(script_path)
495
758
 
@@ -553,6 +816,7 @@ async def execute_code_script(
553
816
  metadata=metadata,
554
817
  json_output=json_output,
555
818
  timeout_s=timeout_s,
819
+ status="TIMEOUT",
556
820
  )
557
821
  except FileNotFoundError:
558
822
  return _build_result(
@@ -564,6 +828,7 @@ async def execute_code_script(
564
828
  metadata=metadata,
565
829
  json_output=json_output,
566
830
  timeout_s=timeout_s,
831
+ status="UNAVAILABLE",
567
832
  )
568
833
  except OSError as e:
569
834
  return _build_result(
@@ -575,10 +840,56 @@ async def execute_code_script(
575
840
  metadata=metadata,
576
841
  json_output=json_output,
577
842
  timeout_s=timeout_s,
843
+ status="UNAVAILABLE",
578
844
  )
579
845
 
580
846
 
847
+ @mcp.tool(
848
+ name="approve_code_script",
849
+ description="Approve a .sh/.py script for execution by adding it to the "
850
+ "code-mode manifest with its SHA-256. Execution is opt-in: only approved "
851
+ "scripts with a matching hash run. Set json_output=true for structured JSON.",
852
+ )
853
+ async def approve_code_script(
854
+ script_name: str,
855
+ json_output: bool = False,
856
+ ) -> str | dict[str, Any]:
857
+ """Approve (or re-approve) a code-mode script.
858
+
859
+ Args:
860
+ script_name: Name of the script in the code-mode directory.
861
+ json_output: When True, return ``{"status", "message"}`` instead of a
862
+ ``[STATUS] message`` string.
863
+
864
+ Returns:
865
+ A contract-labelled result. ``OK`` on success, ``INVALID_INPUT`` when
866
+ the script name is invalid, or ``CORRUPT_DATA``/``UNAVAILABLE`` when
867
+ the manifest cannot be read or written.
868
+ """
869
+ status, message = _approve_script(script_name)
870
+ if json_output:
871
+ return {"status": status, "message": message}
872
+ return f"[{status}] {message}"
873
+
874
+
581
875
  # ── Main Entrypoint ───────────────────────────────────────────────────────────
582
876
 
583
- if __name__ == "__main__":
877
+
878
+ def _cli() -> int:
879
+ """Handle CLI subcommands used by the installer; fall back to serving MCP."""
880
+ argv = sys.argv[1:]
881
+ if "--generate-manifest" in argv:
882
+ scripts_dir: Path | None = None
883
+ if "--scripts-dir" in argv:
884
+ index = argv.index("--scripts-dir")
885
+ if index + 1 < len(argv):
886
+ scripts_dir = Path(argv[index + 1])
887
+ count = _generate_manifest(scripts_dir)
888
+ print(f"code-mode manifest: {count} script(s) approved")
889
+ return 0
584
890
  mcp.run()
891
+ return 0
892
+
893
+
894
+ if __name__ == "__main__":
895
+ raise SystemExit(_cli())
@@ -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
@@ -272,6 +272,20 @@ export class BackgroundJobBoard {
272
272
  return record
273
273
  }
274
274
 
275
+ /**
276
+ * Register a launch only when taskID has not already been claimed.
277
+ *
278
+ * The map check and set happen before the first await, so JavaScript's
279
+ * single-threaded event loop makes this critical section atomic even when
280
+ * two event handlers call it concurrently. The existing record is returned
281
+ * for the losing caller.
282
+ */
283
+ async registerLaunchIfAbsent(input: LaunchInput): Promise<BackgroundJobRecord> {
284
+ const existing = this.jobs.get(input.taskID)
285
+ if (existing !== undefined) return existing
286
+ return this.registerLaunch(input)
287
+ }
288
+
275
289
  /**
276
290
  * Update a job's state to a terminal state.
277
291
  *
@@ -4,11 +4,8 @@
4
4
  *
5
5
  * Reads the opencode session database READ-ONLY (sum tokens by agent and
6
6
  * phase over the last N days → markdown table). Zero new dependencies:
7
- * - primary path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
8
- * - CLI fallback: spawn scripts/cost.mjs with its explicit flag-based
9
- * interface when the direct import is unavailable;
10
- * - missing/unreadable db → FRIENDLY error string, never a crash (tools
11
- * return errors as TEXT).
7
+ * - only path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
8
+ * - missing/unreadable db → a diagnostic contract status as TEXT.
12
9
  *
13
10
  * dbPath resolution: explicit option > env PANTHEON_COST_DB > the version-aware
14
11
  * default selected by PANTHEON_OPENCODE_VERSION (v1 or v2). An unset version
@@ -18,16 +15,13 @@
18
15
  *
19
16
  * @module cost-command
20
17
  */
21
- import { execFile } from 'node:child_process'
22
18
  import { existsSync } from 'node:fs'
23
19
  import { homedir } from 'node:os'
24
20
  import { join } from 'node:path'
25
- import { promisify } from 'node:util'
26
21
  import { z } from 'zod'
27
22
 
28
23
  import type { ToolContextLike } from './delegation.ts'
29
-
30
- const execFileAsync = promisify(execFile)
24
+ import type { NativeTaskStatus } from './native-task-status.ts'
31
25
 
32
26
  /** One aggregated token row (per agent and phase). */
33
27
  export interface CostRow {
@@ -41,10 +35,21 @@ export interface CostRow {
41
35
  export interface CostCommandOptions {
42
36
  /** Explicit db path (testability / unusual installs). Default: resolved candidates. */
43
37
  dbPath?: string
38
+ /** Injectable loader for tests; production always loads node:sqlite. */
39
+ sqliteLoader?: () => Promise<SqliteModule>
44
40
  }
45
41
 
46
42
  export type OpenCodeVersion = 'v1' | 'v2'
47
43
 
44
+ interface SqliteDatabase {
45
+ prepare(sql: string): { get(): unknown; all(...args: unknown[]): unknown[] }
46
+ close(): void
47
+ }
48
+
49
+ export interface SqliteModule {
50
+ DatabaseSync: new (path: string, options: { readOnly: boolean }) => SqliteDatabase
51
+ }
52
+
48
53
  /** One cost tool: description + zod args shape + execute (delegation.ts shape). */
49
54
  export interface CostTool<Args extends z.ZodRawShape = typeof costArgs> {
50
55
  description: string
@@ -125,8 +130,11 @@ function assertCompatibleSchema(db: {
125
130
  }
126
131
 
127
132
  /** Aggregate tokens by agent and phase via node:sqlite (read-only). */
128
- async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRow[]> {
129
- const sqlite = await import('node:sqlite')
133
+ async function queryWithNodeSqlite(
134
+ dbPath: string,
135
+ days: number,
136
+ sqlite: SqliteModule,
137
+ ): Promise<CostRow[]> {
130
138
  const db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
131
139
  try {
132
140
  assertCompatibleSchema(db)
@@ -142,25 +150,6 @@ async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRo
142
150
  }
143
151
  }
144
152
 
145
- /** CLI fallback: delegate the same aggregation to scripts/cost.mjs. */
146
- async function queryWithScript(
147
- scriptPath: string,
148
- dbPath: string,
149
- days: number,
150
- ): Promise<CostRow[]> {
151
- const { stdout } = await execFileAsync(
152
- process.execPath,
153
- [scriptPath, '--db-path', dbPath, '--days', String(days)],
154
- {
155
- timeout: 15_000,
156
- encoding: 'utf8',
157
- },
158
- )
159
- const parsed = JSON.parse(stdout) as { ok: boolean; rows?: CostRow[]; error?: string }
160
- if (!parsed.ok) throw new Error(parsed.error ?? 'cost.mjs failed')
161
- return parsed.rows ?? []
162
- }
163
-
164
153
  /** Parse message.data JSON rows and aggregate tokens by assistant agent/phase. */
165
154
  function aggregateMessages(dataValues: readonly string[]): CostRow[] {
166
155
  const byAgentAndPhase = new Map<string, CostRow>()
@@ -232,7 +221,42 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
232
221
  }
233
222
 
234
223
  export function createCostCommand(options?: CostCommandOptions): CostCommand {
235
- const scriptPath = new URL('../../scripts/cost.mjs', import.meta.url).pathname
224
+ const loadSqlite =
225
+ options?.sqliteLoader ?? (async () => (await import('node:sqlite')) as SqliteModule)
226
+
227
+ const failure = (status: NativeTaskStatus, detail: string): string =>
228
+ `status: ${status}\npantheon_cost failed: ${detail}`
229
+
230
+ const diagnostic = (err: unknown): string => {
231
+ if (err instanceof Error) {
232
+ const stderr = (err as Error & { stderr?: unknown }).stderr
233
+ const suffix =
234
+ typeof stderr === 'string' && stderr.trim() !== '' ? `; stderr: ${stderr.trim()}` : ''
235
+ return `${err.message}${suffix}`
236
+ }
237
+ return String(err)
238
+ }
239
+
240
+ const statusForError = (err: unknown): NativeTaskStatus => {
241
+ const detail = diagnostic(err).toLowerCase()
242
+ if (
243
+ detail.includes('not found') ||
244
+ detail.includes('unknown builtin') ||
245
+ detail.includes('no such built-in') ||
246
+ detail.includes('cannot find module')
247
+ ) {
248
+ return 'UNSUPPORTED'
249
+ }
250
+ if (
251
+ detail.includes('not a database') ||
252
+ detail.includes('malformed') ||
253
+ detail.includes('corrupt') ||
254
+ detail.includes('incompatible opencode.db schema')
255
+ ) {
256
+ return 'CORRUPT_DATA'
257
+ }
258
+ return 'UNAVAILABLE'
259
+ }
236
260
 
237
261
  const execute = async (args: { days?: number }, _ctx: ToolContextLike): Promise<string> => {
238
262
  const days = args?.days ?? 7
@@ -240,20 +264,16 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
240
264
  const dbPath = findExistingDb(options?.dbPath)
241
265
  if (dbPath === undefined) {
242
266
  const expected = resolveCostDbPath(options)
243
- return (
244
- `pantheon_cost failed: opencode.db not found (expected at ${expected}). ` +
245
- `No token history is available until opencode has stored session data.`
267
+ return failure(
268
+ 'UNAVAILABLE',
269
+ `opencode.db not found (expected at ${expected}). No token history is available until opencode has stored session data.`,
246
270
  )
247
271
  }
248
- const rows = await queryWithNodeSqlite(dbPath, days).catch(async (err: unknown) => {
249
- if (err instanceof Error && err.message.startsWith('incompatible opencode.db schema'))
250
- throw err
251
- return queryWithScript(scriptPath, dbPath, days)
252
- })
253
- return renderMarkdown(rows, days)
272
+ const sqlite = await loadSqlite()
273
+ const rows = await queryWithNodeSqlite(dbPath, days, sqlite)
274
+ return `status: OK\n${renderMarkdown(rows, days)}`
254
275
  } catch (err: unknown) {
255
- const reason = err instanceof Error ? err.message : String(err)
256
- return `pantheon_cost failed: ${reason}`
276
+ return failure(statusForError(err), diagnostic(err))
257
277
  }
258
278
  }
259
279