throughline 0.4.10 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import {
8
8
  buildCodexPostToolUseHookCommand,
9
9
  buildCodexStopHookCommand,
10
10
  buildCodexUserPromptSubmitHookCommand,
11
+ resolveCodexHookNodePath,
11
12
  run,
12
13
  resolveThroughlineOnPath,
13
14
  } from './install.mjs';
@@ -123,13 +124,13 @@ test('global install registers Codex session hooks and enables hooks features',
123
124
  .flatMap(g => g.hooks ?? [])
124
125
  .find(h => h.command === expectedPromptCommand);
125
126
  assert.ok(promptHook, 'Codex UserPromptSubmit should have absolute throughline.mjs codex-hook user-prompt-submit');
126
- assert.equal(promptHook.async, false, 'Codex UserPromptSubmit hook should be synchronous for context injection');
127
+ assert.equal(promptHook.async, false, 'Codex UserPromptSubmit hook should be synchronous for capture/monitor state');
127
128
  assert.equal(promptHook.timeoutSec, 30, 'Codex UserPromptSubmit hook should be short');
128
129
  const postToolUseHook = hooks.hooks.PostToolUse
129
130
  .flatMap(g => g.hooks ?? [])
130
131
  .find(h => h.command === expectedPostToolUseCommand);
131
132
  assert.ok(postToolUseHook, 'Codex PostToolUse should have absolute throughline.mjs codex-hook post-tool-use');
132
- assert.equal(postToolUseHook.async, false, 'Codex PostToolUse hook should be synchronous for context injection');
133
+ assert.equal(postToolUseHook.async, false, 'Codex PostToolUse hook should be synchronous for capture/monitor state');
133
134
  assert.equal(postToolUseHook.timeoutSec, 30, 'Codex PostToolUse hook should be short');
134
135
  const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
135
136
  assert.match(config, /^\[features\]\ncodex_hooks = true/m);
@@ -157,9 +158,10 @@ test('global install copies Throughline Codex skill to ~/.codex/skills/', async
157
158
  const metadataBody = readFileSync(metadata, 'utf8');
158
159
  assert.match(skillBody, /name: throughline/);
159
160
  assert.match(skillBody, /Bare "\$throughline"/);
160
- assert.match(skillBody, /throughline trim --execute --host codex --all/);
161
- assert.match(skillBody, /do not run doctor \/ dry-run \/ handoff \/ preflight first/);
162
- assert.match(metadataBody, /scripted current-thread rollback \+ Throughline memory inject/);
161
+ assert.match(skillBody, /throughline codex-handoff-start --execute/);
162
+ assert.match(skillBody, /do not run doctor \/ dry-run \/ preflight first/);
163
+ assert.match(metadataBody, /start a new Codex thread with Throughline handoff memory/);
164
+ assert.doesNotMatch(metadataBody, /scripted current-thread rollback/);
163
165
  assert.doesNotMatch(metadataBody, /preview blocked Codex trim/);
164
166
  assert.doesNotMatch(metadataBody, /inspect guarded Codex trim/);
165
167
  } finally {
@@ -515,6 +517,38 @@ test('resolveThroughlineOnPath: finds throughline binary in PATH directory', ()
515
517
  }
516
518
  });
517
519
 
520
+ test('resolveCodexHookNodePath: prefers a stable PATH node symlink when it resolves to current Node', () => {
521
+ const stableNode = join('/opt/homebrew/bin', process.platform === 'win32' ? 'node.exe' : 'node');
522
+ const cellarNode = join('/opt/homebrew/Cellar/node/26.0.0/bin', process.platform === 'win32' ? 'node.exe' : 'node');
523
+ const realNode = join('/opt/homebrew/Cellar/node/26.0.0/bin', process.platform === 'win32' ? 'node.exe' : 'node');
524
+ const result = resolveCodexHookNodePath({
525
+ env: { PATH: '/opt/homebrew/bin' },
526
+ execPath: cellarNode,
527
+ exists: (p) => p === stableNode,
528
+ realpath: (p) => {
529
+ if (p === stableNode || p === cellarNode) return realNode;
530
+ throw new Error(`unexpected path: ${p}`);
531
+ },
532
+ });
533
+ assert.equal(result, stableNode);
534
+ });
535
+
536
+ test('resolveCodexHookNodePath: falls back to process execPath when PATH node is different', () => {
537
+ const pathNode = join('/usr/local/bin', process.platform === 'win32' ? 'node.exe' : 'node');
538
+ const execPath = join('/opt/homebrew/Cellar/node/26.0.0/bin', process.platform === 'win32' ? 'node.exe' : 'node');
539
+ const result = resolveCodexHookNodePath({
540
+ env: { PATH: '/usr/local/bin' },
541
+ execPath,
542
+ exists: (p) => p === pathNode,
543
+ realpath: (p) => {
544
+ if (p === pathNode) return '/different/node';
545
+ if (p === execPath) return '/current/node';
546
+ throw new Error(`unexpected path: ${p}`);
547
+ },
548
+ });
549
+ assert.equal(result, execPath);
550
+ });
551
+
518
552
  test('install is idempotent: second run keeps exactly one tl.md and one hook entry', async () => {
519
553
  const home = makeTempHome();
520
554
  if (home.resolved !== home.dir) {
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  export const CODEX_APP_SERVER_METHODS = Object.freeze({
5
5
  initialize: 'initialize',
6
6
  initialized: 'initialized',
7
+ threadStart: 'thread/start',
7
8
  threadRead: 'thread/read',
8
9
  threadResume: 'thread/resume',
9
10
  threadTurnsList: 'thread/turns/list',
@@ -125,6 +126,32 @@ export function buildInitializedNotification() {
125
126
  };
126
127
  }
127
128
 
129
+ export function buildThreadStartRequest({
130
+ id,
131
+ cwd,
132
+ approvalPolicy = null,
133
+ sandbox = null,
134
+ model = null,
135
+ sessionStartSource = 'clear',
136
+ }) {
137
+ assertRequestId(id, 'buildThreadStartRequest');
138
+ assertNonEmptyString(cwd, 'buildThreadStartRequest: cwd');
139
+ if (sessionStartSource !== 'startup' && sessionStartSource !== 'clear') {
140
+ throw new Error('buildThreadStartRequest: sessionStartSource must be startup or clear');
141
+ }
142
+ return {
143
+ id,
144
+ method: CODEX_APP_SERVER_METHODS.threadStart,
145
+ params: compactNullish({
146
+ cwd,
147
+ approvalPolicy,
148
+ sandbox,
149
+ model,
150
+ sessionStartSource,
151
+ }),
152
+ };
153
+ }
154
+
128
155
  export function buildThreadReadRequest({ id, threadId, includeTurns = true }) {
129
156
  assertRequestId(id, 'buildThreadReadRequest');
130
157
  assertNonEmptyString(threadId, 'buildThreadReadRequest: threadId');
@@ -1406,6 +1433,116 @@ export async function runCodexRollbackModelVisibleVerify({
1406
1433
  }
1407
1434
  }
1408
1435
 
1436
+ export async function runCodexNewThreadHandoff({
1437
+ cwd,
1438
+ prompt,
1439
+ command = 'codex',
1440
+ commandArgs = ['app-server', '--listen', 'stdio://'],
1441
+ timeoutMs = 120_000,
1442
+ requestTimeoutMs = 60_000,
1443
+ waitForTurn = true,
1444
+ sessionStartSource = 'clear',
1445
+ delivery = 'user-turn',
1446
+ } = {}) {
1447
+ assertNonEmptyString(cwd, 'runCodexNewThreadHandoff: cwd');
1448
+ assertNonEmptyString(prompt, 'runCodexNewThreadHandoff: prompt');
1449
+ assertNonEmptyString(command, 'runCodexNewThreadHandoff: command');
1450
+ if (!Array.isArray(commandArgs)) {
1451
+ throw new Error('runCodexNewThreadHandoff: commandArgs must be an array');
1452
+ }
1453
+ if (sessionStartSource !== 'startup' && sessionStartSource !== 'clear') {
1454
+ throw new Error('runCodexNewThreadHandoff: sessionStartSource must be startup or clear');
1455
+ }
1456
+ if (delivery !== 'user-turn' && delivery !== 'developer-item') {
1457
+ throw new Error('runCodexNewThreadHandoff: delivery must be user-turn or developer-item');
1458
+ }
1459
+
1460
+ const client = startAppServerClient({
1461
+ command,
1462
+ args: commandArgs,
1463
+ cwd,
1464
+ timeoutMs,
1465
+ requestTimeoutMs,
1466
+ });
1467
+
1468
+ try {
1469
+ await initializeThroughlineAppServerClient(client, {
1470
+ clientName: 'throughline-codex-new-thread-handoff',
1471
+ clientTitle: 'Throughline Codex New Thread Handoff',
1472
+ });
1473
+
1474
+ const threadStart = await client.request(
1475
+ buildThreadStartRequest({
1476
+ id: randomUUID(),
1477
+ cwd,
1478
+ sessionStartSource,
1479
+ }),
1480
+ );
1481
+ const threadId = findThreadIdInAppServerPayload(threadStart);
1482
+ if (!isCodexThreadId(threadId)) {
1483
+ throw new Error(`thread/start did not return a Codex thread id: ${JSON.stringify(threadStart).slice(0, 500)}`);
1484
+ }
1485
+
1486
+ if (delivery === 'developer-item') {
1487
+ await client.request(
1488
+ buildThreadInjectItemsRequest({
1489
+ id: randomUUID(),
1490
+ threadId,
1491
+ items: [buildDeveloperMessageItem(prompt)],
1492
+ }),
1493
+ );
1494
+ return {
1495
+ status: 'started',
1496
+ reason: 'new_thread_started_and_developer_memory_injected',
1497
+ threadId,
1498
+ delivery,
1499
+ injectSent: true,
1500
+ turnStatus: 'not-started',
1501
+ notifications: [...new Set(client.notifications)],
1502
+ agentText: collectAgentText(client.notificationEvents),
1503
+ stderr: client.stderr,
1504
+ };
1505
+ }
1506
+
1507
+ await client.request(buildTurnStartRequest({ id: randomUUID(), threadId, text: prompt }));
1508
+ const observedTurnEvent = waitForTurn
1509
+ ? await client.waitForNotification({
1510
+ predicate: (event) => event.method === 'turn/completed' || event.method === 'turn/failed',
1511
+ timeoutMs: requestTimeoutMs,
1512
+ })
1513
+ : null;
1514
+ const turnStatus =
1515
+ observedTurnEvent?.method === 'turn/completed'
1516
+ ? 'completed'
1517
+ : observedTurnEvent?.method === 'turn/failed'
1518
+ ? 'failed'
1519
+ : waitForTurn
1520
+ ? 'timeout'
1521
+ : 'unchecked';
1522
+
1523
+ return {
1524
+ status: turnStatus === 'failed' || turnStatus === 'timeout' ? 'started-unverified' : 'started',
1525
+ reason:
1526
+ turnStatus === 'completed'
1527
+ ? 'new_thread_started_and_turn_completed'
1528
+ : turnStatus === 'failed'
1529
+ ? 'new_thread_started_but_turn_failed'
1530
+ : turnStatus === 'timeout'
1531
+ ? 'new_thread_started_but_turn_completion_not_observed'
1532
+ : 'new_thread_started_without_waiting_for_turn',
1533
+ threadId,
1534
+ delivery,
1535
+ injectSent: false,
1536
+ turnStatus,
1537
+ notifications: [...new Set(client.notifications)],
1538
+ agentText: collectAgentText(client.notificationEvents),
1539
+ stderr: client.stderr,
1540
+ };
1541
+ } finally {
1542
+ await client.close();
1543
+ }
1544
+ }
1545
+
1409
1546
  function initializeThroughlineAppServerClient(client, { clientName, clientTitle }) {
1410
1547
  return client
1411
1548
  .request(
@@ -1427,6 +1564,25 @@ function extractRollbackModelVisibleMarkers(text, markerPrefix) {
1427
1564
  return [...new Set(text.match(pattern) ?? [])];
1428
1565
  }
1429
1566
 
1567
+ function findThreadIdInAppServerPayload(value) {
1568
+ if (!isRecord(value)) return null;
1569
+ if (isCodexThreadId(value.id)) return value.id;
1570
+ if (isCodexThreadId(value.threadId)) return value.threadId;
1571
+ if (isRecord(value.thread)) {
1572
+ const nested = findThreadIdInAppServerPayload(value.thread);
1573
+ if (nested) return nested;
1574
+ }
1575
+ if (isRecord(value.params)) {
1576
+ const nested = findThreadIdInAppServerPayload(value.params);
1577
+ if (nested) return nested;
1578
+ }
1579
+ return null;
1580
+ }
1581
+
1582
+ function isCodexThreadId(value) {
1583
+ return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1584
+ }
1585
+
1430
1586
  async function waitForThreadTurnCount({ client, threadId, expectedTurns, attempts, delayMs }) {
1431
1587
  let lastTurns = null;
1432
1588
  for (let attempt = 1; attempt <= attempts; attempt++) {
@@ -14,6 +14,7 @@ import {
14
14
  buildThreadReadRequest,
15
15
  buildThreadResumeRequest,
16
16
  buildThreadRollbackRequest,
17
+ buildThreadStartRequest,
17
18
  buildThreadTurnsListRequest,
18
19
  buildTurnStartRequest,
19
20
  compareTurnCounts,
@@ -21,6 +22,7 @@ import {
21
22
  parseAppServerLine,
22
23
  resolveRollbackTurnsForAppServer,
23
24
  runCodexModelVisibilitySmoke,
25
+ runCodexNewThreadHandoff,
24
26
  runCodexRollbackModelVisiblePrepare,
25
27
  runCodexRollbackModelVisibleVerify,
26
28
  runCodexTrimPreflight,
@@ -85,6 +87,12 @@ test('buildInitializeRequest opts into the experimental app-server API', () => {
85
87
 
86
88
  test('thread request builders encode the verified rollback/inject flow', () => {
87
89
  const threadId = 'thread-1';
90
+ assert.deepEqual(buildThreadStartRequest({ id: 'start-1', cwd: '/repo' }), {
91
+ id: 'start-1',
92
+ method: CODEX_APP_SERVER_METHODS.threadStart,
93
+ params: { cwd: '/repo', sessionStartSource: 'clear' },
94
+ });
95
+
88
96
  assert.deepEqual(buildThreadReadRequest({ id: 1, threadId }), {
89
97
  id: 1,
90
98
  method: CODEX_APP_SERVER_METHODS.threadRead,