codehere 0.5.0-alpha.6 → 0.6.0

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 (39) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +89 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/context-handoff.js +143 -0
  17. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  18. package/dist/infrastructure/orchestration/failover-config.js +77 -0
  19. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  20. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  21. package/dist/infrastructure/orchestration/graphify-context.js +256 -0
  22. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  23. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  24. package/dist/infrastructure/orchestration/session-manager.js +515 -15
  25. package/dist/infrastructure/protocols/cloud-sync.js +208 -0
  26. package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
  27. package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
  28. package/dist/infrastructure/protocols/ui-page.js +2872 -220
  29. package/dist/infrastructure/security/permissions-config.js +234 -0
  30. package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
  31. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  32. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  33. package/dist/presentation/cli/commands/budget-command.js +79 -0
  34. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  35. package/dist/presentation/cli/commands/permission-command.js +116 -0
  36. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  37. package/dist/presentation/cli/repl.js +29 -0
  38. package/dist/utils/typo-guard.js +77 -17
  39. package/package.json +2 -1
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Cross-vendor tool-call audit mirror — #110 slice 2 (Codex first).
3
+ *
4
+ * When non-Claude adapters emit a tool-call event, we can't BLOCK
5
+ * (no synchronous pre-tool intercept yet) but we CAN mirror the
6
+ * call into the audit log post-hoc. Closes the audit-trail-parity
7
+ * gap: every adapter's tool calls land in the same hook-events.jsonl
8
+ * the per-tool Claude hook writes to.
9
+ *
10
+ * Future slices (per-adapter blocking) replace the post-hoc mirror
11
+ * with pre-call intercepts. For now: visibility today, blocking next.
12
+ */
13
+ import { scanPayload, calculateRiskScore, appendAuditLog } from './claude-sdk-hook.js';
14
+ export function mirrorToolCallToAudit(rec) {
15
+ // Run the same pattern bank the per-tool hook uses, so post-hoc
16
+ // findings show up in /history with the same severity calibration.
17
+ const findings = scanPayload(rec.toolInput);
18
+ const riskScore = calculateRiskScore(findings);
19
+ const truncated = rec.toolInput.length > 500 ? rec.toolInput.slice(0, 500) + '…' : rec.toolInput;
20
+ const entry = {
21
+ timestamp: new Date().toISOString(),
22
+ sessionId: rec.sessionId,
23
+ toolName: rec.toolName,
24
+ toolInput: JSON.stringify({
25
+ agent: rec.agent,
26
+ input: truncated,
27
+ cwd: rec.cwd ?? null,
28
+ inputLength: rec.toolInput.length,
29
+ mirrored: true, // marks post-hoc vs the synchronous Claude hook
30
+ }),
31
+ riskScore,
32
+ blocked: false, // post-hoc — can't block, only log
33
+ findings: findings.length,
34
+ criticalFindings: findings.filter((f) => f.severity === 'critical').length,
35
+ };
36
+ try {
37
+ appendAuditLog(entry);
38
+ }
39
+ catch {
40
+ // Audit-write failures don't propagate — the agent's already running.
41
+ }
42
+ }
43
+ //# sourceMappingURL=mirror-tool-call-to-audit.js.map
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Pre-prompt safety scan — cross-vendor parity, task #110.
3
+ *
4
+ * The substrate the FAQ landing claims for v0.5.x → v0.6: "per-tool
5
+ * blocking parity for Codex / Aider / OpenCode / Gemini." Today, only
6
+ * Claude Code has a per-tool PreToolUse hook. The other 4 adapters
7
+ * spawn raw — every Bash/Edit they make goes unscanned.
8
+ *
9
+ * Closing that gap fully needs per-adapter pre-tool intercepts (huge
10
+ * scope per-adapter — different IPC + lifecycle for each CLI). This
11
+ * module ships the FIRST cross-vendor slice: scan the user's PROMPT
12
+ * before spawn. Catches the prompt-side leaks (API key in prompt,
13
+ * injection-pattern poisoning, credential paste) for ALL 5 adapters
14
+ * with one piece of code.
15
+ *
16
+ * Posture follows getHookMode():
17
+ * block → high+ severity findings refuse spawn (throw)
18
+ * advisory → findings logged + warning event, spawn proceeds
19
+ * log-only → findings logged, spawn proceeds, no warning
20
+ * off → no scan, spawn proceeds
21
+ *
22
+ * Every scan writes one audit-log entry with synthetic toolName
23
+ * 'pre_prompt' so the existing /history feed + hash chain pick it up
24
+ * without surface changes.
25
+ */
26
+ import { scanPayload, calculateRiskScore, appendAuditLog, } from './claude-sdk-hook.js';
27
+ import { getHookMode } from './hook-mode.js';
28
+ import { filterAllowed } from '../security/permissions-config.js';
29
+ /**
30
+ * Scan a user prompt before spawn. Always writes one audit-log entry.
31
+ * Returns allowed=false ONLY when mode === 'block' AND there's a
32
+ * high-or-critical finding. Other modes always allow but still log.
33
+ */
34
+ export function scanPromptBeforeSpawn(input) {
35
+ const rawFindings = scanPayload(input.prompt);
36
+ // P18 slice 1 — filter against the cwd's permissions.yaml allow-list.
37
+ // Allow-listed findings are dropped from the BLOCK calculation but
38
+ // still recorded in the audit-log allowed-list field so a forensic
39
+ // reviewer can see what was suppressed and why.
40
+ const { surviving, allowed } = input.cwd
41
+ ? filterAllowed(rawFindings, input.cwd)
42
+ : { surviving: rawFindings, allowed: [] };
43
+ const findings = surviving;
44
+ const riskScore = calculateRiskScore(findings);
45
+ const mode = (() => {
46
+ try {
47
+ return getHookMode();
48
+ }
49
+ catch {
50
+ return 'off';
51
+ }
52
+ })();
53
+ // Severity gate: only critical/high findings can refuse a spawn.
54
+ // Medium/low findings get logged but never block — they're flagged
55
+ // for visibility, not enforcement. Matches the per-tool hook's
56
+ // existing severity-to-action policy.
57
+ const hardFindings = findings.filter((f) => f.severity === 'critical' || f.severity === 'high');
58
+ const wouldBlock = hardFindings.length > 0;
59
+ const blocked = mode === 'block' && wouldBlock;
60
+ // Audit entry — written for every scan, regardless of mode. Synthetic
61
+ // toolName 'pre_prompt' lets /history filter chips group these
62
+ // separately from per-tool entries (existing chip filters on tool).
63
+ const entry = {
64
+ timestamp: new Date().toISOString(),
65
+ sessionId: input.sessionId,
66
+ toolName: 'pre_prompt',
67
+ // Truncate the prompt for the audit record — full prompts can be
68
+ // 10K+ chars and we don't want the audit log to balloon.
69
+ toolInput: JSON.stringify({
70
+ agent: input.agent,
71
+ prompt: input.prompt.length > 500 ? input.prompt.slice(0, 500) + '…' : input.prompt,
72
+ cwd: input.cwd ?? null,
73
+ promptLength: input.prompt.length,
74
+ // P18 slice 1 — record what got allow-listed for forensic audit.
75
+ // Empty array when no permissions.yaml exists in the cwd.
76
+ ...(allowed.length > 0
77
+ ? {
78
+ allowedByPermissions: allowed.map((a) => ({
79
+ category: a.finding.category,
80
+ matched: a.finding.matched,
81
+ reason: a.entry.reason ?? '(no reason given)',
82
+ })),
83
+ }
84
+ : {}),
85
+ }),
86
+ riskScore,
87
+ blocked,
88
+ findings: findings.length,
89
+ criticalFindings: findings.filter((f) => f.severity === 'critical').length,
90
+ // Mode-driven override marker so the audit reader knows WHY a
91
+ // would-block finding wasn't enforced. Reuses the existing
92
+ // OverrideReason 'log_only_mode' for log-only AND advisory + off
93
+ // (all three skip the block; 'log_only_mode' is the closest
94
+ // shared semantic — the entry's mode field can be added as a new
95
+ // OverrideReason later without breaking the audit chain).
96
+ ...(wouldBlock && !blocked ? { overrideReason: 'log_only_mode' } : {}),
97
+ };
98
+ try {
99
+ appendAuditLog(entry);
100
+ }
101
+ catch {
102
+ // Audit-write failure should NOT itself block a spawn — that would
103
+ // be a denial-of-service vector (fill the log dir → no spawns).
104
+ // The audit infra has its own failure-mode logging.
105
+ }
106
+ // Compose a block reason from the worst findings. Used by the
107
+ // session-manager to throw an actionable error.
108
+ let blockReason = '';
109
+ if (blocked) {
110
+ const top = hardFindings.slice(0, 3);
111
+ const summary = top.map((f) => `[${f.severity}] ${f.description}`).join('; ');
112
+ blockReason = `pre-prompt scan blocked: ${summary}`;
113
+ }
114
+ return {
115
+ allowed: !blocked,
116
+ blocked,
117
+ riskScore,
118
+ findings,
119
+ blockReason,
120
+ };
121
+ }
122
+ //# sourceMappingURL=pre-prompt-scan.js.map
@@ -17,6 +17,7 @@ import { EventEmitter } from 'events';
17
17
  import { randomUUID } from 'crypto';
18
18
  import { ProgressAggregator } from './progress-aggregator.js';
19
19
  import { applyMemoryPrefix } from './memory-injector.js';
20
+ import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
20
21
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
21
22
  const HEADLESS_FLAGS = [
22
23
  '--yes-always', // auto-accept all file-edit confirmations
@@ -99,6 +100,32 @@ export function spawnAider(prompt, options = {}) {
99
100
  if (event.type === 'stdout' && event.data?.line) {
100
101
  const line = String(event.data.line);
101
102
  textAccum += line + '\n';
103
+ // #110 slice 3 — mirror aider tool calls to the audit log. Match
104
+ // the same stdout patterns the aggregator parses (per
105
+ // learning_aider.md): "Running <cmd>" → Bash, "Applied edit to
106
+ // <path>" → Edit, "Creating empty file <path>" → Write.
107
+ // Strip ANSI as defense-in-depth (HEADLESS_FLAGS already includes
108
+ // --no-pretty but extraArgs could override).
109
+ const stripAnsi = (s) => s.replace(/\[[0-9;]*m/g, '');
110
+ const clean = stripAnsi(line).trim();
111
+ let toolName = '';
112
+ let input = '';
113
+ let mirrorMatch;
114
+ if ((mirrorMatch = clean.match(/^Running\s+(.+)$/))) {
115
+ toolName = 'Bash';
116
+ input = mirrorMatch[1];
117
+ }
118
+ else if ((mirrorMatch = clean.match(/^Applied edit to\s+(.+)$/))) {
119
+ toolName = 'Edit';
120
+ input = mirrorMatch[1];
121
+ }
122
+ else if ((mirrorMatch = clean.match(/^Creating empty file\s+(.+)$/))) {
123
+ toolName = 'Write';
124
+ input = mirrorMatch[1];
125
+ }
126
+ if (toolName && input) {
127
+ mirrorToolCallToAudit({ sessionId, agent: 'aider', toolName, toolInput: input, cwd });
128
+ }
102
129
  // Aider emits a cost summary like:
103
130
  // "Tokens: 1.2k sent, 412 received. Cost: $0.01 request, $0.01 session."
104
131
  const m = line.match(/Tokens:\s*([\d.]+)([kM]?)\s*sent,\s*([\d.]+)([kM]?)\s*received/i);
@@ -330,6 +357,7 @@ export function spawnAider(prompt, options = {}) {
330
357
  export const AiderAdapter = {
331
358
  id: 'aider',
332
359
  label: 'Aider',
360
+ description: 'Best when you already know which file to change and what to change in it. Tight, focused edits — no plan-then-explore phase.',
333
361
  processPattern: /(?:^|\/)aider(?:\s|$)/,
334
362
  isInstalled() {
335
363
  try {
@@ -9,11 +9,81 @@
9
9
  *
10
10
  * Agent-agnostic on purpose. Both Claude and Codex mutate the filesystem;
11
11
  * git tells the truth regardless of which tool did the write.
12
+ *
13
+ * v0.6.x: dirty-baseline subtraction. Without it, files the user had
14
+ * uncommitted at spawn time would falsely appear in artifacts even though
15
+ * the agent never touched them — git diff vs spawn-HEAD includes EVERYTHING
16
+ * dirty in the working tree, not just what changed during the session.
17
+ * Now snapshotCwd captures `git status --porcelain` + per-file mtime/size at
18
+ * spawn time. extractArtifacts filters out files whose stat is unchanged
19
+ * since spawn — they were dirty before, not by this session. Files that were
20
+ * dirty at spawn AND further modified during the session still appear.
21
+ *
22
+ * Limitation: cannot distinguish concurrent human writes from agent writes
23
+ * during the session window. True PID-level attribution would require
24
+ * fanotify/audit subsystem. The dirty-baseline filter eliminates the most
25
+ * common false-positive (pre-existing uncommitted work) without that.
12
26
  */
13
27
  import { execFileSync } from 'child_process';
14
28
  import { existsSync, readFileSync, statSync } from 'fs';
15
29
  import { join, isAbsolute } from 'path';
16
30
  import { scoreFile } from '../security/trust-scorer.js';
31
+ /** Capture mtime + size for a single path. Returns null if stat fails. */
32
+ function statBaseline(fullPath) {
33
+ try {
34
+ const st = statSync(fullPath);
35
+ return { mtimeMs: st.mtimeMs, size: st.size };
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Read `git status --porcelain` and stat every dirty path. Best-effort.
42
+ * Paths are git-relative (i.e. relative to repoRoot, not cwd). */
43
+ function captureDirtyBaseline(cwd, repoRoot) {
44
+ const baseline = new Map();
45
+ try {
46
+ const out = execFileSync('git', ['status', '--porcelain', '-z'], {
47
+ cwd,
48
+ encoding: 'utf-8',
49
+ timeout: 5000,
50
+ stdio: ['ignore', 'pipe', 'ignore'],
51
+ });
52
+ // Porcelain -z separates entries by NUL. First two chars are status, then space, then path.
53
+ // Renames have an extra NUL-separated old path which we don't need here.
54
+ for (const entry of out.split('\0')) {
55
+ if (!entry)
56
+ continue;
57
+ // Strip the 2-char status prefix + 1 separator (3 chars total)
58
+ const path = entry.slice(3);
59
+ if (!path)
60
+ continue;
61
+ const full = isAbsolute(path) ? path : join(repoRoot, path);
62
+ const b = statBaseline(full);
63
+ if (b)
64
+ baseline.set(path, b);
65
+ }
66
+ }
67
+ catch {
68
+ /* best-effort — empty baseline means no filtering, status-quo behavior */
69
+ }
70
+ return baseline;
71
+ }
72
+ /** Get repo root via `git rev-parse --show-toplevel`. Returns null if not in a repo. */
73
+ function getRepoRoot(cwd) {
74
+ try {
75
+ const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
76
+ cwd,
77
+ encoding: 'utf-8',
78
+ timeout: 2000,
79
+ stdio: ['ignore', 'pipe', 'ignore'],
80
+ }).trim();
81
+ return out.length > 0 ? out : null;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
17
87
  /** Take a baseline so Layer 3 can diff against it on exit. Cheap; ~5ms. */
18
88
  export function snapshotCwd(cwd) {
19
89
  const spawnedAtMs = Date.now();
@@ -24,10 +94,26 @@ export function snapshotCwd(cwd) {
24
94
  timeout: 2000,
25
95
  stdio: ['ignore', 'pipe', 'ignore'],
26
96
  }).trim();
27
- return { cwd, head: out, isGitRepo: out.length > 0, spawnedAtMs };
97
+ const isGitRepo = out.length > 0;
98
+ const repoRoot = isGitRepo ? (getRepoRoot(cwd) ?? cwd) : cwd;
99
+ return {
100
+ cwd,
101
+ head: out,
102
+ isGitRepo,
103
+ spawnedAtMs,
104
+ repoRoot,
105
+ dirtyBaseline: isGitRepo ? captureDirtyBaseline(cwd, repoRoot) : new Map(),
106
+ };
28
107
  }
29
108
  catch {
30
- return { cwd, head: null, isGitRepo: false, spawnedAtMs };
109
+ return {
110
+ cwd,
111
+ head: null,
112
+ isGitRepo: false,
113
+ spawnedAtMs,
114
+ repoRoot: cwd,
115
+ dirtyBaseline: new Map(),
116
+ };
31
117
  }
32
118
  }
33
119
  /** Collect {path, +, -} from git diff (numstat) + untracked files (wc -l). */
@@ -69,9 +155,10 @@ function changedFiles(snap) {
69
155
  for (const rel of untracked.split('\n')) {
70
156
  if (!rel.trim())
71
157
  continue;
72
- // Count lines in the new file
158
+ // Count lines in the new file. Untracked paths come from git relative
159
+ // to repoRoot — must join with repoRoot, not cwd, when cwd is a subdir.
73
160
  try {
74
- const full = isAbsolute(rel) ? rel : join(snap.cwd, rel);
161
+ const full = isAbsolute(rel) ? rel : join(snap.repoRoot, rel);
75
162
  if (!existsSync(full))
76
163
  continue;
77
164
  const size = statSync(full).size;
@@ -125,7 +212,9 @@ function parseTestSummary(stdout) {
125
212
  function annotateTrust(snap, files) {
126
213
  return files.map((f) => {
127
214
  try {
128
- const full = isAbsolute(f.path) ? f.path : join(snap.cwd, f.path);
215
+ // Git-relative path: join with repoRoot, not cwd (matters when cwd is
216
+ // a subdirectory of the repo).
217
+ const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
129
218
  if (!existsSync(full))
130
219
  return f;
131
220
  const size = statSync(full).size;
@@ -154,7 +243,8 @@ function filterByMtime(snap, files) {
154
243
  const grace = 1000; // ms; allow clock skew between git's touch and our Date.now()
155
244
  return files.filter((f) => {
156
245
  try {
157
- const full = isAbsolute(f.path) ? f.path : join(snap.cwd, f.path);
246
+ // Git-relative path: join with repoRoot, not cwd.
247
+ const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
158
248
  if (!existsSync(full))
159
249
  return true; // file was deleted — keep it
160
250
  const { mtimeMs } = statSync(full);
@@ -165,10 +255,40 @@ function filterByMtime(snap, files) {
165
255
  }
166
256
  });
167
257
  }
258
+ /**
259
+ * Drop files that were dirty at spawn time and have NOT been further modified
260
+ * since. The git-diff-vs-spawn-HEAD path picks up the user's pre-existing
261
+ * uncommitted changes; without this filter, those files get mis-attributed to
262
+ * the agent's task. A file is considered "further modified" if its mtime OR
263
+ * size changed since the spawn snapshot — content-level tampering with same
264
+ * mtime/size is rare enough to ignore.
265
+ */
266
+ function filterBySpawnBaseline(snap, files) {
267
+ if (snap.dirtyBaseline.size === 0)
268
+ return files;
269
+ return files.filter((f) => {
270
+ const baseline = snap.dirtyBaseline.get(f.path);
271
+ if (!baseline)
272
+ return true; // wasn't dirty at spawn — definitely a session change
273
+ try {
274
+ // Git-relative path: join with repoRoot, not cwd.
275
+ const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
276
+ if (!existsSync(full))
277
+ return true; // deleted during session — that's a change
278
+ const { mtimeMs, size } = statSync(full);
279
+ // Further-modified if either mtime advanced or size differs from baseline.
280
+ return mtimeMs > baseline.mtimeMs || size !== baseline.size;
281
+ }
282
+ catch {
283
+ return true; // stat failed — be permissive (don't drop files we can't verify)
284
+ }
285
+ });
286
+ }
168
287
  export function extractArtifacts(snap, collectedStdout) {
169
288
  const raw = changedFiles(snap);
170
- const mine = filterByMtime(snap, raw);
171
- const files = annotateTrust(snap, mine);
289
+ const sessionScoped = filterByMtime(snap, raw);
290
+ const agentLikely = filterBySpawnBaseline(snap, sessionScoped);
291
+ const files = annotateTrust(snap, agentLikely);
172
292
  const tests = parseTestSummary(collectedStdout);
173
293
  return { files, tests };
174
294
  }
@@ -407,6 +407,7 @@ export function spawnClaudeCode(prompt, options = {}) {
407
407
  export const ClaudeCodeAdapter = {
408
408
  id: 'claude-code',
409
409
  label: 'Claude Code',
410
+ description: 'Good default for editing code, running tests, and shell commands. Strongest safety net: codehere can stop risky actions before they run, not just record them after.',
410
411
  processPattern: /(?:^|\/)claude(?:\s|$)/,
411
412
  isInstalled() {
412
413
  try {
@@ -19,6 +19,7 @@ import { randomUUID } from 'crypto';
19
19
  import { ProgressAggregator } from './progress-aggregator.js';
20
20
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
21
21
  import { applyMemoryPrefix } from './memory-injector.js';
22
+ import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
22
23
  const HEADLESS_FLAGS = [
23
24
  'exec',
24
25
  '--json',
@@ -103,6 +104,18 @@ export function spawnCodex(prompt, options = {}) {
103
104
  textAccum += item.aggregated_output + '\n';
104
105
  if (item?.type === 'agent_message' && item.text)
105
106
  textAccum += item.text + '\n';
107
+ // #110 slice 2 — mirror tool calls to the audit log so codex
108
+ // gets the same audit-trail surface as Claude's per-tool hook.
109
+ // Post-hoc only (no block); pre-tool intercept is a future slice.
110
+ if (item?.type === 'command_execution' && typeof item.command === 'string' && item.command) {
111
+ mirrorToolCallToAudit({
112
+ sessionId,
113
+ agent: 'codex',
114
+ toolName: 'Bash',
115
+ toolInput: item.command,
116
+ cwd,
117
+ });
118
+ }
106
119
  }
107
120
  // Accumulate token usage across all turns (one turn.completed per turn).
108
121
  if (event.type === 'turn.completed') {
@@ -358,6 +371,7 @@ export function spawnCodex(prompt, options = {}) {
358
371
  export const CodexAdapter = {
359
372
  id: 'codex',
360
373
  label: 'Codex',
374
+ description: 'OpenAI\'s coding agent. Good when the task needs reading a lot of code first, then changing several files together.',
361
375
  processPattern: /(?:^|\/)codex(?:\s|$)/,
362
376
  isInstalled() {
363
377
  try {
@@ -0,0 +1,143 @@
1
+ /**
2
+ * context-handoff — structured cross-session context handoff protocol (P2).
3
+ *
4
+ * Bridges P0's auto-failover trigger with P1's graphify-ts context packs.
5
+ * When a session fails over (or a new session spawns), the handoff loader
6
+ * finds the most relevant context pack from recent session logs and injects
7
+ * it as a system hint so the replacement agent inherits continuity.
8
+ *
9
+ * Protocol:
10
+ * 1. loadHandoffContext(cwd, { fromSessionId?, sessionId?, maxAgeMs? })
11
+ * — scans .codehere/context-packs.jsonl for the most relevant pack
12
+ * 2. enrichPromptWithHandoff(prompt, cwd, handoff)
13
+ * — merges the handoff context into a prompt string
14
+ * 3. session handoff is automatic during failover (wired in session-manager)
15
+ *
16
+ * Handoff format (injected as system hint):
17
+ * [handoff: previous agent=<agent>, completed=<status>, pack=<packId>]
18
+ * Previous work summary: <pack.payloadPreview or compiled summary>
19
+ * ---
20
+ * <original prompt>
21
+ */
22
+ import { existsSync, readFileSync } from 'fs';
23
+ import { join } from 'path';
24
+ // ── Constants ──────────────────────────────────────────────────────────────────
25
+ const CONTEXT_PACKS_DIR = '.codehere';
26
+ const CONTEXT_PACKS_FILE = 'context-packs.jsonl';
27
+ const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
28
+ const DEFAULT_MIN_NODES = 5;
29
+ // ── Session log scanning ───────────────────────────────────────────────────────
30
+ /**
31
+ * Load all recent context packs from disk, sorted by timestamp (newest first).
32
+ */
33
+ function loadAllPacks(cwd) {
34
+ const packsFile = join(cwd, CONTEXT_PACKS_DIR, CONTEXT_PACKS_FILE);
35
+ if (!existsSync(packsFile)) {
36
+ return [];
37
+ }
38
+ try {
39
+ const content = readFileSync(packsFile, 'utf-8');
40
+ const lines = content.split('\n').filter(Boolean);
41
+ return lines.map((line) => JSON.parse(line)).reverse();
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ /**
48
+ * Find the most relevant handoff context from previous sessions.
49
+ *
50
+ * Priority:
51
+ * 1. Same workspace, same prompt keywords (most recent pack)
52
+ * 2. Same workspace, any pack (most recent)
53
+ * 3. Fallback: no handoff
54
+ */
55
+ export function loadHandoffContext(cwd, { fromSessionId, maxAgeMs = DEFAULT_MAX_AGE_MS, minNodes = DEFAULT_MIN_NODES } = {}) {
56
+ const now = Date.now();
57
+ const packs = loadAllPacks(cwd);
58
+ // Filter by age
59
+ const recent = packs.filter((p) => {
60
+ const ts = p.timestamp;
61
+ return ts && (now - ts) < maxAgeMs;
62
+ });
63
+ // Prefer packs with enough graph structure to be useful
64
+ const structured = recent.filter((p) => {
65
+ const nodes = p.nodeCount ?? 0;
66
+ return nodes >= minNodes;
67
+ });
68
+ if (structured.length === 0)
69
+ return null;
70
+ const last = structured[0];
71
+ return {
72
+ packId: String(last.packId ?? ''),
73
+ agent: String(last.agent ?? 'unknown'),
74
+ prompt: String(last.prompt ?? ''),
75
+ status: String(last.status ?? 'unknown'),
76
+ timestamp: last.timestamp,
77
+ nodeCount: last.nodeCount,
78
+ edgeCount: last.edgeCount,
79
+ task: String(last.task ?? 'explain'),
80
+ payloadPreview: String(last.payloadPreview ?? ''),
81
+ };
82
+ }
83
+ // ── Prompt enrichment ──────────────────────────────────────────────────────────
84
+ /**
85
+ * Inject handoff context into a prompt.
86
+ *
87
+ * Returns the enriched prompt string with the handoff as a system hint
88
+ * prepended before the original prompt. Returns the original prompt
89
+ * if no handoff is available.
90
+ */
91
+ export function enrichPromptWithHandoff(prompt, handoff) {
92
+ if (!handoff)
93
+ return prompt;
94
+ const handoffLine = `[handoff: agent=${handoff.agent}, pack=${handoff.packId}, ${handoff.nodeCount} nodes, ${handoff.edgeCount} edges]`;
95
+ const preview = handoff.payloadPreview ? ` ${handoff.payloadPreview.slice(0, 800)}` : '';
96
+ return [
97
+ `[Previous session context — ${handoffLine}]`,
98
+ `This workspace has recent structural context from a prior session.`,
99
+ `For details, refer to the knowledge graph at the workspace root.`,
100
+ `---`,
101
+ prompt,
102
+ ].join('\n');
103
+ }
104
+ // ── Failover handoff (P2 integration point) ────────────────────────────────────
105
+ /**
106
+ * Build a handoff context for a failover target.
107
+ *
108
+ * When session A fails and session B auto-spawns as replacement,
109
+ * this loads the most recent pack from the same workspace and
110
+ * merges it into the prompt so B has continuity with A's work.
111
+ */
112
+ export function buildFailoverHandoff(cwd, { failedAgent, failedPrompt }) {
113
+ // Load most recent pack from workspace
114
+ const pack = loadHandoffContext(cwd, { maxAgeMs: DEFAULT_MAX_AGE_MS, minNodes: 3 });
115
+ if (!pack)
116
+ return null;
117
+ // Enrich the prompt with the handoff
118
+ const enrichedPrompt = enrichPromptWithHandoff(failedPrompt, {
119
+ ...pack,
120
+ agent: failedAgent,
121
+ status: 'failed',
122
+ });
123
+ return {
124
+ ...pack,
125
+ agent: failedAgent,
126
+ prompt: enrichedPrompt,
127
+ status: 'failed',
128
+ };
129
+ }
130
+ // ── Handoff summary (for UI / API) ─────────────────────────────────────────────
131
+ /**
132
+ * Generate a human-readable handoff summary for the dashboard.
133
+ */
134
+ export function formatHandoffSummary(handoff) {
135
+ const ageMs = Date.now() - handoff.timestamp;
136
+ const ageStr = ageMs < 60_000
137
+ ? `${Math.round(ageMs / 1000)}s ago`
138
+ : ageMs < 3600_000
139
+ ? `${Math.round(ageMs / 60_000)}m ago`
140
+ : `${Math.round(ageMs / 3600_000)}h ago`;
141
+ return `Previous session (${handoff.agent}) completed ${ageStr}. ${handoff.nodeCount} nodes, ${handoff.edgeCount} edges in workspace graph.`;
142
+ }
143
+ //# sourceMappingURL=context-handoff.js.map