throughline 0.4.6 → 0.4.8

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.
@@ -155,6 +155,8 @@ test('runCodexDiagnosis: reports env thread and captured DB session', () => {
155
155
 
156
156
  assert.match(output, /\[Codex primary\]/);
157
157
  assert.match(output, /Codex hooks feature:\s+not enabled/);
158
+ assert.match(output, /Codex UserPrompt hook:\s+not registered/);
159
+ assert.match(output, /Codex PostTool hook:\s+not registered/);
158
160
  assert.match(output, /Codex Stop hook:\s+not registered/);
159
161
  assert.match(output, /VSCode monitor task:\s+not registered/);
160
162
  assert.match(output, /created by the next VSCode hook event/);
@@ -328,7 +330,7 @@ test('buildCodexContextRefreshDiagnosis keeps ready label when restore safety is
328
330
  }
329
331
  });
330
332
 
331
- test('readCodexHookDiagnosis detects legacy bare Throughline Codex Stop hook', () => {
333
+ test('readCodexHookDiagnosis detects Codex prompt and Stop hooks', () => {
332
334
  const codexHome = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-home-'));
333
335
  try {
334
336
  mkdirSync(join(codexHome), { recursive: true });
@@ -337,6 +339,30 @@ test('readCodexHookDiagnosis detects legacy bare Throughline Codex Stop hook', (
337
339
  JSON.stringify(
338
340
  {
339
341
  hooks: {
342
+ UserPromptSubmit: [
343
+ {
344
+ hooks: [
345
+ {
346
+ type: 'command',
347
+ command: '/usr/bin/node /pkg/bin/throughline.mjs codex-hook user-prompt-submit',
348
+ timeoutSec: 30,
349
+ async: false,
350
+ },
351
+ ],
352
+ },
353
+ ],
354
+ PostToolUse: [
355
+ {
356
+ hooks: [
357
+ {
358
+ type: 'command',
359
+ command: '/usr/bin/node /pkg/bin/throughline.mjs codex-hook post-tool-use',
360
+ timeoutSec: 30,
361
+ async: false,
362
+ },
363
+ ],
364
+ },
365
+ ],
340
366
  Stop: [
341
367
  {
342
368
  hooks: [
@@ -355,10 +381,16 @@ test('readCodexHookDiagnosis detects legacy bare Throughline Codex Stop hook', (
355
381
  2,
356
382
  ) + '\n',
357
383
  );
358
- writeFileSync(join(codexHome, 'config.toml'), '[features]\ncodex_hooks = true\n');
384
+ writeFileSync(join(codexHome, 'config.toml'), '[features]\ncodex_hooks = true\nhooks = true\n');
359
385
 
360
386
  const diagnosis = readCodexHookDiagnosis(codexHome);
361
387
  assert.equal(diagnosis.featureEnabled, true);
388
+ assert.equal(diagnosis.codexHooksFeatureEnabled, true);
389
+ assert.equal(diagnosis.hooksFeatureEnabled, true);
390
+ assert.equal(diagnosis.managedPromptHooks.length, 1);
391
+ assert.equal(diagnosis.legacyManagedPromptHooks.length, 1);
392
+ assert.equal(diagnosis.managedPostToolUseHooks.length, 1);
393
+ assert.equal(diagnosis.legacyManagedPostToolUseHooks.length, 1);
362
394
  assert.equal(diagnosis.managedStopHooks.length, 1);
363
395
  assert.equal(diagnosis.legacyManagedStopHooks.length, 1);
364
396
  } finally {
@@ -9,6 +9,8 @@ const REPO_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
9
9
  const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
10
10
  const CODEX_HELP_COMMANDS = [
11
11
  'throughline codex-capture',
12
+ 'throughline codex-hook user-prompt-submit',
13
+ 'throughline codex-hook post-tool-use',
12
14
  'throughline codex-hook stop',
13
15
  'throughline codex-summarize',
14
16
  'throughline codex-resume',
@@ -54,6 +54,8 @@ const SC_HOOKS = {
54
54
 
55
55
  const CODEX_COMMANDS = [
56
56
  'throughline codex-hook stop',
57
+ 'throughline codex-hook user-prompt-submit',
58
+ 'throughline codex-hook post-tool-use',
57
59
  ];
58
60
 
59
61
  function quoteCommandPath(p) {
@@ -67,6 +69,36 @@ export function buildCodexStopHookCommand({
67
69
  return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook stop`;
68
70
  }
69
71
 
72
+ export function buildCodexUserPromptSubmitHookCommand({
73
+ nodePath = process.execPath,
74
+ cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
75
+ } = {}) {
76
+ return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook user-prompt-submit`;
77
+ }
78
+
79
+ export function buildCodexPostToolUseHookCommand({
80
+ nodePath = process.execPath,
81
+ cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
82
+ } = {}) {
83
+ return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook post-tool-use`;
84
+ }
85
+
86
+ export function isThroughlineCodexHookCommand(command) {
87
+ if (typeof command !== 'string') return false;
88
+ const normalized = command.replace(/["']/g, '');
89
+ return (
90
+ normalized === 'throughline codex-hook stop' ||
91
+ normalized === 'throughline codex-hook user-prompt-submit' ||
92
+ normalized === 'throughline codex-hook post-tool-use' ||
93
+ normalized.includes('throughline codex-hook stop') ||
94
+ normalized.includes('throughline codex-hook user-prompt-submit') ||
95
+ normalized.includes('throughline codex-hook post-tool-use') ||
96
+ normalized.includes('throughline.mjs codex-hook stop') ||
97
+ normalized.includes('throughline.mjs codex-hook user-prompt-submit') ||
98
+ normalized.includes('throughline.mjs codex-hook post-tool-use')
99
+ );
100
+ }
101
+
70
102
  export function isThroughlineCodexStopCommand(command) {
71
103
  if (typeof command !== 'string') return false;
72
104
  const normalized = command.replace(/["']/g, '');
@@ -77,8 +109,40 @@ export function isThroughlineCodexStopCommand(command) {
77
109
  );
78
110
  }
79
111
 
112
+ export function isThroughlineCodexPostToolUseCommand(command) {
113
+ if (typeof command !== 'string') return false;
114
+ const normalized = command.replace(/["']/g, '');
115
+ return (
116
+ normalized === 'throughline codex-hook post-tool-use' ||
117
+ normalized.includes('throughline codex-hook post-tool-use') ||
118
+ normalized.includes('throughline.mjs codex-hook post-tool-use')
119
+ );
120
+ }
121
+
80
122
  function createCodexHooks() {
81
123
  return {
124
+ UserPromptSubmit: {
125
+ hooks: [
126
+ {
127
+ type: 'command',
128
+ command: buildCodexUserPromptSubmitHookCommand(),
129
+ timeoutSec: 30,
130
+ async: false,
131
+ statusMessage: null,
132
+ },
133
+ ],
134
+ },
135
+ PostToolUse: {
136
+ hooks: [
137
+ {
138
+ type: 'command',
139
+ command: buildCodexPostToolUseHookCommand(),
140
+ timeoutSec: 30,
141
+ async: false,
142
+ statusMessage: null,
143
+ },
144
+ ],
145
+ },
82
146
  Stop: {
83
147
  hooks: [
84
148
  {
@@ -253,11 +317,19 @@ function ensureCodexHooksFeature(configPath) {
253
317
  const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
254
318
  const lines = existing.split(/\r?\n/);
255
319
  const sectionStart = lines.findIndex((line) => line.trim() === '[features]');
320
+ const ensureFeatureLine = (featureLines, name) => {
321
+ const idx = featureLines.findIndex((line) => new RegExp(`^\\s*${name}\\s*=`).test(line));
322
+ if (idx === -1) {
323
+ featureLines.push(`${name} = true`);
324
+ } else {
325
+ featureLines[idx] = `${name} = true`;
326
+ }
327
+ };
256
328
  let updated;
257
329
 
258
330
  if (sectionStart === -1) {
259
331
  const prefix = existing.trimEnd();
260
- updated = `${prefix}${prefix ? '\n\n' : ''}[features]\ncodex_hooks = true\n`;
332
+ updated = `${prefix}${prefix ? '\n\n' : ''}[features]\ncodex_hooks = true\nhooks = true\n`;
261
333
  } else {
262
334
  let sectionEnd = lines.length;
263
335
  for (let i = sectionStart + 1; i < lines.length; i++) {
@@ -267,14 +339,10 @@ function ensureCodexHooksFeature(configPath) {
267
339
  }
268
340
  }
269
341
 
270
- const codexHooksLine = lines
271
- .slice(sectionStart + 1, sectionEnd)
272
- .findIndex((line) => /^\s*codex_hooks\s*=/.test(line));
273
- if (codexHooksLine === -1) {
274
- lines.splice(sectionStart + 1, 0, 'codex_hooks = true');
275
- } else {
276
- lines[sectionStart + 1 + codexHooksLine] = 'codex_hooks = true';
277
- }
342
+ const featureLines = lines.slice(sectionStart + 1, sectionEnd);
343
+ ensureFeatureLine(featureLines, 'codex_hooks');
344
+ ensureFeatureLine(featureLines, 'hooks');
345
+ lines.splice(sectionStart + 1, sectionEnd - sectionStart - 1, ...featureLines);
278
346
  updated = lines.join('\n').replace(/\n*$/, '\n');
279
347
  }
280
348
 
@@ -294,7 +362,7 @@ function installCodexHooks() {
294
362
  const list = existingHooks[key] ?? [];
295
363
  const preserved = [];
296
364
  for (const group of list) {
297
- const hooks = (group.hooks ?? []).filter(h => !isThroughlineCodexStopCommand(h.command));
365
+ const hooks = (group.hooks ?? []).filter(h => !isThroughlineCodexHookCommand(h.command));
298
366
  if (hooks.length > 0) preserved.push({ ...group, hooks });
299
367
  }
300
368
  existingHooks[key] = [entry, ...preserved];
@@ -323,7 +391,7 @@ function uninstallCodexHooks() {
323
391
  const hooks = (group.hooks ?? []).filter((hook) => {
324
392
  const shouldRemove =
325
393
  CODEX_COMMANDS.includes(hook.command) ||
326
- isThroughlineCodexStopCommand(hook.command);
394
+ isThroughlineCodexHookCommand(hook.command);
327
395
  if (shouldRemove) removed++;
328
396
  return !shouldRemove;
329
397
  });
@@ -422,7 +490,9 @@ export async function run(args = []) {
422
490
  console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
423
491
  console.log(' UserPromptSubmit → throughline prompt-submit (/tl & /clear バトン書き込み)');
424
492
  if (codex) {
425
- console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
493
+ console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (80% 到達時に current session $throughline 指示を注入)`);
494
+ console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (tool loop 中も 80% 到達時に $throughline 指示を注入)`);
495
+ console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
426
496
  }
427
497
  console.log('');
428
498
  if (installedCommands.length > 0) {
@@ -4,7 +4,13 @@ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync
4
4
  import { tmpdir, homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
 
7
- import { buildCodexStopHookCommand, run, resolveThroughlineOnPath } from './install.mjs';
7
+ import {
8
+ buildCodexPostToolUseHookCommand,
9
+ buildCodexStopHookCommand,
10
+ buildCodexUserPromptSubmitHookCommand,
11
+ run,
12
+ resolveThroughlineOnPath,
13
+ } from './install.mjs';
8
14
 
9
15
  function makeTempHome() {
10
16
  const dir = mkdtempSync(join(tmpdir(), 'tl-install-test-'));
@@ -94,7 +100,7 @@ test('project install copies commands to cwd/.claude/commands/', async () => {
94
100
  }
95
101
  });
96
102
 
97
- test('global install registers Codex Stop hook and enables codex_hooks feature', async () => {
103
+ test('global install registers Codex session hooks and enables hooks features', async () => {
98
104
  const home = makeTempHome();
99
105
  if (home.resolved !== home.dir) {
100
106
  home.restore();
@@ -104,15 +110,30 @@ test('global install registers Codex Stop hook and enables codex_hooks feature',
104
110
  try {
105
111
  await run([]);
106
112
  const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
107
- const expectedCommand = buildCodexStopHookCommand();
113
+ const expectedStopCommand = buildCodexStopHookCommand();
114
+ const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
115
+ const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
108
116
  const codexHook = hooks.hooks.Stop
109
117
  .flatMap(g => g.hooks ?? [])
110
- .find(h => h.command === expectedCommand);
118
+ .find(h => h.command === expectedStopCommand);
111
119
  assert.ok(codexHook, 'Codex Stop should have absolute throughline.mjs codex-hook stop');
112
120
  assert.equal(codexHook.async, false, 'Codex Stop hook should be synchronous for Codex');
113
121
  assert.equal(codexHook.timeoutSec, 300, 'Codex Stop hook should allow summarizer time');
122
+ const promptHook = hooks.hooks.UserPromptSubmit
123
+ .flatMap(g => g.hooks ?? [])
124
+ .find(h => h.command === expectedPromptCommand);
125
+ 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.timeoutSec, 30, 'Codex UserPromptSubmit hook should be short');
128
+ const postToolUseHook = hooks.hooks.PostToolUse
129
+ .flatMap(g => g.hooks ?? [])
130
+ .find(h => h.command === expectedPostToolUseCommand);
131
+ 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.timeoutSec, 30, 'Codex PostToolUse hook should be short');
114
134
  const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
115
135
  assert.match(config, /^\[features\]\ncodex_hooks = true/m);
136
+ assert.match(config, /^hooks = true/m);
116
137
  } finally {
117
138
  unsilence();
118
139
  home.restore();
@@ -215,21 +236,30 @@ test('global install preserves existing Codex hooks and is idempotent', async ()
215
236
  await run([]);
216
237
  await run([]);
217
238
  const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
218
- const commands = hooks.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
219
- const expectedCommand = buildCodexStopHookCommand();
220
- assert.ok(commands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
221
- assert.equal(commands.filter(c => c === expectedCommand).length, 1);
222
- assert.ok(!commands.includes('throughline codex-hook stop'));
239
+ const stopCommands = hooks.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
240
+ const promptCommands = hooks.hooks.UserPromptSubmit.flatMap(g => g.hooks ?? []).map(h => h.command);
241
+ const postToolUseCommands = hooks.hooks.PostToolUse.flatMap(g => g.hooks ?? []).map(h => h.command);
242
+ const expectedStopCommand = buildCodexStopHookCommand();
243
+ const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
244
+ const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
245
+ assert.ok(stopCommands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
246
+ assert.equal(stopCommands.filter(c => c === expectedStopCommand).length, 1);
247
+ assert.equal(promptCommands.filter(c => c === expectedPromptCommand).length, 1);
248
+ assert.equal(postToolUseCommands.filter(c => c === expectedPostToolUseCommand).length, 1);
249
+ assert.ok(!stopCommands.includes('throughline codex-hook stop'));
250
+ assert.ok(!promptCommands.includes('throughline codex-hook user-prompt-submit'));
251
+ assert.ok(!postToolUseCommands.includes('throughline codex-hook post-tool-use'));
223
252
  const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
224
253
  assert.match(config, /other = true/);
225
254
  assert.match(config, /codex_hooks = true/);
255
+ assert.match(config, /hooks = true/);
226
256
  } finally {
227
257
  unsilence();
228
258
  home.restore();
229
259
  }
230
260
  });
231
261
 
232
- test('global install updates existing Throughline Codex Stop hook shape', async () => {
262
+ test('global install updates existing Throughline Codex hook shapes', async () => {
233
263
  const home = makeTempHome();
234
264
  if (home.resolved !== home.dir) {
235
265
  home.restore();
@@ -241,6 +271,32 @@ test('global install updates existing Throughline Codex Stop hook shape', async
241
271
  JSON.stringify(
242
272
  {
243
273
  hooks: {
274
+ UserPromptSubmit: [
275
+ {
276
+ hooks: [
277
+ {
278
+ type: 'command',
279
+ command: 'throughline codex-hook user-prompt-submit',
280
+ timeoutSec: 300,
281
+ async: true,
282
+ statusMessage: null,
283
+ },
284
+ ],
285
+ },
286
+ ],
287
+ PostToolUse: [
288
+ {
289
+ hooks: [
290
+ {
291
+ type: 'command',
292
+ command: 'throughline codex-hook post-tool-use',
293
+ timeoutSec: 300,
294
+ async: true,
295
+ statusMessage: null,
296
+ },
297
+ ],
298
+ },
299
+ ],
244
300
  Stop: [
245
301
  {
246
302
  hooks: [
@@ -264,17 +320,39 @@ test('global install updates existing Throughline Codex Stop hook shape', async
264
320
  try {
265
321
  await run([]);
266
322
  const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
267
- const expectedCommand = buildCodexStopHookCommand();
323
+ const expectedStopCommand = buildCodexStopHookCommand();
324
+ const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
325
+ const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
268
326
  const codexHooks = hooks.hooks.Stop
269
327
  .flatMap(g => g.hooks ?? [])
270
- .filter(h => h.command === expectedCommand);
328
+ .filter(h => h.command === expectedStopCommand);
271
329
  assert.equal(codexHooks.length, 1);
272
330
  assert.equal(codexHooks[0].async, false);
273
331
  assert.equal(codexHooks[0].timeoutSec, 300);
332
+ const promptHooks = hooks.hooks.UserPromptSubmit
333
+ .flatMap(g => g.hooks ?? [])
334
+ .filter(h => h.command === expectedPromptCommand);
335
+ assert.equal(promptHooks.length, 1);
336
+ assert.equal(promptHooks[0].async, false);
337
+ assert.equal(promptHooks[0].timeoutSec, 30);
338
+ const postToolUseHooks = hooks.hooks.PostToolUse
339
+ .flatMap(g => g.hooks ?? [])
340
+ .filter(h => h.command === expectedPostToolUseCommand);
341
+ assert.equal(postToolUseHooks.length, 1);
342
+ assert.equal(postToolUseHooks[0].async, false);
343
+ assert.equal(postToolUseHooks[0].timeoutSec, 30);
274
344
  assert.equal(
275
345
  hooks.hooks.Stop.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook stop').length,
276
346
  0,
277
347
  );
348
+ assert.equal(
349
+ hooks.hooks.UserPromptSubmit.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook user-prompt-submit').length,
350
+ 0,
351
+ );
352
+ assert.equal(
353
+ hooks.hooks.PostToolUse.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook post-tool-use').length,
354
+ 0,
355
+ );
278
356
  } finally {
279
357
  unsilence();
280
358
  home.restore();
@@ -330,10 +408,16 @@ test('global uninstall removes only Throughline-managed Codex hook', async () =>
330
408
 
331
409
  await run(['--uninstall']);
332
410
  const after = JSON.parse(readFileSync(hooksPath, 'utf8'));
333
- const commands = after.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
334
- assert.ok(commands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
335
- assert.ok(!commands.includes('throughline codex-hook stop'));
336
- assert.ok(!commands.some(c => c.includes('throughline.mjs codex-hook stop')));
411
+ const stopCommands = after.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
412
+ const promptCommands = after.hooks.UserPromptSubmit?.flatMap(g => g.hooks ?? []).map(h => h.command) ?? [];
413
+ const postToolUseCommands = after.hooks.PostToolUse?.flatMap(g => g.hooks ?? []).map(h => h.command) ?? [];
414
+ assert.ok(stopCommands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
415
+ assert.ok(!stopCommands.includes('throughline codex-hook stop'));
416
+ assert.ok(!stopCommands.some(c => c.includes('throughline.mjs codex-hook stop')));
417
+ assert.ok(!promptCommands.includes('throughline codex-hook user-prompt-submit'));
418
+ assert.ok(!promptCommands.some(c => c.includes('throughline.mjs codex-hook user-prompt-submit')));
419
+ assert.ok(!postToolUseCommands.includes('throughline codex-hook post-tool-use'));
420
+ assert.ok(!postToolUseCommands.some(c => c.includes('throughline.mjs codex-hook post-tool-use')));
337
421
  } finally {
338
422
  unsilence();
339
423
  home.restore();
@@ -2,7 +2,7 @@ import { runCodexTrimExecution } from './codex-app-server.mjs';
2
2
  import { buildCodexRolloutTrimSource } from './codex-rollout-memory.mjs';
3
3
  import { buildTrimPlan } from './trim-model.mjs';
4
4
 
5
- export const CODEX_AUTO_REFRESH_THRESHOLD = 0.9;
5
+ export const CODEX_AUTO_REFRESH_THRESHOLD = 0.8;
6
6
 
7
7
  export function evaluateCodexAutoRefreshUsage(usage, { threshold = CODEX_AUTO_REFRESH_THRESHOLD } = {}) {
8
8
  if (!usage) {
@@ -7,10 +7,10 @@ import {
7
7
  runCodexAutoRefresh,
8
8
  } from './codex-auto-refresh.mjs';
9
9
 
10
- test('evaluateCodexAutoRefreshUsage: default threshold is 90%', () => {
11
- assert.equal(CODEX_AUTO_REFRESH_THRESHOLD, 0.9);
10
+ test('evaluateCodexAutoRefreshUsage: default threshold is 80%', () => {
11
+ assert.equal(CODEX_AUTO_REFRESH_THRESHOLD, 0.8);
12
12
  const below = evaluateCodexAutoRefreshUsage({
13
- tokens: 232_559,
13
+ tokens: 206_719,
14
14
  contextWindowSize: 258_400,
15
15
  estimated: false,
16
16
  contextWindowEstimated: false,
@@ -19,7 +19,7 @@ test('evaluateCodexAutoRefreshUsage: default threshold is 90%', () => {
19
19
  assert.equal(below.reason, 'below_threshold');
20
20
 
21
21
  const atThreshold = evaluateCodexAutoRefreshUsage({
22
- tokens: 232_560,
22
+ tokens: 206_720,
23
23
  contextWindowSize: 258_400,
24
24
  estimated: false,
25
25
  contextWindowEstimated: false,
@@ -25,6 +25,7 @@ import { statSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
25
25
  import { homedir } from 'node:os';
26
26
  import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjectPath, STALE_HIDE_MS } from './state-file.mjs';
27
27
  import { buildCodexMonitorUsage } from './codex-usage.mjs';
28
+ import { listCodexThreadCandidates } from './codex-thread-index.mjs';
28
29
  import { readLatestUsage } from './transcript-usage.mjs';
29
30
  import { startSizeQuery } from './terminal-size.mjs';
30
31
 
@@ -290,7 +291,7 @@ export function resolveColumns() {
290
291
 
291
292
  function formatLine({ state, usage, isActive, now = Date.now() }) {
292
293
  const project = basename(state.projectPath || '?');
293
- const shortId = state.sessionId.slice(0, 8);
294
+ const shortId = formatShortSessionId(state);
294
295
  const host = state.host === 'codex' ? 'Codex' : state.host === 'unknown' ? 'Unknown' : 'Claude';
295
296
  const tokens = usage?.tokens ?? 0;
296
297
  const max = usage?.contextWindowSize ?? 200_000;
@@ -318,11 +319,8 @@ function formatLine({ state, usage, isActive, now = Date.now() }) {
318
319
  const tokCol = `${formatNumber(tokens).padStart(6)} / ${formatNumber(max).padStart(6)}`;
319
320
  const estimateMark = usage?.estimated ? ' est' : '';
320
321
  const windowMark = usage?.contextWindowEstimated ? ' win?' : '';
321
- const liveMark = usage?.liveTurn && usage?.transientOutputTokens
322
- ? ` live+${formatNumber(usage.transientOutputTokens)}`
323
- : '';
324
322
  const modelCol = usage?.model
325
- ? color(ANSI.dim, `${usage.model}${estimateMark}${windowMark}${liveMark}`)
323
+ ? color(ANSI.dim, `${usage.model}${estimateMark}${windowMark}`)
326
324
  : color(ANSI.dim, '(未取得)');
327
325
  // 最終更新からの経過: 表示が「止まって見える」とき、それが idle なのか障害なのかを
328
326
  // 即座に判別できるようにする。updatedAt は state.writeSessionState 時の Date.now()。
@@ -337,6 +335,14 @@ function formatLine({ state, usage, isActive, now = Date.now() }) {
337
335
  return `${marker} ${projectCol} ${hostCol} ${idCol} ${agoCol} ${barCol} ${tokCol} ${modelCol}${warn}`;
338
336
  }
339
337
 
338
+ function formatShortSessionId(state) {
339
+ const sessionId = String(state?.sessionId ?? '');
340
+ if (state?.host === 'codex' && sessionId.startsWith('codex:')) {
341
+ return sessionId.slice('codex:'.length, 'codex:'.length + 8);
342
+ }
343
+ return sessionId.slice(0, 8);
344
+ }
345
+
340
346
  function statFile(path) {
341
347
  if (!path || !existsSync(path)) return null;
342
348
  try {
@@ -375,6 +381,82 @@ function resolveMonitorUsage(state) {
375
381
  return state.usage ?? null;
376
382
  }
377
383
 
384
+ let lastCodexDiscoveryError = null;
385
+
386
+ function reportCodexDiscoveryError(err) {
387
+ const msg = err instanceof Error ? err.message : 'unknown error';
388
+ if (msg === lastCodexDiscoveryError) return;
389
+ lastCodexDiscoveryError = msg;
390
+ process.stderr.write(`[Throughline] codex discovery error: ${msg}\n`);
391
+ }
392
+
393
+ function codexDiscoveryOptions(args = {}, cwd = process.cwd()) {
394
+ const allProjects = Boolean(args.all || args.session);
395
+ return {
396
+ projectPath: cwd,
397
+ allProjects,
398
+ limit: allProjects ? 100 : 30,
399
+ };
400
+ }
401
+
402
+ function discoverCodexSessionStates(args = {}, cwd = process.cwd()) {
403
+ let candidates;
404
+ try {
405
+ candidates = listCodexThreadCandidates(codexDiscoveryOptions(args, cwd));
406
+ } catch (err) {
407
+ reportCodexDiscoveryError(err);
408
+ return [];
409
+ }
410
+
411
+ lastCodexDiscoveryError = null;
412
+ return candidates.map((candidate) => ({
413
+ sessionId: `codex:${candidate.id}`,
414
+ host: 'codex',
415
+ projectPath: normalizeProjectPath(candidate.cwd ?? cwd),
416
+ transcriptPath: null,
417
+ rolloutPath: candidate.rolloutPath,
418
+ pid: null,
419
+ updatedAt: codexCandidateUpdatedAt(candidate),
420
+ discoveredFrom: 'codex-rollout-discovery',
421
+ }));
422
+ }
423
+
424
+ function codexCandidateUpdatedAt(candidate) {
425
+ const mtime = Number(candidate?.mtimeMs);
426
+ if (Number.isFinite(mtime) && mtime > 0) return mtime;
427
+ const parsed = Date.parse(candidate?.updatedAt ?? '');
428
+ return Number.isFinite(parsed) ? parsed : 0;
429
+ }
430
+
431
+ function mergeCodexDiscoveredStates(states, discovered) {
432
+ const bySession = new Map();
433
+ for (const state of states) bySession.set(state.sessionId, state);
434
+
435
+ for (const state of discovered) {
436
+ const existing = bySession.get(state.sessionId);
437
+ if (!existing) {
438
+ bySession.set(state.sessionId, state);
439
+ continue;
440
+ }
441
+
442
+ bySession.set(state.sessionId, {
443
+ ...existing,
444
+ host: existing.host ?? state.host,
445
+ projectPath: existing.projectPath || state.projectPath,
446
+ rolloutPath: state.rolloutPath ?? existing.rolloutPath,
447
+ updatedAt: Math.max(Number(existing.updatedAt) || 0, Number(state.updatedAt) || 0),
448
+ discoveredFrom: state.discoveredFrom,
449
+ });
450
+ }
451
+
452
+ return Array.from(bySession.values());
453
+ }
454
+
455
+ function readMonitorStates(args = {}, cwd = process.cwd()) {
456
+ const states = readAllSessionStates();
457
+ return mergeCodexDiscoveredStates(states, discoverCodexSessionStates(args, cwd));
458
+ }
459
+
378
460
  // --- フィルタ ---
379
461
  /**
380
462
  * セッション一覧に表示フィルタを適用する。
@@ -406,7 +488,7 @@ let lastRenderKey = '';
406
488
  * 注: state-file の mtime は Stop hook のタイミングで更新されるが、
407
489
  * transcript / rollout は実行中に太る。その live file 変化も render key に含める。
408
490
  */
409
- function computeRenderKey() {
491
+ function computeRenderKey(args = {}, cwd = process.cwd()) {
410
492
  const parts = [];
411
493
  // state mtimes
412
494
  const mtimes = snapshotStateMtimes();
@@ -414,7 +496,7 @@ function computeRenderKey() {
414
496
  for (const name of names) parts.push(`s:${name}:${mtimes.get(name)}`);
415
497
  // live transcript / rollout sizes(state ファイルを読まずに直接 stat、IO 最小化)
416
498
  try {
417
- const states = readAllSessionStates();
499
+ const states = readMonitorStates(args, cwd);
418
500
  for (const st of states) {
419
501
  for (const [kind, path] of [['t', st.transcriptPath], ['r', st.rolloutPath]]) {
420
502
  if (!path || !existsSync(path)) continue;
@@ -435,8 +517,8 @@ function computeRenderKey() {
435
517
  /**
436
518
  * 前回と比べてキーが変化していれば true。副作用として lastRenderKey を更新する。
437
519
  */
438
- function needsRerender() {
439
- const key = computeRenderKey();
520
+ function needsRerender(args = {}, cwd = process.cwd()) {
521
+ const key = computeRenderKey(args, cwd);
440
522
  if (key !== lastRenderKey) {
441
523
  lastRenderKey = key;
442
524
  return true;
@@ -451,7 +533,7 @@ function resetRenderKeyCache() {
451
533
 
452
534
  function renderFrame(args) {
453
535
  const now = Date.now();
454
- const states = readAllSessionStates().map((state) => withLiveActivity(state, now));
536
+ const states = readMonitorStates(args).map((state) => withLiveActivity(state, now));
455
537
  const filtered = filterStates(states, args, process.cwd()).sort(
456
538
  (a, b) => b.updatedAt - a.updatedAt,
457
539
  );
@@ -665,7 +747,7 @@ export function main() {
665
747
  safeRenderFrame(args);
666
748
  return;
667
749
  }
668
- if (needsRerender()) safeRenderFrame(args);
750
+ if (needsRerender(args)) safeRenderFrame(args);
669
751
  if (Date.now() - lastTimeAgoRefresh > TIME_AGO_REFRESH_MS) {
670
752
  lastTimeAgoRefresh = Date.now();
671
753
  safeRenderFrame(args);
@@ -741,6 +823,10 @@ export const _internal = {
741
823
  liveActivityMs,
742
824
  withLiveActivity,
743
825
  resolveMonitorUsage,
826
+ codexDiscoveryOptions,
827
+ discoverCodexSessionStates,
828
+ mergeCodexDiscoveredStates,
829
+ readMonitorStates,
744
830
  };
745
831
 
746
832
  // --- エントリポイント自動起動 ---