throughline 0.4.7 → 0.4.9

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.
@@ -31,7 +31,9 @@ function parseArgs(argv) {
31
31
  }
32
32
 
33
33
  if (!out.event) out.event = 'stop';
34
- if (out.event !== 'stop') throw new Error(`unknown Codex hook event: ${out.event}`);
34
+ if (!['stop', 'user-prompt-submit', 'post-tool-use'].includes(out.event)) {
35
+ throw new Error(`unknown Codex hook event: ${out.event}`);
36
+ }
35
37
  return out;
36
38
  }
37
39
 
@@ -69,7 +71,39 @@ function suppressExperimentalWarnings() {
69
71
  });
70
72
  }
71
73
 
72
- export async function runCodexStopHook({
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');
104
+ }
105
+
106
+ async function captureCodexHookSession({
73
107
  args = {},
74
108
  payload = {},
75
109
  env = process.env,
@@ -77,7 +111,7 @@ export async function runCodexStopHook({
77
111
  writeMonitorState = null,
78
112
  ensureMonitorTask = null,
79
113
  buildMonitorUsage = null,
80
- runAutoRefresh = null,
114
+ summarize = true,
81
115
  } = {}) {
82
116
  const [
83
117
  { getDb },
@@ -87,7 +121,6 @@ export async function runCodexStopHook({
87
121
  { writeSessionState },
88
122
  { ensureMonitorTaskFile },
89
123
  { buildCodexMonitorUsage },
90
- { runCodexAutoRefresh },
91
124
  ] = await Promise.all([
92
125
  import('../db.mjs'),
93
126
  import('../codex-capture.mjs'),
@@ -96,7 +129,6 @@ export async function runCodexStopHook({
96
129
  import('../state-file.mjs'),
97
130
  import('../vscode-task.mjs'),
98
131
  import('../codex-usage.mjs'),
99
- import('../codex-auto-refresh.mjs'),
100
132
  ]);
101
133
  const actualDb = db ?? getDb();
102
134
  const identity = resolveCodexHookThreadIdentity({ args, payload, env, resolveCodexThreadIdentity });
@@ -105,8 +137,14 @@ export async function runCodexStopHook({
105
137
  return {
106
138
  status: 'skipped',
107
139
  reason: 'codex_thread_id_not_available',
140
+ db: actualDb,
141
+ identity,
142
+ projectPath: null,
143
+ codexHome: null,
108
144
  captured: null,
109
145
  summarized: null,
146
+ monitorState: null,
147
+ usage: null,
110
148
  };
111
149
  }
112
150
 
@@ -136,9 +174,14 @@ export async function runCodexStopHook({
136
174
  return {
137
175
  status: 'skipped',
138
176
  reason: captured.reason ?? 'codex_capture_not_available',
139
- codexThreadIdSource: identity.codexThreadIdSource,
177
+ db: actualDb,
178
+ identity,
179
+ projectPath,
180
+ codexHome,
140
181
  captured,
141
182
  summarized: null,
183
+ monitorState: null,
184
+ usage: null,
142
185
  };
143
186
  }
144
187
 
@@ -161,23 +204,73 @@ export async function runCodexStopHook({
161
204
  process.stderr.write(`[codex-hook:monitor-state] ${msg}\n`);
162
205
  }
163
206
 
164
- const summarized = summarizeCodexSession(actualDb, {
165
- sessionId: captured.sessionId,
166
- projectPath: captured.projectPath ?? projectPath,
167
- max: 1,
168
- env,
169
- });
207
+ const summarized = summarize
208
+ ? summarizeCodexSession(actualDb, {
209
+ sessionId: captured.sessionId,
210
+ projectPath: captured.projectPath ?? projectPath,
211
+ max: 1,
212
+ env,
213
+ })
214
+ : null;
215
+
216
+ return {
217
+ status: 'ok',
218
+ reason: 'codex_rollout_captured',
219
+ db: actualDb,
220
+ identity,
221
+ projectPath,
222
+ codexHome,
223
+ captured,
224
+ summarized,
225
+ monitorState,
226
+ usage,
227
+ };
228
+ }
229
+
230
+ export async function runCodexStopHook({
231
+ args = {},
232
+ payload = {},
233
+ env = process.env,
234
+ db = null,
235
+ writeMonitorState = null,
236
+ ensureMonitorTask = null,
237
+ buildMonitorUsage = null,
238
+ runAutoRefresh = null,
239
+ } = {}) {
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
+ ]);
253
+
254
+ if (capturedState.status !== 'ok') {
255
+ return {
256
+ status: capturedState.status,
257
+ reason: capturedState.reason,
258
+ codexThreadIdSource: capturedState.identity?.codexThreadIdSource,
259
+ captured: capturedState.captured,
260
+ summarized: capturedState.summarized,
261
+ };
262
+ }
170
263
 
171
264
  let autoRefresh = null;
172
265
  try {
173
266
  autoRefresh = await (runAutoRefresh ?? runCodexAutoRefresh)({
174
- db: actualDb,
175
- threadId: identity.codexThreadId,
176
- codexThreadIdSource: identity.codexThreadIdSource,
177
- codexHome,
178
- projectPath: captured.projectPath ?? projectPath,
179
- sessionId: captured.sessionId,
180
- usage,
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,
181
274
  command: env.THROUGHLINE_CODEX_APP_SERVER_BIN ?? process.env.THROUGHLINE_CODEX_APP_SERVER_BIN ?? 'codex',
182
275
  });
183
276
  } catch (err) {
@@ -193,14 +286,129 @@ export async function runCodexStopHook({
193
286
  return {
194
287
  status: 'ok',
195
288
  reason: 'codex_rollout_captured',
196
- codexThreadIdSource: identity.codexThreadIdSource,
197
- captured,
198
- summarized,
199
- monitorState,
289
+ codexThreadIdSource: capturedState.identity.codexThreadIdSource,
290
+ captured: capturedState.captured,
291
+ summarized: capturedState.summarized,
292
+ monitorState: capturedState.monitorState,
200
293
  autoRefresh,
201
294
  };
202
295
  }
203
296
 
297
+ export async function runCodexUserPromptSubmitHook({
298
+ args = {},
299
+ payload = {},
300
+ env = process.env,
301
+ db = null,
302
+ writeMonitorState = null,
303
+ ensureMonitorTask = null,
304
+ buildMonitorUsage = null,
305
+ } = {}) {
306
+ return runCodexContextRefreshInstructionHook({
307
+ eventName: 'UserPromptSubmit',
308
+ args,
309
+ payload,
310
+ env,
311
+ db,
312
+ writeMonitorState,
313
+ ensureMonitorTask,
314
+ buildMonitorUsage,
315
+ });
316
+ }
317
+
318
+ export async function runCodexPostToolUseHook({
319
+ args = {},
320
+ payload = {},
321
+ env = process.env,
322
+ db = null,
323
+ writeMonitorState = null,
324
+ ensureMonitorTask = null,
325
+ buildMonitorUsage = null,
326
+ } = {}) {
327
+ return runCodexContextRefreshInstructionHook({
328
+ eventName: 'PostToolUse',
329
+ args,
330
+ payload,
331
+ env,
332
+ db,
333
+ writeMonitorState,
334
+ ensureMonitorTask,
335
+ buildMonitorUsage,
336
+ });
337
+ }
338
+
339
+ async function runCodexContextRefreshInstructionHook({
340
+ eventName,
341
+ args = {},
342
+ payload = {},
343
+ env = process.env,
344
+ db = null,
345
+ writeMonitorState = null,
346
+ ensureMonitorTask = null,
347
+ buildMonitorUsage = null,
348
+ } = {}) {
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
+ ]);
362
+
363
+ if (capturedState.status !== 'ok') {
364
+ return {
365
+ status: capturedState.status,
366
+ reason: capturedState.reason,
367
+ codexThreadIdSource: capturedState.identity?.codexThreadIdSource,
368
+ captured: capturedState.captured,
369
+ monitorState: capturedState.monitorState,
370
+ autoRefreshPrompt: null,
371
+ };
372
+ }
373
+
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
+ return {
397
+ status: 'ok',
398
+ reason: 'codex_rollout_captured',
399
+ codexThreadIdSource: capturedState.identity.codexThreadIdSource,
400
+ captured: capturedState.captured,
401
+ monitorState: capturedState.monitorState,
402
+ autoRefreshPrompt: {
403
+ status: 'ready',
404
+ reason: 'threshold_reached',
405
+ decision,
406
+ context,
407
+ output: codexContextOutput(context, eventName),
408
+ },
409
+ };
410
+ }
411
+
204
412
  export async function run(argv = []) {
205
413
  suppressExperimentalWarnings();
206
414
  let parsed;
@@ -215,12 +423,31 @@ export async function run(argv = []) {
215
423
  }
216
424
 
217
425
  try {
218
- const result = await runCodexStopHook({
219
- args: parsed,
220
- payload,
221
- env: process.env,
222
- });
223
- if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
426
+ let result;
427
+ if (parsed.event === 'user-prompt-submit') {
428
+ result = await runCodexUserPromptSubmitHook({
429
+ args: parsed,
430
+ payload,
431
+ env: process.env,
432
+ });
433
+ } else if (parsed.event === 'post-tool-use') {
434
+ result = await runCodexPostToolUseHook({
435
+ args: parsed,
436
+ payload,
437
+ env: process.env,
438
+ });
439
+ } else {
440
+ result = await runCodexStopHook({
441
+ args: parsed,
442
+ payload,
443
+ env: process.env,
444
+ });
445
+ }
446
+ if (parsed.json) {
447
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
448
+ } else if (result.autoRefreshPrompt?.output) {
449
+ process.stdout.write(result.autoRefreshPrompt.output + '\n');
450
+ }
224
451
  process.exit(result.status === 'ok' || result.status === 'skipped' ? 0 : 1);
225
452
  } catch (err) {
226
453
  const msg = err instanceof Error ? err.message : 'unknown';
@@ -248,8 +475,10 @@ export async function run(argv = []) {
248
475
 
249
476
  export const _internal = {
250
477
  codexHomeFromTranscriptPath,
478
+ codexContextOutput,
251
479
  parseArgs,
252
480
  parsePayload,
481
+ renderAutoRefreshContext,
253
482
  resolveCodexHookThreadIdentity,
254
483
  };
255
484
 
@@ -5,7 +5,12 @@ import { join } from 'node:path';
5
5
  import { test } from 'node:test';
6
6
  import { DatabaseSync } from 'node:sqlite';
7
7
 
8
- import { runCodexStopHook, _internal } from './codex-hook.mjs';
8
+ import {
9
+ runCodexPostToolUseHook,
10
+ runCodexStopHook,
11
+ runCodexUserPromptSubmitHook,
12
+ _internal,
13
+ } from './codex-hook.mjs';
9
14
 
10
15
  function makeDb() {
11
16
  const db = new DatabaseSync(':memory:');
@@ -121,7 +126,7 @@ test('codex-hook stop captures rollout using Codex stdin payload fields', async
121
126
  }
122
127
  });
123
128
 
124
- test('codex-hook stop runs auto refresh when verified usage reaches 80%', async () => {
129
+ test('codex-hook stop runs auto refresh when verified usage reaches 75%', async () => {
125
130
  const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
126
131
  const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
127
132
  const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
@@ -182,6 +187,160 @@ test('codex-hook stop runs auto refresh when verified usage reaches 80%', async
182
187
  }
183
188
  });
184
189
 
190
+ test('codex-hook user-prompt-submit injects current-session throughline instruction at 75%', async () => {
191
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
192
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
193
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
194
+ const db = makeDb();
195
+ let monitorState = null;
196
+ try {
197
+ const rolloutPath = writeRollout(codexHome, {
198
+ id: threadId,
199
+ cwd: project,
200
+ events: [
201
+ event('user_message', { message: 'hook request' }),
202
+ event('task_started'),
203
+ turnContext({ model: 'gpt-5.5', cwd: project }),
204
+ event('agent_message', { message: 'hook answer' }),
205
+ event('task_complete'),
206
+ tokenCountEvent({
207
+ inputTokens: 240_000,
208
+ outputTokens: 67,
209
+ contextWindow: 258400,
210
+ }),
211
+ ],
212
+ });
213
+
214
+ const result = await runCodexUserPromptSubmitHook({
215
+ payload: {
216
+ session_id: threadId,
217
+ transcript_path: rolloutPath,
218
+ cwd: project,
219
+ prompt: 'next user prompt',
220
+ },
221
+ env: {},
222
+ db,
223
+ ensureMonitorTask: () => {},
224
+ writeMonitorState: (state) => {
225
+ monitorState = state;
226
+ },
227
+ });
228
+
229
+ 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/);
238
+ assert.equal(result.captured.sessionId, `codex:${threadId}`);
239
+ assert.equal(monitorState.sessionId, `codex:${threadId}`);
240
+ } finally {
241
+ db.close();
242
+ rmSync(codexHome, { recursive: true, force: true });
243
+ rmSync(project, { recursive: true, force: true });
244
+ }
245
+ });
246
+
247
+ test('codex-hook user-prompt-submit stays quiet below 75%', async () => {
248
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
249
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
250
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
251
+ const db = makeDb();
252
+ try {
253
+ const rolloutPath = writeRollout(codexHome, {
254
+ id: threadId,
255
+ cwd: project,
256
+ events: [
257
+ event('user_message', { message: 'hook request' }),
258
+ event('task_started'),
259
+ event('agent_message', { message: 'hook answer' }),
260
+ event('task_complete'),
261
+ tokenCountEvent({
262
+ inputTokens: 12345,
263
+ outputTokens: 67,
264
+ contextWindow: 258400,
265
+ }),
266
+ ],
267
+ });
268
+
269
+ const result = await runCodexUserPromptSubmitHook({
270
+ payload: {
271
+ session_id: threadId,
272
+ transcript_path: rolloutPath,
273
+ cwd: project,
274
+ prompt: 'next user prompt',
275
+ },
276
+ env: {},
277
+ db,
278
+ ensureMonitorTask: () => {},
279
+ writeMonitorState: () => {},
280
+ });
281
+
282
+ assert.equal(result.status, 'ok');
283
+ assert.equal(result.autoRefreshPrompt.status, 'skipped');
284
+ assert.equal(result.autoRefreshPrompt.reason, 'below_threshold');
285
+ assert.equal(result.autoRefreshPrompt.output, undefined);
286
+ } finally {
287
+ db.close();
288
+ rmSync(codexHome, { recursive: true, force: true });
289
+ rmSync(project, { recursive: true, force: true });
290
+ }
291
+ });
292
+
293
+ test('codex-hook post-tool-use injects current-session throughline instruction at 75%', async () => {
294
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
295
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
296
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
297
+ const db = makeDb();
298
+ try {
299
+ const rolloutPath = writeRollout(codexHome, {
300
+ id: threadId,
301
+ cwd: project,
302
+ events: [
303
+ event('user_message', { message: 'hook request' }),
304
+ event('task_started'),
305
+ turnContext({ model: 'gpt-5.5', cwd: project }),
306
+ event('agent_message', { message: 'hook answer' }),
307
+ tokenCountEvent({
308
+ inputTokens: 240_000,
309
+ outputTokens: 67,
310
+ contextWindow: 258400,
311
+ }),
312
+ ],
313
+ });
314
+
315
+ const result = await runCodexPostToolUseHook({
316
+ payload: {
317
+ session_id: threadId,
318
+ transcript_path: rolloutPath,
319
+ cwd: project,
320
+ hook_event_name: 'PostToolUse',
321
+ tool_name: 'exec_command',
322
+ },
323
+ env: {},
324
+ db,
325
+ ensureMonitorTask: () => {},
326
+ writeMonitorState: () => {},
327
+ });
328
+
329
+ 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/);
337
+ } finally {
338
+ db.close();
339
+ rmSync(codexHome, { recursive: true, force: true });
340
+ rmSync(project, { recursive: true, force: true });
341
+ }
342
+ });
343
+
185
344
  test('codex-hook stop reports camelCase payload thread id source', async () => {
186
345
  const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
187
346
  const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
@@ -241,6 +400,14 @@ test('codexHomeFromTranscriptPath infers CODEX_HOME from rollout path', () => {
241
400
  assert.equal(_internal.codexHomeFromTranscriptPath(path), '/tmp/codex-home');
242
401
  });
243
402
 
403
+ test('parseArgs: accepts user-prompt-submit Codex hook event', () => {
404
+ assert.equal(_internal.parseArgs(['user-prompt-submit']).event, 'user-prompt-submit');
405
+ });
406
+
407
+ test('parseArgs: accepts post-tool-use Codex hook event', () => {
408
+ assert.equal(_internal.parseArgs(['post-tool-use']).event, 'post-tool-use');
409
+ });
410
+
244
411
  function writeRollout(home, { id, cwd, events }) {
245
412
  const dir = join(home, 'sessions', '2026', '05', '06');
246
413
  mkdirSync(dir, { recursive: true });