throughline 0.4.10 → 0.4.12

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.
@@ -18,10 +18,15 @@ quoted/tool-output field に残り得ることは確認済みですが、それ
18
18
  model-visible input に入ることは controlled smoke で再現していません。incident-shaped
19
19
  run の retained text は risk evidence として残す一方、単独では mutation 前 blocker にしません。
20
20
  Codex current-thread trim は、明示 `--execute`、Throughline DB injectable memory、
21
- Codex thread identity を条件に実行します。rollout/app-server turn-count mismatch は診断に残し、
22
- app-server `thread/read` / `thread/resume` が同じ count を返す場合は、その差分で rollback `numTurns`
23
- を補正します。Codex Stop hook
24
- auto-refresh verified usage 75% 以上で同じ guarded path を試行し、estimate usage では実行しません。
21
+ Codex thread identity を条件にした診断用 path として残します。rollout/app-server
22
+ turn-count mismatch は診断に残し、app-server `thread/read` / `thread/resume` が同じ
23
+ count を返す場合は、その差分で rollback `numTurns` を補正します。
24
+ 2026-05-10 update: live token_count 実験で、rollback / inject 後に token count
25
+ 一時的に下がっても同一 thread で戻る挙動を確認したため、Codex automatic
26
+ current-thread refresh は無効化します。`UserPromptSubmit` / `PostToolUse` / `Stop`
27
+ hooks は capture / monitor state write のみ行い、`codex_auto_refresh_disabled` で
28
+ quiet にします。bare `$throughline` は `codex-handoff-start --execute` による
29
+ app-server 新スレッド handoff とします。
25
30
 
26
31
  最初のインシデント仮説に対する重要な訂正:
27
32
 
@@ -648,13 +653,11 @@ TODO:
648
653
  という記述を訂正する。
649
654
  - [x] hypothesis を compacted replacement-history restore に言及する形へ更新する。
650
655
  - [x] Codex Rewind-equivalent trim が complete だという roadmap claim を格下げする。
651
- - [x] Codex auto-refresh verified usage 75% 以上で guarded rollback / inject を
652
- 実行する。estimate usage では実行しない。
653
- - [x] Codex 75% trigger は token-monitor 依存にしない。Codex `UserPromptSubmit`
654
- / `PostToolUse` hooks が当該 session rollout `token_count` を直接読み、
655
- verified 75% 以上なら current session へ `$throughline` workflow 実行指示を
656
- 注入する。`PostToolUse` により、tool loop 中に閾値を超えた場合も次の継続前に
657
- 同じ判定を走らせる。
656
+ - [x] Codex auto-refresh は無効化する。明示 `trim --execute --host codex`
657
+ diagnostic current-thread rollback / inject として残す。
658
+ - [x] Codex `UserPromptSubmit` / `PostToolUse` hooks は token-monitor 依存にせず
659
+ rollout capture / monitor state write だけを行う。verified 75% 以上でも current
660
+ session へ `$throughline` workflow 実行指示を注入しない。
658
661
  - [x] 新セッションが古い「Codex side complete」claim ではなく、この fix plan から
659
662
  再開できるように `CLAUDE.md` を更新する。
660
663
 
@@ -674,11 +677,11 @@ TODO:
674
677
  5. フェーズ 4: durable host behavior を調査し、製品仕様を決める。
675
678
  6. フェーズ 5: 挙動修正後に広範な docs を更新する。
676
679
 
677
- 作業量や複雑さを理由に目標を下げない。2026-05-08 時点の製品仕様は、
678
- 同一 Codex thread guarded rollback + Throughline DB memory inject を実行し、
679
- rollout 上の新 rollback event injected active-work memory で durable evidence を確認すること。
680
- restore-safety / host primitive audit / rollout-app-server turn-count mismatch は diagnostics として残すが、単独では execute / auto-refresh の blocker にしない。
681
- mutation refusalthread identity と Throughline DB injectable memory に限定する。
680
+ 作業量や複雑さを理由に目標を下げない。2026-05-10 時点の製品仕様は、
681
+ 通常 `$throughline` を新スレッド handoff prompt とし、Codex hooks automatic
682
+ current-thread mutation を行わないこと。明示 `trim --execute --host codex` は診断用
683
+ current-thread rollback / inject として残す。restore-safety / host primitive audit /
684
+ rollout-app-server turn-count mismatchdiagnostics として残す。
682
685
  2026-05-09 update: rollout/app-server turn-count mismatch は mutation 前 refusal から外した。
683
686
  app-server `thread/read` / `thread/resume` が同じ count を返す場合、planned rollback turns に
684
687
  `readTurns - expectedTurns` を足して `thread/rollback.numTurns` を送る。例:
@@ -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.12",
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
+ });