throughline 0.4.10 → 0.4.12

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.
@@ -71,36 +71,11 @@ function suppressExperimentalWarnings() {
71
71
  });
72
72
  }
73
73
 
74
- export function codexContextOutput(text, eventName = 'UserPromptSubmit') {
75
- return JSON.stringify({
76
- hookSpecificOutput: {
77
- hookEventName: eventName,
78
- additionalContext: text,
79
- },
80
- });
81
- }
82
-
83
- function renderAutoRefreshContext({ usage, decision, threadId, eventName = 'UserPromptSubmit' }) {
84
- const tokens = Number(usage?.tokens);
85
- const contextWindowSize = Number(usage?.contextWindowSize);
86
- const ratio = Number(decision?.ratio);
87
- const percent = Number.isFinite(ratio) ? `${(ratio * 100).toFixed(1)}%` : 'unknown';
88
- const timing =
89
- eventName === 'PostToolUse'
90
- ? 'Before continuing the current tool loop, run the installed $throughline workflow now.'
91
- : 'Before answering the user prompt, run the installed $throughline workflow now.';
92
- return [
93
- '[throughline] Codex context refresh threshold reached for this current session.',
94
- `Thread: ${threadId}`,
95
- `Usage: ${Number.isFinite(tokens) ? tokens : 'unknown'} / ${Number.isFinite(contextWindowSize) ? contextWindowSize : 'unknown'} (${percent}); threshold ${(decision.threshold * 100).toFixed(0)}%.`,
96
- '',
97
- timing,
98
- 'Execute exactly:',
99
- 'throughline trim --execute --host codex --all --json',
100
- '',
101
- 'Report only the execution status, whether rollback/inject were sent, durable evidence if present, and the selected memory session; then continue from the refreshed context.',
102
- 'This trigger was computed from the current Codex rollout token_count, not from token-monitor output.',
103
- ].join('\n');
74
+ function codexAutoRefreshDisabledResult() {
75
+ return {
76
+ status: 'skipped',
77
+ reason: 'codex_auto_refresh_disabled',
78
+ };
104
79
  }
105
80
 
106
81
  async function captureCodexHookSession({
@@ -236,20 +211,20 @@ export async function runCodexStopHook({
236
211
  ensureMonitorTask = null,
237
212
  buildMonitorUsage = null,
238
213
  runAutoRefresh = null,
214
+ autoRefreshStateStore = null,
239
215
  } = {}) {
240
- const [{ runCodexAutoRefresh }, capturedState] = await Promise.all([
241
- import('../codex-auto-refresh.mjs'),
242
- captureCodexHookSession({
243
- args,
244
- payload,
245
- env,
246
- db,
247
- writeMonitorState,
248
- ensureMonitorTask,
249
- buildMonitorUsage,
250
- summarize: true,
251
- }),
252
- ]);
216
+ void runAutoRefresh;
217
+ void autoRefreshStateStore;
218
+ const capturedState = await captureCodexHookSession({
219
+ args,
220
+ payload,
221
+ env,
222
+ db,
223
+ writeMonitorState,
224
+ ensureMonitorTask,
225
+ buildMonitorUsage,
226
+ summarize: true,
227
+ });
253
228
 
254
229
  if (capturedState.status !== 'ok') {
255
230
  return {
@@ -261,28 +236,6 @@ export async function runCodexStopHook({
261
236
  };
262
237
  }
263
238
 
264
- let autoRefresh = null;
265
- try {
266
- autoRefresh = await (runAutoRefresh ?? runCodexAutoRefresh)({
267
- db: capturedState.db,
268
- threadId: capturedState.identity.codexThreadId,
269
- codexThreadIdSource: capturedState.identity.codexThreadIdSource,
270
- codexHome: capturedState.codexHome,
271
- projectPath: capturedState.captured.projectPath ?? capturedState.projectPath,
272
- sessionId: capturedState.captured.sessionId,
273
- usage: capturedState.usage,
274
- command: env.THROUGHLINE_CODEX_APP_SERVER_BIN ?? process.env.THROUGHLINE_CODEX_APP_SERVER_BIN ?? 'codex',
275
- });
276
- } catch (err) {
277
- const msg = err instanceof Error ? err.message : 'unknown';
278
- autoRefresh = {
279
- status: 'error',
280
- reason: 'auto_refresh_failed',
281
- message: msg,
282
- };
283
- process.stderr.write(`[codex-hook:auto-refresh] ${msg}\n`);
284
- }
285
-
286
239
  return {
287
240
  status: 'ok',
288
241
  reason: 'codex_rollout_captured',
@@ -290,7 +243,7 @@ export async function runCodexStopHook({
290
243
  captured: capturedState.captured,
291
244
  summarized: capturedState.summarized,
292
245
  monitorState: capturedState.monitorState,
293
- autoRefresh,
246
+ autoRefresh: codexAutoRefreshDisabledResult(),
294
247
  };
295
248
  }
296
249
 
@@ -302,6 +255,7 @@ export async function runCodexUserPromptSubmitHook({
302
255
  writeMonitorState = null,
303
256
  ensureMonitorTask = null,
304
257
  buildMonitorUsage = null,
258
+ autoRefreshStateStore = null,
305
259
  } = {}) {
306
260
  return runCodexContextRefreshInstructionHook({
307
261
  eventName: 'UserPromptSubmit',
@@ -312,6 +266,7 @@ export async function runCodexUserPromptSubmitHook({
312
266
  writeMonitorState,
313
267
  ensureMonitorTask,
314
268
  buildMonitorUsage,
269
+ autoRefreshStateStore,
315
270
  });
316
271
  }
317
272
 
@@ -323,6 +278,7 @@ export async function runCodexPostToolUseHook({
323
278
  writeMonitorState = null,
324
279
  ensureMonitorTask = null,
325
280
  buildMonitorUsage = null,
281
+ autoRefreshStateStore = null,
326
282
  } = {}) {
327
283
  return runCodexContextRefreshInstructionHook({
328
284
  eventName: 'PostToolUse',
@@ -333,6 +289,7 @@ export async function runCodexPostToolUseHook({
333
289
  writeMonitorState,
334
290
  ensureMonitorTask,
335
291
  buildMonitorUsage,
292
+ autoRefreshStateStore,
336
293
  });
337
294
  }
338
295
 
@@ -345,20 +302,20 @@ async function runCodexContextRefreshInstructionHook({
345
302
  writeMonitorState = null,
346
303
  ensureMonitorTask = null,
347
304
  buildMonitorUsage = null,
305
+ autoRefreshStateStore = null,
348
306
  } = {}) {
349
- const [{ evaluateCodexAutoRefreshUsage }, capturedState] = await Promise.all([
350
- import('../codex-auto-refresh.mjs'),
351
- captureCodexHookSession({
352
- args,
353
- payload,
354
- env,
355
- db,
356
- writeMonitorState,
357
- ensureMonitorTask,
358
- buildMonitorUsage,
359
- summarize: false,
360
- }),
361
- ]);
307
+ void eventName;
308
+ void autoRefreshStateStore;
309
+ const capturedState = await captureCodexHookSession({
310
+ args,
311
+ payload,
312
+ env,
313
+ db,
314
+ writeMonitorState,
315
+ ensureMonitorTask,
316
+ buildMonitorUsage,
317
+ summarize: false,
318
+ });
362
319
 
363
320
  if (capturedState.status !== 'ok') {
364
321
  return {
@@ -371,41 +328,13 @@ async function runCodexContextRefreshInstructionHook({
371
328
  };
372
329
  }
373
330
 
374
- const decision = evaluateCodexAutoRefreshUsage(capturedState.usage);
375
- if (!decision.shouldRefresh) {
376
- return {
377
- status: 'ok',
378
- reason: 'codex_rollout_captured',
379
- codexThreadIdSource: capturedState.identity.codexThreadIdSource,
380
- captured: capturedState.captured,
381
- monitorState: capturedState.monitorState,
382
- autoRefreshPrompt: {
383
- status: 'skipped',
384
- reason: decision.reason,
385
- decision,
386
- },
387
- };
388
- }
389
-
390
- const context = renderAutoRefreshContext({
391
- usage: capturedState.usage,
392
- decision,
393
- threadId: capturedState.identity.codexThreadId,
394
- eventName,
395
- });
396
331
  return {
397
332
  status: 'ok',
398
333
  reason: 'codex_rollout_captured',
399
334
  codexThreadIdSource: capturedState.identity.codexThreadIdSource,
400
335
  captured: capturedState.captured,
401
336
  monitorState: capturedState.monitorState,
402
- autoRefreshPrompt: {
403
- status: 'ready',
404
- reason: 'threshold_reached',
405
- decision,
406
- context,
407
- output: codexContextOutput(context, eventName),
408
- },
337
+ autoRefreshPrompt: codexAutoRefreshDisabledResult(),
409
338
  };
410
339
  }
411
340
 
@@ -475,10 +404,8 @@ export async function run(argv = []) {
475
404
 
476
405
  export const _internal = {
477
406
  codexHomeFromTranscriptPath,
478
- codexContextOutput,
479
407
  parseArgs,
480
408
  parsePayload,
481
- renderAutoRefreshContext,
482
409
  resolveCodexHookThreadIdentity,
483
410
  };
484
411
 
@@ -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('');