codehere 0.6.1 → 0.7.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.
- package/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +84 -5
- package/dist/application/use-cases/generate-narration.js +20 -3
- package/dist/application/use-cases/generate-thread-title.js +4 -0
- package/dist/application/use-cases/thread-export.js +155 -0
- package/dist/application/use-cases/verify-claim.js +274 -0
- package/dist/application/use-cases/write-pass.js +277 -0
- package/dist/index.js +218 -19
- package/dist/infrastructure/completion/bash-completion.js +2 -2
- package/dist/infrastructure/config/config-manager.js +1 -1
- package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
- package/dist/infrastructure/context/adapters/figma-source.js +163 -0
- package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
- package/dist/infrastructure/context/adapters/linear-source.js +190 -0
- package/dist/infrastructure/context/context-resolver.js +144 -0
- package/dist/infrastructure/context/context-source.js +114 -0
- package/dist/infrastructure/context/oauth-flow.js +207 -0
- package/dist/infrastructure/context/oauth-store.js +119 -0
- package/dist/infrastructure/context/registry-bootstrap.js +99 -0
- package/dist/infrastructure/context/write-pass-resolver.js +106 -0
- package/dist/infrastructure/cost/budget-config.js +1 -1
- package/dist/infrastructure/cost/spend-aggregator.js +5 -5
- package/dist/infrastructure/hooks/claude-sdk-hook.js +79 -59
- package/dist/infrastructure/hooks/session-analyzer.js +65 -8
- package/dist/infrastructure/observability/execution-tracer.js +0 -5
- package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
- package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
- package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
- package/dist/infrastructure/orchestration/codex-adapter.js +157 -19
- package/dist/infrastructure/orchestration/context-handoff.js +4 -3
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
- package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +15 -17
- package/dist/infrastructure/orchestration/graphify-context.js +6 -13
- package/dist/infrastructure/orchestration/memory-injector.js +11 -16
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +23 -3
- package/dist/infrastructure/orchestration/progress-aggregator.js +178 -5
- package/dist/infrastructure/orchestration/session-manager.js +142 -29
- package/dist/infrastructure/orchestration/session-persistence.js +139 -30
- package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
- package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
- package/dist/infrastructure/protocols/cloud-sync.js +10 -11
- package/dist/infrastructure/protocols/mcp-server-impl.js +1295 -17
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
- package/dist/infrastructure/protocols/ui-page.js +12 -3299
- package/dist/infrastructure/protocols/verify-page.js +941 -0
- package/dist/infrastructure/security/trust-scorer.js +76 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +20 -3
- package/dist/infrastructure/storage/project-thread-migration.js +14 -9
- package/dist/infrastructure/storage/project-thread-reader.js +11 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +7 -3
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/config-command.js +2 -2
- package/dist/presentation/cli/commands/context-source-command.js +198 -0
- package/dist/presentation/cli/commands/help-command.js +51 -1
- package/dist/presentation/cli/commands/init-command.js +5 -4
- package/dist/presentation/cli/commands/memory-command.js +29 -15
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
- package/dist/presentation/cli/commands/report-command.js +6 -4
- package/dist/presentation/cli/commands/setup-command.js +71 -18
- package/dist/presentation/cli/commands/status-command.js +0 -4
- package/dist/presentation/cli/commands/thread-command.js +288 -0
- package/dist/presentation/cli/commands/verify-command.js +233 -0
- package/dist/presentation/cli/error-display.js +1 -1
- package/dist/presentation/cli/repl.js +52 -2
- package/dist/presentation/cli/task-deliverable.js +127 -0
- package/dist/testing/cassette.js +125 -0
- package/dist/testing/replay-adapter.js +166 -0
- package/dist/ui.js +54 -29
- package/dist/utils/typo-guard.js +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
[](https://www.npmjs.com/package/codehere)
|
|
7
7
|
[](../LICENSE)
|
|
8
|
-
[]()
|
|
9
|
+
[](../docs/TRUTHFUL_STATE_2026-05-19.md)
|
|
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
|
-
All 5 adapters (Claude Code, Codex, Aider, Gemini, OpenCode)
|
|
13
|
+
All 5 adapters (Claude Code, Codex, Aider, Gemini, OpenCode) ship adapter code for session-tracing, Final Reports, and trust scoring. Claude Code additionally gets full pre-execution safety-hook enforcement on every tool call via the Claude Agent SDK. The *memory*, *audit log*, *failover*, and *Final Report* surfaces are wired across all 5 adapters today.
|
|
14
|
+
|
|
15
|
+
> **Honest status (2026-05-19)** — The 1200 internal tests verify codehere's code against codehere's own assumptions about what each adapter emits. They do **not** systematically verify against real-adapter outputs in production. Real-adapter contract verification is the v0.7 release work — see [`docs/TRUTHFUL_STATE_2026-05-19.md`](../docs/TRUTHFUL_STATE_2026-05-19.md) for the verified / assumed / unverified classification of every surface.
|
|
14
16
|
|
|
15
17
|
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
18
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-domain trust grade for W-PASS write targets · v0.9 must-ship.
|
|
3
|
+
*
|
|
4
|
+
* Demo v12 + locked v1 tier scope: "linear.app A · *.atlassian.net B ·
|
|
5
|
+
* unknown F · first-write confirmation per domain."
|
|
6
|
+
*
|
|
7
|
+
* Two pieces of state:
|
|
8
|
+
* 1. WELL_KNOWN_GRADES · hardcoded · the platforms we've vetted ourselves
|
|
9
|
+
* ship with grade A. Subdomain wildcards get B. Everything else
|
|
10
|
+
* defaults to F (forces explicit user confirmation on first write).
|
|
11
|
+
* 2. USER_TRUSTED_DOMAINS · ~/.codehere/keys/trusted-domains.json ·
|
|
12
|
+
* domains the user has explicitly trusted after a first-write prompt.
|
|
13
|
+
* Domains here promote from F → B.
|
|
14
|
+
*
|
|
15
|
+
* Pure logic. CLI + HTTP wrap it.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
import { dirname, join } from 'path';
|
|
20
|
+
const WELL_KNOWN_GRADES = [
|
|
21
|
+
// Grade A · platforms codehere has shipped+tested adapters for
|
|
22
|
+
{ pattern: /^api\.linear\.app$/i, grade: 'A', reason: 'official Linear API · codehere ships a vetted adapter' },
|
|
23
|
+
{ pattern: /^api\.github\.com$/i, grade: 'A', reason: 'official GitHub API · codehere ships a vetted adapter' },
|
|
24
|
+
{ pattern: /^api\.figma\.com$/i, grade: 'A', reason: 'official Figma API · codehere ships a vetted adapter' },
|
|
25
|
+
// Grade B · same vendor, surrounding subdomains
|
|
26
|
+
{ pattern: /^[a-z0-9-]+\.linear\.app$/i, grade: 'B', reason: 'Linear subdomain · adjacent to the vetted API' },
|
|
27
|
+
{ pattern: /^[a-z0-9-]+\.github\.com$/i, grade: 'B', reason: 'GitHub subdomain · adjacent to the vetted API' },
|
|
28
|
+
{ pattern: /^[a-z0-9-]+\.figma\.com$/i, grade: 'B', reason: 'Figma subdomain · adjacent to the vetted API' },
|
|
29
|
+
{
|
|
30
|
+
pattern: /^[a-z0-9-]+\.atlassian\.net$/i,
|
|
31
|
+
grade: 'B',
|
|
32
|
+
reason: 'Atlassian-hosted instance · vendor known but instance not vetted',
|
|
33
|
+
},
|
|
34
|
+
// Grade C · public hosts known to be commonly used
|
|
35
|
+
{ pattern: /^raw\.githubusercontent\.com$/i, grade: 'C', reason: 'GitHub-hosted raw content · static, read-mostly' },
|
|
36
|
+
];
|
|
37
|
+
function trustedDomainsPath() {
|
|
38
|
+
const home = process.env.CODEHERE_HOME || homedir();
|
|
39
|
+
return join(home, '.codehere', 'keys', 'trusted-domains.json');
|
|
40
|
+
}
|
|
41
|
+
export function loadTrustedDomains() {
|
|
42
|
+
const path = trustedDomainsPath();
|
|
43
|
+
if (!existsSync(path))
|
|
44
|
+
return { schema: 1, domains: {} };
|
|
45
|
+
try {
|
|
46
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
47
|
+
if (raw && raw.schema === 1 && typeof raw.domains === 'object')
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* fall through · empty trust store */
|
|
52
|
+
}
|
|
53
|
+
return { schema: 1, domains: {} };
|
|
54
|
+
}
|
|
55
|
+
export function trustDomain(host, note) {
|
|
56
|
+
const file = loadTrustedDomains();
|
|
57
|
+
file.domains[host.toLowerCase()] = { addedAt: Date.now(), note };
|
|
58
|
+
mkdirSync(dirname(trustedDomainsPath()), { recursive: true });
|
|
59
|
+
writeFileSync(trustedDomainsPath(), JSON.stringify(file, null, 2) + '\n', 'utf-8');
|
|
60
|
+
}
|
|
61
|
+
export function untrustDomain(host) {
|
|
62
|
+
const file = loadTrustedDomains();
|
|
63
|
+
const key = host.toLowerCase();
|
|
64
|
+
if (!file.domains[key])
|
|
65
|
+
return false;
|
|
66
|
+
delete file.domains[key];
|
|
67
|
+
writeFileSync(trustedDomainsPath(), JSON.stringify(file, null, 2) + '\n', 'utf-8');
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
export function gradeDomain(host) {
|
|
71
|
+
const userFile = loadTrustedDomains();
|
|
72
|
+
const userTrusted = !!userFile.domains[host.toLowerCase()];
|
|
73
|
+
for (const rule of WELL_KNOWN_GRADES) {
|
|
74
|
+
if (rule.pattern.test(host)) {
|
|
75
|
+
return {
|
|
76
|
+
host,
|
|
77
|
+
grade: rule.grade,
|
|
78
|
+
reason: rule.reason,
|
|
79
|
+
userTrusted,
|
|
80
|
+
requiresFirstWriteConfirmation: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (userTrusted) {
|
|
85
|
+
return {
|
|
86
|
+
host,
|
|
87
|
+
grade: 'B',
|
|
88
|
+
reason: 'previously trusted by you · stored in trusted-domains.json',
|
|
89
|
+
userTrusted: true,
|
|
90
|
+
requiresFirstWriteConfirmation: false,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
host,
|
|
95
|
+
grade: 'F',
|
|
96
|
+
reason: 'unknown domain · not in well-known list · not previously trusted',
|
|
97
|
+
userTrusted: false,
|
|
98
|
+
requiresFirstWriteConfirmation: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Extract the host from a fully-resolved HTTPS URL. */
|
|
102
|
+
export function hostOf(url) {
|
|
103
|
+
try {
|
|
104
|
+
return new URL(url).host;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=domain-trust.js.map
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* Claude Squad, AI-Agents-Orchestrator) advertise.
|
|
25
25
|
*/
|
|
26
26
|
import { spawn } from 'child_process';
|
|
27
|
+
import { isMockMode } from '../../infrastructure/orchestration/mocks/resolve-agent-spawn.js';
|
|
28
|
+
import { mockFinalReport } from '../../infrastructure/orchestration/mocks/mock-synthesis.js';
|
|
27
29
|
const PROMPT_TEMPLATE = (i) => `You are a PM reporting back to a developer about what their AI coding agent
|
|
28
30
|
just did. Write 3 short paragraphs in plain English. No headers, no bullet
|
|
29
31
|
lists, no markdown — just three paragraphs separated by blank lines.
|
|
@@ -41,8 +43,12 @@ Paragraph 1: What the agent ATTEMPTED. Verb-led. Describe steps as listed.
|
|
|
41
43
|
Mention specific filenames ONLY if they appear in the data
|
|
42
44
|
below. 1-3 sentences.
|
|
43
45
|
Paragraph 2: What was TOUCHED. Reference ONLY the "Files changed" list
|
|
44
|
-
below. If the list is empty
|
|
45
|
-
|
|
46
|
+
below. If the list is empty AND "File tracking" says git is
|
|
47
|
+
available, say "No files were changed." If "File tracking" says
|
|
48
|
+
git is NOT available, say codehere could not track file changes
|
|
49
|
+
because this folder is not a git repository — do NOT say no files
|
|
50
|
+
were changed, because the agent may well have changed files
|
|
51
|
+
codehere cannot see. 1-2 sentences.
|
|
46
52
|
Paragraph 3: What the developer should LOOK AT. If steps + files data show
|
|
47
53
|
nothing risky, say so explicitly ("nothing to verify; the
|
|
48
54
|
session was read-only" or similar). 1-2 sentences.
|
|
@@ -59,9 +65,13 @@ Status: ${i.status}
|
|
|
59
65
|
Steps the agent took:
|
|
60
66
|
${i.steps.length ? i.steps.map((s) => `- ${s.label} (${s.state})`).join('\n') : '(no structured steps captured)'}
|
|
61
67
|
|
|
68
|
+
File tracking: ${i.artifacts?.noGitTracking ? 'git NOT available (this folder is not a git repository — file changes cannot be tracked)' : 'git available'}
|
|
69
|
+
|
|
62
70
|
Files changed:
|
|
63
71
|
${i.artifacts?.files?.length
|
|
64
|
-
? i.artifacts.files
|
|
72
|
+
? i.artifacts.files
|
|
73
|
+
.map((f) => `- ${f.path} (+${f.added}/-${f.removed}${f.trust !== undefined ? `, trust: ${f.trust}` : ''})`)
|
|
74
|
+
.join('\n')
|
|
65
75
|
: '(no file artifacts captured)'}
|
|
66
76
|
|
|
67
77
|
Token usage: ${i.artifacts?.usage?.inputTokens ?? 0} in / ${i.artifacts?.usage?.outputTokens ?? 0} out
|
|
@@ -75,16 +85,75 @@ function selectProviderBinary() {
|
|
|
75
85
|
return [override];
|
|
76
86
|
return ['claude-free', 'claude'];
|
|
77
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* UAT-003 — patterns that indicate the wrapped agent's session terminated
|
|
90
|
+
* because of a network / auth / quota failure rather than a real task.
|
|
91
|
+
* When we see these in the would-be summary text, we prepend a plain-English
|
|
92
|
+
* recovery hint instead of letting the raw provider error echo through.
|
|
93
|
+
*
|
|
94
|
+
* Pre-fix: bare `exit` in REPL → Conductor spawned claude-code → real API
|
|
95
|
+
* call failed with "All connection attempts failed Request ID: req_...";
|
|
96
|
+
* Final Report quoted that text verbatim. Per Core Values #4 + #6, this is
|
|
97
|
+
* unacceptable — the user sees a Request ID that means nothing.
|
|
98
|
+
*/
|
|
99
|
+
const NETWORK_FAILURE_PATTERNS = /(?:all connection attempts failed|ECONNREFUSED|ENOTFOUND|fetch failed|network error|request id:\s*req_|connection timed out|getaddrinfo)/i;
|
|
100
|
+
function isLikelyNetworkFailureSummary(text) {
|
|
101
|
+
return NETWORK_FAILURE_PATTERNS.test(text);
|
|
102
|
+
}
|
|
103
|
+
function buildErrorRecoveryReport(input, durationMs) {
|
|
104
|
+
// Tailor the hint to the specific failure shape we can detect from input.
|
|
105
|
+
const agentName = input.agent === 'conductor' ? 'the agent codehere chose' : input.agent;
|
|
106
|
+
return {
|
|
107
|
+
summary: `The ${agentName} session ended without completing the task. ` +
|
|
108
|
+
`This usually means a network hiccup, an expired login, or a rate limit on the provider.\n\n` +
|
|
109
|
+
`What to try: check your internet, run \`codehere health\` to confirm reachability + auth, ` +
|
|
110
|
+
`or wait a minute and rerun the same prompt. If it keeps happening, try a different agent ` +
|
|
111
|
+
`from the dropdown — codehere is vendor-neutral on purpose.\n\n` +
|
|
112
|
+
`codehere couldn't confirm any file changes from this attempt.`,
|
|
113
|
+
generatedAt: Date.now(),
|
|
114
|
+
durationMs,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
78
117
|
/**
|
|
79
118
|
* Generate a Final Report. Returns null if no provider is reachable.
|
|
80
119
|
* Never throws — failure modes return null + log a debug line.
|
|
120
|
+
*
|
|
121
|
+
* UAT-003 — when the input.status is 'error', we skip the LLM call entirely
|
|
122
|
+
* and return a deterministic recovery hint. Saves tokens on a known-failed
|
|
123
|
+
* task AND gives the user a plain-English next step instead of waiting for
|
|
124
|
+
* a possibly-unhelpful LLM summary.
|
|
81
125
|
*/
|
|
82
126
|
export async function generateFinalReport(input) {
|
|
127
|
+
// CODEHERE_MOCK — synthesize a grounded report with zero API so the full
|
|
128
|
+
// demo flow (incl. this headline deliverable + the verify cross-check) runs
|
|
129
|
+
// under mock. Errors still get the deterministic recovery report below.
|
|
130
|
+
if (isMockMode() && input.status !== 'error') {
|
|
131
|
+
return mockFinalReport(input);
|
|
132
|
+
}
|
|
133
|
+
// Short-circuit on errored sessions — don't waste tokens summarizing nothing.
|
|
134
|
+
if (input.status === 'error') {
|
|
135
|
+
return buildErrorRecoveryReport(input, 0);
|
|
136
|
+
}
|
|
83
137
|
const prompt = PROMPT_TEMPLATE(input);
|
|
84
138
|
for (const binary of selectProviderBinary()) {
|
|
85
139
|
const result = await tryProvider(binary, prompt, input.sessionId);
|
|
86
|
-
if (result)
|
|
140
|
+
if (result) {
|
|
141
|
+
// Defense-in-depth: even on 'completed' status, if the generated
|
|
142
|
+
// summary leaks a provider network-failure string (e.g., the wrapped
|
|
143
|
+
// agent exited 0 but its assistant text was an error message), wrap
|
|
144
|
+
// the summary with a recovery preamble.
|
|
145
|
+
if (isLikelyNetworkFailureSummary(result.summary)) {
|
|
146
|
+
return {
|
|
147
|
+
summary: `Couldn't reach the provider cleanly — the agent's session text looks like a network or auth failure.\n\n` +
|
|
148
|
+
`What to try: check your internet, run \`codehere health\`, or rerun in a minute. ` +
|
|
149
|
+
`If it persists, switch agents from the dropdown.\n\n` +
|
|
150
|
+
`— Raw agent message —\n${result.summary}`,
|
|
151
|
+
generatedAt: result.generatedAt,
|
|
152
|
+
durationMs: result.durationMs,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
87
155
|
return result;
|
|
156
|
+
}
|
|
88
157
|
}
|
|
89
158
|
return null;
|
|
90
159
|
}
|
|
@@ -100,7 +169,17 @@ function tryProvider(binary, prompt, sessionId) {
|
|
|
100
169
|
settled = true;
|
|
101
170
|
resolve(result);
|
|
102
171
|
};
|
|
103
|
-
|
|
172
|
+
// Provider args per-binary:
|
|
173
|
+
// - `claude-free` (FCC proxy binary) needs `--bare` to skip session
|
|
174
|
+
// setup that the proxy doesn't implement.
|
|
175
|
+
// - Standard `claude` (the Claude Code Max CLI) MUST NOT use `--bare`
|
|
176
|
+
// because `--bare` explicitly rejects OAuth and keychain auth — and
|
|
177
|
+
// Claude Code Max users authenticate via OAuth. With `--bare`, the
|
|
178
|
+
// fallback path returned empty silently, no Final Report ever
|
|
179
|
+
// reached the UI, and the dashboard showed `✓ Final Report` on a
|
|
180
|
+
// missing artifact. (May 21, 2026 — root cause of CRIT #1.)
|
|
181
|
+
const args = binary === 'claude-free' ? ['--bare', '-p', prompt] : ['-p', prompt];
|
|
182
|
+
const child = spawn(binary, args, {
|
|
104
183
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
105
184
|
});
|
|
106
185
|
const timeout = setTimeout(() => {
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { spawn } from 'child_process';
|
|
23
23
|
import { loadTemplate, renderTemplate } from './narrator-prompt-template.js';
|
|
24
|
+
import { isMockMode } from '../../infrastructure/orchestration/mocks/resolve-agent-spawn.js';
|
|
25
|
+
import { mockNarration } from '../../infrastructure/orchestration/mocks/mock-synthesis.js';
|
|
24
26
|
/** v0.6 S6 — prompt template lifted into ~/.codehere/narrator-prompt.md so
|
|
25
27
|
* the user can edit the agent's voice. loadTemplate() falls back to the
|
|
26
28
|
* baked-in default on missing file or missing required placeholders, so
|
|
@@ -37,6 +39,15 @@ function selectProviderBinary() {
|
|
|
37
39
|
return ['claude-free', 'claude'];
|
|
38
40
|
}
|
|
39
41
|
export async function generateNarration(input) {
|
|
42
|
+
if (isMockMode()) {
|
|
43
|
+
const m = mockNarration({ steps: input.steps });
|
|
44
|
+
return {
|
|
45
|
+
summary: m.summary,
|
|
46
|
+
generatedAt: Date.now(),
|
|
47
|
+
durationMs: m.durationMs,
|
|
48
|
+
stepCountAtSnapshot: input.steps.length,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
40
51
|
const prompt = buildPrompt(input);
|
|
41
52
|
const candidates = selectProviderBinary();
|
|
42
53
|
for (const binary of candidates) {
|
|
@@ -75,11 +86,17 @@ function tryProvider(binary, prompt, sessionId, stepCount) {
|
|
|
75
86
|
try {
|
|
76
87
|
child.kill();
|
|
77
88
|
}
|
|
78
|
-
catch {
|
|
89
|
+
catch {
|
|
90
|
+
/* ignore */
|
|
91
|
+
}
|
|
79
92
|
finish(null);
|
|
80
93
|
}, 20_000);
|
|
81
|
-
child.stdout?.on('data', (chunk) => {
|
|
82
|
-
|
|
94
|
+
child.stdout?.on('data', (chunk) => {
|
|
95
|
+
stdout += chunk.toString('utf-8');
|
|
96
|
+
});
|
|
97
|
+
child.stderr?.on('data', (chunk) => {
|
|
98
|
+
stderr += chunk.toString('utf-8');
|
|
99
|
+
});
|
|
83
100
|
child.on('close', (code) => {
|
|
84
101
|
clearTimeout(timeout);
|
|
85
102
|
if (code !== 0) {
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* available; degrades silently to "(untitled)" when no provider responds.
|
|
22
22
|
*/
|
|
23
23
|
import { spawn } from 'child_process';
|
|
24
|
+
import { isMockMode } from '../../infrastructure/orchestration/mocks/resolve-agent-spawn.js';
|
|
25
|
+
import { mockTitle } from '../../infrastructure/orchestration/mocks/mock-synthesis.js';
|
|
24
26
|
const TITLE_TIMEOUT_MS = 15_000; // titles are short; abort fast if stuck
|
|
25
27
|
const PROMPT_TEMPLATE = (firstPrompt, cwd) => `Generate a 4-6 word project title from this first user prompt.
|
|
26
28
|
|
|
@@ -163,6 +165,8 @@ export function sanitizeTitle(raw) {
|
|
|
163
165
|
* Never throws — failure modes return null.
|
|
164
166
|
*/
|
|
165
167
|
export async function generateThreadTitle(firstPrompt, cwd) {
|
|
168
|
+
if (isMockMode())
|
|
169
|
+
return sanitizeTitle(mockTitle(firstPrompt, cwd)) || null;
|
|
166
170
|
const prompt = PROMPT_TEMPLATE(firstPrompt, cwd);
|
|
167
171
|
for (const binary of selectProviderBinary()) {
|
|
168
172
|
const result = await tryProvider(binary, prompt);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread export · v0.8 S4 · signed-payload "GitHub for agent sessions"
|
|
3
|
+
*
|
|
4
|
+
* Produces a self-contained, Ed25519-signed bundle of one PersistedSession
|
|
5
|
+
* that a colleague can verify and replay locally. Local-first by design:
|
|
6
|
+
* never relayed through codehere's infrastructure · the user shares the
|
|
7
|
+
* file (or signed URL) by their preferred channel (Slack, email, PR
|
|
8
|
+
* description) and the recipient runs `codehere thread import <file>`.
|
|
9
|
+
*
|
|
10
|
+
* The signature covers a CANONICAL JSON form of the bundle body so two
|
|
11
|
+
* machines that re-serialize the same data get the same hash. Verification
|
|
12
|
+
* recomputes the canonical form, re-derives the hash, checks the signature
|
|
13
|
+
* against the included public key, and returns one of:
|
|
14
|
+
* - 'valid' · signature matches · safe to replay
|
|
15
|
+
* - 'tampered' · signature does NOT match · refuse
|
|
16
|
+
* - 'malformed' · bundle is structurally wrong · refuse
|
|
17
|
+
* - 'unknown_key' · signature valid but public key not in user's trust
|
|
18
|
+
* store · prompt user to trust+continue or refuse
|
|
19
|
+
*
|
|
20
|
+
* Pure logic. The CLI + HTTP routes wrap this and do I/O.
|
|
21
|
+
*/
|
|
22
|
+
import { sign as cryptoSign, verify as cryptoVerify, generateKeyPairSync, createPrivateKey, createPublicKey, } from 'crypto';
|
|
23
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
24
|
+
/* Canonicalization · deterministic JSON form so signatures match */
|
|
25
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
26
|
+
/**
|
|
27
|
+
* Stable JSON: object keys sorted, no whitespace, primitives preserved.
|
|
28
|
+
* Two machines that build the same body MUST produce the same bytes for
|
|
29
|
+
* the signature to verify. We deliberately don't use JSON.stringify with
|
|
30
|
+
* a replacer because Node's serializer doesn't guarantee key order across
|
|
31
|
+
* versions/realms.
|
|
32
|
+
*/
|
|
33
|
+
export function canonicalize(value) {
|
|
34
|
+
if (value === null)
|
|
35
|
+
return 'null';
|
|
36
|
+
if (typeof value === 'number') {
|
|
37
|
+
if (!Number.isFinite(value))
|
|
38
|
+
throw new Error(`cannot canonicalize non-finite number: ${value}`);
|
|
39
|
+
return JSON.stringify(value);
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === 'string' || typeof value === 'boolean')
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
return '[' + value.map(canonicalize).join(',') + ']';
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === 'object') {
|
|
47
|
+
const obj = value;
|
|
48
|
+
const keys = Object.keys(obj).sort();
|
|
49
|
+
const parts = keys
|
|
50
|
+
.filter((k) => obj[k] !== undefined) // undefined → drop the key (JSON convention)
|
|
51
|
+
.map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k]));
|
|
52
|
+
return '{' + parts.join(',') + '}';
|
|
53
|
+
}
|
|
54
|
+
if (value === undefined)
|
|
55
|
+
return 'null';
|
|
56
|
+
throw new Error(`cannot canonicalize type: ${typeof value}`);
|
|
57
|
+
}
|
|
58
|
+
/** Generate a fresh Ed25519 keypair · used for first-time setup. */
|
|
59
|
+
export function generateKeypair() {
|
|
60
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
61
|
+
return { publicKey, privateKey };
|
|
62
|
+
}
|
|
63
|
+
/** Sign a bundle body and produce the full ThreadBundle.
|
|
64
|
+
* Ed25519 is NOT a hash-then-sign scheme · pass the message directly to
|
|
65
|
+
* crypto.sign(null, msg, key) · the algorithm is implied by the key. */
|
|
66
|
+
export function signBundle(body, keys) {
|
|
67
|
+
const canonical = canonicalize(body);
|
|
68
|
+
const sig = cryptoSign(null, Buffer.from(canonical, 'utf-8'), keys.privateKey);
|
|
69
|
+
const pubRaw = keys.publicKey.export({ format: 'der', type: 'spki' });
|
|
70
|
+
return {
|
|
71
|
+
body,
|
|
72
|
+
signature: sig.toString('base64'),
|
|
73
|
+
publicKey: pubRaw.toString('base64'),
|
|
74
|
+
alg: 'ed25519',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Verify a bundle. Returns one of:
|
|
79
|
+
* - valid · signature matches AND the publicKey is in trustedKeys
|
|
80
|
+
* - valid-untrusted · signature matches but the key isn't in trustedKeys
|
|
81
|
+
* (returned with status='valid' but trusted=false)
|
|
82
|
+
* - tampered · signature doesn't match the recomputed body bytes
|
|
83
|
+
* - malformed · bundle structure broken
|
|
84
|
+
*/
|
|
85
|
+
export function verifyBundle(bundle, trustedKeys = new Set()) {
|
|
86
|
+
if (!isBundle(bundle))
|
|
87
|
+
return { status: 'malformed', reason: 'bundle shape is invalid' };
|
|
88
|
+
if (bundle.alg !== 'ed25519')
|
|
89
|
+
return { status: 'malformed', reason: `unsupported alg: ${bundle.alg}` };
|
|
90
|
+
try {
|
|
91
|
+
const canonical = canonicalize(bundle.body);
|
|
92
|
+
const pubBuf = Buffer.from(bundle.publicKey, 'base64');
|
|
93
|
+
const sigBuf = Buffer.from(bundle.signature, 'base64');
|
|
94
|
+
const pubKey = createPublicKey({ key: pubBuf, format: 'der', type: 'spki' });
|
|
95
|
+
const ok = cryptoVerify(null, Buffer.from(canonical, 'utf-8'), pubKey, sigBuf);
|
|
96
|
+
if (!ok)
|
|
97
|
+
return { status: 'tampered', reason: 'signature does not match body' };
|
|
98
|
+
const trusted = trustedKeys.has(bundle.publicKey);
|
|
99
|
+
if (!trusted) {
|
|
100
|
+
return { status: 'unknown_key', bundle, publicKey: bundle.publicKey };
|
|
101
|
+
}
|
|
102
|
+
return { status: 'valid', bundle, trusted: true };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return { status: 'malformed', reason: err instanceof Error ? err.message : String(err) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function isBundle(x) {
|
|
109
|
+
if (typeof x !== 'object' || x === null)
|
|
110
|
+
return false;
|
|
111
|
+
const obj = x;
|
|
112
|
+
if (typeof obj.signature !== 'string')
|
|
113
|
+
return false;
|
|
114
|
+
if (typeof obj.publicKey !== 'string')
|
|
115
|
+
return false;
|
|
116
|
+
if (typeof obj.alg !== 'string')
|
|
117
|
+
return false;
|
|
118
|
+
const body = obj.body;
|
|
119
|
+
if (typeof body !== 'object' || body === null)
|
|
120
|
+
return false;
|
|
121
|
+
const b = body;
|
|
122
|
+
if (b.schema !== 1)
|
|
123
|
+
return false;
|
|
124
|
+
if (b.kind !== 'thread')
|
|
125
|
+
return false;
|
|
126
|
+
if (typeof b.sessionId !== 'string')
|
|
127
|
+
return false;
|
|
128
|
+
if (typeof b.session !== 'object' || b.session === null)
|
|
129
|
+
return false;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
133
|
+
/* Key derivation helpers · PEM encoding for storage */
|
|
134
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
135
|
+
export function exportPrivatePem(key) {
|
|
136
|
+
return key.export({ format: 'pem', type: 'pkcs8' }).toString();
|
|
137
|
+
}
|
|
138
|
+
export function exportPublicPem(key) {
|
|
139
|
+
return key.export({ format: 'pem', type: 'spki' }).toString();
|
|
140
|
+
}
|
|
141
|
+
export function importPrivatePem(pem) {
|
|
142
|
+
return createPrivateKey({ key: pem, format: 'pem', type: 'pkcs8' });
|
|
143
|
+
}
|
|
144
|
+
export function importPublicPem(pem) {
|
|
145
|
+
return createPublicKey({ key: pem, format: 'pem', type: 'spki' });
|
|
146
|
+
}
|
|
147
|
+
/** Compute the base64 publicKey string for a KeyObject · same encoding as in bundles. */
|
|
148
|
+
export function publicKeyToBase64(key) {
|
|
149
|
+
return key.export({ format: 'der', type: 'spki' }).toString('base64');
|
|
150
|
+
}
|
|
151
|
+
/** Short fingerprint used in human-readable output. */
|
|
152
|
+
export function shortFingerprint(publicKeyB64) {
|
|
153
|
+
return publicKeyB64.slice(0, 8) + '...' + publicKeyB64.slice(-4);
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=thread-export.js.map
|