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.
@@ -9,7 +9,7 @@
9
9
  | 文書 | 役割 |
10
10
  |---|---|
11
11
  | [THROUGHLINE_CODEX_FIRST_ROADMAP.md](THROUGHLINE_CODEX_FIRST_ROADMAP.md) | 2026-05-06 以降の次フェーズ計画。Codex primary と Codex Rewind 互換を先行する |
12
- | [THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md](THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md) | 2026-05-06 incident 後の修正計画。2026-05-08controlled smoke 後、automatic mutation は再有効化し、restore-safety / host primitive audit diagnostics として残す |
12
+ | [THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md](THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md) | 2026-05-06 incident 後の修正計画。2026-05-10live token_count 実験後、Codex automatic current-thread mutation は無効化し、通常 `$throughline` は新スレッド handoff に戻す |
13
13
  | [THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md](THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md) | この気づきと Claude / Codex 両対応計画を統合した旧計画と実装履歴。完了済み根拠として参照する |
14
14
  | [THROUGHLINE_CODEX_DUAL_SUPPORT.md](THROUGHLINE_CODEX_DUAL_SUPPORT.md) | Throughline を Claude primary のまま Codex adapter / sidecar に対応させる architecture brief |
15
15
 
@@ -23,7 +23,9 @@
23
23
 
24
24
  2026-05-09 update: rollout/app-server turn-count 不一致も mutation 前 blocker から外した。app-server `thread/read` / `thread/resume` が同じ count を返す場合、planned rollback turns に `readTurns - expectedTurns` を足して `thread/rollback.numTurns` を送る。`expectedTurns = 6` / `readTurns = resumedTurns = 7` / `--all` なら `numTurns = 7`。
25
25
 
26
- 2026-05-09 skill UX: bare `$throughline` は diagnostics / dry-run / preflight を AI に順番実行させる surface ではなく、`throughline trim --execute --host codex --all --json` を直接走らせる scripted current-thread refresh とする。目的は rollback と、Throughline DB の L2 最新 20 full bodies + older L1 summaries + L3 references-only memory injection のみ。doctor / dry-run / preflight / fresh-thread handoff / restore-safety / host primitive audit は明示診断用であり、通常 `$throughline` の前段にはしない。
26
+ 2026-05-09 skill UX: bare `$throughline` は diagnostics / dry-run / preflight を AI に順番実行させる surface ではなく、`throughline trim --execute --host codex --all` を直接走らせる scripted current-thread refresh としていた。目的は rollback と、Throughline DB の L2 最新 20 full bodies + older L1 summaries + L3 references-only memory injection のみだった。2026-05-10 以降は `--json` full plan / memory preview tool output として context を再膨張させるため、通常 path では付けないようにした。
27
+
28
+ 2026-05-10 rollback UX correction: 追加 live 実験で、current-thread rollback / inject 後に token_count が一時的に下がっても同一 thread で戻る挙動を確認した。これを受け、Codex automatic current-thread refresh は無効化し、`UserPromptSubmit` / `PostToolUse` / `Stop` hooks は capture / monitor state write のみ行う。bare `$throughline` は `throughline codex-handoff-start --execute` による app-server 新スレッド handoff とし、明示 `trim --execute --host codex` は診断用 current-thread path として残す。
27
29
 
28
30
  2026-05-07 host primitive audit: `throughline codex-host-primitive-audit` で installed Codex app-server schema を機械監査した。`thread/rollback` / `thread/inject_items` / `thread/compact/start` / `thread/start` / `thread/fork` / `thread/resume` は存在するが、rollback 済み user text を current-thread の model-visible input へ復活させない deletion / isolation / projection primitive は見つからなかった。`thread/resume(history)` は schema 上 `[UNSTABLE] FOR CODEX CLOUD - DO NOT USE` で、`thread_id` も ignored になるため、Throughline の current-thread repair primitive には採用しない。
29
31
 
@@ -265,11 +267,13 @@ Codex で考えられる流れ:
265
267
  9. ユーザーは同じ Codex thread で続行する。
266
268
  ```
267
269
 
268
- 現行実装では、Codex Stop hook 後の 75% automatic refresh guarded rollback / inject
269
- mutation を試行する。明示 CLI の `throughline trim --execute --host codex --codex-thread-id <id>`
270
- env gate なしで実行する。明示 Codex thread identity と injectable Throughline DB memory
271
- は live mutation の最低条件であり、rollout/app-server turn-count mismatch は診断と app-server count 由来の rollback `numTurns` 補正に使う。durable success は
272
- post-execute rollout evidence `execute-durable-verified` として別判定する。
270
+ 現行実装では、Codex hooks からの 75% automatic refresh は無効化している。明示 CLI
271
+ `throughline trim --execute --host codex --codex-thread-id <id>` は診断用 current-thread
272
+ path として env gate なしで実行できる。明示 Codex thread identity と injectable
273
+ Throughline DB memory は live mutation の最低条件であり、rollout/app-server
274
+ turn-count mismatch は診断と app-server count 由来の rollback `numTurns` 補正に使う。
275
+ durable success は post-execute rollout evidence で `execute-durable-verified` として
276
+ 別判定する。
273
277
 
274
278
  重要な考え:
275
279
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.4.10",
3
+ "version": "0.4.11",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -30,7 +30,7 @@
30
30
  "LICENSE"
31
31
  ],
32
32
  "scripts": {
33
- "test": "node --test src/*.test.mjs src/cli/*.test.mjs"
33
+ "test": "node --import ./src/test-env.mjs --test src/*.test.mjs src/cli/*.test.mjs"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=22.5"
@@ -10,6 +10,7 @@ import {
10
10
  DEFAULT_CODEX_HANDOFF_MODEL_SMOKE_TIMEOUT_MS,
11
11
  runCodexHandoffModelSmoke,
12
12
  } from '../codex-handoff-model-smoke.mjs';
13
+ import { sameProjectPath } from '../project-path.mjs';
13
14
 
14
15
  async function readStdin() {
15
16
  let raw = '';
@@ -104,16 +105,15 @@ function parseArgs(args) {
104
105
  }
105
106
 
106
107
  function findLatestCodexSessionId(db, projectPath) {
107
- const row = db
108
+ const rows = db
108
109
  .prepare(
109
- `SELECT session_id
110
+ `SELECT session_id, project_path
110
111
  FROM sessions
111
- WHERE lower(project_path) = lower(?)
112
- AND session_id LIKE 'codex:%'
113
- ORDER BY updated_at DESC
114
- LIMIT 1`,
112
+ WHERE session_id LIKE 'codex:%'
113
+ ORDER BY updated_at DESC`,
115
114
  )
116
- .get(projectPath);
115
+ .all();
116
+ const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
117
117
  return row?.session_id ?? null;
118
118
  }
119
119
 
@@ -1,6 +1,7 @@
1
1
  import { getDb } from '../db.mjs';
2
2
  import { buildHandoffRecord } from '../handoff-record.mjs';
3
3
  import { buildCodexHandoffSmoke } from '../codex-handoff-smoke.mjs';
4
+ import { sameProjectPath } from '../project-path.mjs';
4
5
 
5
6
  async function readStdin() {
6
7
  let raw = '';
@@ -75,16 +76,15 @@ function parseArgs(args) {
75
76
  }
76
77
 
77
78
  function findLatestCodexSessionId(db, projectPath) {
78
- const row = db
79
+ const rows = db
79
80
  .prepare(
80
- `SELECT session_id
81
+ `SELECT session_id, project_path
81
82
  FROM sessions
82
- WHERE lower(project_path) = lower(?)
83
- AND session_id LIKE 'codex:%'
84
- ORDER BY updated_at DESC
85
- LIMIT 1`,
83
+ WHERE session_id LIKE 'codex:%'
84
+ ORDER BY updated_at DESC`,
86
85
  )
87
- .get(projectPath);
86
+ .all();
87
+ const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
88
88
  return row?.session_id ?? null;
89
89
  }
90
90
 
@@ -3,7 +3,10 @@ import { buildHandoffRecord } from '../handoff-record.mjs';
3
3
  import { renderCodexNewThreadHandoff } from '../codex-handoff.mjs';
4
4
  import { buildCodexHandoffSmoke } from '../codex-handoff-smoke.mjs';
5
5
  import { buildCodexHandoffModelSmokePrompt } from '../codex-handoff-model-smoke.mjs';
6
+ import { runCodexNewThreadHandoff } from '../codex-app-server.mjs';
6
7
  import { estimateTokens } from '../token-estimator.mjs';
8
+ import { sameProjectPath } from '../project-path.mjs';
9
+ import { spawnSync } from 'node:child_process';
7
10
 
8
11
  async function readStdin() {
9
12
  let raw = '';
@@ -43,6 +46,11 @@ function parseArgs(args) {
43
46
  maxDetailRefs: undefined,
44
47
  maxRecentBodies: undefined,
45
48
  maxBodyChars: undefined,
49
+ execute: false,
50
+ openHost: 'auto',
51
+ codexAppServerBin: null,
52
+ timeoutMs: 120_000,
53
+ requestTimeoutMs: 60_000,
46
54
  };
47
55
 
48
56
  for (let i = 0; i < args.length; i++) {
@@ -67,6 +75,24 @@ function parseArgs(args) {
67
75
  out.maxRecentBodies = parseNonNegativeInteger(args, ++i, '--max-recent-bodies');
68
76
  } else if (arg === '--max-body-chars') {
69
77
  out.maxBodyChars = parseNonNegativeInteger(args, ++i, '--max-body-chars');
78
+ } else if (arg === '--execute') {
79
+ out.execute = true;
80
+ } else if (arg === '--open-host') {
81
+ const value = args[++i];
82
+ if (!['auto', 'vscode', 'cli', 'none'].includes(value)) {
83
+ throw new Error('--open-host must be auto, vscode, cli, or none');
84
+ }
85
+ out.openHost = value;
86
+ } else if (arg === '--codex-app-server-bin') {
87
+ const value = args[++i];
88
+ if (!value || value.startsWith('-')) {
89
+ throw new Error('--codex-app-server-bin requires a command path');
90
+ }
91
+ out.codexAppServerBin = value;
92
+ } else if (arg === '--timeout-ms') {
93
+ out.timeoutMs = parsePositiveInteger(args, ++i, '--timeout-ms');
94
+ } else if (arg === '--request-timeout-ms') {
95
+ out.requestTimeoutMs = parsePositiveInteger(args, ++i, '--request-timeout-ms');
70
96
  } else if (!arg.startsWith('-') && !out.sessionId) {
71
97
  out.sessionId = arg;
72
98
  } else {
@@ -78,16 +104,15 @@ function parseArgs(args) {
78
104
  }
79
105
 
80
106
  function findLatestCodexSessionId(db, projectPath) {
81
- const row = db
107
+ const rows = db
82
108
  .prepare(
83
- `SELECT session_id
109
+ `SELECT session_id, project_path
84
110
  FROM sessions
85
- WHERE lower(project_path) = lower(?)
86
- AND session_id LIKE 'codex:%'
87
- ORDER BY updated_at DESC
88
- LIMIT 1`,
111
+ WHERE session_id LIKE 'codex:%'
112
+ ORDER BY updated_at DESC`,
89
113
  )
90
- .get(projectPath);
114
+ .all();
115
+ const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
91
116
  return row?.session_id ?? null;
92
117
  }
93
118
 
@@ -170,7 +195,9 @@ function buildGuidance({ sessionId, parsed, handoffSmoke, handoffPrompt }) {
170
195
  reason: ready ? 'fresh_thread_handoff_start_ready' : 'handoff_smoke_not_ready',
171
196
  sessionId,
172
197
  mutatesCurrentThread: false,
173
- startThreadManually: true,
198
+ startThreadManually: !parsed.execute,
199
+ execute: parsed.execute,
200
+ openHost: parsed.openHost,
174
201
  handoffSmoke,
175
202
  modelPromptChars: modelPrompt.length,
176
203
  estimatedModelPromptTokens: estimateTokens(modelPrompt),
@@ -187,7 +214,9 @@ function buildGuidance({ sessionId, parsed, handoffSmoke, handoffPrompt }) {
187
214
  ...(parsed.memoStdin
188
215
  ? ['When replaying individual commands, pipe the same memo because they include --memo-stdin.']
189
216
  : []),
190
- 'Start a new Codex thread and provide the handoff prompt as the opening context.',
217
+ parsed.execute
218
+ ? 'This command will start a new Codex thread through app-server, inject handoff memory, then open the selected host.'
219
+ : 'Start a new Codex thread and provide the handoff prompt as the opening context.',
191
220
  ]
192
221
  : [
193
222
  'Fix the failing handoff smoke checks before starting a new Codex thread.',
@@ -204,12 +233,23 @@ function renderTextResult(result) {
204
233
  lines.push(` reason: ${result.reason}`);
205
234
  lines.push(` session: ${result.sessionId}`);
206
235
  lines.push(` mutates thread: ${result.mutatesCurrentThread ? 'yes' : 'no'}`);
236
+ lines.push(` execute: ${result.execute ? 'yes' : 'no'}`);
237
+ lines.push(` open host: ${result.openHost}`);
207
238
  lines.push(` handoff smoke: ${result.handoffSmoke.status}`);
208
239
  lines.push(` prompt chars: ${result.handoffSmoke.promptChars}/${result.handoffSmoke.maxPromptChars}`);
209
240
  lines.push(` model prompt: ${result.modelPromptChars}`);
210
241
  if (result.memoReplayNote) {
211
242
  lines.push(` memo replay: ${result.memoReplayNote}`);
212
243
  }
244
+ if (result.newThread) {
245
+ lines.push(` new thread: ${result.newThread.threadId}`);
246
+ lines.push(` turn status: ${result.newThread.turnStatus}`);
247
+ }
248
+ if (result.open) {
249
+ lines.push(` open status: ${result.open.status}`);
250
+ lines.push(` vscode url: ${result.open.vscodeUrl}`);
251
+ lines.push(` resume command: ${result.open.resumeCommand}`);
252
+ }
213
253
  lines.push('');
214
254
  lines.push(' commands:');
215
255
  lines.push(` structural smoke: ${result.commands.structuralSmoke}`);
@@ -228,6 +268,114 @@ function renderTextResult(result) {
228
268
  return lines.join('\n');
229
269
  }
230
270
 
271
+ function resolveOpenHost(host) {
272
+ if (host !== 'auto') return host;
273
+ if (
274
+ process.env.VSCODE_IPC_HOOK_CLI ||
275
+ process.env.VSCODE_IPC_HOOK ||
276
+ process.env.TERM_PROGRAM === 'vscode' ||
277
+ process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE === 'codex_vscode'
278
+ ) {
279
+ return 'vscode';
280
+ }
281
+ return 'cli';
282
+ }
283
+
284
+ function openStartedCodexThread({ threadId, host, cwd }) {
285
+ const resolvedHost = resolveOpenHost(host);
286
+ const vscodeUrl = `vscode://openai.chatgpt/local/${encodeURIComponent(threadId)}`;
287
+ const resumeCommand = `codex resume ${threadId} --no-alt-screen`;
288
+ if (resolvedHost === 'none') {
289
+ return {
290
+ status: 'skipped',
291
+ reason: 'open_host_none',
292
+ host: resolvedHost,
293
+ vscodeUrl,
294
+ resumeCommand,
295
+ };
296
+ }
297
+
298
+ if (resolvedHost === 'vscode') {
299
+ const result =
300
+ process.platform === 'darwin'
301
+ ? spawnSync('open', [vscodeUrl], { encoding: 'utf8' })
302
+ : process.platform === 'win32'
303
+ ? spawnSync('cmd.exe', ['/c', 'start', '', vscodeUrl], { encoding: 'utf8' })
304
+ : spawnSync('xdg-open', [vscodeUrl], { encoding: 'utf8' });
305
+ if (result.status !== 0) {
306
+ return {
307
+ status: 'failed',
308
+ reason: 'vscode_deep_link_open_failed',
309
+ host: resolvedHost,
310
+ vscodeUrl,
311
+ resumeCommand,
312
+ error: (result.stderr || result.stdout || '').trim(),
313
+ };
314
+ }
315
+ return {
316
+ status: 'opened',
317
+ reason: 'vscode_deep_link_opened',
318
+ host: resolvedHost,
319
+ vscodeUrl,
320
+ resumeCommand,
321
+ };
322
+ }
323
+
324
+ if (resolvedHost === 'cli') {
325
+ if (process.platform === 'darwin') {
326
+ const shellCommand = `cd ${shQuote(cwd)} && TERM=xterm-256color codex resume ${shQuote(threadId)} --no-alt-screen`;
327
+ const result = spawnSync('osascript', [], {
328
+ input: `tell application "Terminal"
329
+ activate
330
+ do script ${appleString(shellCommand)}
331
+ end tell
332
+ `,
333
+ encoding: 'utf8',
334
+ });
335
+ if (result.status !== 0) {
336
+ return {
337
+ status: 'failed',
338
+ reason: 'terminal_open_failed',
339
+ host: resolvedHost,
340
+ vscodeUrl,
341
+ resumeCommand,
342
+ error: (result.stderr || result.stdout || '').trim(),
343
+ };
344
+ }
345
+ return {
346
+ status: 'opened',
347
+ reason: 'terminal_resume_opened',
348
+ host: resolvedHost,
349
+ vscodeUrl,
350
+ resumeCommand,
351
+ };
352
+ }
353
+ return {
354
+ status: 'manual',
355
+ reason: 'cli_auto_open_unsupported_on_platform',
356
+ host: resolvedHost,
357
+ vscodeUrl,
358
+ resumeCommand,
359
+ };
360
+ }
361
+
362
+ return {
363
+ status: 'failed',
364
+ reason: 'unsupported_open_host',
365
+ host: resolvedHost,
366
+ vscodeUrl,
367
+ resumeCommand,
368
+ };
369
+ }
370
+
371
+ function shQuote(value) {
372
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
373
+ }
374
+
375
+ function appleString(value) {
376
+ return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
377
+ }
378
+
231
379
  export async function run(args) {
232
380
  let parsed;
233
381
  try {
@@ -280,9 +428,35 @@ export async function run(args) {
280
428
  result.prompt = handoffPrompt;
281
429
  }
282
430
 
431
+ if (parsed.execute && result.status === 'ready') {
432
+ const startResult = await runCodexNewThreadHandoff({
433
+ cwd: process.cwd(),
434
+ prompt: handoffPrompt,
435
+ command: parsed.codexAppServerBin ?? 'codex',
436
+ commandArgs: parsed.codexAppServerBin ? [] : ['app-server', '--listen', 'stdio://'],
437
+ timeoutMs: parsed.timeoutMs,
438
+ requestTimeoutMs: parsed.requestTimeoutMs,
439
+ waitForTurn: false,
440
+ delivery: 'developer-item',
441
+ });
442
+ const openResult = openStartedCodexThread({
443
+ threadId: startResult.threadId,
444
+ host: parsed.openHost,
445
+ cwd: process.cwd(),
446
+ });
447
+ result.status = startResult.status === 'started' && openResult.status !== 'failed' ? 'started' : 'started-unverified';
448
+ result.reason =
449
+ startResult.status === 'started' && openResult.status !== 'failed'
450
+ ? 'new_thread_handoff_started'
451
+ : 'new_thread_handoff_started_but_open_unverified';
452
+ result.startThreadManually = openResult.status === 'manual';
453
+ result.newThread = startResult;
454
+ result.open = openResult;
455
+ }
456
+
283
457
  if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
284
458
  else process.stdout.write(renderTextResult(result) + '\n');
285
- process.exit(result.status === 'ready' ? 0 : 1);
459
+ process.exit(result.status === 'ready' || result.status === 'started' ? 0 : 1);
286
460
  }
287
461
 
288
462
  export const _internal = {
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
@@ -16,6 +16,41 @@ function makeTempProject() {
16
16
  return mkdtempSync(join(tmpdir(), 'tl-codex-handoff-start-project-'));
17
17
  }
18
18
 
19
+ function makeFakeCodexAppServer(dir) {
20
+ const script = join(dir, 'fake-codex-app-server.mjs');
21
+ const log = join(dir, 'fake-codex-app-server.log');
22
+ writeFileSync(
23
+ script,
24
+ `#!/usr/bin/env node
25
+ import { appendFileSync } from 'node:fs';
26
+ import { createInterface } from 'node:readline';
27
+ const log = ${JSON.stringify(log)};
28
+ const rl = createInterface({ input: process.stdin });
29
+ function send(message) { process.stdout.write(JSON.stringify(message) + '\\n'); }
30
+ rl.on('line', (line) => {
31
+ const msg = JSON.parse(line);
32
+ appendFileSync(log, JSON.stringify(msg) + '\\n');
33
+ if (msg.method === 'initialized') return;
34
+ if (msg.method === 'initialize') {
35
+ send({ id: msg.id, result: { userAgent: 'fake-codex' } });
36
+ } else if (msg.method === 'thread/start') {
37
+ send({ id: msg.id, result: { thread: { id: '019e2000-0000-7000-8000-000000000001', turns: [] } } });
38
+ } else if (msg.method === 'thread/inject_items') {
39
+ send({ id: msg.id, result: { thread: { id: msg.params.threadId, turns: [] } } });
40
+ } else if (msg.method === 'turn/start') {
41
+ send({ id: msg.id, result: { turn: { id: 'turn-handoff' } } });
42
+ send({ method: 'item/agentMessage/delta', params: { threadId: msg.params.threadId, turnId: 'turn-handoff', itemId: 'item-1', delta: 'OK' } });
43
+ send({ method: 'turn/completed', params: { threadId: msg.params.threadId, turnId: 'turn-handoff' } });
44
+ } else {
45
+ send({ id: msg.id, error: { code: -32601, message: 'unknown method' } });
46
+ }
47
+ });
48
+ `,
49
+ );
50
+ chmodSync(script, 0o755);
51
+ return { script, log };
52
+ }
53
+
19
54
  async function seedDb(home, project) {
20
55
  const originalHome = process.env.HOME;
21
56
  const originalUserProfile = process.env.USERPROFILE;
@@ -192,3 +227,47 @@ test('codex-handoff-start propagates memo-stdin to replay commands in JSON guida
192
227
  rmSync(home, { recursive: true, force: true });
193
228
  }
194
229
  });
230
+
231
+ test('codex-handoff-start execute creates a new app-server thread and can skip opening a host', async () => {
232
+ const home = makeTempHome();
233
+ const project = makeTempProject();
234
+ try {
235
+ await seedDb(home, project);
236
+ const { script, log } = makeFakeCodexAppServer(project);
237
+ const result = runStart(home, project, [
238
+ '--session',
239
+ 'codex:thread-handoff-start',
240
+ '--execute',
241
+ '--open-host',
242
+ 'none',
243
+ '--codex-app-server-bin',
244
+ script,
245
+ '--json',
246
+ ]);
247
+
248
+ assert.equal(result.status, 0, result.stderr);
249
+ const payload = JSON.parse(result.stdout);
250
+ assert.equal(payload.status, 'started');
251
+ assert.equal(payload.reason, 'new_thread_handoff_started');
252
+ assert.equal(payload.execute, true);
253
+ assert.equal(payload.startThreadManually, false);
254
+ assert.equal(payload.newThread.threadId, '019e2000-0000-7000-8000-000000000001');
255
+ assert.equal(payload.newThread.delivery, 'developer-item');
256
+ assert.equal(payload.newThread.injectSent, true);
257
+ assert.equal(payload.newThread.turnStatus, 'not-started');
258
+ assert.equal(payload.open.status, 'skipped');
259
+ assert.equal(payload.open.vscodeUrl, 'vscode://openai.chatgpt/local/019e2000-0000-7000-8000-000000000001');
260
+ assert.match(payload.open.resumeCommand, /codex resume 019e2000-0000-7000-8000-000000000001/);
261
+
262
+ const fakeLog = readFileSync(log, 'utf8');
263
+ assert.match(fakeLog, /"method":"thread\/start"/);
264
+ assert.match(fakeLog, /"sessionStartSource":"clear"/);
265
+ assert.match(fakeLog, /"method":"thread\/inject_items"/);
266
+ assert.doesNotMatch(fakeLog, /"method":"turn\/start"/);
267
+ assert.match(fakeLog, /## Throughline: New Codex Thread Handoff/);
268
+ assert.match(fakeLog, /latest handoff start body/);
269
+ } finally {
270
+ rmSync(project, { recursive: true, force: true });
271
+ rmSync(home, { recursive: true, force: true });
272
+ }
273
+ });