tlc-claude-code 2.4.2 → 2.4.4

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 (66) hide show
  1. package/.claude/commands/tlc/build.md +75 -5
  2. package/.claude/commands/tlc/discuss.md +174 -123
  3. package/.claude/commands/tlc/e2e-verify.md +1 -1
  4. package/.claude/commands/tlc/plan.md +77 -2
  5. package/.claude/commands/tlc/recall.md +59 -87
  6. package/.claude/commands/tlc/remember.md +76 -71
  7. package/.claude/commands/tlc/review.md +76 -21
  8. package/.claude/commands/tlc/tlc.md +204 -473
  9. package/.claude/hooks/tlc-capture-exchange.sh +50 -21
  10. package/.claude/hooks/tlc-session-init.sh +30 -0
  11. package/CLAUDE.md +6 -5
  12. package/bin/init.js +12 -3
  13. package/package.json +4 -1
  14. package/scripts/dev-link.sh +29 -0
  15. package/scripts/test-package.sh +54 -0
  16. package/scripts/version-sync.js +42 -0
  17. package/scripts/version-sync.test.js +100 -0
  18. package/server/lib/capture/classifier.js +71 -0
  19. package/server/lib/capture/classifier.test.js +71 -0
  20. package/server/lib/capture/claude-capture.js +140 -0
  21. package/server/lib/capture/claude-capture.test.js +152 -0
  22. package/server/lib/capture/codex-capture.js +79 -0
  23. package/server/lib/capture/codex-capture.test.js +161 -0
  24. package/server/lib/capture/codex-event-parser.js +76 -0
  25. package/server/lib/capture/codex-event-parser.test.js +83 -0
  26. package/server/lib/capture/ensure-ready.js +56 -0
  27. package/server/lib/capture/ensure-ready.test.js +135 -0
  28. package/server/lib/capture/envelope.js +77 -0
  29. package/server/lib/capture/envelope.test.js +169 -0
  30. package/server/lib/capture/extractor.js +51 -0
  31. package/server/lib/capture/extractor.test.js +92 -0
  32. package/server/lib/capture/generic-capture.js +96 -0
  33. package/server/lib/capture/generic-capture.test.js +171 -0
  34. package/server/lib/capture/index.js +117 -0
  35. package/server/lib/capture/index.test.js +263 -0
  36. package/server/lib/capture/redactor.js +68 -0
  37. package/server/lib/capture/redactor.test.js +93 -0
  38. package/server/lib/capture/spool-processor.js +155 -0
  39. package/server/lib/capture/spool-processor.test.js +278 -0
  40. package/server/lib/health-check.js +255 -0
  41. package/server/lib/health-check.test.js +243 -0
  42. package/server/lib/model-router.js +11 -2
  43. package/server/lib/model-router.test.js +27 -1
  44. package/server/lib/orchestration/cli-dispatch.js +200 -0
  45. package/server/lib/orchestration/cli-dispatch.test.js +242 -0
  46. package/server/lib/orchestration/codex-orchestrator.js +185 -0
  47. package/server/lib/orchestration/codex-orchestrator.test.js +221 -0
  48. package/server/lib/orchestration/dep-linker.js +61 -0
  49. package/server/lib/orchestration/dep-linker.test.js +174 -0
  50. package/server/lib/orchestration/prompt-builder.js +118 -0
  51. package/server/lib/orchestration/prompt-builder.test.js +200 -0
  52. package/server/lib/orchestration/standalone-compat.js +39 -0
  53. package/server/lib/orchestration/standalone-compat.test.js +144 -0
  54. package/server/lib/orchestration/worktree-manager.js +43 -0
  55. package/server/lib/orchestration/worktree-manager.test.js +50 -0
  56. package/server/lib/router-config.js +18 -3
  57. package/server/lib/router-config.test.js +57 -1
  58. package/server/lib/routing/index.js +34 -0
  59. package/server/lib/routing/index.test.js +33 -0
  60. package/server/lib/routing-command.js +11 -2
  61. package/server/lib/routing-command.test.js +39 -1
  62. package/server/lib/routing-preamble.integration.test.js +319 -0
  63. package/server/lib/routing-preamble.js +34 -11
  64. package/server/lib/routing-preamble.test.js +11 -0
  65. package/server/lib/task-router-config.js +35 -14
  66. package/server/lib/task-router-config.test.js +77 -13
@@ -0,0 +1,140 @@
1
+ const path = require('path');
2
+
3
+ const { createEnvelope } = require('./envelope');
4
+ const { redact } = require('./redactor');
5
+ const { ensureMemoryReady } = require('./ensure-ready');
6
+
7
+ const SPOOL_PATH = path.join('.tlc', 'memory', '.spool.jsonl');
8
+
9
+ function parseHookInput(hookInput) {
10
+ if (typeof hookInput !== 'string') {
11
+ return null;
12
+ }
13
+
14
+ try {
15
+ const parsed = JSON.parse(hookInput);
16
+ return parsed && typeof parsed === 'object' ? parsed : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function normalizeContent(content) {
23
+ if (typeof content === 'string') {
24
+ return content;
25
+ }
26
+
27
+ if (Array.isArray(content)) {
28
+ return content
29
+ .map((item) => {
30
+ if (!item || typeof item !== 'object') {
31
+ return '';
32
+ }
33
+
34
+ if (typeof item.text === 'string') {
35
+ return item.text;
36
+ }
37
+
38
+ if (typeof item.content === 'string') {
39
+ return item.content;
40
+ }
41
+
42
+ return '';
43
+ })
44
+ .filter(Boolean)
45
+ .join('\n');
46
+ }
47
+
48
+ return '';
49
+ }
50
+
51
+ function extractUserMessage(transcriptPath, fs) {
52
+ if (typeof transcriptPath !== 'string' || transcriptPath.trim() === '') {
53
+ return '';
54
+ }
55
+
56
+ try {
57
+ const content = fs.readFileSync(transcriptPath, 'utf8').trim();
58
+
59
+ if (!content) {
60
+ return '';
61
+ }
62
+
63
+ const lines = content.split('\n');
64
+ let lastUserMessage = '';
65
+
66
+ for (const line of lines) {
67
+ if (!line.trim()) {
68
+ continue;
69
+ }
70
+
71
+ try {
72
+ const entry = JSON.parse(line);
73
+
74
+ if (entry.role !== 'user') {
75
+ continue;
76
+ }
77
+
78
+ const message = normalizeContent(entry.content || entry.message);
79
+
80
+ if (message.trim()) {
81
+ lastUserMessage = message;
82
+ }
83
+ } catch {
84
+ // Skip malformed transcript lines.
85
+ }
86
+ }
87
+
88
+ return lastUserMessage;
89
+ } catch {
90
+ return '';
91
+ }
92
+ }
93
+
94
+ function redactEnvelope(envelope) {
95
+ const userMessage = redact(envelope.metadata && envelope.metadata.userMessage ? envelope.metadata.userMessage : '');
96
+ const assistantMessage = redact(envelope.metadata && envelope.metadata.assistantMessage ? envelope.metadata.assistantMessage : '');
97
+
98
+ return {
99
+ ...envelope,
100
+ text: redact(envelope.text),
101
+ metadata: {
102
+ ...envelope.metadata,
103
+ userMessage,
104
+ assistantMessage,
105
+ },
106
+ };
107
+ }
108
+
109
+ function captureClaudeExchange({ hookInput, projectDir, fs = require('fs') }) {
110
+ const parsed = parseHookInput(hookInput);
111
+
112
+ if (!parsed) {
113
+ return { captured: 0, skipped: 1 };
114
+ }
115
+
116
+ if (typeof parsed.assistant_message !== 'string' || parsed.assistant_message.trim() === '') {
117
+ return { captured: 0, skipped: 0 };
118
+ }
119
+
120
+ const userMessage = extractUserMessage(parsed.transcript_path, fs);
121
+ const envelope = redactEnvelope(createEnvelope({
122
+ provider: 'claude',
123
+ source: 'stop-hook',
124
+ threadId: typeof parsed.session_id === 'string' ? parsed.session_id : undefined,
125
+ userMessage,
126
+ assistantMessage: parsed.assistant_message,
127
+ }));
128
+
129
+ ensureMemoryReady(projectDir, { fs });
130
+
131
+ const spoolPath = path.join(projectDir, SPOOL_PATH);
132
+ fs.mkdirSync(path.dirname(spoolPath), { recursive: true });
133
+ fs.appendFileSync(spoolPath, `${JSON.stringify(envelope)}\n`);
134
+
135
+ return { captured: 1, skipped: 0 };
136
+ }
137
+
138
+ module.exports = {
139
+ captureClaudeExchange,
140
+ };
@@ -0,0 +1,152 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ describe('capture/claude-capture', () => {
7
+ let projectDir;
8
+ let captureClaudeExchange;
9
+
10
+ beforeEach(async () => {
11
+ vi.useFakeTimers();
12
+ vi.setSystemTime(new Date('2026-03-28T12:34:56.000Z'));
13
+ projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-claude-capture-test-'));
14
+ ({ captureClaudeExchange } = await import('./claude-capture.js'));
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.useRealTimers();
19
+ vi.resetModules();
20
+ fs.rmSync(projectDir, { recursive: true, force: true });
21
+ });
22
+
23
+ it('returns skipped for malformed hook input', () => {
24
+ const result = captureClaudeExchange({
25
+ hookInput: '{"session_id"',
26
+ projectDir,
27
+ });
28
+
29
+ expect(result).toEqual({ captured: 0, skipped: 1 });
30
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.spool.jsonl'))).toBe(false);
31
+ });
32
+
33
+ it('returns no-op when assistant_message is missing', () => {
34
+ const result = captureClaudeExchange({
35
+ hookInput: JSON.stringify({
36
+ session_id: 'sess-1',
37
+ transcript_path: path.join(projectDir, 'transcript.jsonl'),
38
+ }),
39
+ projectDir,
40
+ });
41
+
42
+ expect(result).toEqual({ captured: 0, skipped: 0 });
43
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.spool.jsonl'))).toBe(false);
44
+ });
45
+
46
+ it('captures the transcript user message and assistant message into the spool', () => {
47
+ const transcriptPath = path.join(projectDir, 'transcript.jsonl');
48
+
49
+ fs.writeFileSync(transcriptPath, [
50
+ JSON.stringify({ role: 'user', content: 'First prompt' }),
51
+ JSON.stringify({ role: 'assistant', content: 'First reply' }),
52
+ JSON.stringify({ role: 'user', content: 'Use PostgreSQL with JSONB' }),
53
+ ].join('\n') + '\n');
54
+
55
+ const result = captureClaudeExchange({
56
+ hookInput: JSON.stringify({
57
+ session_id: 'sess-2',
58
+ cwd: projectDir,
59
+ transcript_path: transcriptPath,
60
+ assistant_message: 'We should use PostgreSQL with JSONB indexes.',
61
+ }),
62
+ projectDir,
63
+ });
64
+
65
+ expect(result).toEqual({ captured: 1, skipped: 0 });
66
+
67
+ const spoolPath = path.join(projectDir, '.tlc', 'memory', '.spool.jsonl');
68
+ const envelope = JSON.parse(fs.readFileSync(spoolPath, 'utf8').trim());
69
+
70
+ expect(envelope).toMatchObject({
71
+ provider: 'claude',
72
+ source: 'stop-hook',
73
+ threadId: 'sess-2',
74
+ text: 'Use PostgreSQL with JSONB\n\nWe should use PostgreSQL with JSONB indexes.',
75
+ timestamp: '2026-03-28T12:34:56.000Z',
76
+ metadata: {
77
+ userMessage: 'Use PostgreSQL with JSONB',
78
+ assistantMessage: 'We should use PostgreSQL with JSONB indexes.',
79
+ },
80
+ });
81
+ });
82
+
83
+ it('redacts sensitive values before appending the envelope', () => {
84
+ const transcriptPath = path.join(projectDir, 'transcript.jsonl');
85
+
86
+ fs.writeFileSync(transcriptPath, `${JSON.stringify({
87
+ role: 'user',
88
+ content: 'token="sk-secret123" path=/Users/jurgen/private/app.js',
89
+ })}\n`);
90
+
91
+ captureClaudeExchange({
92
+ hookInput: JSON.stringify({
93
+ session_id: 'sess-3',
94
+ transcript_path: transcriptPath,
95
+ assistant_message: 'password=hunter2 Bearer abc.def.ghi',
96
+ }),
97
+ projectDir,
98
+ });
99
+
100
+ const spoolPath = path.join(projectDir, '.tlc', 'memory', '.spool.jsonl');
101
+ const envelope = JSON.parse(fs.readFileSync(spoolPath, 'utf8').trim());
102
+
103
+ expect(envelope.text).toContain('[TOKEN_REDACTED]');
104
+ expect(envelope.text).toContain('[REDACTED]');
105
+ expect(envelope.text).toContain('Bearer [TOKEN_REDACTED]');
106
+ expect(envelope.text).toContain('~/private/app.js');
107
+ expect(envelope.text).not.toContain('hunter2');
108
+ expect(envelope.text).not.toContain('/Users/jurgen');
109
+ });
110
+
111
+ it('appends new envelopes instead of overwriting the spool file', () => {
112
+ const spoolPath = path.join(projectDir, '.tlc', 'memory', '.spool.jsonl');
113
+ fs.mkdirSync(path.dirname(spoolPath), { recursive: true });
114
+ fs.writeFileSync(spoolPath, `${JSON.stringify({ existing: true })}\n`);
115
+
116
+ captureClaudeExchange({
117
+ hookInput: JSON.stringify({
118
+ session_id: 'sess-4',
119
+ assistant_message: 'Assistant only message',
120
+ }),
121
+ projectDir,
122
+ });
123
+
124
+ const lines = fs.readFileSync(spoolPath, 'utf8').trim().split('\n');
125
+
126
+ expect(lines).toHaveLength(2);
127
+ expect(JSON.parse(lines[0])).toEqual({ existing: true });
128
+ expect(JSON.parse(lines[1])).toMatchObject({
129
+ provider: 'claude',
130
+ source: 'stop-hook',
131
+ threadId: 'sess-4',
132
+ text: 'Assistant only message',
133
+ });
134
+ });
135
+
136
+ it('initializes the memory tree before writing the spool file', () => {
137
+ captureClaudeExchange({
138
+ hookInput: JSON.stringify({
139
+ session_id: 'sess-5',
140
+ assistant_message: 'Create the memory tree first',
141
+ }),
142
+ projectDir,
143
+ });
144
+
145
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.team'))).toBe(false);
146
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions'))).toBe(true);
147
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas'))).toBe(true);
148
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'))).toBe(true);
149
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions', '.gitkeep'))).toBe(true);
150
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas', '.gitkeep'))).toBe(true);
151
+ });
152
+ });
@@ -0,0 +1,79 @@
1
+ const path = require('path');
2
+
3
+ const { createEnvelope } = require('./envelope');
4
+ const { redact } = require('./redactor');
5
+ const { ensureMemoryReady } = require('./ensure-ready');
6
+ const { parseCodexEvents } = require('./codex-event-parser');
7
+
8
+ const SPOOL_FILENAME = '.spool.jsonl';
9
+
10
+ function resolveCaptureText({ lastMessageFile, jsonStream, stdout, fs }) {
11
+ if (lastMessageFile && fs.existsSync(lastMessageFile)) {
12
+ return {
13
+ source: 'last-message-file',
14
+ text: fs.readFileSync(lastMessageFile, 'utf8'),
15
+ parsedThreadId: null,
16
+ };
17
+ }
18
+
19
+ if (typeof jsonStream === 'string') {
20
+ const parsed = parseCodexEvents(jsonStream);
21
+ const joinedMessages = parsed.agentMessages.join('\n\n');
22
+
23
+ if (joinedMessages.trim() !== '') {
24
+ return {
25
+ source: 'json-stream',
26
+ text: joinedMessages,
27
+ parsedThreadId: parsed.threadId,
28
+ };
29
+ }
30
+ }
31
+
32
+ return {
33
+ source: 'stdout',
34
+ text: typeof stdout === 'string' ? stdout : '',
35
+ parsedThreadId: null,
36
+ };
37
+ }
38
+
39
+ function captureCodexOutput({
40
+ lastMessageFile,
41
+ jsonStream,
42
+ stdout,
43
+ taskName,
44
+ threadId,
45
+ projectDir,
46
+ fs = require('fs'),
47
+ }) {
48
+ const capture = resolveCaptureText({ lastMessageFile, jsonStream, stdout, fs });
49
+
50
+ try {
51
+ const envelope = createEnvelope({
52
+ provider: 'codex',
53
+ source: capture.source,
54
+ taskName,
55
+ threadId: threadId || capture.parsedThreadId,
56
+ assistantMessage: capture.text,
57
+ });
58
+
59
+ envelope.text = redact(envelope.text);
60
+ if (envelope.metadata && typeof envelope.metadata.assistantMessage === 'string') {
61
+ envelope.metadata.assistantMessage = redact(envelope.metadata.assistantMessage);
62
+ }
63
+
64
+ ensureMemoryReady(projectDir, { fs });
65
+
66
+ const spoolPath = path.join(projectDir, '.tlc', 'memory', SPOOL_FILENAME);
67
+ fs.appendFileSync(spoolPath, JSON.stringify(envelope) + '\n', 'utf8');
68
+
69
+ return { captured: 1, skipped: 0 };
70
+ } catch (_) {
71
+ return { captured: 0, skipped: 1 };
72
+ }
73
+ }
74
+
75
+ module.exports = {
76
+ captureCodexOutput,
77
+ resolveCaptureText,
78
+ SPOOL_FILENAME,
79
+ };
@@ -0,0 +1,161 @@
1
+ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ import { captureCodexOutput } from './codex-capture.js';
7
+
8
+ const SPOOL_PATH = ['.tlc', 'memory', '.spool.jsonl'];
9
+
10
+ describe('capture/codex-capture', () => {
11
+ let projectDir;
12
+
13
+ beforeEach(() => {
14
+ projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-codex-capture-'));
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ fs.rmSync(projectDir, { recursive: true, force: true });
20
+ });
21
+
22
+ function readSpoolEntries() {
23
+ const spoolPath = path.join(projectDir, ...SPOOL_PATH);
24
+ const content = fs.readFileSync(spoolPath, 'utf8').trim();
25
+ return content.split('\n').filter(Boolean).map((line) => JSON.parse(line));
26
+ }
27
+
28
+ it('prefers lastMessageFile when it exists', () => {
29
+ const lastMessageFile = path.join(projectDir, 'last-message.txt');
30
+ fs.writeFileSync(lastMessageFile, 'Message from file');
31
+
32
+ const result = captureCodexOutput({
33
+ lastMessageFile,
34
+ jsonStream: '{"type":"agent_message","message":{"items":[{"type":"text","text":"json"}]}}',
35
+ stdout: 'raw stdout',
36
+ taskName: 'capture',
37
+ threadId: 'thread-from-arg',
38
+ projectDir,
39
+ });
40
+
41
+ const [entry] = readSpoolEntries();
42
+
43
+ expect(result).toEqual({ captured: 1, skipped: 0 });
44
+ expect(entry.provider).toBe('codex');
45
+ expect(entry.source).toBe('last-message-file');
46
+ expect(entry.text).toBe('Message from file');
47
+ expect(entry.threadId).toBe('thread-from-arg');
48
+ });
49
+
50
+ it('falls back to agent messages from jsonStream when no lastMessageFile is available', () => {
51
+ const jsonStream = [
52
+ '{"type":"thread.started","thread_id":"thread-from-json"}',
53
+ JSON.stringify({
54
+ type: 'agent_message',
55
+ message: {
56
+ items: [
57
+ { type: 'text', text: 'First agent message' },
58
+ { type: 'text', text: 'Second agent message' },
59
+ ],
60
+ },
61
+ }),
62
+ ].join('\n');
63
+
64
+ const result = captureCodexOutput({
65
+ lastMessageFile: path.join(projectDir, 'missing.txt'),
66
+ jsonStream,
67
+ stdout: 'raw stdout',
68
+ taskName: 'capture',
69
+ threadId: null,
70
+ projectDir,
71
+ });
72
+
73
+ const [entry] = readSpoolEntries();
74
+
75
+ expect(result).toEqual({ captured: 1, skipped: 0 });
76
+ expect(entry.source).toBe('json-stream');
77
+ expect(entry.threadId).toBe('thread-from-json');
78
+ expect(entry.text).toBe('First agent message\n\nSecond agent message');
79
+ });
80
+
81
+ it('falls back to raw stdout when jsonStream has no agent messages', () => {
82
+ const result = captureCodexOutput({
83
+ jsonStream: '{"type":"status","message":"booting"}\n',
84
+ stdout: 'stdout fallback text',
85
+ taskName: 'capture',
86
+ threadId: 'thread-stdout',
87
+ projectDir,
88
+ });
89
+
90
+ const [entry] = readSpoolEntries();
91
+
92
+ expect(result).toEqual({ captured: 1, skipped: 0 });
93
+ expect(entry.source).toBe('stdout');
94
+ expect(entry.text).toBe('stdout fallback text');
95
+ expect(entry.threadId).toBe('thread-stdout');
96
+ });
97
+
98
+ it('redacts sensitive values before appending to spool', () => {
99
+ captureCodexOutput({
100
+ stdout: 'token=abc123 sk-secret-key /Users/jurgen/private/file.js',
101
+ taskName: 'capture',
102
+ threadId: 'thread-redact',
103
+ projectDir,
104
+ });
105
+
106
+ const [entry] = readSpoolEntries();
107
+
108
+ expect(entry.text).toContain('[TOKEN_REDACTED]');
109
+ expect(entry.text).toContain('[API_KEY_REDACTED]');
110
+ expect(entry.text).toContain('~/private/file.js');
111
+ });
112
+
113
+ it('creates the memory spool path before writing', () => {
114
+ captureCodexOutput({
115
+ stdout: 'create spool dirs',
116
+ taskName: 'capture',
117
+ threadId: 'thread-dirs',
118
+ projectDir,
119
+ });
120
+
121
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory'))).toBe(true);
122
+ expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.spool.jsonl'))).toBe(true);
123
+ });
124
+
125
+ it('skips capture when all candidate sources are empty', () => {
126
+ const result = captureCodexOutput({
127
+ stdout: ' ',
128
+ jsonStream: 'not-json\n',
129
+ taskName: 'capture',
130
+ threadId: 'thread-empty',
131
+ projectDir,
132
+ });
133
+
134
+ const spoolPath = path.join(projectDir, ...SPOOL_PATH);
135
+
136
+ expect(result).toEqual({ captured: 0, skipped: 1 });
137
+ expect(fs.existsSync(spoolPath)).toBe(false);
138
+ });
139
+
140
+ it('supports injected fs implementations', () => {
141
+ const injectedFs = {
142
+ ...fs,
143
+ appendFileSync: vi.fn(fs.appendFileSync),
144
+ existsSync: vi.fn(fs.existsSync),
145
+ mkdirSync: vi.fn(fs.mkdirSync),
146
+ readFileSync: vi.fn(fs.readFileSync),
147
+ writeFileSync: vi.fn(fs.writeFileSync),
148
+ };
149
+
150
+ const result = captureCodexOutput({
151
+ stdout: 'injected fs',
152
+ taskName: 'capture',
153
+ threadId: 'thread-injected',
154
+ projectDir,
155
+ fs: injectedFs,
156
+ });
157
+
158
+ expect(result).toEqual({ captured: 1, skipped: 0 });
159
+ expect(injectedFs.appendFileSync).toHaveBeenCalledTimes(1);
160
+ });
161
+ });
@@ -0,0 +1,76 @@
1
+ function parseCodexEventLine(line) {
2
+ if (typeof line !== 'string' || line.trim() === '') {
3
+ return null;
4
+ }
5
+
6
+ try {
7
+ return JSON.parse(line);
8
+ } catch (_) {
9
+ return null;
10
+ }
11
+ }
12
+
13
+ function extractThreadId(event) {
14
+ if (
15
+ event &&
16
+ event.type === 'thread.started' &&
17
+ typeof event.thread_id === 'string' &&
18
+ event.thread_id.trim() !== ''
19
+ ) {
20
+ return event.thread_id;
21
+ }
22
+
23
+ return null;
24
+ }
25
+
26
+ function extractAgentMessages(event) {
27
+ if (!event || event.type !== 'agent_message') {
28
+ return [];
29
+ }
30
+
31
+ const items = Array.isArray(event.message && event.message.items)
32
+ ? event.message.items
33
+ : Array.isArray(event.items)
34
+ ? event.items
35
+ : [];
36
+
37
+ return items
38
+ .filter((item) => item && item.type === 'text' && typeof item.text === 'string' && item.text.trim() !== '')
39
+ .map((item) => item.text);
40
+ }
41
+
42
+ function parseCodexEvents(jsonStream) {
43
+ const lines = typeof jsonStream === 'string' ? jsonStream.split('\n') : [];
44
+ const events = [];
45
+ const agentMessages = [];
46
+ let threadId = null;
47
+
48
+ for (const line of lines) {
49
+ const event = parseCodexEventLine(line);
50
+ if (!event) {
51
+ continue;
52
+ }
53
+
54
+ events.push(event);
55
+
56
+ const nextThreadId = extractThreadId(event);
57
+ if (nextThreadId) {
58
+ threadId = nextThreadId;
59
+ }
60
+
61
+ agentMessages.push(...extractAgentMessages(event));
62
+ }
63
+
64
+ return {
65
+ threadId,
66
+ agentMessages,
67
+ events,
68
+ };
69
+ }
70
+
71
+ module.exports = {
72
+ extractAgentMessages,
73
+ extractThreadId,
74
+ parseCodexEventLine,
75
+ parseCodexEvents,
76
+ };
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { parseCodexEvents } from './codex-event-parser.js';
4
+
5
+ describe('capture/codex-event-parser', () => {
6
+ it('extracts threadId from thread.started events', () => {
7
+ const result = parseCodexEvents([
8
+ '{"type":"status","message":"booting"}',
9
+ '{"type":"thread.started","thread_id":"thread_123"}',
10
+ ].join('\n'));
11
+
12
+ expect(result.threadId).toBe('thread_123');
13
+ });
14
+
15
+ it('extracts text from agent_message item arrays', () => {
16
+ const result = parseCodexEvents([
17
+ JSON.stringify({
18
+ type: 'agent_message',
19
+ message: {
20
+ items: [
21
+ { type: 'text', text: 'First message' },
22
+ { type: 'image', url: 'ignored' },
23
+ { type: 'text', text: 'Second message' },
24
+ ],
25
+ },
26
+ }),
27
+ ].join('\n'));
28
+
29
+ expect(result.agentMessages).toEqual(['First message', 'Second message']);
30
+ });
31
+
32
+ it('preserves all parsed JSON events while skipping malformed lines', () => {
33
+ const result = parseCodexEvents([
34
+ '{"type":"status","message":"booting"}',
35
+ 'not-json',
36
+ '{"type":"thread.started","thread_id":"thread_999"}',
37
+ ].join('\n'));
38
+
39
+ expect(result.events).toEqual([
40
+ { type: 'status', message: 'booting' },
41
+ { type: 'thread.started', thread_id: 'thread_999' },
42
+ ]);
43
+ });
44
+
45
+ it('returns null threadId when no thread.started event exists', () => {
46
+ const result = parseCodexEvents('{"type":"status","message":"booting"}\n');
47
+
48
+ expect(result.threadId).toBeNull();
49
+ });
50
+
51
+ it('ignores blank lines and non-agent_message events', () => {
52
+ const result = parseCodexEvents([
53
+ '',
54
+ '{"type":"status","message":"booting"}',
55
+ '',
56
+ '{"type":"message.delta","delta":"partial"}',
57
+ '',
58
+ ].join('\n'));
59
+
60
+ expect(result.agentMessages).toEqual([]);
61
+ expect(result.events).toEqual([
62
+ { type: 'status', message: 'booting' },
63
+ { type: 'message.delta', delta: 'partial' },
64
+ ]);
65
+ });
66
+
67
+ it('handles missing or non-string agent message text safely', () => {
68
+ const result = parseCodexEvents([
69
+ JSON.stringify({
70
+ type: 'agent_message',
71
+ message: {
72
+ items: [
73
+ { type: 'text', text: '' },
74
+ { type: 'text', text: 42 },
75
+ { type: 'text', text: 'usable' },
76
+ ],
77
+ },
78
+ }),
79
+ ].join('\n'));
80
+
81
+ expect(result.agentMessages).toEqual(['usable']);
82
+ });
83
+ });