codehere 0.6.0 → 0.6.2

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 (30) hide show
  1. package/README.md +8 -8
  2. package/dist/application/use-cases/generate-final-report.js +57 -2
  3. package/dist/application/use-cases/generate-narration.js +9 -3
  4. package/dist/index.js +34 -4
  5. package/dist/infrastructure/cost/budget-config.js +1 -1
  6. package/dist/infrastructure/cost/spend-aggregator.js +5 -5
  7. package/dist/infrastructure/hooks/claude-sdk-hook.js +48 -2
  8. package/dist/infrastructure/orchestration/codex-adapter.js +11 -6
  9. package/dist/infrastructure/orchestration/context-handoff.js +1 -1
  10. package/dist/infrastructure/orchestration/gemini-adapter.js +3 -15
  11. package/dist/infrastructure/orchestration/graphify-context.js +6 -13
  12. package/dist/infrastructure/orchestration/memory-injector.js +11 -16
  13. package/dist/infrastructure/orchestration/opencode-adapter.js +42 -1
  14. package/dist/infrastructure/orchestration/progress-aggregator.js +1 -2
  15. package/dist/infrastructure/orchestration/session-manager.js +178 -76
  16. package/dist/infrastructure/protocols/cloud-sync.js +9 -5
  17. package/dist/infrastructure/protocols/mcp-server-impl.js +368 -334
  18. package/dist/infrastructure/storage/cross-tool-agents.js +19 -2
  19. package/dist/infrastructure/storage/project-thread-migration.js +12 -7
  20. package/dist/infrastructure/storage/project-thread-reader.js +11 -1
  21. package/dist/infrastructure/storage/project-thread-title-backfill.js +6 -2
  22. package/dist/presentation/cli/commands/budget-command.js +2 -2
  23. package/dist/presentation/cli/commands/init-command.js +5 -4
  24. package/dist/presentation/cli/commands/memory-command.js +29 -15
  25. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  26. package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
  27. package/dist/presentation/cli/repl.js +20 -1
  28. package/dist/ui.js +36 -28
  29. package/dist/utils/typo-guard.js +1 -1
  30. package/package.json +2 -1
package/README.md CHANGED
@@ -5,12 +5,12 @@
5
5
 
6
6
  [![npm](https://img.shields.io/npm/v/codehere)](https://www.npmjs.com/package/codehere)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](../LICENSE)
8
- [![Tests](https://img.shields.io/badge/tests-668%2F668-brightgreen)]()
8
+ [![Tests](https://img.shields.io/badge/tests-1196%2F1196-brightgreen)]()
9
9
  [![Vulnerabilities](https://img.shields.io/badge/vulnerabilities-0-brightgreen)]()
10
10
 
11
11
  **Codehere is the vendor-neutral layer between you and the coding agents you already use** — Claude Code, Codex, Aider, Gemini, OpenCode. One `npm install`. Local-first, OSS, no account, no IAM, no compliance prereqs. Switch agent, keep your memory. End the day with one Final Report covering everything they did.
12
12
 
13
- Today, Claude Code gets full pre-execution safety-hook enforcement on every tool call; the other adapters are session-traced and reported, with per-tool hook parity in progress for v0.6. The vendor-neutral *memory*, *audit log*, and *Final Report* surfaces ship across all 5 adapters today.
13
+ All 5 adapters (Claude Code, Codex, Aider, Gemini, OpenCode) get session-tracing, Final Reports, and trust scoring. Claude Code additionally gets full pre-execution safety-hook enforcement on every tool call. The *memory*, *audit log*, *failover*, and *Final Report* surfaces ship across all 5 adapters today.
14
14
 
15
15
  The enterprise tier is owned by [OpenAI Frontier](https://openai.com/index/introducing-openai-frontier/) (8-14 weeks of deploy prereqs, sales-team gated) and [Anthropic Cowork](https://www.anthropic.com/product/claude-cowork) (paid Claude subscription, single-vendor). Codehere fills the gap they structurally can't reach: **the individual / small-team / no-IT-department tier, vendor-neutral**.
16
16
 
@@ -29,7 +29,7 @@ One layer, every agent, your work stays portable.
29
29
 
30
30
  | | | |
31
31
  |---|---|---|
32
- | 1 | **Install** | `npm install -g codehere@alpha` (Node 20+) — current alpha track is v0.5; `@latest` still on v0.4 |
32
+ | 1 | **Install** | `npm install -g codehere` (Node 20+) — v0.6.0 is the current stable |
33
33
  | 2 | **Detect environment** | `codehere setup` — checks API key, finds your repo |
34
34
  | 3 | **Install the safety hook** | `codehere hook install` — every Claude Code tool call now audited |
35
35
  | 4 | **Install at least one specialist** | Claude Code: `npm i -g @anthropic-ai/claude-code` (Codex / Aider / OpenCode / Gemini also supported · see [docs/integrations/](../docs/integrations/) for Cursor MCP setup) |
@@ -51,7 +51,7 @@ Context + End State + Constraints
51
51
 
52
52
  ### Folder convention (what `codehere init` writes)
53
53
 
54
- The v0.5 layout — F3 project memory:
54
+ The v0.6 layout — F3 project memory:
55
55
 
56
56
  | Path | Purpose | Committed? |
57
57
  |---|---|---|
@@ -90,9 +90,9 @@ codehere report # generates a shareable markdown audit report
90
90
  codehere status # quick dashboard · sessions, blocks, trust trends
91
91
  ```
92
92
 
93
- The hook intercepts every `Bash`, `Edit`, `Write`, `MultiEdit`, and `NotebookEdit` call from any Claude Code session running on your machine, runs it through the trust-scorer pattern bank in under 5 ms, blocks anything matching the destructive / credential / injection patterns, and appends every decision to an append-only JSONL audit log at `~/.codehere/audit/`. (Hook coverage today is Claude Code-only — Codex / Aider / OpenCode adapters spawn through their native CLIs and emit to `~/.codehere/sessions.jsonl`; full hook parity across all adapters is v0.4 work.)
93
+ The hook intercepts every `Bash`, `Edit`, `Write`, `MultiEdit`, and `NotebookEdit` call from any Claude Code session running on your machine, runs it through the trust-scorer pattern bank in under 5 ms, blocks anything matching the destructive / credential / injection patterns, and appends every decision to an append-only JSONL audit log at `~/.codehere/audit/`. Codex, Aider, OpenCode, and Gemini adapters spawn through their native CLIs and emit to `~/.codehere/sessions.jsonl` with per-tool tracing via the cross-vendor mirror hook.
94
94
 
95
- ## Memory · F3 (v0.5)
95
+ ## Memory · F3 (v0.6)
96
96
 
97
97
  Once you've run `codehere init` in a project, every spawned agent — Claude Code, Codex, Aider, OpenCode, or Gemini — automatically reads the project's memory on entry. The agent starts warm with the briefing, recent lessons, and the most-referenced symbols in your codebase, instead of cold-grepping every session.
98
98
 
@@ -124,7 +124,7 @@ codehere memory show protocols # safety patterns by category
124
124
 
125
125
  **Other memory commands:**
126
126
  - `codehere memory init` — migrate the v0.4.1 layout (`CONTEXT.md` / `HANDOVER.md` / `PROMPTS.md`) into the F3 layout (one shot, leaves back-compat symlinks; `--dry-run` previews)
127
- - `codehere memory repo-map [--rebuild]` — build the symbol map; v0.5 ships a TS-native MVP, v0.6 swaps in tree-sitter
127
+ - `codehere memory repo-map [--rebuild]` — build the symbol map; currently TS-native MVP
128
128
  - `codehere protocols export [--rebuild]` — write the trust-scorer pattern bank to `.codehere/protocols/hook_patterns.json` for inspection / cross-tool ingestion
129
129
 
130
130
  ## Going deeper
@@ -204,7 +204,7 @@ For depth beyond this README:
204
204
  ## Requirements
205
205
 
206
206
  - **Node.js 20+** (pinned in `.nvmrc` and `engines.node`)
207
- - **macOS or Linux** · Windows via WSL works for development; native Windows is on the v0.4 roadmap
207
+ - **macOS or Linux** · Windows via WSL works for development
208
208
  - **Optional but recommended:** at least one supported agent (Claude Code, Codex, Aider, OpenCode, or Gemini) installed before running `codehere start`
209
209
 
210
210
  ## License
@@ -61,7 +61,9 @@ ${i.steps.length ? i.steps.map((s) => `- ${s.label} (${s.state})`).join('\n') :
61
61
 
62
62
  Files changed:
63
63
  ${i.artifacts?.files?.length
64
- ? i.artifacts.files.map((f) => `- ${f.path} (+${f.added}/-${f.removed}${f.trust !== undefined ? `, trust: ${f.trust}` : ''})`).join('\n')
64
+ ? i.artifacts.files
65
+ .map((f) => `- ${f.path} (+${f.added}/-${f.removed}${f.trust !== undefined ? `, trust: ${f.trust}` : ''})`)
66
+ .join('\n')
65
67
  : '(no file artifacts captured)'}
66
68
 
67
69
  Token usage: ${i.artifacts?.usage?.inputTokens ?? 0} in / ${i.artifacts?.usage?.outputTokens ?? 0} out
@@ -75,16 +77,69 @@ function selectProviderBinary() {
75
77
  return [override];
76
78
  return ['claude-free', 'claude'];
77
79
  }
80
+ /**
81
+ * UAT-003 — patterns that indicate the wrapped agent's session terminated
82
+ * because of a network / auth / quota failure rather than a real task.
83
+ * When we see these in the would-be summary text, we prepend a plain-English
84
+ * recovery hint instead of letting the raw provider error echo through.
85
+ *
86
+ * Pre-fix: bare `exit` in REPL → Conductor spawned claude-code → real API
87
+ * call failed with "All connection attempts failed Request ID: req_...";
88
+ * Final Report quoted that text verbatim. Per Core Values #4 + #6, this is
89
+ * unacceptable — the user sees a Request ID that means nothing.
90
+ */
91
+ const NETWORK_FAILURE_PATTERNS = /(?:all connection attempts failed|ECONNREFUSED|ENOTFOUND|fetch failed|network error|request id:\s*req_|connection timed out|getaddrinfo)/i;
92
+ function isLikelyNetworkFailureSummary(text) {
93
+ return NETWORK_FAILURE_PATTERNS.test(text);
94
+ }
95
+ function buildErrorRecoveryReport(input, durationMs) {
96
+ // Tailor the hint to the specific failure shape we can detect from input.
97
+ const agentName = input.agent === 'conductor' ? 'the agent codehere chose' : input.agent;
98
+ return {
99
+ summary: `The ${agentName} session ended without completing the task. ` +
100
+ `This usually means a network hiccup, an expired login, or a rate limit on the provider.\n\n` +
101
+ `What to try: check your internet, run \`codehere health\` to confirm reachability + auth, ` +
102
+ `or wait a minute and rerun the same prompt. If it keeps happening, try a different agent ` +
103
+ `from the dropdown — codehere is vendor-neutral on purpose.\n\n` +
104
+ `No files were changed in this attempt.`,
105
+ generatedAt: Date.now(),
106
+ durationMs,
107
+ };
108
+ }
78
109
  /**
79
110
  * Generate a Final Report. Returns null if no provider is reachable.
80
111
  * Never throws — failure modes return null + log a debug line.
112
+ *
113
+ * UAT-003 — when the input.status is 'error', we skip the LLM call entirely
114
+ * and return a deterministic recovery hint. Saves tokens on a known-failed
115
+ * task AND gives the user a plain-English next step instead of waiting for
116
+ * a possibly-unhelpful LLM summary.
81
117
  */
82
118
  export async function generateFinalReport(input) {
119
+ // Short-circuit on errored sessions — don't waste tokens summarizing nothing.
120
+ if (input.status === 'error') {
121
+ return buildErrorRecoveryReport(input, 0);
122
+ }
83
123
  const prompt = PROMPT_TEMPLATE(input);
84
124
  for (const binary of selectProviderBinary()) {
85
125
  const result = await tryProvider(binary, prompt, input.sessionId);
86
- if (result)
126
+ if (result) {
127
+ // Defense-in-depth: even on 'completed' status, if the generated
128
+ // summary leaks a provider network-failure string (e.g., the wrapped
129
+ // agent exited 0 but its assistant text was an error message), wrap
130
+ // the summary with a recovery preamble.
131
+ if (isLikelyNetworkFailureSummary(result.summary)) {
132
+ return {
133
+ summary: `Couldn't reach the provider cleanly — the agent's session text looks like a network or auth failure.\n\n` +
134
+ `What to try: check your internet, run \`codehere health\`, or rerun in a minute. ` +
135
+ `If it persists, switch agents from the dropdown.\n\n` +
136
+ `— Raw agent message —\n${result.summary}`,
137
+ generatedAt: result.generatedAt,
138
+ durationMs: result.durationMs,
139
+ };
140
+ }
87
141
  return result;
142
+ }
88
143
  }
89
144
  return null;
90
145
  }
@@ -75,11 +75,17 @@ function tryProvider(binary, prompt, sessionId, stepCount) {
75
75
  try {
76
76
  child.kill();
77
77
  }
78
- catch { /* ignore */ }
78
+ catch {
79
+ /* ignore */
80
+ }
79
81
  finish(null);
80
82
  }, 20_000);
81
- child.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf-8'); });
82
- child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf-8'); });
83
+ child.stdout?.on('data', (chunk) => {
84
+ stdout += chunk.toString('utf-8');
85
+ });
86
+ child.stderr?.on('data', (chunk) => {
87
+ stderr += chunk.toString('utf-8');
88
+ });
83
89
  child.on('close', (code) => {
84
90
  clearTimeout(timeout);
85
91
  if (code !== 0) {
package/dist/index.js CHANGED
@@ -15,6 +15,16 @@ import { config } from 'dotenv';
15
15
  import { join, dirname } from 'path';
16
16
  import { fileURLToPath } from 'url';
17
17
  import { homedir } from 'os';
18
+ // ── Global crash safety ───────────────────────────────────────────────────
19
+ // Unhandled rejections and exceptions must never silently kill the daemon.
20
+ process.on('unhandledRejection', (reason) => {
21
+ console.error('[codehere] unhandledRejection:', reason instanceof Error ? reason.stack : reason);
22
+ });
23
+ process.on('uncaughtException', (err) => {
24
+ console.error('[codehere] uncaughtException:', err.stack);
25
+ // persist what we can before dying
26
+ process.exitCode = 1;
27
+ });
18
28
  import { executeSetupCommand } from './presentation/cli/commands/setup-command.js';
19
29
  import { executeInitCommand } from './presentation/cli/commands/init-command.js';
20
30
  import { executeMemoryCommand } from './presentation/cli/commands/memory-command.js';
@@ -67,7 +77,7 @@ program
67
77
  // typo guard miss the unknown-command shape. See typo-guard.ts.
68
78
  .argument('[query...]', 'Optional initial task to delegate (otherwise enter interactive REPL)')
69
79
  .action(async (queryParts) => {
70
- const joined = (queryParts && queryParts.length > 0) ? queryParts.join(' ') : '';
80
+ const joined = queryParts && queryParts.length > 0 ? queryParts.join(' ') : '';
71
81
  // One-shot delegate: `codehere "review src/auth.ts"` runs that task and exits.
72
82
  // Useful for scripting or quick one-offs without entering the REPL.
73
83
  if (joined && joined.trim()) {
@@ -464,6 +474,7 @@ program
464
474
  console.log(colors.dim('\ncodehere MCP server stopped'));
465
475
  process.exit(0);
466
476
  });
477
+ process.on('SIGTERM', () => process.exit(0));
467
478
  await new Promise((resolve) => {
468
479
  server.on('close', resolve);
469
480
  });
@@ -565,15 +576,34 @@ program
565
576
  startCloudSync();
566
577
  }
567
578
  await openBrowser();
568
- process.on('SIGINT', () => {
579
+ const shutdown = () => {
580
+ try {
581
+ const { SessionManager } = require('./infrastructure/orchestration/session-manager.js');
582
+ SessionManager.getInstance().shutdown();
583
+ }
584
+ catch {
585
+ /* graceful degradation — daemon may not have started fully */
586
+ }
569
587
  if (cloud) {
570
- const { stopCloudSync } = require('./infrastructure/protocols/cloud-sync.js');
571
- stopCloudSync();
588
+ try {
589
+ const { stopCloudSync } = require('./infrastructure/protocols/cloud-sync.js');
590
+ stopCloudSync();
591
+ }
592
+ catch {
593
+ /* cloud sync may not be loaded */
594
+ }
572
595
  }
573
596
  server.close();
597
+ };
598
+ process.on('SIGINT', () => {
599
+ shutdown();
574
600
  console.log(colors.dim('\ncodehere stopped'));
575
601
  process.exit(0);
576
602
  });
603
+ process.on('SIGTERM', () => {
604
+ shutdown();
605
+ process.exit(0);
606
+ });
577
607
  await new Promise((resolve) => {
578
608
  server.on('close', resolve);
579
609
  });
@@ -84,7 +84,7 @@ export function saveBudget(cfg) {
84
84
  mkdirSync(dir, { recursive: true });
85
85
  const lines = [
86
86
  '# codehere daily-spend budget. Edit by hand or via `codehere budget set <amount>`.',
87
- '# daily_usd: hard ceiling for today\'s estimated spend. Set to a number to enable.',
87
+ "# daily_usd: hard ceiling for today's estimated spend. Set to a number to enable.",
88
88
  '# Leave commented out / omit to disable budget enforcement entirely.',
89
89
  ];
90
90
  if (cfg.dailyUsd !== null) {
@@ -36,11 +36,11 @@ function sessionsFile() {
36
36
  * under-promise and miss a real bill. */
37
37
  const COST_PER_M_OUTPUT_TOKENS_USD = {
38
38
  'claude-code': 15.0, // Sonnet 4.6 baseline
39
- 'codex': 10.0, // GPT-5 / codex baseline
40
- 'aider': 15.0, // typically Sonnet
41
- 'gemini': 0, // free tier (Google AI Studio)
42
- 'opencode': 0, // typically free local / NIM-routed
43
- 'conductor': 15.0, // routes to Claude Code by default
39
+ codex: 10.0, // GPT-5 / codex baseline
40
+ aider: 15.0, // typically Sonnet
41
+ gemini: 0, // free tier (Google AI Studio)
42
+ opencode: 0, // typically free local / NIM-routed
43
+ conductor: 15.0, // routes to Claude Code by default
44
44
  };
45
45
  const INPUT_RATE_RATIO = 0.2; // input ≈ 1/5 of output
46
46
  const CACHE_READ_RATIO = 0.1; // cache reads ≈ 1/10 of input (so 1/50 output)
@@ -13,13 +13,20 @@
13
13
  *
14
14
  * Docs: https://docs.anthropic.com/en/docs/claude-code/hooks
15
15
  */
16
- import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readSync, statSync, readFileSync, } from 'fs';
16
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readSync, statSync, readFileSync, unlinkSync, } from 'fs';
17
17
  import { join } from 'path';
18
18
  import { homedir } from 'os';
19
19
  import { createHash } from 'crypto';
20
20
  // P18 slice 2 — Tier 1 hook honors per-cwd permissions.yaml.
21
21
  import { filterAllowed } from '../security/permissions-config.js';
22
- const PATTERNS = [
22
+ /**
23
+ * Instruction-level scan pattern bank used by `scanPayload()`. Exported so
24
+ * docs and the landing page can quote `PATTERNS.length` instead of hardcoding
25
+ * a number that silently drifts (May 2026 audit found 50 / 47 / 60+ all in
26
+ * the same PRD). Pair this with `CODE_PATTERNS` from `trust-scorer.ts` — the
27
+ * two banks are scanned in different passes and counted separately.
28
+ */
29
+ export const PATTERNS = [
23
30
  // Prompt Injection
24
31
  // Allow chained modifiers: "ignore all previous instructions", "ignore all your prior rules", etc.
25
32
  {
@@ -494,6 +501,42 @@ function readChainTip(filepath) {
494
501
  return null;
495
502
  }
496
503
  export function appendAuditLog(entry) {
504
+ // Cross-process file locking. Multiple Claude Code sessions (e.g., two
505
+ // terminals, or IDE + terminal) can trigger PreToolUse concurrently.
506
+ // Without a lock, two hook processes read the same chain tip and both
507
+ // write seq 43 — corrupting the hash chain. We use an exclusive-create
508
+ // lock file (+O_EXCL) which is atomic on all POSIX systems.
509
+ const LOCK_FILE = AUDIT_FILE + '.lock';
510
+ const lockAcquired = () => {
511
+ for (let attempt = 0; attempt < 20; attempt++) {
512
+ try {
513
+ const fd = openSync(LOCK_FILE, 'wx');
514
+ closeSync(fd);
515
+ return true;
516
+ }
517
+ catch {
518
+ // Lock held by another process — spin-wait ~10ms
519
+ const deadline = Date.now() + 10;
520
+ while (Date.now() < deadline) {
521
+ /* busy-wait — sync context */
522
+ }
523
+ }
524
+ }
525
+ return false;
526
+ };
527
+ const lockReleased = () => {
528
+ try {
529
+ unlinkSync(LOCK_FILE);
530
+ }
531
+ catch {
532
+ /* best effort */
533
+ }
534
+ };
535
+ if (!lockAcquired()) {
536
+ // Fall through unlocked rather than drop the entry. The entry still
537
+ // appends; verification may flag a chain break, but the audit data
538
+ // is preserved.
539
+ }
497
540
  try {
498
541
  const dir = AUDIT_FILE.substring(0, AUDIT_FILE.lastIndexOf('/'));
499
542
  if (dir && !existsSync(dir)) {
@@ -526,6 +569,9 @@ export function appendAuditLog(entry) {
526
569
  catch {
527
570
  // Audit log failure must not block tool execution
528
571
  }
572
+ finally {
573
+ lockReleased();
574
+ }
529
575
  }
530
576
  /**
531
577
  * Walk the audit file and verify the hash chain. Returns:
@@ -216,9 +216,7 @@ export function spawnCodex(prompt, options = {}) {
216
216
  continue;
217
217
  try {
218
218
  const data = JSON.parse(line);
219
- if (codexThreadId === undefined &&
220
- data.type === 'thread.started' &&
221
- typeof data.thread_id === 'string') {
219
+ if (codexThreadId === undefined && data.type === 'thread.started' && typeof data.thread_id === 'string') {
222
220
  codexThreadId = data.thread_id;
223
221
  }
224
222
  emit({ type: data.type ?? 'codex_event', timestamp: Date.now(), data });
@@ -325,8 +323,15 @@ export function spawnCodex(prompt, options = {}) {
325
323
  let stdout = '';
326
324
  let stderr = '';
327
325
  // codex exec resume <SESSION_ID> - → reads prompt from stdin
328
- const followup = spawn('codex', ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox',
329
- 'resume', codexThreadId, '-'], {
326
+ const followup = spawn('codex', [
327
+ 'exec',
328
+ '--json',
329
+ '--skip-git-repo-check',
330
+ '--dangerously-bypass-approvals-and-sandbox',
331
+ 'resume',
332
+ codexThreadId,
333
+ '-',
334
+ ], {
330
335
  cwd,
331
336
  env: spawnEnv,
332
337
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -371,7 +376,7 @@ export function spawnCodex(prompt, options = {}) {
371
376
  export const CodexAdapter = {
372
377
  id: 'codex',
373
378
  label: 'Codex',
374
- description: 'OpenAI\'s coding agent. Good when the task needs reading a lot of code first, then changing several files together.',
379
+ description: "OpenAI's coding agent. Good when the task needs reading a lot of code first, then changing several files together.",
375
380
  processPattern: /(?:^|\/)codex(?:\s|$)/,
376
381
  isInstalled() {
377
382
  try {
@@ -58,7 +58,7 @@ export function loadHandoffContext(cwd, { fromSessionId, maxAgeMs = DEFAULT_MAX_
58
58
  // Filter by age
59
59
  const recent = packs.filter((p) => {
60
60
  const ts = p.timestamp;
61
- return ts && (now - ts) < maxAgeMs;
61
+ return ts && now - ts < maxAgeMs;
62
62
  });
63
63
  // Prefer packs with enough graph structure to be useful
64
64
  const structured = recent.filter((p) => {
@@ -48,13 +48,7 @@ import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
48
48
  //
49
49
  // `--skip-trust` — skip the per-workspace trust prompt that gemini shows on
50
50
  // first run in a new directory.
51
- const HEADLESS_FLAGS = [
52
- '--approval-mode',
53
- 'yolo',
54
- '--skip-trust',
55
- '-o',
56
- 'stream-json',
57
- ];
51
+ const HEADLESS_FLAGS = ['--approval-mode', 'yolo', '--skip-trust', '-o', 'stream-json'];
58
52
  // Error patterns scoped to STDERR / result.error / error events only — NOT
59
53
  // every JSON-serialized event (which would false-positive on tool output).
60
54
  const ERROR_PATTERNS = [
@@ -340,13 +334,7 @@ export function spawnGemini(prompt, options = {}) {
340
334
  }
341
335
  }
342
336
  statusValue =
343
- killRequested || signal !== null
344
- ? 'killed'
345
- : errorEmitted
346
- ? 'error'
347
- : code === 0
348
- ? 'completed'
349
- : 'error';
337
+ killRequested || signal !== null ? 'killed' : errorEmitted ? 'error' : code === 0 ? 'completed' : 'error';
350
338
  if (aggregator.finalize(statusValue === 'completed' ? 'completed' : statusValue === 'killed' ? 'killed' : 'error')) {
351
339
  stepsSnapshot = aggregator.steps.map((s) => ({ ...s }));
352
340
  emit({ type: 'codehere:steps', timestamp: Date.now(), data: { steps: stepsSnapshot } });
@@ -518,7 +506,7 @@ export function spawnGemini(prompt, options = {}) {
518
506
  export const GeminiAdapter = {
519
507
  id: 'gemini',
520
508
  label: 'Gemini',
521
- description: 'Google\'s coding agent. Has a free tier and can read very large amounts of code or docs at once. Useful when the paid agents are out of credits.',
509
+ description: "Google's coding agent. Has a free tier and can read very large amounts of code or docs at once. Useful when the paid agents are out of credits.",
522
510
  // Match `gemini`, `node /path/.../gemini`, and `./gemini` invocations.
523
511
  processPattern: /(?:^|\/)gemini(?:\s|$)|node\s+\S*gemini/,
524
512
  isInstalled() {
@@ -74,7 +74,7 @@ export function buildGraphSync(cwd, { incremental = true } = {}) {
74
74
  * Compile a compact context pack for a prompt + workspace.
75
75
  * Synchronous — calls graphify-ts pack and returns the payload.
76
76
  */
77
- export function compileContextPackSync(prompt, cwd, { task = 'explain', budget = 3000, } = {}) {
77
+ export function compileContextPackSync(prompt, cwd, { task = 'explain', budget = 3000 } = {}) {
78
78
  const startTime = Date.now();
79
79
  const graphPath = getGraphPath(cwd);
80
80
  // Build graph if it doesn't exist yet
@@ -86,12 +86,7 @@ export function compileContextPackSync(prompt, cwd, { task = 'explain', budget =
86
86
  return null;
87
87
  }
88
88
  try {
89
- const output = execFileSync('npx', [
90
- 'graphify-ts', 'pack', prompt,
91
- '--graph', graphPath,
92
- '--task', task,
93
- '--budget', String(budget),
94
- ], {
89
+ const output = execFileSync('npx', ['graphify-ts', 'pack', prompt, '--graph', graphPath, '--task', task, '--budget', String(budget)], {
95
90
  cwd,
96
91
  encoding: 'utf-8',
97
92
  maxBuffer: 512 * 1024,
@@ -125,7 +120,9 @@ export function compileSessionPackSync(cwd, { prompt, artifacts, steps, agent })
125
120
  agent ? `Agent: ${agent}` : undefined,
126
121
  artifacts ? `Artifacts:\n${artifacts}` : undefined,
127
122
  steps ? `Steps taken:\n${steps}` : undefined,
128
- ].filter(Boolean).join('\n\n');
123
+ ]
124
+ .filter(Boolean)
125
+ .join('\n\n');
129
126
  return compileContextPackSync(sessionContext, cwd, { task: 'impact', budget: 2500 });
130
127
  }
131
128
  // ── Context pack persistence ───────────────────────────────────────────────────
@@ -232,11 +229,7 @@ export function queryGraphSync(question, cwd, { maxTokens = 2000 } = {}) {
232
229
  return null;
233
230
  }
234
231
  try {
235
- const output = execFileSync('npx', [
236
- 'graphify-ts', 'query', question,
237
- '--graph', graphPath,
238
- '--budget', String(maxTokens),
239
- ], {
232
+ const output = execFileSync('npx', ['graphify-ts', 'query', question, '--graph', graphPath, '--budget', String(maxTokens)], {
240
233
  cwd,
241
234
  encoding: 'utf-8',
242
235
  maxBuffer: 256 * 1024,
@@ -65,17 +65,13 @@ function looksLikeInitSeed(content, kind) {
65
65
  /\[Tone for generated code\./,
66
66
  /\[Don't do X\. Always do Y\./,
67
67
  ];
68
- const hits = markers.filter(re => re.test(content)).length;
68
+ const hits = markers.filter((re) => re.test(content)).length;
69
69
  return hits >= 2;
70
70
  }
71
71
  if (kind === 'session') {
72
72
  // The init session.md seed has this header + structure
73
- const markers = [
74
- /^# Working session/m,
75
- /^> Per-machine, gitignored/m,
76
- /^## In flight\s*\n+- \[ \]\s*$/m,
77
- ];
78
- const hits = markers.filter(re => re.test(content)).length;
73
+ const markers = [/^# Working session/m, /^> Per-machine, gitignored/m, /^## In flight\s*\n+- \[ \]\s*$/m];
74
+ const hits = markers.filter((re) => re.test(content)).length;
79
75
  return hits >= 2;
80
76
  }
81
77
  return false;
@@ -142,9 +138,9 @@ function readLessons(codehereDir, n) {
142
138
  const body = readFileSync(p, 'utf8');
143
139
  return body
144
140
  .split('\n')
145
- .filter(line => line.trim().length > 0)
141
+ .filter((line) => line.trim().length > 0)
146
142
  .slice(0, n)
147
- .map(line => {
143
+ .map((line) => {
148
144
  try {
149
145
  const obj = JSON.parse(line);
150
146
  return typeof obj.text === 'string' ? obj.text : line;
@@ -182,11 +178,11 @@ function formatSession(session) {
182
178
  return `# Current session scratch (.codehere/memory/working/session.md)\n\n${session}\n`;
183
179
  }
184
180
  function formatLessons(lessons) {
185
- const items = lessons.map(l => `- ${l}`).join('\n');
181
+ const items = lessons.map((l) => `- ${l}`).join('\n');
186
182
  return `# Project lessons (.codehere/memory/semantic/lessons.jsonl)\n\n${items}\n`;
187
183
  }
188
184
  function formatSymbols(symbols) {
189
- const lines = symbols.map(s => `- \`${s.name}\` — ${s.file}:${s.line} (${s.references} refs)`).join('\n');
185
+ const lines = symbols.map((s) => `- \`${s.name}\` — ${s.file}:${s.line} (${s.references} refs)`).join('\n');
190
186
  return `# Top symbols in this repo (.codehere/memory/episodic/repo_map.json)\n\n${lines}\n`;
191
187
  }
192
188
  /**
@@ -232,7 +228,7 @@ export function buildMemoryPrefix(projectRoot, options = {}) {
232
228
  let kept = [...candidates];
233
229
  while (kept.length > 0) {
234
230
  const totalChars = kept.reduce((acc, c) => acc + c.body.length, 0);
235
- const totalTokens = estimateTokens(kept.map(c => c.body).join('\n')) + separatorTokens;
231
+ const totalTokens = estimateTokens(kept.map((c) => c.body).join('\n')) + separatorTokens;
236
232
  if (totalTokens <= maxTokens)
237
233
  break;
238
234
  // Drop the last (least-essential) section.
@@ -250,10 +246,10 @@ export function buildMemoryPrefix(projectRoot, options = {}) {
250
246
  if (kept.length === 0) {
251
247
  return { prefix: '', included: [], tokens: 0, truncated };
252
248
  }
253
- const prefix = kept.map(c => c.body).join('\n') + SEPARATOR;
249
+ const prefix = kept.map((c) => c.body).join('\n') + SEPARATOR;
254
250
  return {
255
251
  prefix,
256
- included: kept.map(c => c.key),
252
+ included: kept.map((c) => c.key),
257
253
  tokens: estimateTokens(prefix),
258
254
  truncated,
259
255
  };
@@ -296,8 +292,7 @@ function findProjectRoot(startPath) {
296
292
  }
297
293
  // Bound the loop at the filesystem root by checking dirname stability.
298
294
  for (;;) {
299
- if (existsSync(join(current, '.codehere')) ||
300
- existsSync(join(current, 'AGENTS.md'))) {
295
+ if (existsSync(join(current, '.codehere')) || existsSync(join(current, 'AGENTS.md'))) {
301
296
  return current;
302
297
  }
303
298
  const parent = dirname(current);
@@ -19,6 +19,7 @@ import { join } from 'path';
19
19
  import { promisify } from 'util';
20
20
  import { ProgressAggregator } from './progress-aggregator.js';
21
21
  import { applyMemoryPrefix } from './memory-injector.js';
22
+ import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
22
23
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
23
24
  const execFileAsync = promisify(execFile);
24
25
  const HEADLESS_FLAGS = ['run', '--format', 'json'];
@@ -142,6 +143,46 @@ export function spawnOpenCode(prompt, options = {}) {
142
143
  if (txt)
143
144
  textAccum += txt + '\n';
144
145
  }
146
+ // #114 — cross-vendor audit mirror for opencode (closes the gap left by
147
+ // #110 slice 4, which wired Codex/Aider/Gemini but deferred opencode).
148
+ // OpenCode's `run --format json` emits `tool_use` events with shape
149
+ // { type: 'tool_use', part: { tool: 'bash'|'read'|'write'|..., state:
150
+ // { input: {...}, output, status, ... } } } (verified via deepwiki +
151
+ // takopi cheatsheet). Same post-hoc visibility surface as Codex/Aider/
152
+ // Gemini — no synchronous block until upstream pre-tool hooks land.
153
+ if (event.type === 'tool_use') {
154
+ const part = event.data?.part;
155
+ const rawTool = String(part?.tool ?? 'tool').toLowerCase();
156
+ // Normalize to Claude-cased tool names so /history filter chips group
157
+ // cross-vendor (a bash call from claude-code and opencode show under
158
+ // the same "Bash" filter chip).
159
+ const toolNameMap = {
160
+ bash: 'Bash',
161
+ read: 'Read',
162
+ write: 'Write',
163
+ edit: 'Edit',
164
+ grep: 'Grep',
165
+ glob: 'Glob',
166
+ webfetch: 'WebFetch',
167
+ websearch: 'WebSearch',
168
+ task: 'Task',
169
+ };
170
+ const toolName = toolNameMap[rawTool] ?? rawTool;
171
+ const input = part?.state?.input ?? {};
172
+ const inputStr = (typeof input.command === 'string' && input.command) ||
173
+ (typeof input.file_path === 'string' && input.file_path) ||
174
+ (typeof input.path === 'string' && input.path) ||
175
+ (typeof input.pattern === 'string' && input.pattern) ||
176
+ (typeof input.query === 'string' && input.query) ||
177
+ JSON.stringify(input).slice(0, 500);
178
+ mirrorToolCallToAudit({
179
+ sessionId,
180
+ agent: 'opencode',
181
+ toolName,
182
+ toolInput: String(inputStr),
183
+ cwd,
184
+ });
185
+ }
145
186
  if (!errorEmitted) {
146
187
  const data = event.data;
147
188
  let text = '';
@@ -393,7 +434,7 @@ export function spawnOpenCode(prompt, options = {}) {
393
434
  export const OpenCodeAdapter = {
394
435
  id: 'opencode',
395
436
  label: 'OpenCode',
396
- description: 'Runs several agents at once for genuinely independent jobs. You won\'t see live progress while it works — the trade-off for the speed of doing things in parallel.',
437
+ description: "Runs several agents at once for genuinely independent jobs. You won't see live progress while it works — the trade-off for the speed of doing things in parallel.",
397
438
  processPattern: /(?:^|\/)opencode(?:\s|$)/,
398
439
  isInstalled() {
399
440
  try {
@@ -464,8 +464,7 @@ export class ProgressAggregator {
464
464
  if (itemType === 'tool_call' || itemType === 'local_shell_call') {
465
465
  const name = String(item.tool_name ?? item.name ?? 'tool');
466
466
  // Tool-call items may carry a `result` or `output` field too.
467
- const result = (typeof item.result === 'string' ? item.result : '') ||
468
- (typeof item.output === 'string' ? item.output : '');
467
+ const result = (typeof item.result === 'string' ? item.result : '') || (typeof item.output === 'string' ? item.output : '');
469
468
  this._steps.push({
470
469
  label: truncate(name),
471
470
  state: 'done',