codex-claude-relay 0.1.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/LICENSE +21 -0
- package/README.md +534 -0
- package/README.zh.md +522 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +304 -0
- package/dist/git.d.ts +11 -0
- package/dist/git.js +58 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/launch.d.ts +29 -0
- package/dist/launch.js +66 -0
- package/dist/parse/jsonl.d.ts +21 -0
- package/dist/parse/jsonl.js +75 -0
- package/dist/providers/claude.d.ts +36 -0
- package/dist/providers/claude.js +401 -0
- package/dist/providers/codex.d.ts +11 -0
- package/dist/providers/codex.js +310 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +84 -0
- package/dist/summarize.d.ts +13 -0
- package/dist/summarize.js +241 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +9 -0
- package/package.json +56 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { detectGitContext, getDiff } from './git.js';
|
|
4
|
+
import { pickCodexSession, parseCodexSession, discoverCodexSessions, CODEX_SESSIONS_DIR, } from './providers/codex.js';
|
|
5
|
+
import { pickClaudeSession, parseClaudeSession, discoverClaudeSessions, readClaudeMemory, CLAUDE_PROJECTS_DIR, } from './providers/claude.js';
|
|
6
|
+
import { renderHandoff } from './summarize.js';
|
|
7
|
+
import { launchAgentAsync, hasBinary } from './launch.js';
|
|
8
|
+
import { DEFAULT_OPTIONS } from './types.js';
|
|
9
|
+
const VERSION = '0.1.0';
|
|
10
|
+
const HELP = `codex-claude-relay v${VERSION} — stateless handoff between Codex CLI and Claude Code
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
relay <target> Launch the target agent with a handoff from the OTHER agent
|
|
14
|
+
relay preview <target> Print the handoff that would be sent (no launch)
|
|
15
|
+
relay inspect Show discovery results without parsing or launching
|
|
16
|
+
relay --help Show this help
|
|
17
|
+
relay --version Show version
|
|
18
|
+
|
|
19
|
+
Targets:
|
|
20
|
+
claude Handoff Codex -> Claude Code (reads ~/.codex/sessions, launches \`claude\`)
|
|
21
|
+
codex Handoff Claude Code -> Codex (reads ~/.claude/projects, launches \`codex\`)
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--last Use the most recently modified session (skip repo-relevance ranking)
|
|
25
|
+
--with-diff Append \`git diff HEAD\` to the handoff
|
|
26
|
+
--max-chars N Cap the handoff size (default ${DEFAULT_OPTIONS.maxChars})
|
|
27
|
+
--dry-run Build & print the handoff, do not launch the target agent
|
|
28
|
+
--no-redact Disable secret redaction (default: ON)
|
|
29
|
+
--debug Verbose discovery / parsing info on stderr
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
relay claude
|
|
33
|
+
relay codex --with-diff
|
|
34
|
+
relay preview claude --max-chars 6000
|
|
35
|
+
relay inspect
|
|
36
|
+
`;
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
const positional = [];
|
|
39
|
+
const options = { ...DEFAULT_OPTIONS };
|
|
40
|
+
let cmd = 'help';
|
|
41
|
+
let target;
|
|
42
|
+
let i = 0;
|
|
43
|
+
while (i < argv.length) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
if (a === '--help' || a === '-h') {
|
|
46
|
+
cmd = 'help';
|
|
47
|
+
return { cmd, options, positional };
|
|
48
|
+
}
|
|
49
|
+
if (a === '--version' || a === '-v') {
|
|
50
|
+
cmd = 'version';
|
|
51
|
+
return { cmd, options, positional };
|
|
52
|
+
}
|
|
53
|
+
if (a === '--last') {
|
|
54
|
+
options.last = true;
|
|
55
|
+
i += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (a === '--with-diff') {
|
|
59
|
+
options.withDiff = true;
|
|
60
|
+
i += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (a === '--dry-run') {
|
|
64
|
+
options.dryRun = true;
|
|
65
|
+
i += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (a === '--no-redact') {
|
|
69
|
+
options.noRedact = true;
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (a === '--debug') {
|
|
74
|
+
options.debug = true;
|
|
75
|
+
i += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (a === '--max-chars') {
|
|
79
|
+
const next = argv[i + 1];
|
|
80
|
+
const n = next ? parseInt(next, 10) : NaN;
|
|
81
|
+
if (!Number.isFinite(n) || n < 500) {
|
|
82
|
+
process.stderr.write(`codex-claude-relay: --max-chars requires a positive integer >= 500\n`);
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
options.maxChars = n;
|
|
86
|
+
i += 2;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (a.startsWith('--max-chars=')) {
|
|
90
|
+
const n = parseInt(a.slice('--max-chars='.length), 10);
|
|
91
|
+
if (!Number.isFinite(n) || n < 500) {
|
|
92
|
+
process.stderr.write(`codex-claude-relay: --max-chars requires a positive integer >= 500\n`);
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
options.maxChars = n;
|
|
96
|
+
i += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (a.startsWith('-')) {
|
|
100
|
+
process.stderr.write(`codex-claude-relay: unknown option ${a}\n`);
|
|
101
|
+
process.exit(2);
|
|
102
|
+
}
|
|
103
|
+
positional.push(a);
|
|
104
|
+
i += 1;
|
|
105
|
+
}
|
|
106
|
+
if (positional.length === 0) {
|
|
107
|
+
cmd = 'help';
|
|
108
|
+
return { cmd, options, positional };
|
|
109
|
+
}
|
|
110
|
+
const head = positional[0];
|
|
111
|
+
if (head === 'claude' || head === 'codex') {
|
|
112
|
+
cmd = head;
|
|
113
|
+
target = head;
|
|
114
|
+
}
|
|
115
|
+
else if (head === 'preview') {
|
|
116
|
+
cmd = 'preview';
|
|
117
|
+
const t = positional[1];
|
|
118
|
+
if (t === 'claude' || t === 'codex')
|
|
119
|
+
target = t;
|
|
120
|
+
else {
|
|
121
|
+
process.stderr.write(`codex-claude-relay: \`preview\` requires a target: \`relay preview claude\` or \`relay preview codex\`\n`);
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (head === 'inspect') {
|
|
126
|
+
cmd = 'inspect';
|
|
127
|
+
}
|
|
128
|
+
else if (head === 'help') {
|
|
129
|
+
cmd = 'help';
|
|
130
|
+
}
|
|
131
|
+
else if (head === 'version') {
|
|
132
|
+
cmd = 'version';
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
process.stderr.write(`codex-claude-relay: unknown command "${head}"\n`);
|
|
136
|
+
process.exit(2);
|
|
137
|
+
}
|
|
138
|
+
return { cmd, options, positional, target };
|
|
139
|
+
}
|
|
140
|
+
function debug(opts, msg) {
|
|
141
|
+
if (opts.debug)
|
|
142
|
+
process.stderr.write(`[relay] ${msg}\n`);
|
|
143
|
+
}
|
|
144
|
+
async function runHandoff(target, opts, mode) {
|
|
145
|
+
const git = detectGitContext(process.cwd());
|
|
146
|
+
if (!git.inRepo) {
|
|
147
|
+
process.stderr.write(`codex-claude-relay: warning — current directory is not a git repo. Falling back to cwd: ${git.root}\n`);
|
|
148
|
+
}
|
|
149
|
+
debug(opts, `git root: ${git.root} (inRepo=${git.inRepo})`);
|
|
150
|
+
const sourceAgent = target === 'claude' ? 'codex' : 'claude';
|
|
151
|
+
debug(opts, `source agent: ${sourceAgent}, target agent: ${target}`);
|
|
152
|
+
if (sourceAgent === 'codex') {
|
|
153
|
+
if (!existsSync(CODEX_SESSIONS_DIR)) {
|
|
154
|
+
process.stderr.write(`codex-claude-relay: no Codex session directory found at ${CODEX_SESSIONS_DIR}.\n` +
|
|
155
|
+
` Run Codex CLI at least once to generate transcripts, or check ~/.codex/sessions.\n`);
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
const pick = await pickCodexSession(git, opts.last);
|
|
159
|
+
if (!pick) {
|
|
160
|
+
process.stderr.write(`codex-claude-relay: no Codex rollout files found under ${CODEX_SESSIONS_DIR}.\n`);
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
debug(opts, `picked codex session: ${pick.path} (score=${pick.score.toFixed(1)})`);
|
|
164
|
+
debug(opts, `reasons: ${pick.reasons.join(' | ')}`);
|
|
165
|
+
const session = await parseCodexSession(pick.path);
|
|
166
|
+
debug(opts, `parsed ${session.parsedLines} events, skipped ${session.skippedLines} malformed lines`);
|
|
167
|
+
const diff = opts.withDiff && git.inRepo ? getDiff(git.root, 6000) : null;
|
|
168
|
+
const handoff = renderHandoff({
|
|
169
|
+
sourceAgent,
|
|
170
|
+
targetAgent: target,
|
|
171
|
+
git,
|
|
172
|
+
session,
|
|
173
|
+
diff,
|
|
174
|
+
options: opts,
|
|
175
|
+
});
|
|
176
|
+
return finishHandoff(target, handoff.text, opts, mode);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// sourceAgent === 'claude'
|
|
180
|
+
if (!existsSync(CLAUDE_PROJECTS_DIR)) {
|
|
181
|
+
process.stderr.write(`codex-claude-relay: no Claude Code projects directory found at ${CLAUDE_PROJECTS_DIR}.\n` +
|
|
182
|
+
` Run Claude Code at least once to generate transcripts, or check ~/.claude/projects.\n`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
const pick = await pickClaudeSession(git, opts.last);
|
|
186
|
+
if (!pick) {
|
|
187
|
+
process.stderr.write(`codex-claude-relay: no Claude Code session JSONLs found under ${CLAUDE_PROJECTS_DIR}.\n`);
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
debug(opts, `picked claude session: ${pick.path} (score=${pick.score.toFixed(1)})`);
|
|
191
|
+
debug(opts, `reasons: ${pick.reasons.join(' | ')}`);
|
|
192
|
+
const session = await parseClaudeSession(pick.path);
|
|
193
|
+
debug(opts, `parsed ${session.parsedLines} events, skipped ${session.skippedLines} malformed lines`);
|
|
194
|
+
const mem = await readClaudeMemory(git);
|
|
195
|
+
debug(opts, `claude memory: exists=${mem.exists} bytes=${mem.summary.length}`);
|
|
196
|
+
const diff = opts.withDiff && git.inRepo ? getDiff(git.root, 6000) : null;
|
|
197
|
+
const handoff = renderHandoff({
|
|
198
|
+
sourceAgent,
|
|
199
|
+
targetAgent: target,
|
|
200
|
+
git,
|
|
201
|
+
session,
|
|
202
|
+
memorySummary: mem.exists ? mem.summary : null,
|
|
203
|
+
diff,
|
|
204
|
+
options: opts,
|
|
205
|
+
});
|
|
206
|
+
return finishHandoff(target, handoff.text, opts, mode);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function finishHandoff(target, prompt, opts, mode) {
|
|
210
|
+
if (mode === 'preview' || opts.dryRun) {
|
|
211
|
+
if (opts.dryRun && mode === 'launch') {
|
|
212
|
+
process.stderr.write(`codex-claude-relay: --dry-run — printing handoff (would launch \`${target}\`)\n\n`);
|
|
213
|
+
}
|
|
214
|
+
process.stdout.write(prompt);
|
|
215
|
+
if (!prompt.endsWith('\n'))
|
|
216
|
+
process.stdout.write('\n');
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
if (!hasBinary(target)) {
|
|
220
|
+
process.stderr.write(`codex-claude-relay: \`${target}\` is not on PATH. Install it (or use \`relay preview ${target}\` / \`--dry-run\`).\n`);
|
|
221
|
+
return 127;
|
|
222
|
+
}
|
|
223
|
+
process.stderr.write(`codex-claude-relay: launching \`${target}\` with handoff (${prompt.length} chars)\n`);
|
|
224
|
+
const res = await launchAgentAsync({ agent: target, prompt });
|
|
225
|
+
return res.code;
|
|
226
|
+
}
|
|
227
|
+
async function runInspect(opts) {
|
|
228
|
+
const git = detectGitContext(process.cwd());
|
|
229
|
+
const codexPaths = existsSync(CODEX_SESSIONS_DIR) ? await discoverCodexSessions(git) : [];
|
|
230
|
+
const claudePaths = existsSync(CLAUDE_PROJECTS_DIR) ? await discoverClaudeSessions(git) : [];
|
|
231
|
+
const mem = await readClaudeMemory(git);
|
|
232
|
+
const claudeOnPath = hasBinary('claude');
|
|
233
|
+
const codexOnPath = hasBinary('codex');
|
|
234
|
+
const lines = [];
|
|
235
|
+
lines.push(`codex-claude-relay v${VERSION} inspect`);
|
|
236
|
+
lines.push('');
|
|
237
|
+
lines.push(`Git context:`);
|
|
238
|
+
lines.push(` cwd: ${process.cwd()}`);
|
|
239
|
+
lines.push(` inRepo: ${git.inRepo}`);
|
|
240
|
+
lines.push(` root: ${git.root}`);
|
|
241
|
+
lines.push(` branch: ${git.branch ?? '(unknown)'}`);
|
|
242
|
+
lines.push('');
|
|
243
|
+
lines.push(`Codex sessions (~/.codex/sessions):`);
|
|
244
|
+
lines.push(` dir exists: ${existsSync(CODEX_SESSIONS_DIR)}`);
|
|
245
|
+
lines.push(` count: ${codexPaths.length}`);
|
|
246
|
+
if (codexPaths.length > 0) {
|
|
247
|
+
const best = codexPaths[0];
|
|
248
|
+
lines.push(` best: ${best.path}`);
|
|
249
|
+
lines.push(` score=${best.score.toFixed(1)} mtime=${new Date(best.mtimeMs).toISOString()}`);
|
|
250
|
+
lines.push(` cwd=${best.recordedCwd ?? '(unknown)'}`);
|
|
251
|
+
lines.push(` reasons: ${best.reasons.join(' | ')}`);
|
|
252
|
+
}
|
|
253
|
+
lines.push('');
|
|
254
|
+
lines.push(`Claude Code sessions (~/.claude/projects):`);
|
|
255
|
+
lines.push(` dir exists: ${existsSync(CLAUDE_PROJECTS_DIR)}`);
|
|
256
|
+
lines.push(` count: ${claudePaths.length}`);
|
|
257
|
+
if (claudePaths.length > 0) {
|
|
258
|
+
const best = claudePaths[0];
|
|
259
|
+
lines.push(` best: ${best.path}`);
|
|
260
|
+
lines.push(` score=${best.score.toFixed(1)} mtime=${new Date(best.mtimeMs).toISOString()}`);
|
|
261
|
+
lines.push(` cwd=${best.recordedCwd ?? '(unknown)'}`);
|
|
262
|
+
lines.push(` reasons: ${best.reasons.join(' | ')}`);
|
|
263
|
+
}
|
|
264
|
+
lines.push('');
|
|
265
|
+
lines.push(`Claude memory for this project:`);
|
|
266
|
+
lines.push(` exists: ${mem.exists}`);
|
|
267
|
+
lines.push(` dir: ${mem.dir}`);
|
|
268
|
+
lines.push(` bytes: ${mem.summary.length}`);
|
|
269
|
+
lines.push('');
|
|
270
|
+
lines.push(`Binaries on PATH:`);
|
|
271
|
+
lines.push(` claude: ${claudeOnPath ? 'yes' : 'NO'}`);
|
|
272
|
+
lines.push(` codex: ${codexOnPath ? 'yes' : 'NO'}`);
|
|
273
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
async function main() {
|
|
277
|
+
const argv = process.argv.slice(2);
|
|
278
|
+
const parsed = parseArgs(argv);
|
|
279
|
+
switch (parsed.cmd) {
|
|
280
|
+
case 'help':
|
|
281
|
+
process.stdout.write(HELP);
|
|
282
|
+
return 0;
|
|
283
|
+
case 'version':
|
|
284
|
+
process.stdout.write(`${VERSION}\n`);
|
|
285
|
+
return 0;
|
|
286
|
+
case 'inspect':
|
|
287
|
+
return runInspect(parsed.options);
|
|
288
|
+
case 'preview':
|
|
289
|
+
if (!parsed.target) {
|
|
290
|
+
process.stderr.write(`codex-claude-relay: missing target for preview\n`);
|
|
291
|
+
return 2;
|
|
292
|
+
}
|
|
293
|
+
return runHandoff(parsed.target, parsed.options, 'preview');
|
|
294
|
+
case 'claude':
|
|
295
|
+
case 'codex':
|
|
296
|
+
return runHandoff(parsed.cmd, parsed.options, 'launch');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
main()
|
|
300
|
+
.then((code) => process.exit(code))
|
|
301
|
+
.catch((err) => {
|
|
302
|
+
process.stderr.write(`codex-claude-relay: unexpected error: ${err?.stack ?? err}\n`);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
});
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { GitContext } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Detect the git context for the current working directory.
|
|
4
|
+
*
|
|
5
|
+
* If we're not inside a repo we still return a usable context (root = cwd,
|
|
6
|
+
* inRepo = false) so the rest of the pipeline degrades gracefully — callers
|
|
7
|
+
* are expected to warn when inRepo is false.
|
|
8
|
+
*/
|
|
9
|
+
export declare function detectGitContext(cwd?: string): GitContext;
|
|
10
|
+
/** Return a short git diff (staged + unstaged) capped at maxChars. */
|
|
11
|
+
export declare function getDiff(root: string, maxChars?: number): string | null;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
function safeGit(args, cwd) {
|
|
4
|
+
try {
|
|
5
|
+
const out = execFileSync('git', args, {
|
|
6
|
+
cwd,
|
|
7
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
8
|
+
encoding: 'utf8',
|
|
9
|
+
timeout: 3000,
|
|
10
|
+
});
|
|
11
|
+
return out.trim();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Detect the git context for the current working directory.
|
|
19
|
+
*
|
|
20
|
+
* If we're not inside a repo we still return a usable context (root = cwd,
|
|
21
|
+
* inRepo = false) so the rest of the pipeline degrades gracefully — callers
|
|
22
|
+
* are expected to warn when inRepo is false.
|
|
23
|
+
*/
|
|
24
|
+
export function detectGitContext(cwd = process.cwd()) {
|
|
25
|
+
const root = safeGit(['rev-parse', '--show-toplevel'], cwd);
|
|
26
|
+
if (!root) {
|
|
27
|
+
return {
|
|
28
|
+
root: cwd,
|
|
29
|
+
inRepo: false,
|
|
30
|
+
repoName: basename(cwd),
|
|
31
|
+
branch: null,
|
|
32
|
+
statusShort: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const branch = safeGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], root) ??
|
|
36
|
+
safeGit(['rev-parse', '--short', 'HEAD'], root);
|
|
37
|
+
// Keep the status excerpt small — we just want a flavor for the prompt.
|
|
38
|
+
let statusShort = safeGit(['status', '--short'], root);
|
|
39
|
+
if (statusShort && statusShort.length > 2000) {
|
|
40
|
+
statusShort = statusShort.slice(0, 2000) + '\n... (truncated)';
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
root,
|
|
44
|
+
inRepo: true,
|
|
45
|
+
repoName: basename(root),
|
|
46
|
+
branch: branch ?? null,
|
|
47
|
+
statusShort,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Return a short git diff (staged + unstaged) capped at maxChars. */
|
|
51
|
+
export function getDiff(root, maxChars = 6000) {
|
|
52
|
+
const diff = safeGit(['diff', '--no-color', 'HEAD'], root);
|
|
53
|
+
if (!diff)
|
|
54
|
+
return null;
|
|
55
|
+
if (diff.length <= maxChars)
|
|
56
|
+
return diff;
|
|
57
|
+
return diff.slice(0, maxChars) + '\n... (diff truncated)';
|
|
58
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export { detectGitContext, getDiff } from './git.js';
|
|
3
|
+
export { CODEX_SESSIONS_DIR, discoverCodexSessions, pickCodexSession, parseCodexSession, } from './providers/codex.js';
|
|
4
|
+
export { CLAUDE_PROJECTS_DIR, discoverClaudeSessions, pickClaudeSession, parseClaudeSession, readClaudeMemory, encodeProjectDir, } from './providers/claude.js';
|
|
5
|
+
export { renderHandoff } from './summarize.js';
|
|
6
|
+
export { redact } from './redact.js';
|
|
7
|
+
export { launchAgentAsync, hasBinary } from './launch.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Public programmatic API for codex-claude-relay. The CLI in src/cli.ts is the
|
|
2
|
+
// primary interface, but this re-exports the building blocks for users who
|
|
3
|
+
// want to embed handoff generation in their own tooling.
|
|
4
|
+
export * from './types.js';
|
|
5
|
+
export { detectGitContext, getDiff } from './git.js';
|
|
6
|
+
export { CODEX_SESSIONS_DIR, discoverCodexSessions, pickCodexSession, parseCodexSession, } from './providers/codex.js';
|
|
7
|
+
export { CLAUDE_PROJECTS_DIR, discoverClaudeSessions, pickClaudeSession, parseClaudeSession, readClaudeMemory, encodeProjectDir, } from './providers/claude.js';
|
|
8
|
+
export { renderHandoff } from './summarize.js';
|
|
9
|
+
export { redact } from './redact.js';
|
|
10
|
+
export { launchAgentAsync, hasBinary } from './launch.js';
|
package/dist/launch.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AgentName } from './types.js';
|
|
2
|
+
export interface LaunchResult {
|
|
3
|
+
/** Exit code of the child process. */
|
|
4
|
+
code: number;
|
|
5
|
+
/** Final argv used (for debugging / inspect). */
|
|
6
|
+
argv: string[];
|
|
7
|
+
/** Path to the temp file used, if any (cleaned up after the child exits). */
|
|
8
|
+
tempFile: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface LaunchOptions {
|
|
11
|
+
agent: AgentName;
|
|
12
|
+
prompt: string;
|
|
13
|
+
/** Override binary path (defaults to PATH lookup). */
|
|
14
|
+
binary?: string;
|
|
15
|
+
/** Additional CLI args to forward, placed before the prompt. */
|
|
16
|
+
extraArgs?: string[];
|
|
17
|
+
}
|
|
18
|
+
/** Returns true if the named binary is on PATH. */
|
|
19
|
+
export declare function hasBinary(name: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Launch the target agent with the handoff prompt as its initial input.
|
|
22
|
+
*
|
|
23
|
+
* For short prompts we pass the prompt as a positional argv item, which is the
|
|
24
|
+
* native form for both `claude` and `codex`. For long prompts we write the
|
|
25
|
+
* handoff to a 0600 temp file in a per-invocation 0700 directory and pass a
|
|
26
|
+
* short reference prompt instead — that way we never blow past the OS argv
|
|
27
|
+
* limit and never leak the handoff via process listings.
|
|
28
|
+
*/
|
|
29
|
+
export declare function launchAgentAsync(opts: LaunchOptions): Promise<LaunchResult>;
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { writeFileSync, unlinkSync, mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/** Limit beyond which we switch to a temp-file launch. */
|
|
6
|
+
const ARG_INLINE_LIMIT = 8000;
|
|
7
|
+
/** Returns true if the named binary is on PATH. */
|
|
8
|
+
export function hasBinary(name) {
|
|
9
|
+
const res = spawnSync(process.platform === 'win32' ? 'where' : 'command', process.platform === 'win32' ? [name] : ['-v', name], {
|
|
10
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
11
|
+
encoding: 'utf8',
|
|
12
|
+
shell: process.platform === 'win32',
|
|
13
|
+
});
|
|
14
|
+
return res.status === 0 && (res.stdout?.trim().length ?? 0) > 0;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Launch the target agent with the handoff prompt as its initial input.
|
|
18
|
+
*
|
|
19
|
+
* For short prompts we pass the prompt as a positional argv item, which is the
|
|
20
|
+
* native form for both `claude` and `codex`. For long prompts we write the
|
|
21
|
+
* handoff to a 0600 temp file in a per-invocation 0700 directory and pass a
|
|
22
|
+
* short reference prompt instead — that way we never blow past the OS argv
|
|
23
|
+
* limit and never leak the handoff via process listings.
|
|
24
|
+
*/
|
|
25
|
+
export function launchAgentAsync(opts) {
|
|
26
|
+
const binary = opts.binary ?? opts.agent;
|
|
27
|
+
let tempFile = null;
|
|
28
|
+
let argv;
|
|
29
|
+
if (opts.prompt.length > ARG_INLINE_LIMIT) {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), 'codex-claude-relay-'));
|
|
31
|
+
tempFile = join(dir, 'handoff.md');
|
|
32
|
+
writeFileSync(tempFile, opts.prompt, { encoding: 'utf8', mode: 0o600 });
|
|
33
|
+
const refPrompt = `Read the handoff context file at "${tempFile}" and continue the prior session ` +
|
|
34
|
+
`(it was produced by codex-claude-relay). After reading, briefly confirm what you ` +
|
|
35
|
+
`understand the next action to be, then proceed cautiously.`;
|
|
36
|
+
argv = [...(opts.extraArgs ?? []), refPrompt];
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
argv = [...(opts.extraArgs ?? []), opts.prompt];
|
|
40
|
+
}
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const child = spawn(binary, argv, {
|
|
43
|
+
stdio: 'inherit',
|
|
44
|
+
shell: false,
|
|
45
|
+
});
|
|
46
|
+
const cleanup = () => {
|
|
47
|
+
if (tempFile) {
|
|
48
|
+
try {
|
|
49
|
+
unlinkSync(tempFile);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// ignore — file may already be gone
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
child.on('exit', (code) => {
|
|
57
|
+
cleanup();
|
|
58
|
+
resolve({ code: code ?? 0, argv, tempFile });
|
|
59
|
+
});
|
|
60
|
+
child.on('error', (err) => {
|
|
61
|
+
cleanup();
|
|
62
|
+
process.stderr.write(`codex-claude-relay: failed to launch \`${binary}\`: ${err.message}\n`);
|
|
63
|
+
resolve({ code: 127, argv, tempFile });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface JsonlParseResult<T> {
|
|
2
|
+
records: T[];
|
|
3
|
+
skipped: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Stream-parse a JSONL file. Each line is fed to `accept(obj, lineNo)`; if it
|
|
7
|
+
* returns a non-null value the record is kept. Malformed lines are silently
|
|
8
|
+
* counted in `skipped`.
|
|
9
|
+
*
|
|
10
|
+
* We use streaming because Claude/Codex transcripts can be tens of megabytes,
|
|
11
|
+
* and we never need the whole raw array in memory — providers only keep the
|
|
12
|
+
* normalized events.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseJsonl<T>(path: string, accept: (obj: unknown, lineNo: number) => T | null): Promise<JsonlParseResult<T>>;
|
|
15
|
+
/**
|
|
16
|
+
* Read just the head of a JSONL file to peek at session metadata cheaply.
|
|
17
|
+
* Returns the first N parsed objects (default 5).
|
|
18
|
+
*/
|
|
19
|
+
export declare function peekJsonl(path: string, n?: number): Promise<unknown[]>;
|
|
20
|
+
/** Compactly truncate a string for inclusion in summaries. */
|
|
21
|
+
export declare function clip(s: string, n: number): string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
/**
|
|
4
|
+
* Stream-parse a JSONL file. Each line is fed to `accept(obj, lineNo)`; if it
|
|
5
|
+
* returns a non-null value the record is kept. Malformed lines are silently
|
|
6
|
+
* counted in `skipped`.
|
|
7
|
+
*
|
|
8
|
+
* We use streaming because Claude/Codex transcripts can be tens of megabytes,
|
|
9
|
+
* and we never need the whole raw array in memory — providers only keep the
|
|
10
|
+
* normalized events.
|
|
11
|
+
*/
|
|
12
|
+
export async function parseJsonl(path, accept) {
|
|
13
|
+
const records = [];
|
|
14
|
+
let skipped = 0;
|
|
15
|
+
let lineNo = 0;
|
|
16
|
+
const stream = createReadStream(path, { encoding: 'utf8' });
|
|
17
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
18
|
+
for await (const raw of rl) {
|
|
19
|
+
lineNo += 1;
|
|
20
|
+
const line = raw.trim();
|
|
21
|
+
if (!line)
|
|
22
|
+
continue;
|
|
23
|
+
let obj;
|
|
24
|
+
try {
|
|
25
|
+
obj = JSON.parse(line);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
skipped += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const kept = accept(obj, lineNo);
|
|
33
|
+
if (kept !== null && kept !== undefined) {
|
|
34
|
+
records.push(kept);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// accept() threw on a malformed-but-valid-JSON record; skip it.
|
|
39
|
+
skipped += 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { records, skipped };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read just the head of a JSONL file to peek at session metadata cheaply.
|
|
46
|
+
* Returns the first N parsed objects (default 5).
|
|
47
|
+
*/
|
|
48
|
+
export async function peekJsonl(path, n = 5) {
|
|
49
|
+
const out = [];
|
|
50
|
+
const stream = createReadStream(path, { encoding: 'utf8' });
|
|
51
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
52
|
+
for await (const raw of rl) {
|
|
53
|
+
const line = raw.trim();
|
|
54
|
+
if (!line)
|
|
55
|
+
continue;
|
|
56
|
+
try {
|
|
57
|
+
out.push(JSON.parse(line));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// ignore malformed
|
|
61
|
+
}
|
|
62
|
+
if (out.length >= n) {
|
|
63
|
+
rl.close();
|
|
64
|
+
stream.destroy();
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Compactly truncate a string for inclusion in summaries. */
|
|
71
|
+
export function clip(s, n) {
|
|
72
|
+
if (s.length <= n)
|
|
73
|
+
return s;
|
|
74
|
+
return s.slice(0, n) + `... (+${s.length - n} chars)`;
|
|
75
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { GitContext, ParsedSession, SessionCandidate } from '../types.js';
|
|
2
|
+
export declare const CLAUDE_PROJECTS_DIR: string;
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code encodes the project directory by replacing path separators with
|
|
5
|
+
* dashes. e.g. /Users/alice/work/foo -> -Users-alice-work-foo
|
|
6
|
+
*
|
|
7
|
+
* We can't reverse this perfectly (a `-` in a real directory name is ambiguous)
|
|
8
|
+
* but we can compute the most likely encoded name and use it as a fast-path.
|
|
9
|
+
*/
|
|
10
|
+
export declare function encodeProjectDir(absPath: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Discover Claude session JSONL files and rank them.
|
|
13
|
+
*
|
|
14
|
+
* Strategy:
|
|
15
|
+
* 1. Fast path: try `~/.claude/projects/<encoded(root)>/*.jsonl` first; those
|
|
16
|
+
* get a big score boost.
|
|
17
|
+
* 2. Fallback: scan the full `projects/` tree (one level deep is typical),
|
|
18
|
+
* detect cwd from each transcript, and rank.
|
|
19
|
+
*/
|
|
20
|
+
export declare function discoverClaudeSessions(git: GitContext): Promise<SessionCandidate[]>;
|
|
21
|
+
export declare function pickClaudeSession(git: GitContext, forceLast: boolean): Promise<SessionCandidate | null>;
|
|
22
|
+
export interface ClaudeMemory {
|
|
23
|
+
exists: boolean;
|
|
24
|
+
dir: string;
|
|
25
|
+
index: string | null;
|
|
26
|
+
/** A short summary string suitable for inclusion in the handoff. */
|
|
27
|
+
summary: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Read the Claude Code auto-memory directory for the current project.
|
|
31
|
+
*
|
|
32
|
+
* Layout: `~/.claude/projects/<encoded>/memory/MEMORY.md` plus arbitrarily-named
|
|
33
|
+
* `.md` files referenced by it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readClaudeMemory(git: GitContext): Promise<ClaudeMemory>;
|
|
36
|
+
export declare function parseClaudeSession(path: string): Promise<ParsedSession>;
|