throughline 0.6.2 → 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +79 -4
  2. package/README.md +77 -25
  3. package/bin/throughline.mjs +14 -0
  4. package/docs/00_overview.md +12 -0
  5. package/docs/02_clear_auto_handoff_plan.md +39 -13
  6. package/docs/04_public_release_plan.md +2 -1
  7. package/docs/13_native_factory_diagnostics_plan.md +4 -2
  8. package/docs/14_observer_completed_turn_feed_plan.md +290 -0
  9. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +31 -4
  10. package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
  11. package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
  12. package/docs/adr/0004-observer-db-pair-projection.md +71 -0
  13. package/docs/adr/0005-observer-read-pagination.md +51 -0
  14. package/docs/adr/0006-observer-page-offset-proof.md +33 -0
  15. package/docs/adr/0007-observer-read-cli-contract.md +61 -0
  16. package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
  17. package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
  18. package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
  19. package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
  20. package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
  21. package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
  22. package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
  23. package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
  24. package/package.json +1 -1
  25. package/rag/01-hooks/hook-stdout-10k-persisted-output.md +48 -0
  26. package/rag/INDEX.md +4 -0
  27. package/src/auditor-context.mjs +92 -11
  28. package/src/auditor-context.test.mjs +116 -1
  29. package/src/baton.mjs +27 -7
  30. package/src/baton.test.mjs +44 -0
  31. package/src/body-digest.mjs +9 -0
  32. package/src/cli/auditor-context.test.mjs +1 -1
  33. package/src/cli/factory-diagnostics.mjs +5 -2
  34. package/src/cli/factory-diagnostics.test.mjs +31 -2
  35. package/src/cli/observer-read.mjs +73 -0
  36. package/src/cli/observer-read.test.mjs +93 -0
  37. package/src/cli/observer-wait.mjs +123 -0
  38. package/src/cli/observer-wait.test.mjs +167 -0
  39. package/src/codex-rollout-memory.mjs +13 -0
  40. package/src/codex-rollout-memory.test.mjs +27 -0
  41. package/src/codex-thread-index.mjs +1 -1
  42. package/src/codex-thread-index.test.mjs +18 -0
  43. package/src/completed-turn-receipts.mjs +373 -0
  44. package/src/completed-turn-receipts.test.mjs +186 -0
  45. package/src/db-schema.test.mjs +9 -2
  46. package/src/db.mjs +20 -1
  47. package/src/decision-log.mjs +24 -0
  48. package/src/factory-diagnostics.mjs +0 -1
  49. package/src/factory-diagnostics.test.mjs +18 -0
  50. package/src/haiku-summarizer.mjs +93 -16
  51. package/src/haiku-summarizer.test.mjs +118 -9
  52. package/src/handoff-executor.mjs +159 -0
  53. package/src/hook-entrypoints.test.mjs +192 -12
  54. package/src/observer-codex-projection.test.mjs +49 -0
  55. package/src/observer-turn-feed.mjs +392 -0
  56. package/src/observer-turn-feed.test.mjs +339 -0
  57. package/src/observer-turn-wait.mjs +102 -0
  58. package/src/observer-turn-wait.test.mjs +122 -0
  59. package/src/pending-handoff.mjs +96 -0
  60. package/src/pending-handoff.test.mjs +107 -0
  61. package/src/prompt-submit.mjs +46 -1
  62. package/src/resume-context.mjs +226 -62
  63. package/src/resume-context.test.mjs +134 -1
  64. package/src/runtime-error-store.mjs +74 -32
  65. package/src/runtime-error-store.test.mjs +51 -3
  66. package/src/session-start.mjs +70 -233
  67. package/src/transcript-reader.mjs +32 -0
  68. package/src/turn-backfill.mjs +3 -2
  69. package/src/turn-backfill.test.mjs +10 -4
  70. package/src/turn-processor.mjs +90 -1
  71. package/src/turn-processor.test.mjs +141 -0
  72. package/src/windows-acl-test-helper.mjs +29 -0
@@ -0,0 +1,123 @@
1
+ import { statSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
3
+ import {
4
+ OBSERVER_WAIT_SCHEMA,
5
+ DEFAULT_OBSERVER_WAIT_TIMEOUT_SECONDS,
6
+ waitForObserverTurnChange,
7
+ } from '../observer-turn-wait.mjs';
8
+
9
+ const ERRORS = Object.freeze({
10
+ args: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_ARGS', message: 'invalid observer-wait arguments' },
11
+ input: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_INPUT', message: 'observer wait input is invalid' },
12
+ cancelled: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_CANCELLED', message: 'observer wait was cancelled' },
13
+ internal: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_INTERNAL', message: 'observer wait failed' },
14
+ });
15
+ const PARENT_WATCH_INTERVAL_MS = 1000;
16
+
17
+ export function parseArgs(argv = []) {
18
+ const out = { projectPath: null, afterCursor: null, timeoutSeconds: DEFAULT_OBSERVER_WAIT_TIMEOUT_SECONDS, json: false };
19
+ const seen = new Set();
20
+ for (let index = 0; index < argv.length; index++) {
21
+ const arg = argv[index];
22
+ if (arg === '--json') {
23
+ if (seen.has(arg)) throw new TypeError('duplicate option');
24
+ seen.add(arg); out.json = true; continue;
25
+ }
26
+ if (!['--project', '--after-cursor', '--timeout-seconds'].includes(arg) || seen.has(arg)) {
27
+ throw new TypeError('invalid option');
28
+ }
29
+ const value = argv[++index];
30
+ if (!value || value.startsWith('-')) throw new TypeError('missing option value');
31
+ seen.add(arg);
32
+ if (arg === '--project') out.projectPath = value;
33
+ else if (arg === '--after-cursor') out.afterCursor = value;
34
+ else out.timeoutSeconds = parseTimeout(value);
35
+ }
36
+ if (!out.json || !out.projectPath || !out.afterCursor) throw new TypeError('missing required option');
37
+ return out;
38
+ }
39
+
40
+ export async function run(argv = [], {
41
+ wait = waitForObserverTurnChange,
42
+ validateProject = assertProjectDirectory,
43
+ stdout = process.stdout,
44
+ stderr = process.stderr,
45
+ processRef = process,
46
+ setIntervalFn = setInterval,
47
+ clearIntervalFn = clearInterval,
48
+ parentWatchIntervalMs = PARENT_WATCH_INTERVAL_MS,
49
+ } = {}) {
50
+ let args;
51
+ try { args = parseArgs(argv); } catch { writeJson(stderr, ERRORS.args); return 1; }
52
+
53
+ try { validateProject(args.projectPath); } catch { writeJson(stderr, ERRORS.input); return 1; }
54
+
55
+ const controller = new AbortController();
56
+ const cleanup = installCancellation({ controller, processRef, setIntervalFn, clearIntervalFn, parentWatchIntervalMs });
57
+ try {
58
+ const result = await wait({
59
+ projectPath: args.projectPath,
60
+ afterCursor: args.afterCursor,
61
+ timeoutSeconds: args.timeoutSeconds,
62
+ signal: controller.signal,
63
+ });
64
+ writeJson(stdout, result);
65
+ return 0;
66
+ } catch (error) {
67
+ writeJson(stderr, mapError(error));
68
+ return 1;
69
+ } finally {
70
+ cleanup();
71
+ }
72
+ }
73
+
74
+ function parseTimeout(value) {
75
+ if (!/^\d+$/.test(value)) throw new TypeError('invalid timeout');
76
+ const timeout = Number(value);
77
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 3600) throw new TypeError('invalid timeout');
78
+ return timeout;
79
+ }
80
+
81
+ function assertProjectDirectory(projectPath) {
82
+ if (!isAbsolute(projectPath) || !statSync(projectPath).isDirectory()) {
83
+ throw new TypeError('project must be an absolute existing directory');
84
+ }
85
+ }
86
+
87
+ function installCancellation({ controller, processRef, setIntervalFn, clearIntervalFn, parentWatchIntervalMs }) {
88
+ const abort = () => controller.abort();
89
+ const parentPid = processRef.ppid;
90
+ processRef.on('SIGINT', abort);
91
+ processRef.on('SIGTERM', abort);
92
+ processRef.on('disconnect', abort);
93
+
94
+ let parentTimer = null;
95
+ if (Number.isSafeInteger(parentPid) && parentPid > 1) {
96
+ const checkParent = () => {
97
+ if (processRef.ppid !== parentPid) return abort();
98
+ try {
99
+ processRef.kill(parentPid, 0);
100
+ } catch (error) {
101
+ if (error?.code === 'ESRCH') abort();
102
+ }
103
+ };
104
+ parentTimer = setIntervalFn(checkParent, parentWatchIntervalMs);
105
+ parentTimer?.unref?.();
106
+ }
107
+
108
+ return () => {
109
+ processRef.removeListener('SIGINT', abort);
110
+ processRef.removeListener('SIGTERM', abort);
111
+ processRef.removeListener('disconnect', abort);
112
+ if (parentTimer !== null) clearIntervalFn(parentTimer);
113
+ };
114
+ }
115
+
116
+ function mapError(error) {
117
+ if (error?.code === 'E_OBSERVER_WAIT_CANCELLED') return ERRORS.cancelled;
118
+ return ERRORS.internal;
119
+ }
120
+
121
+ function writeJson(stream, value) { stream.write(`${JSON.stringify(value)}\n`); }
122
+
123
+ export const _internal = { assertProjectDirectory, installCancellation, PARENT_WATCH_INTERVAL_MS };
@@ -0,0 +1,167 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { EventEmitter, getEventListeners } from 'node:events';
4
+ import { mkdtemp, mkdir, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { join } from 'node:path';
8
+ import test from 'node:test';
9
+ import { OBSERVER_WAIT_SCHEMA } from '../observer-turn-wait.mjs';
10
+ import { parseArgs, run } from './observer-wait.mjs';
11
+
12
+ const REPO_ROOT = join(fileURLToPath(new URL('../..', import.meta.url)));
13
+ const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
14
+ const AFTER = 'tlc1.after';
15
+
16
+ function streams() {
17
+ return { stdout: { text: '', write(value) { this.text += value; } }, stderr: { text: '', write(value) { this.text += value; } } };
18
+ }
19
+
20
+ function fakeProcess({ ppid = 42, kill = () => {} } = {}) {
21
+ const result = new EventEmitter();
22
+ result.ppid = ppid;
23
+ result.kill = kill;
24
+ return result;
25
+ }
26
+
27
+ test('observer-wait strictly parses its public arguments', async () => {
28
+ assert.deepEqual(parseArgs(['--project', '/repo', '--after-cursor', AFTER, '--timeout-seconds', '1', '--json']), {
29
+ projectPath: '/repo', afterCursor: AFTER, timeoutSeconds: 1, json: true,
30
+ });
31
+ assert.equal(parseArgs(['--project', '/repo', '--after-cursor', AFTER, '--json']).timeoutSeconds, 3600);
32
+ for (const argv of [
33
+ ['--project', '/repo', '--project', '/repo', '--after-cursor', AFTER, '--json'],
34
+ ['--project', '/repo', '--after-cursor', AFTER, '--json', '--json'],
35
+ ['--project', '/repo', '--after-cursor', AFTER, '--timeout-seconds', '0', '--json'],
36
+ ['--project', '/repo', '--after-cursor', AFTER, '--timeout-seconds', '3601', '--json'],
37
+ ['--project', '/repo', '--after-cursor', AFTER, 'extra', '--json'],
38
+ ['--project', '/repo', '--json'],
39
+ ]) {
40
+ const io = streams();
41
+ assert.equal(await run(argv, { ...io }), 1);
42
+ assert.equal(io.stdout.text, '');
43
+ assert.deepEqual(JSON.parse(io.stderr.text), { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_ARGS', message: 'invalid observer-wait arguments' });
44
+ }
45
+ });
46
+
47
+ test('observer-wait returns each known core status as successful JSON', async () => {
48
+ for (const [status, throughCursor] of [['changed', 'tlc1.changed'], ['timeout', AFTER], ['resync_required', null], ['ambiguous_parent', null]]) {
49
+ const io = streams();
50
+ assert.equal(await run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
51
+ ...io,
52
+ validateProject: () => {},
53
+ processRef: fakeProcess(),
54
+ setIntervalFn: () => ({ unref() {} }), clearIntervalFn: () => {},
55
+ wait: async () => ({ schema: OBSERVER_WAIT_SCHEMA, status, afterCursor: AFTER, throughCursor }),
56
+ }), 0);
57
+ assert.deepEqual(JSON.parse(io.stdout.text), { schema: OBSERVER_WAIT_SCHEMA, status, afterCursor: AFTER, throughCursor });
58
+ assert.equal(io.stderr.text, '');
59
+ }
60
+ });
61
+
62
+ test('observer-wait maps project, cancel, and internal failures to fixed errors without leakage', async () => {
63
+ for (const [dependencies, code] of [
64
+ [{ validateProject: () => { throw new Error('/private/project'); } }, 'E_OBSERVER_WAIT_INPUT'],
65
+ [{ wait: async () => { throw Object.assign(new Error('secret cursor'), { code: 'E_OBSERVER_WAIT_CANCELLED' }); } }, 'E_OBSERVER_WAIT_CANCELLED'],
66
+ [{ wait: async () => { throw new Error('secret /private/cursor'); } }, 'E_OBSERVER_WAIT_INTERNAL'],
67
+ ]) {
68
+ const io = streams();
69
+ assert.equal(await run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
70
+ ...io, validateProject: () => {}, processRef: fakeProcess(),
71
+ setIntervalFn: () => ({ unref() {} }), clearIntervalFn: () => {}, ...dependencies,
72
+ }), 1);
73
+ assert.equal(io.stdout.text, '');
74
+ assert.equal(JSON.parse(io.stderr.text).code, code);
75
+ assert.doesNotMatch(io.stderr.text, /private|secret|cursor/i);
76
+ }
77
+ });
78
+
79
+ test('observer-wait cancels on signals and removes listeners and parent watcher', async () => {
80
+ for (const event of ['SIGINT', 'SIGTERM', 'disconnect']) {
81
+ const io = streams();
82
+ const processRef = fakeProcess();
83
+ let checkParent;
84
+ let cleared = 0;
85
+ const pending = run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
86
+ ...io, validateProject: () => {}, processRef,
87
+ setIntervalFn(callback) { checkParent = callback; return { unref() {} }; },
88
+ clearIntervalFn() { cleared++; },
89
+ wait: ({ signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('cancelled'), { code: 'E_OBSERVER_WAIT_CANCELLED' })), { once: true })),
90
+ });
91
+ await new Promise((resolve) => setImmediate(resolve));
92
+ assert.equal(getEventListeners(processRef, 'SIGINT').length, 1);
93
+ assert.equal(getEventListeners(processRef, 'SIGTERM').length, 1);
94
+ assert.equal(getEventListeners(processRef, 'disconnect').length, 1);
95
+ assert.equal(typeof checkParent, 'function');
96
+ processRef.emit(event);
97
+ assert.equal(await pending, 1);
98
+ assert.equal(JSON.parse(io.stderr.text).code, 'E_OBSERVER_WAIT_CANCELLED');
99
+ assert.equal(cleared, 1);
100
+ assert.equal(getEventListeners(processRef, 'SIGINT').length, 0);
101
+ assert.equal(getEventListeners(processRef, 'SIGTERM').length, 0);
102
+ assert.equal(getEventListeners(processRef, 'disconnect').length, 0);
103
+ }
104
+ });
105
+
106
+ test('observer-wait treats changed parent or ESRCH as cancellation but not EPERM', async () => {
107
+ for (const [mode, kill] of [
108
+ ['ppid', () => {}],
109
+ ['esrch', () => { throw Object.assign(new Error('gone'), { code: 'ESRCH' }); }],
110
+ ]) {
111
+ const io = streams();
112
+ const processRef = fakeProcess({ kill });
113
+ let checkParent;
114
+ const pending = run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
115
+ ...io, validateProject: () => {}, processRef,
116
+ setIntervalFn(callback) { checkParent = callback; return { unref() {} }; }, clearIntervalFn: () => {},
117
+ wait: ({ signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('cancelled'), { code: 'E_OBSERVER_WAIT_CANCELLED' })), { once: true })),
118
+ });
119
+ await new Promise((resolve) => setImmediate(resolve));
120
+ if (mode === 'ppid') processRef.ppid = 1;
121
+ checkParent();
122
+ assert.equal(await pending, 1);
123
+ assert.equal(JSON.parse(io.stderr.text).code, 'E_OBSERVER_WAIT_CANCELLED');
124
+ }
125
+
126
+ const io = streams();
127
+ const processRef = fakeProcess({ kill: () => { throw Object.assign(new Error('denied'), { code: 'EPERM' }); } });
128
+ let checkParent;
129
+ const pending = run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
130
+ ...io, validateProject: () => {}, processRef,
131
+ setIntervalFn(callback) { checkParent = callback; return { unref() {} }; }, clearIntervalFn: () => {},
132
+ wait: ({ signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('cancelled'), { code: 'E_OBSERVER_WAIT_CANCELLED' })), { once: true })),
133
+ });
134
+ await new Promise((resolve) => setImmediate(resolve));
135
+ checkParent();
136
+ assert.equal(io.stderr.text, '');
137
+ processRef.emit('disconnect');
138
+ assert.equal(await pending, 1);
139
+ assert.equal(JSON.parse(io.stderr.text).code, 'E_OBSERVER_WAIT_CANCELLED');
140
+ });
141
+
142
+ test('observer-wait bin dispatch and help use the JSON-only contract', async () => {
143
+ const help = spawnSync(process.execPath, [BIN_PATH, '--help'], { cwd: REPO_ROOT, encoding: 'utf8' });
144
+ assert.equal(help.status, 0, help.stderr);
145
+ assert.match(help.stdout, /throughline observer-wait --project <absolute-directory> --after-cursor <opaque> --json/);
146
+
147
+ const root = await mkdtemp(join(tmpdir(), 'throughline-observer-wait-'));
148
+ const home = join(root, 'home');
149
+ const state = join(root, 'state');
150
+ const codexHome = join(root, 'codex');
151
+ const project = join(root, 'project');
152
+ try {
153
+ await Promise.all([mkdir(home), mkdir(state), mkdir(codexHome), mkdir(project)]);
154
+ const result = spawnSync(process.execPath, [BIN_PATH, 'observer-wait', '--project', project, '--after-cursor', 'invalid', '--json'], {
155
+ cwd: REPO_ROOT, encoding: 'utf8', timeout: 5000,
156
+ env: { ...process.env, HOME: home, USERPROFILE: home, XDG_STATE_HOME: state, CODEX_HOME: codexHome },
157
+ });
158
+ assert.equal(result.status, 0, result.stderr);
159
+ assert.equal(result.stderr, '');
160
+ const output = JSON.parse(result.stdout);
161
+ assert.equal(output.schema, OBSERVER_WAIT_SCHEMA);
162
+ assert.equal(output.status, 'resync_required');
163
+ assert.equal(output.throughCursor, null);
164
+ } finally {
165
+ await rm(root, { recursive: true, force: true });
166
+ }
167
+ });
@@ -135,6 +135,7 @@ export function parseCodexRolloutFile(
135
135
  number: `rollout-${stats.parsedRows}`,
136
136
  messages: [],
137
137
  details: [],
138
+ completedAt: null,
138
139
  };
139
140
  activeTurns.push(postRollbackTurn);
140
141
  }
@@ -162,6 +163,7 @@ export function parseCodexRolloutFile(
162
163
  number: stats.taskStarted + 1,
163
164
  messages: pendingSplit.currentMessages,
164
165
  details: pendingSplit.currentDetails,
166
+ completedAt: null,
165
167
  };
166
168
  pendingMessages = [];
167
169
  pendingDetails = [];
@@ -175,6 +177,7 @@ export function parseCodexRolloutFile(
175
177
 
176
178
  if (payload.type === 'task_complete') {
177
179
  stats.taskComplete++;
180
+ if (openTurn) openTurn.completedAt = parseHostTimestamp(row.timestamp);
178
181
  openTurn = null;
179
182
  continue;
180
183
  }
@@ -230,6 +233,7 @@ export function parseCodexRolloutFile(
230
233
  number: `rollout-${stats.parsedRows}`,
231
234
  messages: [],
232
235
  details: [],
236
+ completedAt: null,
233
237
  };
234
238
  activeTurns.push(postRollbackTurn);
235
239
  }
@@ -262,6 +266,7 @@ export function parseCodexRolloutFile(
262
266
  number: `rollout-${stats.parsedRows}`,
263
267
  messages: pendingMessages,
264
268
  details: pendingDetails,
269
+ completedAt: null,
265
270
  });
266
271
  }
267
272
 
@@ -410,6 +415,7 @@ function splitPendingMessagesForTaskStart({ messages, details, syntheticNumber }
410
415
  number: syntheticNumber,
411
416
  messages,
412
417
  details,
418
+ completedAt: null,
413
419
  },
414
420
  currentMessages: [],
415
421
  currentDetails: [],
@@ -443,12 +449,19 @@ function splitPendingMessagesForTaskStart({ messages, details, syntheticNumber }
443
449
  number: syntheticNumber,
444
450
  messages: syntheticMessages,
445
451
  details: syntheticDetails,
452
+ completedAt: null,
446
453
  },
447
454
  currentMessages,
448
455
  currentDetails,
449
456
  };
450
457
  }
451
458
 
459
+ function parseHostTimestamp(value) {
460
+ if (typeof value !== 'string') return null;
461
+ const timestamp = Date.parse(value);
462
+ return Number.isSafeInteger(timestamp) && timestamp >= 0 ? timestamp : null;
463
+ }
464
+
452
465
  function removeRolledBackTurns(activeTurns, count) {
453
466
  return activeTurns.splice(Math.max(0, activeTurns.length - count), count);
454
467
  }
@@ -216,6 +216,33 @@ test('parseCodexRolloutFile: keeps final pending messages as current synthetic t
216
216
  }
217
217
  });
218
218
 
219
+ test('parseCodexRolloutFile: completedAtは自身のtask_completeを観測した通常turnだけに付く', () => {
220
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
221
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
222
+ try {
223
+ const rollout = writeRollout(home, {
224
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9', cwd: project,
225
+ events: [
226
+ event('user_message', { message: 'completed request' }),
227
+ event('task_started'),
228
+ event('agent_message', { message: 'completed answer' }),
229
+ { timestamp: '2026-05-06T00:41:00.000Z', type: 'event_msg', payload: { type: 'task_complete' } },
230
+ event('agent_message', { message: 'synthetic continuation' }),
231
+ event('user_message', { message: 'open request' }),
232
+ event('task_started'),
233
+ event('agent_message', { message: 'open answer' }),
234
+ ],
235
+ });
236
+ const parsed = parseCodexRolloutFile(rollout);
237
+ assert.equal(parsed.activeTurns[0].completedAt, Date.parse('2026-05-06T00:41:00.000Z'));
238
+ assert.equal(parsed.activeTurns[1].completedAt, null);
239
+ assert.equal(parsed.activeTurns[2].completedAt, null);
240
+ } finally {
241
+ rmSync(home, { recursive: true, force: true });
242
+ rmSync(project, { recursive: true, force: true });
243
+ }
244
+ });
245
+
219
246
  test('parseCodexRolloutFile: trim source can exclude the current in-flight turn', () => {
220
247
  const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
221
248
  const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
@@ -185,7 +185,7 @@ function normalizePath(value) {
185
185
  } catch {
186
186
  // Keep the lexical path when it cannot be resolved.
187
187
  }
188
- return resolved.split(sep).join('/').replace(/\/+$/, '').toLowerCase();
188
+ return resolved.split(sep).join('/').replace(/\/+$/, '');
189
189
  }
190
190
 
191
191
  function isSameProjectOrDescendant(candidate, root) {
@@ -67,6 +67,24 @@ test('listCodexThreadCandidates: filters rollouts to current project and merges
67
67
  }
68
68
  });
69
69
 
70
+ test('listCodexThreadCandidates: POSIX project path matching preserves case', {
71
+ skip: process.platform === 'win32' ? 'Windows paths are case-insensitive' : undefined,
72
+ }, () => {
73
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
74
+ const root = mkdtempSync(join(tmpdir(), 'tl-codex-case-'));
75
+ const project = join(root, 'Project');
76
+ const other = join(root, 'project');
77
+ try {
78
+ writeRollout(home, { day: '06', started: '2026-05-06T09-40-50', id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9', cwd: project });
79
+ writeRollout(home, { day: '06', started: '2026-05-06T09-41-50', id: '019dfabb-1111-7111-8111-111111111111', cwd: other });
80
+ const candidates = listCodexThreadCandidates({ codexHome: home, projectPath: project });
81
+ assert.deepEqual(candidates.map((entry) => entry.cwd), [project]);
82
+ } finally {
83
+ rmSync(home, { recursive: true, force: true });
84
+ rmSync(root, { recursive: true, force: true });
85
+ }
86
+ });
87
+
70
88
  test('listCodexThreadCandidates: sorts by rollout mtime rather than stale index updated_at', () => {
71
89
  const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
72
90
  const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));