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.
- package/CHANGELOG.md +29 -0
- package/README.ja.md +38 -30
- package/README.md +47 -32
- package/bin/throughline.mjs +15 -2
- package/docs/PUBLIC_RELEASE_PLAN.md +5 -3
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +1 -1
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +4 -3
- package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +19 -0
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +2 -2
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +7 -2
- package/docs/throughline-rollback-context-trim-insight.md +1 -1
- package/package.json +1 -1
- package/src/cli/codex-hook.mjs +258 -29
- package/src/cli/codex-hook.test.mjs +169 -2
- package/src/cli/doctor.mjs +59 -5
- package/src/cli/doctor.test.mjs +34 -2
- package/src/cli/help.test.mjs +2 -0
- package/src/cli/install.mjs +82 -12
- package/src/cli/install.test.mjs +100 -16
- package/src/codex-auto-refresh.mjs +1 -1
- package/src/codex-auto-refresh.test.mjs +4 -4
- package/src/token-monitor.mjs +97 -11
- package/src/token-monitor.test.mjs +84 -3
package/src/cli/doctor.test.mjs
CHANGED
|
@@ -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
|
|
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 {
|
package/src/cli/help.test.mjs
CHANGED
|
@@ -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',
|
package/src/cli/install.mjs
CHANGED
|
@@ -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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 => !
|
|
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
|
-
|
|
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
|
|
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) {
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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 ===
|
|
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
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
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 ===
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
assert.ok(
|
|
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.
|
|
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
|
|
11
|
-
assert.equal(CODEX_AUTO_REFRESH_THRESHOLD, 0.
|
|
10
|
+
test('evaluateCodexAutoRefreshUsage: default threshold is 80%', () => {
|
|
11
|
+
assert.equal(CODEX_AUTO_REFRESH_THRESHOLD, 0.8);
|
|
12
12
|
const below = evaluateCodexAutoRefreshUsage({
|
|
13
|
-
tokens:
|
|
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:
|
|
22
|
+
tokens: 206_720,
|
|
23
23
|
contextWindowSize: 258_400,
|
|
24
24
|
estimated: false,
|
|
25
25
|
contextWindowEstimated: false,
|
package/src/token-monitor.mjs
CHANGED
|
@@ -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
|
|
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}
|
|
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 =
|
|
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 =
|
|
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
|
// --- エントリポイント自動起動 ---
|