throughline 0.6.0 → 0.6.2

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 (47) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +46 -5
  3. package/bin/throughline.mjs +41 -0
  4. package/docs/00_overview.md +2 -0
  5. package/docs/04_public_release_plan.md +2 -0
  6. package/docs/12_desktop_clear_handoff_plan.md +3 -3
  7. package/docs/13_native_factory_diagnostics_plan.md +46 -0
  8. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +52 -0
  9. package/package.json +2 -2
  10. package/src/auditor-context.mjs +330 -0
  11. package/src/auditor-context.test.mjs +303 -0
  12. package/src/cli/auditor-context.mjs +141 -0
  13. package/src/cli/auditor-context.test.mjs +148 -0
  14. package/src/cli/codex-hook.mjs +27 -4
  15. package/src/cli/codex-hook.test.mjs +4 -0
  16. package/src/cli/codex-restore-smoke.mjs +2 -1
  17. package/src/cli/codex-restore-source-audit.mjs +1 -1
  18. package/src/cli/doctor.mjs +5 -1
  19. package/src/cli/factory-diagnostics.mjs +246 -0
  20. package/src/cli/factory-diagnostics.test.mjs +201 -0
  21. package/src/cli/runtime-errors.mjs +85 -0
  22. package/src/cli/runtime-errors.test.mjs +75 -0
  23. package/src/cli/trim.mjs +4 -4
  24. package/src/codex-handoff-model-smoke.mjs +2 -3
  25. package/src/codex-sidecar-cli.test.mjs +19 -8
  26. package/src/codex-sidecar.mjs +2 -5
  27. package/src/codex-sidecar.test.mjs +17 -9
  28. package/src/codex-thread-index.mjs +17 -2
  29. package/src/db.mjs +1 -1
  30. package/src/factory-diagnostics.mjs +118 -0
  31. package/src/factory-diagnostics.test.mjs +97 -0
  32. package/src/haiku-summarizer.mjs +3 -5
  33. package/src/haiku-summarizer.test.mjs +52 -47
  34. package/src/hook-entrypoints.test.mjs +3 -1
  35. package/src/phase0-spotter-contract.test.mjs +279 -0
  36. package/src/portable-spawn-sync.mjs +58 -0
  37. package/src/portable-spawn-sync.test.mjs +40 -0
  38. package/src/prompt-submit.mjs +2 -0
  39. package/src/runtime-error-hook.test.mjs +106 -0
  40. package/src/runtime-error-observer.mjs +8 -0
  41. package/src/runtime-error-store.mjs +595 -0
  42. package/src/runtime-error-store.test.mjs +307 -0
  43. package/src/session-start.mjs +2 -0
  44. package/src/test-env.mjs +59 -2
  45. package/src/turn-backfill.test.mjs +2 -2
  46. package/src/turn-processor.mjs +2 -0
  47. package/src/windows-acl-test-helper.mjs +29 -0
@@ -0,0 +1,201 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ import {
9
+ collectFactoryDiagnostics,
10
+ inspectFactoryDatabase,
11
+ inspectFactoryHooks,
12
+ parseArgs,
13
+ run,
14
+ } from './factory-diagnostics.mjs';
15
+
16
+ test('factory-diagnostics CLI: JSON-only contract and schema fixture', () => {
17
+ const output = [];
18
+ const exitCode = run(['--json'], {
19
+ stdout: { write(value) { output.push(value); } },
20
+ version: '0.6.1',
21
+ inspectThread: () => ({ status: 'ready', rolloutAvailable: true }),
22
+ inspectDatabase: () => ({ status: 'ready', schemaVersion: 8, handoffMemory: true }),
23
+ inspectHooks: () => ({
24
+ status: 'ready',
25
+ claudeStatus: 'ready',
26
+ events: { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' },
27
+ }),
28
+ });
29
+
30
+ assert.equal(exitCode, 0);
31
+ assert.equal(output.length, 1);
32
+ const parsed = JSON.parse(output[0]);
33
+ assert.equal(parsed.schema, 'throughline.native_factory_diagnostics.v1');
34
+ assert.equal(parsed.version, '0.6.1');
35
+ assert.equal(parsed.readiness.restore.status, 'ready');
36
+ assert.equal(parsed.evidence.restoreSmoke.status, 'unverified');
37
+ });
38
+
39
+ test('factory-diagnostics CLI: invalid request is a fixed JSON error', () => {
40
+ const output = [];
41
+ const exitCode = run(['--project', '/secret/path'], {
42
+ stdout: { write(value) { output.push(value); } },
43
+ });
44
+
45
+ assert.equal(exitCode, 2);
46
+ assert.equal(output.length, 1);
47
+ const parsed = JSON.parse(output[0]);
48
+ assert.equal(parsed.error, 'invalid_diagnostics_request');
49
+ assert.doesNotMatch(output[0], /secret\/path/);
50
+ assert.throws(() => parseArgs([]), /usage error/);
51
+ assert.throws(() => parseArgs(['--json', '--json']), /usage error/);
52
+ });
53
+
54
+ test('factory-diagnostics CLI: internal failure is not reported as a usage error', () => {
55
+ const output = [];
56
+ const exitCode = run(['--json'], {
57
+ stdout: { write(value) { output.push(value); } },
58
+ inspectThread: () => { throw new Error('/secret/internal'); },
59
+ });
60
+
61
+ assert.equal(exitCode, 1);
62
+ assert.equal(JSON.parse(output[0]).error, 'diagnostics_internal_error');
63
+ assert.doesNotMatch(output[0], /secret|internal\//);
64
+ });
65
+
66
+ test('factory-diagnostics CLI: unverified inspection remains unverified', () => {
67
+ const result = collectFactoryDiagnostics({
68
+ version: '0.6.1',
69
+ inspectThread: () => ({ status: 'unverified', rolloutAvailable: false }),
70
+ inspectDatabase: () => ({ status: 'unverified', schemaVersion: null, handoffMemory: false }),
71
+ inspectHooks: () => ({ status: 'unverified', claudeStatus: 'unverified', events: {} }),
72
+ });
73
+
74
+ assert.equal(result.overall.status, 'unverified');
75
+ assert.equal(result.readiness.capture.status, 'unverified');
76
+ assert.equal(result.readiness.handoff.status, 'unverified');
77
+ assert.throws(() => parseArgs(['--bad']), /usage error/);
78
+ });
79
+
80
+ test('factory-diagnostics DB inspection is read-only and does not create an absent DB', () => {
81
+ const dir = mkdtempSync(join(tmpdir(), 'tl-factory-diagnostics-'));
82
+ const dbPath = join(dir, 'throughline.db');
83
+ const missingPath = join(dir, 'missing.db');
84
+ try {
85
+ const db = new DatabaseSync(dbPath);
86
+ createFactorySchema(db);
87
+ db.close();
88
+ const before = statSync(dbPath).mtimeMs;
89
+
90
+ const result = inspectFactoryDatabase({ dbPath, threadId: 'thread-1' });
91
+ assert.equal(result.status, 'ready');
92
+ assert.equal(result.handoffMemory, false);
93
+ assert.equal(statSync(dbPath).mtimeMs, before);
94
+
95
+ const writer = new DatabaseSync(dbPath);
96
+ writer.exec(`
97
+ INSERT INTO sessions VALUES ('codex:thread-1', '/project/a', 'active', 1, 1, NULL);
98
+ INSERT INTO skeletons VALUES (1, 'codex:thread-1', 1, 'assistant', 'safe', 1, 'codex:thread-1');
99
+ `);
100
+ writer.close();
101
+ const matching = inspectFactoryDatabase({ dbPath, threadId: 'thread-1', projectPath: '/project/a' });
102
+ const mismatch = inspectFactoryDatabase({ dbPath, threadId: 'thread-1', projectPath: '/project/b' });
103
+ assert.equal(matching.handoffMemory, true);
104
+ assert.equal(mismatch.handoffMemory, false);
105
+
106
+ const missing = inspectFactoryDatabase({ dbPath: missingPath });
107
+ assert.equal(missing.status, 'not_applicable');
108
+ assert.equal(existsSync(missingPath), false);
109
+ } finally {
110
+ rmSync(dir, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ test('factory-diagnostics DB inspection rejects version-only fake schema', () => {
115
+ const dir = mkdtempSync(join(tmpdir(), 'tl-factory-fake-schema-'));
116
+ const dbPath = join(dir, 'throughline.db');
117
+ try {
118
+ const db = new DatabaseSync(dbPath);
119
+ db.exec('PRAGMA user_version = 8; CREATE TABLE sessions (session_id TEXT)');
120
+ db.close();
121
+ assert.equal(inspectFactoryDatabase({ dbPath }).status, 'not_ready');
122
+ } finally {
123
+ rmSync(dir, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('factory-diagnostics DB inspection rejects missing runtime constraints', () => {
128
+ const dir = mkdtempSync(join(tmpdir(), 'tl-factory-fake-constraint-'));
129
+ const dbPath = join(dir, 'throughline.db');
130
+ try {
131
+ const db = new DatabaseSync(dbPath);
132
+ createFactorySchema(db);
133
+ db.exec(`
134
+ DROP TABLE handoff_batons;
135
+ CREATE TABLE handoff_batons (project_path TEXT, session_id TEXT NOT NULL, created_at INTEGER NOT NULL);
136
+ `);
137
+ db.close();
138
+ assert.equal(inspectFactoryDatabase({ dbPath }).status, 'not_ready');
139
+ } finally {
140
+ rmSync(dir, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test('factory-diagnostics hook inspection rejects corrupt and false-ready shapes', () => {
145
+ const corrupt = inspectFactoryHooks({
146
+ readHooks: () => ({ configExists: true, configReadable: false, hooksExists: true, hooksReadable: false }),
147
+ });
148
+ assert.equal(corrupt.status, 'unverified');
149
+
150
+ const malformed = inspectFactoryHooks({
151
+ readHooks: () => ({
152
+ configExists: true,
153
+ configReadable: true,
154
+ hooksExists: true,
155
+ hooksReadable: true,
156
+ featureEnabled: true,
157
+ expectedPromptCommand: 'prompt',
158
+ expectedPostToolUseCommand: 'post',
159
+ expectedStopCommand: 'stop',
160
+ managedPromptHooks: [{ type: 'command', command: 'prompt', timeoutSec: 30, async: true }],
161
+ legacyManagedPromptHooks: [],
162
+ managedPostToolUseHooks: [{ type: 'command', command: 'post', timeoutSec: 30, async: false }],
163
+ legacyManagedPostToolUseHooks: [],
164
+ managedStopHooks: [{ type: 'command', command: 'stop', timeoutSec: 300, async: false }],
165
+ legacyManagedStopHooks: [],
166
+ }),
167
+ });
168
+ assert.equal(malformed.status, 'not_ready');
169
+ });
170
+
171
+ function createFactorySchema(db) {
172
+ db.exec(`
173
+ PRAGMA user_version = 8;
174
+ CREATE TABLE sessions (
175
+ session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL, status TEXT NOT NULL,
176
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, merged_into TEXT
177
+ );
178
+ CREATE TABLE skeletons (
179
+ id INTEGER, session_id TEXT, turn_number INTEGER, role TEXT, summary TEXT,
180
+ created_at INTEGER, origin_session_id TEXT
181
+ );
182
+ CREATE TABLE bodies (
183
+ id INTEGER, session_id TEXT, origin_session_id TEXT, turn_number INTEGER,
184
+ role TEXT, text TEXT, token_count INTEGER, created_at INTEGER,
185
+ UNIQUE(session_id, origin_session_id, turn_number, role)
186
+ );
187
+ CREATE TABLE details (
188
+ id INTEGER, session_id TEXT, turn_number INTEGER, tool_name TEXT,
189
+ input_text TEXT, output_text TEXT, token_count INTEGER, created_at INTEGER,
190
+ origin_session_id TEXT, kind TEXT, source_id TEXT
191
+ );
192
+ CREATE TABLE handoff_batons (
193
+ project_path TEXT PRIMARY KEY, session_id TEXT NOT NULL, created_at INTEGER NOT NULL
194
+ );
195
+ CREATE UNIQUE INDEX uq_skeletons_turn_v3
196
+ ON skeletons(session_id, origin_session_id, turn_number, role);
197
+ CREATE UNIQUE INDEX uq_details_source
198
+ ON details(session_id, origin_session_id, source_id)
199
+ WHERE source_id IS NOT NULL;
200
+ `);
201
+ }
@@ -0,0 +1,85 @@
1
+ import {
2
+ acknowledgeRuntimeErrors,
3
+ compactRuntimeErrors,
4
+ getRuntimeErrorDiagnostics,
5
+ readRuntimeErrorSnapshot,
6
+ reopenRuntimeError,
7
+ resolveRuntimeError,
8
+ } from '../runtime-error-store.mjs';
9
+
10
+ const USAGE = 'usage: throughline runtime-errors <snapshot|diagnostics|ack|resolve|reopen|compact> [arguments] --json';
11
+
12
+ export function parseArgs(argv = []) {
13
+ const command = argv[0];
14
+ if (!['snapshot', 'diagnostics', 'ack', 'resolve', 'reopen', 'compact'].includes(command)) {
15
+ throw new TypeError(USAGE);
16
+ }
17
+ const options = { command, json: false, afterCursor: 0, limit: 256, value: null };
18
+ for (let index = 1; index < argv.length; index++) {
19
+ const arg = argv[index];
20
+ if (arg === '--json' && !options.json) {
21
+ options.json = true;
22
+ } else if (command === 'snapshot' && arg === '--after-cursor' && argv[index + 1]) {
23
+ options.afterCursor = parseInteger(argv[++index], '--after-cursor');
24
+ } else if (command === 'snapshot' && arg === '--limit' && argv[index + 1]) {
25
+ options.limit = parseInteger(argv[++index], '--limit');
26
+ } else if (['ack', 'resolve', 'reopen'].includes(command) && options.value === null && !arg.startsWith('-')) {
27
+ options.value = arg;
28
+ } else {
29
+ throw new TypeError(USAGE);
30
+ }
31
+ }
32
+ if (!options.json || (['ack', 'resolve', 'reopen'].includes(command) && options.value === null)) {
33
+ throw new TypeError(USAGE);
34
+ }
35
+ return options;
36
+ }
37
+
38
+ export function run(argv = [], dependencies = {}) {
39
+ let options;
40
+ try {
41
+ options = parseArgs(argv);
42
+ } catch {
43
+ process.stderr.write(`[runtime-errors] ${USAGE}\n`);
44
+ return 2;
45
+ }
46
+
47
+ try {
48
+ const env = dependencies.env ?? process.env;
49
+ let result;
50
+ if (options.command === 'snapshot') {
51
+ result = (dependencies.readSnapshot ?? readRuntimeErrorSnapshot)({
52
+ env,
53
+ afterCursor: options.afterCursor,
54
+ limit: options.limit,
55
+ });
56
+ } else if (options.command === 'diagnostics') {
57
+ result = (dependencies.getDiagnostics ?? getRuntimeErrorDiagnostics)({ env });
58
+ } else if (options.command === 'ack') {
59
+ result = (dependencies.acknowledge ?? acknowledgeRuntimeErrors)(
60
+ parseInteger(options.value, 'cursor'),
61
+ { env },
62
+ );
63
+ } else if (options.command === 'resolve') {
64
+ result = (dependencies.resolve ?? resolveRuntimeError)(options.value, { env });
65
+ } else if (options.command === 'reopen') {
66
+ result = (dependencies.reopen ?? reopenRuntimeError)(options.value, { env });
67
+ } else {
68
+ result = (dependencies.compact ?? compactRuntimeErrors)({ env });
69
+ }
70
+ process.stdout.write(`${JSON.stringify(result)}\n`);
71
+ return 0;
72
+ } catch {
73
+ process.stderr.write('[runtime-errors] operation_failed\n');
74
+ return 1;
75
+ }
76
+ }
77
+
78
+ function parseInteger(value, name) {
79
+ if (typeof value !== 'string' || !/^\d+$/.test(value)) throw new TypeError(`${name} invalid`);
80
+ const parsed = Number(value);
81
+ if (!Number.isSafeInteger(parsed)) throw new TypeError(`${name} invalid`);
82
+ return parsed;
83
+ }
84
+
85
+ export const _internal = { USAGE };
@@ -0,0 +1,75 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { parseArgs, run } from './runtime-errors.mjs';
10
+ import { defaultFactoryReporterConfigPath } from '../runtime-error-store.mjs';
11
+ import { applyWindowsPrivateAcl } from '../windows-acl-test-helper.mjs';
12
+
13
+ test('runtime-errors CLI: strict command surface accepts no raw payload options', () => {
14
+ assert.deepEqual(parseArgs(['snapshot', '--after-cursor', '2', '--limit', '3', '--json']), {
15
+ command: 'snapshot', json: true, afterCursor: 2, limit: 3, value: null,
16
+ });
17
+ assert.equal(parseArgs(['reopen', 'a'.repeat(64), '--json']).command, 'reopen');
18
+ for (const args of [
19
+ ['snapshot', '--stderr', 'secret', '--json'],
20
+ ['snapshot', '--path', '/Users/private', '--json'],
21
+ ['resolve', 'abc', '--context', 'secret', '--json'],
22
+ ['diagnostics'],
23
+ ]) assert.throws(() => parseArgs(args));
24
+ });
25
+
26
+ test('runtime-errors CLI: internal failure is fixed and does not reflect exceptions or paths', () => {
27
+ const writes = { stdout: '', stderr: '' };
28
+ const originalOut = process.stdout.write;
29
+ const originalErr = process.stderr.write;
30
+ process.stdout.write = (chunk) => { writes.stdout += chunk; return true; };
31
+ process.stderr.write = (chunk) => { writes.stderr += chunk; return true; };
32
+ try {
33
+ const exitCode = run(['snapshot', '--json'], {
34
+ readSnapshot() { throw new Error('secret /Users/private stack'); },
35
+ });
36
+ assert.equal(exitCode, 1);
37
+ } finally {
38
+ process.stdout.write = originalOut;
39
+ process.stderr.write = originalErr;
40
+ }
41
+ assert.equal(writes.stdout, '');
42
+ assert.equal(writes.stderr, '[runtime-errors] operation_failed\n');
43
+ });
44
+
45
+ test('runtime-errors CLI: snapshot and diagnostics are JSON-only and contain no state path', () => {
46
+ const root = mkdtempSync(join(tmpdir(), 'throughline-runtime-cli-'));
47
+ const env = {
48
+ ...process.env,
49
+ HOME: root,
50
+ USERPROFILE: root,
51
+ LOCALAPPDATA: root,
52
+ XDG_CONFIG_HOME: join(root, 'config'),
53
+ XDG_STATE_HOME: join(root, 'state'),
54
+ };
55
+ const configPath = defaultFactoryReporterConfigPath(env);
56
+ mkdirSync(dirname(configPath), { recursive: true });
57
+ writeFileSync(configPath, JSON.stringify({
58
+ schema_version: '1.0',
59
+ host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
60
+ collection: { enabled: true },
61
+ reporting: { enabled: false },
62
+ }));
63
+ applyWindowsPrivateAcl(configPath);
64
+ const bin = new URL('../../bin/throughline.mjs', import.meta.url);
65
+ for (const args of [
66
+ ['runtime-errors', 'snapshot', '--json'],
67
+ ['runtime-errors', 'diagnostics', '--json'],
68
+ ]) {
69
+ const result = spawnSync(process.execPath, [fileURLToPath(bin), ...args], { env, encoding: 'utf8' });
70
+ assert.equal(result.status, 0, result.stderr);
71
+ const json = JSON.parse(result.stdout);
72
+ assert.equal(typeof json.schema, 'string');
73
+ assert.doesNotMatch(result.stdout, new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
74
+ }
75
+ });
package/src/cli/trim.mjs CHANGED
@@ -164,11 +164,11 @@ export async function run(args) {
164
164
  } else {
165
165
  process.stdout.write(renderTrimActionReport(result) + '\n');
166
166
  }
167
- process.exit(
167
+ process.exitCode =
168
168
  result.status === 'preflight-ready' || result.status === 'execute-durable-verified'
169
169
  ? 0
170
- : 1,
171
- );
170
+ : 1;
171
+ return;
172
172
  }
173
173
 
174
174
  if (parsed.json) {
@@ -177,7 +177,7 @@ export async function run(args) {
177
177
  process.stdout.write(renderTrimDryRunReport(plan) + '\n');
178
178
  }
179
179
 
180
- process.exit(plan.status === 'unavailable' ? 1 : 0);
180
+ process.exitCode = plan.status === 'unavailable' ? 1 : 0;
181
181
  }
182
182
 
183
183
  async function runExecute(parsed, plan) {
@@ -1,4 +1,4 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { spawnPortableSync } from './portable-spawn-sync.mjs';
2
2
 
3
3
  export const CODEX_HANDOFF_MODEL_SMOKE_ENV = 'THROUGHLINE_EXPERIMENTAL_CODEX_HANDOFF_MODEL_SMOKE';
4
4
  export const DEFAULT_CODEX_HANDOFF_MODEL_SMOKE_TIMEOUT_MS = 120_000;
@@ -48,7 +48,7 @@ export function runCodexHandoffModelSmoke({
48
48
  assertNonEmptyString(command, 'command');
49
49
  assertPositiveInteger(timeoutMs, 'timeoutMs');
50
50
 
51
- const result = spawnSync(
51
+ const result = spawnPortableSync(
52
52
  command,
53
53
  [
54
54
  'exec',
@@ -65,7 +65,6 @@ export function runCodexHandoffModelSmoke({
65
65
  {
66
66
  encoding: 'utf8',
67
67
  timeout: timeoutMs,
68
- shell: process.platform === 'win32',
69
68
  env,
70
69
  cwd,
71
70
  },
@@ -8,12 +8,25 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
10
10
 
11
+ function makeSidecarFixture(dir, body) {
12
+ const script = join(dir, 'fake-sidecar.mjs');
13
+ writeFileSync(script, body);
14
+ if (process.platform === 'win32') {
15
+ const bin = join(dir, 'fake-sidecar.cmd');
16
+ writeFileSync(bin, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(script)} %*\r\n`);
17
+ writeFileSync(join(dir, 'fake-sidecar.ps1'), `& ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args\nexit $LASTEXITCODE\n`);
18
+ return bin;
19
+ }
20
+ const bin = join(dir, 'fake-sidecar');
21
+ writeFileSync(bin, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`);
22
+ chmodSync(bin, 0o755);
23
+ return bin;
24
+ }
25
+
11
26
  test('codex-sidecar-diagnostics CLI exits 0 only for configured diagnostics', () => {
12
27
  const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-cli-'));
13
28
  try {
14
- const bin = join(dir, 'fake-sidecar');
15
- writeFileSync(bin, '#!/usr/bin/env bash\nprintf "ok\\n"\nexit 0\n');
16
- chmodSync(bin, 0o755);
29
+ const bin = makeSidecarFixture(dir, "process.stdout.write('ok\\n');\n");
17
30
 
18
31
  const result = spawnSync(
19
32
  process.execPath,
@@ -38,12 +51,10 @@ test('codex-sidecar-diagnostics CLI exits 0 only for configured diagnostics', ()
38
51
  test('codex-sidecar-dry-run CLI exits 0 for normalized dry-run request', () => {
39
52
  const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-dry-run-cli-'));
40
53
  try {
41
- const bin = join(dir, 'fake-sidecar');
42
- writeFileSync(
43
- bin,
44
- '#!/usr/bin/env bash\nprintf \'{"status":"dry-run","workflow":"risk-check","normalizedRequest":{"dryRun":true}}\\n\'\n',
54
+ const bin = makeSidecarFixture(
55
+ dir,
56
+ "process.stdout.write('{\"status\":\"dry-run\",\"workflow\":\"risk-check\",\"normalizedRequest\":{\"dryRun\":true}}\\n');\n",
45
57
  );
46
- chmodSync(bin, 0o755);
47
58
 
48
59
  const result = spawnSync(
49
60
  process.execPath,
@@ -1,4 +1,4 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { spawnPortableSync } from './portable-spawn-sync.mjs';
2
2
 
3
3
  export const CODEX_SIDECAR_WORKFLOWS = Object.freeze([
4
4
  'review',
@@ -27,10 +27,7 @@ export function shouldShellWrapSidecarCommand(platform = process.platform) {
27
27
  }
28
28
 
29
29
  export function runCodexSidecarCommand(command, args, options = {}) {
30
- return spawnSync(command, args, {
31
- ...options,
32
- shell: shouldShellWrapSidecarCommand(),
33
- });
30
+ return spawnPortableSync(command, args, options);
34
31
  }
35
32
 
36
33
  export function inferWorkflowForPreset(preset) {
@@ -12,8 +12,16 @@ import {
12
12
  } from './codex-sidecar.mjs';
13
13
 
14
14
  function makeExecutable(dir, name, body) {
15
+ const script = join(dir, `${name}.mjs`);
16
+ writeFileSync(script, body);
17
+ if (process.platform === 'win32') {
18
+ const path = join(dir, `${name}.cmd`);
19
+ writeFileSync(path, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(script)} %*\r\n`);
20
+ writeFileSync(join(dir, `${name}.ps1`), `& ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args\nexit $LASTEXITCODE\n`);
21
+ return path;
22
+ }
15
23
  const path = join(dir, name);
16
- writeFileSync(path, body);
24
+ writeFileSync(path, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`);
17
25
  chmodSync(path, 0o755);
18
26
  return path;
19
27
  }
@@ -44,7 +52,7 @@ test('diagnoseCodexSidecar: non-zero diagnostics is unavailable', () => {
44
52
  const bin = makeExecutable(
45
53
  dir,
46
54
  'fake-sidecar',
47
- '#!/usr/bin/env bash\nprintf "bad config" >&2\nexit 7\n',
55
+ "process.stderr.write('bad config'); process.exit(7);\n",
48
56
  );
49
57
  const result = diagnoseCodexSidecar({
50
58
  projectPath: '/repo',
@@ -66,7 +74,7 @@ test('diagnoseCodexSidecar: zero diagnostics is configured', () => {
66
74
  const bin = makeExecutable(
67
75
  dir,
68
76
  'fake-sidecar',
69
- '#!/usr/bin/env bash\nprintf "ok diagnostics for $*\\n"\nexit 0\n',
77
+ "process.stdout.write(`ok diagnostics for ${process.argv.slice(2).join(' ')}\\n`);\n",
70
78
  );
71
79
  const result = diagnoseCodexSidecar({
72
80
  projectPath: '/repo',
@@ -102,9 +110,9 @@ test('runCodexSidecarDryRun: emits a dry-run request for review preset', () => {
102
110
  const bin = makeExecutable(
103
111
  dir,
104
112
  'fake-sidecar',
105
- `#!/usr/bin/env bash
106
- printf '%s\\n' "$@" > "${argsFile}"
107
- printf '{"status":"dry-run","workflow":"review","normalizedRequest":{"dryRun":true}}\\n'
113
+ `import { writeFileSync } from 'node:fs';
114
+ writeFileSync(${JSON.stringify(argsFile)}, process.argv.slice(2).join('\\n') + '\\n');
115
+ process.stdout.write('{"status":"dry-run","workflow":"review","normalizedRequest":{"dryRun":true}}\\n');
108
116
  `,
109
117
  );
110
118
  const result = runCodexSidecarDryRun({
@@ -141,9 +149,9 @@ test('runCodexSidecarDryRun: infers risk-check workflow from preset', () => {
141
149
  const bin = makeExecutable(
142
150
  dir,
143
151
  'fake-sidecar',
144
- `#!/usr/bin/env bash
145
- printf '%s\\n' "$@" > "${argsFile}"
146
- printf '{"status":"dry-run","workflow":"risk-check","normalizedRequest":{"dryRun":true}}\\n'
152
+ `import { writeFileSync } from 'node:fs';
153
+ writeFileSync(${JSON.stringify(argsFile)}, process.argv.slice(2).join('\\n') + '\\n');
154
+ process.stdout.write('{"status":"dry-run","workflow":"risk-check","normalizedRequest":{"dryRun":true}}\\n');
147
155
  `,
148
156
  );
149
157
  const result = runCodexSidecarDryRun({
@@ -28,7 +28,7 @@ export function listCodexThreadCandidates({
28
28
  const meta = readSessionMeta(rollout.path);
29
29
  const indexed = index.get(rollout.threadId) ?? {};
30
30
  const cwd = meta?.cwd ?? null;
31
- const matchesProject = cwd ? normalizePath(cwd) === normalizedProject : false;
31
+ const matchesProject = cwd ? isSameProjectOrDescendant(normalizePath(cwd), normalizedProject) : false;
32
32
  return {
33
33
  id: rollout.threadId,
34
34
  threadName: indexed.thread_name ?? null,
@@ -69,7 +69,7 @@ export function findCodexThreadCandidate({
69
69
  const meta = readSessionMeta(rollout.path);
70
70
  const indexed = index.get(rollout.threadId) ?? {};
71
71
  const cwd = meta?.cwd ?? null;
72
- const matchesProject = cwd ? normalizePath(cwd) === normalizedProject : false;
72
+ const matchesProject = cwd ? isSameProjectOrDescendant(normalizePath(cwd), normalizedProject) : false;
73
73
  return {
74
74
  id: rollout.threadId,
75
75
  threadName: indexed.thread_name ?? null,
@@ -169,6 +169,16 @@ function compareCandidates(a, b) {
169
169
  }
170
170
 
171
171
  function normalizePath(value) {
172
+ const raw = String(value);
173
+ if (/^[A-Za-z]:[\\/]/.test(raw)) {
174
+ let resolved = raw;
175
+ try {
176
+ if (existsSync(raw)) resolved = realpathSync.native(raw);
177
+ } catch {
178
+ // Keep the lexical Windows path when it cannot be resolved.
179
+ }
180
+ return resolved.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
181
+ }
172
182
  let resolved = resolve(value);
173
183
  try {
174
184
  if (existsSync(resolved)) resolved = realpathSync.native(resolved);
@@ -177,3 +187,8 @@ function normalizePath(value) {
177
187
  }
178
188
  return resolved.split(sep).join('/').replace(/\/+$/, '').toLowerCase();
179
189
  }
190
+
191
+ function isSameProjectOrDescendant(candidate, root) {
192
+ if (!candidate || !root) return false;
193
+ return candidate === root || candidate.startsWith(`${root}/`);
194
+ }
package/src/db.mjs CHANGED
@@ -9,7 +9,7 @@ import { join } from 'path';
9
9
 
10
10
  const DB_DIR = join(homedir(), '.throughline');
11
11
  const DB_PATH = join(DB_DIR, 'throughline.db');
12
- const CURRENT_VERSION = 8;
12
+ export const CURRENT_VERSION = 8;
13
13
 
14
14
  let _db = null;
15
15