throughline 0.4.9 → 0.4.11

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.
@@ -117,7 +117,7 @@ test('codex-hook stop captures rollout using Codex stdin payload fields', async
117
117
  assert.equal(monitorState.usage.source, 'codex-rollout-token-count');
118
118
  assert.equal(result.monitorState.sessionId, `codex:${threadId}`);
119
119
  assert.equal(result.autoRefresh.status, 'skipped');
120
- assert.equal(result.autoRefresh.reason, 'below_threshold');
120
+ assert.equal(result.autoRefresh.reason, 'codex_auto_refresh_disabled');
121
121
  assert.equal(monitorTaskCwd, project);
122
122
  } finally {
123
123
  db.close();
@@ -126,12 +126,12 @@ test('codex-hook stop captures rollout using Codex stdin payload fields', async
126
126
  }
127
127
  });
128
128
 
129
- test('codex-hook stop runs auto refresh when verified usage reaches 75%', async () => {
129
+ test('codex-hook stop captures only and does not auto refresh at 75%', async () => {
130
130
  const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
131
131
  const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
132
132
  const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
133
133
  const db = makeDb();
134
- let autoRefreshArgs = null;
134
+ let runAutoRefreshCalled = false;
135
135
  try {
136
136
  const rolloutPath = writeRollout(codexHome, {
137
137
  id: threadId,
@@ -161,7 +161,7 @@ test('codex-hook stop runs auto refresh when verified usage reaches 75%', async
161
161
  ensureMonitorTask: () => {},
162
162
  writeMonitorState: () => {},
163
163
  runAutoRefresh: async (args) => {
164
- autoRefreshArgs = args;
164
+ runAutoRefreshCalled = true;
165
165
  return {
166
166
  status: 'refreshed-live',
167
167
  reason: 'rollback_and_inject_sent_live',
@@ -170,16 +170,9 @@ test('codex-hook stop runs auto refresh when verified usage reaches 75%', async
170
170
  });
171
171
 
172
172
  assert.equal(result.status, 'ok');
173
- assert.equal(result.autoRefresh.status, 'refreshed-live');
174
- assert.equal(autoRefreshArgs.threadId, threadId);
175
- assert.equal(autoRefreshArgs.codexThreadIdSource, 'payload:session_id');
176
- assert.equal(autoRefreshArgs.codexHome, codexHome);
177
- assert.equal(autoRefreshArgs.projectPath, project);
178
- assert.equal(autoRefreshArgs.sessionId, `codex:${threadId}`);
179
- assert.equal(autoRefreshArgs.usage.tokens, 240_000);
180
- assert.equal(autoRefreshArgs.usage.contextWindowSize, 258400);
181
- assert.equal(autoRefreshArgs.usage.estimated, false);
182
- assert.equal(autoRefreshArgs.command, 'codex');
173
+ assert.equal(result.autoRefresh.status, 'skipped');
174
+ assert.equal(result.autoRefresh.reason, 'codex_auto_refresh_disabled');
175
+ assert.equal(runAutoRefreshCalled, false);
183
176
  } finally {
184
177
  db.close();
185
178
  rmSync(codexHome, { recursive: true, force: true });
@@ -187,7 +180,7 @@ test('codex-hook stop runs auto refresh when verified usage reaches 75%', async
187
180
  }
188
181
  });
189
182
 
190
- test('codex-hook user-prompt-submit injects current-session throughline instruction at 75%', async () => {
183
+ test('codex-hook user-prompt-submit does not inject auto-refresh instruction at 75%', async () => {
191
184
  const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
192
185
  const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
193
186
  const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
@@ -227,14 +220,9 @@ test('codex-hook user-prompt-submit injects current-session throughline instruct
227
220
  });
228
221
 
229
222
  assert.equal(result.status, 'ok');
230
- assert.equal(result.autoRefreshPrompt.status, 'ready');
231
- assert.equal(result.autoRefreshPrompt.reason, 'threshold_reached');
232
- assert.match(result.autoRefreshPrompt.context, /Before answering the user prompt/);
233
- assert.match(result.autoRefreshPrompt.context, /throughline trim --execute --host codex --all --json/);
234
- assert.match(result.autoRefreshPrompt.context, /current Codex rollout token_count/);
235
- const output = JSON.parse(result.autoRefreshPrompt.output);
236
- assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
237
- assert.match(output.hookSpecificOutput.additionalContext, /installed \$throughline workflow/);
223
+ assert.equal(result.autoRefreshPrompt.status, 'skipped');
224
+ assert.equal(result.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
225
+ assert.equal(result.autoRefreshPrompt.output, undefined);
238
226
  assert.equal(result.captured.sessionId, `codex:${threadId}`);
239
227
  assert.equal(monitorState.sessionId, `codex:${threadId}`);
240
228
  } finally {
@@ -244,6 +232,71 @@ test('codex-hook user-prompt-submit injects current-session throughline instruct
244
232
  }
245
233
  });
246
234
 
235
+ test('codex-hook prompt and tool hooks both stay quiet when auto-refresh is disabled', async () => {
236
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
237
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
238
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
239
+ const db = makeDb();
240
+ const autoRefreshStateStore = makeMemoryAutoRefreshStateStore();
241
+ try {
242
+ const rolloutPath = writeRollout(codexHome, {
243
+ id: threadId,
244
+ cwd: project,
245
+ events: [
246
+ event('user_message', { message: 'hook request' }),
247
+ event('task_started'),
248
+ turnContext({ model: 'gpt-5.5', cwd: project }),
249
+ event('agent_message', { message: 'hook answer' }),
250
+ event('task_complete'),
251
+ tokenCountEvent({
252
+ inputTokens: 240_000,
253
+ outputTokens: 67,
254
+ contextWindow: 258400,
255
+ }),
256
+ ],
257
+ });
258
+
259
+ const first = await runCodexUserPromptSubmitHook({
260
+ payload: {
261
+ session_id: threadId,
262
+ transcript_path: rolloutPath,
263
+ cwd: project,
264
+ prompt: 'next user prompt',
265
+ },
266
+ env: {},
267
+ db,
268
+ ensureMonitorTask: () => {},
269
+ writeMonitorState: () => {},
270
+ autoRefreshStateStore,
271
+ });
272
+ const second = await runCodexPostToolUseHook({
273
+ payload: {
274
+ session_id: threadId,
275
+ transcript_path: rolloutPath,
276
+ cwd: project,
277
+ hook_event_name: 'PostToolUse',
278
+ tool_name: 'exec_command',
279
+ },
280
+ env: {},
281
+ db,
282
+ ensureMonitorTask: () => {},
283
+ writeMonitorState: () => {},
284
+ autoRefreshStateStore,
285
+ });
286
+
287
+ assert.equal(first.autoRefreshPrompt.status, 'skipped');
288
+ assert.equal(first.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
289
+ assert.equal(first.autoRefreshPrompt.output, undefined);
290
+ assert.equal(second.autoRefreshPrompt.status, 'skipped');
291
+ assert.equal(second.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
292
+ assert.equal(second.autoRefreshPrompt.output, undefined);
293
+ } finally {
294
+ db.close();
295
+ rmSync(codexHome, { recursive: true, force: true });
296
+ rmSync(project, { recursive: true, force: true });
297
+ }
298
+ });
299
+
247
300
  test('codex-hook user-prompt-submit stays quiet below 75%', async () => {
248
301
  const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
249
302
  const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
@@ -281,7 +334,7 @@ test('codex-hook user-prompt-submit stays quiet below 75%', async () => {
281
334
 
282
335
  assert.equal(result.status, 'ok');
283
336
  assert.equal(result.autoRefreshPrompt.status, 'skipped');
284
- assert.equal(result.autoRefreshPrompt.reason, 'below_threshold');
337
+ assert.equal(result.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
285
338
  assert.equal(result.autoRefreshPrompt.output, undefined);
286
339
  } finally {
287
340
  db.close();
@@ -290,7 +343,7 @@ test('codex-hook user-prompt-submit stays quiet below 75%', async () => {
290
343
  }
291
344
  });
292
345
 
293
- test('codex-hook post-tool-use injects current-session throughline instruction at 75%', async () => {
346
+ test('codex-hook post-tool-use does not inject auto-refresh instruction at 75%', async () => {
294
347
  const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
295
348
  const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
296
349
  const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
@@ -327,13 +380,9 @@ test('codex-hook post-tool-use injects current-session throughline instruction a
327
380
  });
328
381
 
329
382
  assert.equal(result.status, 'ok');
330
- assert.equal(result.autoRefreshPrompt.status, 'ready');
331
- assert.equal(result.autoRefreshPrompt.reason, 'threshold_reached');
332
- assert.match(result.autoRefreshPrompt.context, /Before continuing the current tool loop/);
333
- assert.match(result.autoRefreshPrompt.context, /throughline trim --execute --host codex --all --json/);
334
- const output = JSON.parse(result.autoRefreshPrompt.output);
335
- assert.equal(output.hookSpecificOutput.hookEventName, 'PostToolUse');
336
- assert.match(output.hookSpecificOutput.additionalContext, /installed \$throughline workflow/);
383
+ assert.equal(result.autoRefreshPrompt.status, 'skipped');
384
+ assert.equal(result.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
385
+ assert.equal(result.autoRefreshPrompt.output, undefined);
337
386
  } finally {
338
387
  db.close();
339
388
  rmSync(codexHome, { recursive: true, force: true });
@@ -380,6 +429,59 @@ test('codex-hook stop reports camelCase payload thread id source', async () => {
380
429
  }
381
430
  });
382
431
 
432
+ test('codex-hook stop ignores auto-refresh backoff store while disabled', async () => {
433
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
434
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
435
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
436
+ const db = makeDb();
437
+ const autoRefreshStateStore = makeMemoryAutoRefreshStateStore();
438
+ try {
439
+ const rolloutPath = writeRollout(codexHome, {
440
+ id: threadId,
441
+ cwd: project,
442
+ events: [
443
+ event('user_message', { message: 'hook request' }),
444
+ event('task_started'),
445
+ turnContext({ model: 'gpt-5.5', cwd: project }),
446
+ event('agent_message', { message: 'hook answer' }),
447
+ event('task_complete'),
448
+ tokenCountEvent({
449
+ inputTokens: 240_000,
450
+ outputTokens: 67,
451
+ contextWindow: 258400,
452
+ }),
453
+ ],
454
+ });
455
+
456
+ const result = await runCodexStopHook({
457
+ payload: {
458
+ session_id: threadId,
459
+ transcript_path: rolloutPath,
460
+ cwd: project,
461
+ },
462
+ env: {},
463
+ db,
464
+ ensureMonitorTask: () => {},
465
+ writeMonitorState: () => {},
466
+ autoRefreshStateStore,
467
+ runAutoRefresh: async (args) => {
468
+ assert.fail('runAutoRefresh should not be called while Codex auto-refresh is disabled');
469
+ return {
470
+ status: 'skipped',
471
+ reason: 'auto_refresh_backoff',
472
+ };
473
+ },
474
+ });
475
+
476
+ assert.equal(result.status, 'ok');
477
+ assert.equal(result.autoRefresh.reason, 'codex_auto_refresh_disabled');
478
+ } finally {
479
+ db.close();
480
+ rmSync(codexHome, { recursive: true, force: true });
481
+ rmSync(project, { recursive: true, force: true });
482
+ }
483
+ });
484
+
383
485
  test('codex-hook stop skips cleanly when Codex thread id is unavailable', async () => {
384
486
  const db = makeDb();
385
487
  try {
@@ -458,3 +560,16 @@ function tokenCountEvent({ inputTokens, outputTokens, contextWindow }) {
458
560
  },
459
561
  });
460
562
  }
563
+
564
+ function makeMemoryAutoRefreshStateStore() {
565
+ const states = new Map();
566
+ return {
567
+ read(threadId) {
568
+ const state = states.get(threadId);
569
+ return state ? structuredClone(state) : null;
570
+ },
571
+ write(threadId, state) {
572
+ states.set(threadId, structuredClone(state));
573
+ },
574
+ };
575
+ }
@@ -5,6 +5,7 @@ import {
5
5
  renderCodexNewThreadHandoff,
6
6
  toCodexDeveloperMessageItem,
7
7
  } from '../codex-handoff.mjs';
8
+ import { sameProjectPath } from '../project-path.mjs';
8
9
 
9
10
  const FORMATS = new Set(['text', 'handoff', 'item-json']);
10
11
 
@@ -75,16 +76,15 @@ function parseArgs(args) {
75
76
  }
76
77
 
77
78
  function findLatestCodexSessionId(db, projectPath) {
78
- const row = db
79
+ const rows = db
79
80
  .prepare(
80
- `SELECT session_id
81
+ `SELECT session_id, project_path
81
82
  FROM sessions
82
- WHERE lower(project_path) = lower(?)
83
- AND session_id LIKE 'codex:%'
84
- ORDER BY updated_at DESC
85
- LIMIT 1`,
83
+ WHERE session_id LIKE 'codex:%'
84
+ ORDER BY updated_at DESC`,
86
85
  )
87
- .get(projectPath);
86
+ .all();
87
+ const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
88
88
  return row?.session_id ?? null;
89
89
  }
90
90
 
@@ -5,6 +5,7 @@ import {
5
5
  pickOldestUnsummarizedTurn,
6
6
  } from '../turn-processor.mjs';
7
7
  import { summarizeToL1 } from '../haiku-summarizer.mjs';
8
+ import { sameProjectPath } from '../project-path.mjs';
8
9
 
9
10
  function parseArgs(args) {
10
11
  const out = {
@@ -51,16 +52,15 @@ function parseArgs(args) {
51
52
  }
52
53
 
53
54
  function findLatestCodexSessionId(db, projectPath) {
54
- const row = db
55
+ const rows = db
55
56
  .prepare(
56
- `SELECT session_id
57
+ `SELECT session_id, project_path
57
58
  FROM sessions
58
- WHERE lower(project_path) = lower(?)
59
- AND session_id LIKE 'codex:%'
60
- ORDER BY updated_at DESC
61
- LIMIT 1`,
59
+ WHERE session_id LIKE 'codex:%'
60
+ ORDER BY updated_at DESC`,
62
61
  )
63
- .get(projectPath);
62
+ .all();
63
+ const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
64
64
  return row?.session_id ?? null;
65
65
  }
66
66
 
@@ -1,6 +1,7 @@
1
1
  import { getDb } from '../db.mjs';
2
2
  import { buildHandoffRecord } from '../handoff-record.mjs';
3
3
  import { toThroughlineHandoffBlock } from '../codex-handoff.mjs';
4
+ import { sameProjectPath } from '../project-path.mjs';
4
5
 
5
6
  function parseArgs(args) {
6
7
  const out = {
@@ -33,15 +34,14 @@ function parseArgs(args) {
33
34
  }
34
35
 
35
36
  function findLatestSessionId(db, projectPath) {
36
- const row = db
37
+ const rows = db
37
38
  .prepare(
38
- `SELECT session_id
39
+ `SELECT session_id, project_path
39
40
  FROM sessions
40
- WHERE lower(project_path) = lower(?)
41
- ORDER BY updated_at DESC
42
- LIMIT 1`,
41
+ ORDER BY updated_at DESC`,
43
42
  )
44
- .get(projectPath);
43
+ .all();
44
+ const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
45
45
  return row?.session_id ?? null;
46
46
  }
47
47
 
@@ -47,7 +47,9 @@ test('CLI help exposes guided Codex handoff and guarded execute semantics', () =
47
47
  for (const command of CODEX_HELP_COMMANDS) {
48
48
  assert.match(result.stdout, new RegExp(command.replaceAll('-', '\\-')));
49
49
  }
50
- assert.match(result.stdout, /Guided read-only fresh-thread handoff start plan/);
50
+ assert.match(result.stdout, /Fresh-thread Codex handoff start plan/);
51
+ assert.match(result.stdout, /Use --execute to/);
52
+ assert.match(result.stdout, /--open-host auto\|vscode\|cli\|none/);
51
53
  assert.match(result.stdout, /throughline trim --execute/);
52
54
  assert.match(result.stdout, /injectable DB memory/);
53
55
  assert.match(result.stdout, /matching/);
@@ -11,7 +11,7 @@
11
11
  * script path で登録する。
12
12
  */
13
13
 
14
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, rmSync } from 'node:fs';
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
15
15
  import { join, dirname, resolve, delimiter } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { homedir } from 'node:os';
@@ -62,22 +62,58 @@ function quoteCommandPath(p) {
62
62
  return /\s/.test(p) ? `"${p.replace(/"/g, '\\"')}"` : p;
63
63
  }
64
64
 
65
+ function safeRealpath(p, realpath = realpathSync.native) {
66
+ try {
67
+ return realpath(p);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export function resolveCodexHookNodePath({
74
+ env = process.env,
75
+ execPath = process.execPath,
76
+ platform = process.platform,
77
+ exists = existsSync,
78
+ realpath = realpathSync.native,
79
+ } = {}) {
80
+ const execRealpath = safeRealpath(execPath, realpath);
81
+ const pathEnv = env.PATH || env.Path || '';
82
+ const dirs = pathEnv.split(delimiter).filter(Boolean);
83
+ const names = platform === 'win32'
84
+ ? ['node.exe', 'node.cmd', 'node.bat', 'node']
85
+ : ['node'];
86
+
87
+ for (const dir of dirs) {
88
+ for (const name of names) {
89
+ const candidate = join(dir, name);
90
+ if (!exists(candidate)) continue;
91
+ const candidateRealpath = safeRealpath(candidate, realpath);
92
+ if (execRealpath && candidateRealpath && candidateRealpath === execRealpath) {
93
+ return candidate;
94
+ }
95
+ }
96
+ }
97
+
98
+ return execPath;
99
+ }
100
+
65
101
  export function buildCodexStopHookCommand({
66
- nodePath = process.execPath,
102
+ nodePath = resolveCodexHookNodePath(),
67
103
  cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
68
104
  } = {}) {
69
105
  return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook stop`;
70
106
  }
71
107
 
72
108
  export function buildCodexUserPromptSubmitHookCommand({
73
- nodePath = process.execPath,
109
+ nodePath = resolveCodexHookNodePath(),
74
110
  cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
75
111
  } = {}) {
76
112
  return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook user-prompt-submit`;
77
113
  }
78
114
 
79
115
  export function buildCodexPostToolUseHookCommand({
80
- nodePath = process.execPath,
116
+ nodePath = resolveCodexHookNodePath(),
81
117
  cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
82
118
  } = {}) {
83
119
  return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook post-tool-use`;
@@ -490,8 +526,8 @@ export async function run(args = []) {
490
526
  console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
491
527
  console.log(' UserPromptSubmit → throughline prompt-submit (/tl & /clear バトン書き込み)');
492
528
  if (codex) {
493
- console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (75% 到達時に current session $throughline 指示を注入)`);
494
- console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (tool loop 中も 75% 到達時に $throughline 指示を注入)`);
529
+ console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (capture / monitor state only; auto refresh disabled)`);
530
+ console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (capture / monitor state only; auto refresh disabled)`);
495
531
  console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
496
532
  }
497
533
  console.log('');
@@ -8,6 +8,7 @@ import {
8
8
  buildCodexPostToolUseHookCommand,
9
9
  buildCodexStopHookCommand,
10
10
  buildCodexUserPromptSubmitHookCommand,
11
+ resolveCodexHookNodePath,
11
12
  run,
12
13
  resolveThroughlineOnPath,
13
14
  } from './install.mjs';
@@ -123,13 +124,13 @@ test('global install registers Codex session hooks and enables hooks features',
123
124
  .flatMap(g => g.hooks ?? [])
124
125
  .find(h => h.command === expectedPromptCommand);
125
126
  assert.ok(promptHook, 'Codex UserPromptSubmit should have absolute throughline.mjs codex-hook user-prompt-submit');
126
- assert.equal(promptHook.async, false, 'Codex UserPromptSubmit hook should be synchronous for context injection');
127
+ assert.equal(promptHook.async, false, 'Codex UserPromptSubmit hook should be synchronous for capture/monitor state');
127
128
  assert.equal(promptHook.timeoutSec, 30, 'Codex UserPromptSubmit hook should be short');
128
129
  const postToolUseHook = hooks.hooks.PostToolUse
129
130
  .flatMap(g => g.hooks ?? [])
130
131
  .find(h => h.command === expectedPostToolUseCommand);
131
132
  assert.ok(postToolUseHook, 'Codex PostToolUse should have absolute throughline.mjs codex-hook post-tool-use');
132
- assert.equal(postToolUseHook.async, false, 'Codex PostToolUse hook should be synchronous for context injection');
133
+ assert.equal(postToolUseHook.async, false, 'Codex PostToolUse hook should be synchronous for capture/monitor state');
133
134
  assert.equal(postToolUseHook.timeoutSec, 30, 'Codex PostToolUse hook should be short');
134
135
  const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
135
136
  assert.match(config, /^\[features\]\ncodex_hooks = true/m);
@@ -157,9 +158,10 @@ test('global install copies Throughline Codex skill to ~/.codex/skills/', async
157
158
  const metadataBody = readFileSync(metadata, 'utf8');
158
159
  assert.match(skillBody, /name: throughline/);
159
160
  assert.match(skillBody, /Bare "\$throughline"/);
160
- assert.match(skillBody, /throughline trim --execute --host codex --all/);
161
- assert.match(skillBody, /do not run doctor \/ dry-run \/ handoff \/ preflight first/);
162
- assert.match(metadataBody, /scripted current-thread rollback \+ Throughline memory inject/);
161
+ assert.match(skillBody, /throughline codex-handoff-start --execute/);
162
+ assert.match(skillBody, /do not run doctor \/ dry-run \/ preflight first/);
163
+ assert.match(metadataBody, /start a new Codex thread with Throughline handoff memory/);
164
+ assert.doesNotMatch(metadataBody, /scripted current-thread rollback/);
163
165
  assert.doesNotMatch(metadataBody, /preview blocked Codex trim/);
164
166
  assert.doesNotMatch(metadataBody, /inspect guarded Codex trim/);
165
167
  } finally {
@@ -515,6 +517,38 @@ test('resolveThroughlineOnPath: finds throughline binary in PATH directory', ()
515
517
  }
516
518
  });
517
519
 
520
+ test('resolveCodexHookNodePath: prefers a stable PATH node symlink when it resolves to current Node', () => {
521
+ const stableNode = join('/opt/homebrew/bin', process.platform === 'win32' ? 'node.exe' : 'node');
522
+ const cellarNode = join('/opt/homebrew/Cellar/node/26.0.0/bin', process.platform === 'win32' ? 'node.exe' : 'node');
523
+ const realNode = join('/opt/homebrew/Cellar/node/26.0.0/bin', process.platform === 'win32' ? 'node.exe' : 'node');
524
+ const result = resolveCodexHookNodePath({
525
+ env: { PATH: '/opt/homebrew/bin' },
526
+ execPath: cellarNode,
527
+ exists: (p) => p === stableNode,
528
+ realpath: (p) => {
529
+ if (p === stableNode || p === cellarNode) return realNode;
530
+ throw new Error(`unexpected path: ${p}`);
531
+ },
532
+ });
533
+ assert.equal(result, stableNode);
534
+ });
535
+
536
+ test('resolveCodexHookNodePath: falls back to process execPath when PATH node is different', () => {
537
+ const pathNode = join('/usr/local/bin', process.platform === 'win32' ? 'node.exe' : 'node');
538
+ const execPath = join('/opt/homebrew/Cellar/node/26.0.0/bin', process.platform === 'win32' ? 'node.exe' : 'node');
539
+ const result = resolveCodexHookNodePath({
540
+ env: { PATH: '/usr/local/bin' },
541
+ execPath,
542
+ exists: (p) => p === pathNode,
543
+ realpath: (p) => {
544
+ if (p === pathNode) return '/different/node';
545
+ if (p === execPath) return '/current/node';
546
+ throw new Error(`unexpected path: ${p}`);
547
+ },
548
+ });
549
+ assert.equal(result, execPath);
550
+ });
551
+
518
552
  test('install is idempotent: second run keeps exactly one tl.md and one hook entry', async () => {
519
553
  const home = makeTempHome();
520
554
  if (home.resolved !== home.dir) {
package/src/cli/trim.mjs CHANGED
@@ -366,15 +366,6 @@ async function runPreflight(parsed, plan) {
366
366
  expectedTurns: expectedCodexAppServerTurns(plan),
367
367
  command,
368
368
  });
369
- const turnCountStatus = preflight.turnCountCheck?.status;
370
- if (turnCountStatus === 'mismatch' || turnCountStatus === 'unknown') {
371
- return {
372
- status: 'preflight-refused',
373
- reason: preflight.turnCountCheck.reason,
374
- plan,
375
- preflight,
376
- };
377
- }
378
369
 
379
370
  return {
380
371
  status: 'preflight-ready',