throughline 0.10.0 → 0.10.2

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.ja.md +1 -1
  3. package/README.md +2 -2
  4. package/docs/16_readonly_handoff_context_plan.md +2 -2
  5. package/package.json +1 -1
  6. package/src/auditor-context.mjs +4 -3
  7. package/src/cli/auditor-context.mjs +2 -1
  8. package/src/cli/codex-handoff-start.mjs +5 -23
  9. package/src/cli/codex-summarize.mjs +2 -1
  10. package/src/cli/codex-visibility-smoke.mjs +5 -4
  11. package/src/cli/doctor.mjs +7 -6
  12. package/src/cli/grok-continue.mjs +8 -21
  13. package/src/cli/runtime-errors.test.mjs +1 -1
  14. package/src/cli/trim.mjs +3 -2
  15. package/src/codex-auto-refresh.mjs +3 -3
  16. package/src/codex-capture.mjs +14 -18
  17. package/src/codex-handoff-model-smoke.mjs +1 -1
  18. package/src/codex-sidecar.mjs +3 -2
  19. package/src/completed-turn-receipts.mjs +6 -33
  20. package/src/completed-turn-receipts.test.mjs +1 -1
  21. package/src/haiku-summarizer.mjs +1 -1
  22. package/src/handoff-record.mjs +3 -1
  23. package/src/hosts/claude.mjs +27 -0
  24. package/src/hosts/codex.mjs +28 -0
  25. package/src/hosts/grok.mjs +96 -0
  26. package/src/{hook-envelope.test.mjs → hosts/grok.test.mjs} +1 -1
  27. package/src/hosts/identity.mjs +71 -0
  28. package/src/hosts/identity.test.mjs +65 -0
  29. package/src/hosts/index.mjs +37 -0
  30. package/src/os/app-dirs.mjs +25 -0
  31. package/src/os/macos-terminal.mjs +44 -0
  32. package/src/os/open-url.mjs +16 -0
  33. package/src/os/paths.mjs +16 -0
  34. package/src/os/shell.mjs +16 -0
  35. package/src/os/windows-acl.mjs +57 -0
  36. package/src/project-path.mjs +4 -4
  37. package/src/prompt-submit.mjs +23 -55
  38. package/src/prompt-submit.test.mjs +27 -10
  39. package/src/runtime-error-hook.test.mjs +1 -1
  40. package/src/runtime-error-store.mjs +9 -47
  41. package/src/runtime-error-store.test.mjs +1 -1
  42. package/src/session-start.mjs +12 -4
  43. package/src/state-file.mjs +6 -5
  44. package/src/token-monitor.mjs +7 -6
  45. package/src/trim-model.mjs +6 -5
  46. package/src/turn-processor.mjs +2 -2
  47. package/src/hook-envelope.mjs +0 -51
  48. /package/src/{portable-spawn-sync.mjs → os/portable-spawn-sync.mjs} +0 -0
  49. /package/src/{portable-spawn-sync.test.mjs → os/portable-spawn-sync.test.mjs} +0 -0
  50. /package/src/{windows-acl-test-helper.mjs → os/windows-acl-test-helper.mjs} +0 -0
package/CHANGELOG.md CHANGED
@@ -10,6 +10,39 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.10.2] — 2026-08-24
14
+
15
+ ### Changed
16
+
17
+ - 挙動不変のOS/harness層集約リファクタ(harness用語統一campaignの分離規約):
18
+ - LOCALAPPDATA / XDG_CONFIG_HOME / XDG_STATE_HOME のベースディレクトリ組み立てを
19
+ 新設`src/os/app-dirs.mjs`へ一本化(runtime-error-store / completed-turn-receipts
20
+ に3重実装されていた)。
21
+ - hostリテラル比較(`'claude'`/`'codex'`)6ファイルを`src/hosts/identity.mjs`の
22
+ 定数importへ統一し、「識別の唯一の正本」宣言と実装を一致させた。
23
+ - codex-auto-refreshの独自パス正規化を`src/os/paths.mjs`の
24
+ `foldPathCaseForPlatform`へ、codex-sidecarのwin32判定を同`isWin32Platform`へ委譲。
25
+ - `hosts/identity.mjs`の分類語をvendorからharnessへ更新(用語のみ)。
26
+
27
+ ## [0.10.1] — 2026-08-23
28
+
29
+ ### Changed
30
+
31
+ - Internal refactor with no behavior change. Vendor (hook host) identity and
32
+ per-host behavior moved into `src/hosts/` — `hosts/identity.mjs` is the
33
+ single source of the `codex:` / `grok:` session prefixes (previously
34
+ duplicated across four files), and the shared hook entrypoints
35
+ (`session-start` / `prompt-submit` / `process-turn`) now branch through
36
+ `hosts/{claude,codex,grok}.mjs` adapters instead of inline host checks.
37
+ `src/hook-envelope.mjs` is merged into `hosts/grok.mjs`.
38
+ - OS-specific code moved into `src/os/` — the Windows owner-only ACL
39
+ PowerShell implementation that was duplicated verbatim in
40
+ `runtime-error-store` and `completed-turn-receipts` is now the single
41
+ `os/windows-acl.mjs`, alongside macOS Terminal launch, OS URL open,
42
+ shell/AppleScript quoting, Windows path case folding, and the portable
43
+ spawn helper. DB schema, injection contract, and every CLI surface are
44
+ unchanged; full regression is 761 pass / 0 fail.
45
+
13
46
  ## [0.10.0] — 2026-08-17
14
47
 
15
48
  ### Added
@@ -1271,7 +1304,11 @@ two attempts, instrument first instead of patching again.
1271
1304
 
1272
1305
  ---
1273
1306
 
1274
- [Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.9...HEAD
1307
+ [Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.1...HEAD
1308
+ [0.10.1]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.0...v0.10.1
1309
+ [0.10.0]: https://github.com/kitepon-rgb/Throughline/compare/v0.9.1...v0.10.0
1310
+ [0.9.1]: https://github.com/kitepon-rgb/Throughline/compare/v0.9.0...v0.9.1
1311
+ [0.9.0]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.9...v0.9.0
1275
1312
  [0.8.9]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.8...v0.8.9
1276
1313
  [0.8.8]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.7...v0.8.8
1277
1314
  [0.8.7]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.6...v0.8.7
package/README.ja.md CHANGED
@@ -390,7 +390,7 @@ throughline handoff-context --session codex:<thread-id> --json
390
390
  成功時の`throughline.handoff_context.v1`は`schema`、`status`、`sessionId`、`context`だけを返す。
391
391
  `context`はSessionStartと同じ予算付き継承文脈で、DB作成・migration・baton消費・session merge・
392
392
  latest session推測・`sessions.merged_into`変更・L1/L2/L3 rowの所属変更は行わない。AItermは任意の
393
- vendor portable forkでこの境界を使う。Observer feedはcompleted-turn projectionであり代替ではない。
393
+ harness portable forkでこの境界を使う。Observer feedはcompleted-turn projectionであり代替ではない。
394
394
 
395
395
  スラッシュコマンド (Claude Code 内でユーザーが叩く):
396
396
 
package/README.md CHANGED
@@ -834,7 +834,7 @@ local-only contracts.
834
834
  | `throughline runtime-errors reopen <fingerprint> --json` | Explicitly reopen a resolved aggregate without fabricating a new occurrence |
835
835
  | `throughline runtime-errors compact --json` | Remove only acknowledged, resolved aggregates after retention; open or unacknowledged records remain |
836
836
  | `throughline handoff-preview --session <id>` | Print a Codex-facing `throughline_handoff` JSON projection |
837
- | `throughline handoff-context --session <id> --json` | Print the exact SessionStart inheritance context as versioned JSON without moving memory rows or changing `sessions.merged_into`; intended for local launchers that need portable cross-vendor context |
837
+ | `throughline handoff-context --session <id> --json` | Print the exact SessionStart inheritance context as versioned JSON without moving memory rows or changing `sessions.merged_into`; intended for local launchers that need portable cross-harness context |
838
838
  | `throughline grok-continue --session <id>` | Spawn a person-facing Grok seat whose first user text is the handoff-context body. cwd is the source session `project_path`. Does not spawn without ready context. No `--rules`. macOS Terminal only |
839
839
  | `throughline codex-capture --codex-thread-id <id>` | Capture active Codex rollout turns into a `codex:<thread_id>` DB session |
840
840
  | `throughline codex-summarize --session codex:<id>` | Summarize captured Codex L2 into L1 with the Codex CLI backend |
@@ -872,7 +872,7 @@ The successful `throughline.handoff_context.v1` object contains only `schema`,
872
872
  inheritance text used by SessionStart. The command does not create or migrate a
873
873
  database, consume a baton, merge sessions, infer a latest session, change
874
874
  `sessions.merged_into`, or reassign L1/L2/L3 rows. AIterm uses this boundary for
875
- its optional cross-vendor portable fork; the Observer feed is a separate
875
+ its optional cross-harness portable fork; the Observer feed is a separate
876
876
  completed-turn projection and is not a substitute.
877
877
 
878
878
  Slash commands (invoked by the user in Claude Code):
@@ -4,14 +4,14 @@
4
4
 
5
5
  通常 handoff が使う `buildBudgetedResumeContext` の出力を、DB 所有権を変えずにローカルプログラムが
6
6
  取得できる read-only CLI として公開する。最初の consumer は AIterm の portable fork だが、I/F は
7
- 特定 consumer や vendor に依存させない。
7
+ 特定 consumer や harness に依存させない。
8
8
 
9
9
  実行 ToDo、依存、状態、完了証拠の正本は Lattice plan `readonly-handoff-context` とする。
10
10
 
11
11
  ## 完了
12
12
 
13
13
  2026-08-04に`throughline@0.9.0`としてnpm、tag、GitHub Release、global installまで公開した。
14
- focused契約testと全回帰は729 pass/1 skip/0 fail。AIterm v0.23.0の代表cross-vendor smokeでは
14
+ focused契約testと全回帰は729 pass/1 skip/0 fail。AIterm v0.23.0の代表cross-harness smokeでは
15
15
  Codex source memoryをClaudeへ注入し、前後でsource session、`sessions.merged_into`、L1/L2/L3 row所属が
16
16
  完全一致することを確認した。公開後の現行ドキュメント全域監査は、Latticeの終端ToDoを再openして
17
17
  README、作業者入口、配布Codex skill、docs索引、計画、CHANGELOGへ同期した。変更Markdownの
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, and Grok (/clear-safe context compression)",
6
6
  "keywords": [
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
3
3
  import { isAbsolute, join, resolve, sep } from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
5
  import { buildBodyRowsFromActiveTurns } from './codex-capture.mjs';
6
+ import { CLAUDE_HOST, CODEX_HOST, isCodexSessionId } from './hosts/identity.mjs';
6
7
  import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
7
8
  import { getLogicalTurnGroups } from './transcript-reader.mjs';
8
9
  import { hashAuditorBody, normalizeAuditorBody } from './body-digest.mjs';
@@ -23,7 +24,7 @@ export function defaultAuditorContextDbPath() {
23
24
  export function deriveAuditorFreshnessExpectation({ host, transcriptPath, sessionId } = {}) {
24
25
  assertNonEmptyString(transcriptPath, 'transcriptPath');
25
26
  assertNonEmptyString(sessionId, 'sessionId');
26
- if (host === 'claude') {
27
+ if (host === CLAUDE_HOST) {
27
28
  const latest = getLogicalTurnGroups(transcriptPath).at(-1);
28
29
  if (!latest) return null;
29
30
  return {
@@ -33,7 +34,7 @@ export function deriveAuditorFreshnessExpectation({ host, transcriptPath, sessio
33
34
  expectedAssistantSha256: hashAuditorBody(latest.representative.content),
34
35
  };
35
36
  }
36
- if (host === 'codex') {
37
+ if (host === CODEX_HOST) {
37
38
  const parsed = parseCodexRolloutFile(transcriptPath, { includeInFlightTurn: false });
38
39
  const rows = buildBodyRowsFromActiveTurns(parsed.activeTurns, { sessionId, now: 0 });
39
40
  const latestTurnNumber = rows.reduce((max, row) => Math.max(max, row.turnNumber), 0);
@@ -119,7 +120,7 @@ export function readAuditorContext({
119
120
 
120
121
  const latest = completed.at(-1);
121
122
  const stableTurnIdentityAvailable = Number.isInteger(expectedTurnNumber) && expectedTurnNumber >= 0;
122
- const codexPairIdentity = sessionId.startsWith('codex:') && expectedOriginSessionId === sessionId;
123
+ const codexPairIdentity = isCodexSessionId(sessionId) && expectedOriginSessionId === sessionId;
123
124
  const expectationComplete =
124
125
  typeof expectedOriginSessionId === 'string' && expectedOriginSessionId.length > 0 &&
125
126
  (stableTurnIdentityAvailable || codexPairIdentity) &&
@@ -1,3 +1,4 @@
1
+ import { CLAUDE_HOST, CODEX_HOST } from '../hosts/identity.mjs';
1
2
  import {
2
3
  AUDITOR_CONTEXT_SCHEMA,
3
4
  deriveAuditorFreshnessExpectation,
@@ -53,7 +54,7 @@ export function parseArgs(argv = []) {
53
54
  else if (arg === '--max-body-chars') out.maxBodyChars = parsePositiveInteger(value);
54
55
  else if (arg === '--max-total-chars') out.maxTotalChars = parsePositiveInteger(value);
55
56
  else if (arg === '--db') out.dbPath = value;
56
- else if (arg === '--host' && (value === 'claude' || value === 'codex')) out.host = value;
57
+ else if (arg === '--host' && (value === CLAUDE_HOST || value === CODEX_HOST)) out.host = value;
57
58
  else if (arg === '--transcript') out.transcriptPath = value;
58
59
  else throw new TypeError('unknown option');
59
60
  }
@@ -6,7 +6,9 @@ import { buildCodexHandoffModelSmokePrompt } from '../codex-handoff-model-smoke.
6
6
  import { runCodexNewThreadHandoff } from '../codex-app-server.mjs';
7
7
  import { estimateTokens } from '../token-estimator.mjs';
8
8
  import { sameProjectPath } from '../project-path.mjs';
9
- import { spawnSync } from 'node:child_process';
9
+ import { shQuote } from '../os/shell.mjs';
10
+ import { openUrlWithOsHandler } from '../os/open-url.mjs';
11
+ import { runTerminalDoScript } from '../os/macos-terminal.mjs';
10
12
 
11
13
  async function readStdin() {
12
14
  let raw = '';
@@ -319,12 +321,7 @@ function openStartedCodexThread({ threadId, host, cwd }) {
319
321
 
320
322
  if (resolvedHost === 'desktop' || resolvedHost === 'vscode') {
321
323
  const url = resolvedHost === 'desktop' ? desktopUrl : vscodeUrl;
322
- const result =
323
- process.platform === 'darwin'
324
- ? spawnSync('open', [url], { encoding: 'utf8' })
325
- : process.platform === 'win32'
326
- ? spawnSync('cmd.exe', ['/c', 'start', '', url], { encoding: 'utf8' })
327
- : spawnSync('xdg-open', [url], { encoding: 'utf8' });
324
+ const result = openUrlWithOsHandler(url);
328
325
  if (result.status !== 0) {
329
326
  return {
330
327
  status: 'failed',
@@ -353,14 +350,7 @@ function openStartedCodexThread({ threadId, host, cwd }) {
353
350
  if (resolvedHost === 'cli') {
354
351
  if (process.platform === 'darwin') {
355
352
  const shellCommand = `cd ${shQuote(cwd)} && TERM=xterm-256color codex resume ${shQuote(threadId)} --no-alt-screen`;
356
- const result = spawnSync('osascript', [], {
357
- input: `tell application "Terminal"
358
- activate
359
- do script ${appleString(shellCommand)}
360
- end tell
361
- `,
362
- encoding: 'utf8',
363
- });
353
+ const result = runTerminalDoScript(shellCommand);
364
354
  if (result.status !== 0) {
365
355
  return {
366
356
  status: 'failed',
@@ -409,14 +399,6 @@ end tell
409
399
  };
410
400
  }
411
401
 
412
- function shQuote(value) {
413
- return `'${String(value).replaceAll("'", "'\\''")}'`;
414
- }
415
-
416
- function appleString(value) {
417
- return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
418
- }
419
-
420
402
  export async function run(args) {
421
403
  let parsed;
422
404
  try {
@@ -1,4 +1,5 @@
1
1
  import { getDb } from '../db.mjs';
2
+ import { isCodexSessionId } from '../hosts/identity.mjs';
2
3
  import {
3
4
  L2_WINDOW,
4
5
  countDistinctBodyTurns,
@@ -94,7 +95,7 @@ function insertSkeleton(db, { sessionId, originSessionId, turnNumber, summary, c
94
95
  }
95
96
 
96
97
  export function summarizeCodexSession(db, { sessionId, projectPath, max, env = process.env }) {
97
- if (!sessionId?.startsWith('codex:')) {
98
+ if (!isCodexSessionId(sessionId)) {
98
99
  throw new Error('codex-summarize requires a codex:<thread-id> session');
99
100
  }
100
101
 
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { CODEX_SESSION_PREFIX, codexSessionIdToThreadId, isCodexSessionId } from '../hosts/identity.mjs';
2
3
 
3
4
  import { getDb } from '../db.mjs';
4
5
  import { buildHandoffRecord } from '../handoff-record.mjs';
@@ -82,10 +83,10 @@ function parseArgs(args) {
82
83
  }
83
84
 
84
85
  if (!out.sessionId && out.codexThreadId) {
85
- out.sessionId = `codex:${out.codexThreadId}`;
86
+ out.sessionId = `${CODEX_SESSION_PREFIX}${out.codexThreadId}`;
86
87
  }
87
- if (!out.codexThreadId && out.sessionId?.startsWith('codex:')) {
88
- out.codexThreadId = out.sessionId.slice('codex:'.length);
88
+ if (!out.codexThreadId && isCodexSessionId(out.sessionId)) {
89
+ out.codexThreadId = codexSessionIdToThreadId(out.sessionId);
89
90
  }
90
91
 
91
92
  return out;
@@ -154,7 +155,7 @@ export async function run(args) {
154
155
  const db = getDb();
155
156
  const sessionId = parsed.sessionId ?? findLatestCodexSessionId(db, process.cwd());
156
157
  const codexThreadId =
157
- parsed.codexThreadId ?? (sessionId?.startsWith('codex:') ? sessionId.slice('codex:'.length) : null);
158
+ parsed.codexThreadId ?? codexSessionIdToThreadId(sessionId);
158
159
  if (!sessionId || !codexThreadId) {
159
160
  process.stderr.write(
160
161
  '[codex-visibility-smoke] pass --session codex:<thread-id> or --codex-thread-id <id>.\n',
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { existsSync, accessSync, readFileSync, constants, readdirSync, statSync } from 'node:fs';
17
+ import { CODEX_HOST } from '../hosts/identity.mjs';
17
18
  import { join, dirname } from 'node:path';
18
19
  import { homedir } from 'node:os';
19
20
  import { execSync } from 'node:child_process';
@@ -302,9 +303,9 @@ function runTrimDiagnosis(
302
303
  ) {
303
304
  const info = describeTrimHost(host);
304
305
  const codexIdentity =
305
- info.host === 'codex' ? resolveCodexThreadIdentity({ codexThreadId: null }, env) : null;
306
+ info.host === CODEX_HOST ? resolveCodexThreadIdentity({ codexThreadId: null }, env) : null;
306
307
  const hostPrimitiveDiagnosis =
307
- info.host === 'codex' ? readCodexHostPrimitiveDiagnosis({ env, auditRunner }) : null;
308
+ info.host === CODEX_HOST ? readCodexHostPrimitiveDiagnosis({ env, auditRunner }) : null;
308
309
  console.log(`${BOLD}[Trim]${RESET}\n`);
309
310
  console.log(` host: ${info.host}`);
310
311
  console.log(` default keep-recent: ${DEFAULT_TRIM_KEEP_RECENT}`);
@@ -330,19 +331,19 @@ function runTrimDiagnosis(
330
331
  }
331
332
  console.log('');
332
333
  console.log(' dry-run command:');
333
- if (info.host === 'codex' && !codexIdentity?.codexThreadId) {
334
+ if (info.host === CODEX_HOST && !codexIdentity?.codexThreadId) {
334
335
  console.log(' throughline trim --dry-run --host codex --codex-thread-id <id>');
335
336
  console.log(' throughline trim --preflight --host codex --codex-thread-id <id>');
336
- } else if (info.host === 'codex') {
337
+ } else if (info.host === CODEX_HOST) {
337
338
  console.log(' throughline trim --dry-run --host codex');
338
339
  console.log(' throughline trim --preflight --host codex');
339
340
  } else {
340
341
  console.log(` throughline trim --dry-run --host ${info.host}`);
341
342
  }
342
- if (info.host === 'codex') {
343
+ if (info.host === CODEX_HOST) {
343
344
  console.log(' throughline trim --execute --host codex');
344
345
  }
345
- if (info.host === 'codex') {
346
+ if (info.host === CODEX_HOST) {
346
347
  const sessionId = codexIdentity?.codexThreadId
347
348
  ? `codex:${codexIdentity.codexThreadId}`
348
349
  : 'codex:<thread-id>';
@@ -1,10 +1,11 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { randomUUID } from 'node:crypto';
3
2
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
3
  import { homedir, tmpdir } from 'node:os';
5
4
  import { delimiter, join } from 'node:path';
6
5
 
7
6
  import { readHandoffContext, readSessionProjectPath } from './handoff-context.mjs';
7
+ import { shQuote } from '../os/shell.mjs';
8
+ import { appleScriptForTerminalExec, runTerminalScriptDetached } from '../os/macos-terminal.mjs';
8
9
 
9
10
  export const GROK_CONTINUE_PREAMBLE = 'この発言は直前 Throughline 席の履歴を前提とする。';
10
11
  export const GROK_CONTINUE_REQUEST = '直前の作業の自然な続きとして応答すること。';
@@ -29,10 +30,6 @@ export function buildGrokContinuePrompt(context) {
29
30
  return `${GROK_CONTINUE_PREAMBLE}\n\n${context}\n\n${GROK_CONTINUE_REQUEST}\n\n${GROK_CONTINUE_WAIT}`;
30
31
  }
31
32
 
32
- export function shQuote(value) {
33
- return `'${String(value).replace(/'/g, `'\\''`)}'`;
34
- }
35
-
36
33
  export function resolveGrokBin({
37
34
  home = homedir(),
38
35
  env = process.env,
@@ -62,15 +59,8 @@ export function buildLaunchScript({ cwd, grokBin, sessionUuid, promptFile }) {
62
59
  ].join('\n');
63
60
  }
64
61
 
65
- export function appleScriptForLaunch(launchScriptPath) {
66
- return [
67
- 'tell application "Terminal"',
68
- ' activate',
69
- ` do script "exec " & quoted form of ${JSON.stringify(launchScriptPath)}`,
70
- 'end tell',
71
- '',
72
- ].join('\n');
73
- }
62
+ // macOS Terminal 起動の実体は os/macos-terminal.mjs (OS 境界)
63
+ export const appleScriptForLaunch = appleScriptForTerminalExec;
74
64
 
75
65
  export function buildContinuePlan({
76
66
  context,
@@ -113,13 +103,10 @@ export function writeLaunchArtifacts({
113
103
  return { promptFile, launchFile };
114
104
  }
115
105
 
116
- export function defaultSpawnLaunch({ launchFile, spawnImpl = spawn }) {
117
- const child = spawnImpl('osascript', ['-e', appleScriptForLaunch(launchFile)], {
118
- detached: true,
119
- stdio: 'ignore',
120
- });
121
- child.unref?.();
122
- return child;
106
+ export function defaultSpawnLaunch({ launchFile, spawnImpl }) {
107
+ return spawnImpl === undefined
108
+ ? runTerminalScriptDetached({ launchFile })
109
+ : runTerminalScriptDetached({ launchFile, spawnImpl });
123
110
  }
124
111
 
125
112
  export function run(argv = [], {
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  import { parseArgs, run } from './runtime-errors.mjs';
10
10
  import { defaultFactoryReporterConfigPath } from '../runtime-error-store.mjs';
11
- import { applyWindowsPrivateAcl } from '../windows-acl-test-helper.mjs';
11
+ import { applyWindowsPrivateAcl } from '../os/windows-acl-test-helper.mjs';
12
12
 
13
13
  test('runtime-errors CLI: strict command surface accepts no raw payload options', () => {
14
14
  assert.deepEqual(parseArgs(['snapshot', '--after-cursor', '2', '--limit', '3', '--json']), {
package/src/cli/trim.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { runCodexTrimExecution, runCodexTrimPreflight } from '../codex-app-server.mjs';
2
+ import { CODEX_HOST } from '../hosts/identity.mjs';
2
3
  import {
3
4
  buildCodexRolloutTrimSource,
4
5
  parseCodexRolloutFile,
@@ -122,7 +123,7 @@ export async function run(args) {
122
123
  const inflightMemo = parsed.memoStdin ? await readStdin() : null;
123
124
  const db = getDb();
124
125
  const trimSource =
125
- parsed.host === 'codex' && parsed.codexThreadId
126
+ parsed.host === CODEX_HOST && parsed.codexThreadId
126
127
  ? buildCodexRolloutTrimSource({
127
128
  threadId: parsed.codexThreadId,
128
129
  projectPath: process.cwd(),
@@ -376,7 +377,7 @@ async function runPreflight(parsed, plan) {
376
377
  }
377
378
 
378
379
  function validateCodexAction(parsed, plan, action) {
379
- if (parsed.host !== 'codex') {
380
+ if (parsed.host !== CODEX_HOST) {
380
381
  return {
381
382
  status: `${action}-refused`,
382
383
  reason: `${action}_requires_codex_host`,
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { homedir, platform } from 'node:os';
2
+ import { foldPathCaseForPlatform } from './os/paths.mjs';
3
+ import { homedir } from 'node:os';
3
4
  import { join, resolve } from 'node:path';
4
5
 
5
6
  import { runCodexTrimExecution } from './codex-app-server.mjs';
@@ -509,8 +510,7 @@ function normalizeProjectPath(value) {
509
510
  if (typeof value !== 'string' || value.length === 0) return '';
510
511
  let result = resolve(value).replace(/\\/g, '/');
511
512
  if (result.length > 1 && result.endsWith('/')) result = result.slice(0, -1);
512
- if (platform() === 'win32') result = result.toLowerCase();
513
- return result;
513
+ return foldPathCaseForPlatform(result);
514
514
  }
515
515
 
516
516
  function numberOrNull(value) {
@@ -1,23 +1,19 @@
1
1
  import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
2
2
  import { defaultCodexHome, findCodexThreadCandidate } from './codex-thread-index.mjs';
3
-
4
- export const CODEX_SESSION_PREFIX = 'codex:';
5
-
6
- export function buildCodexThroughlineSessionId(threadId) {
7
- if (typeof threadId !== 'string' || threadId.trim().length === 0) {
8
- throw new Error('threadId is required');
9
- }
10
- return `${CODEX_SESSION_PREFIX}${threadId.trim()}`;
11
- }
12
-
13
- export function isCodexThroughlineSessionId(sessionId) {
14
- return typeof sessionId === 'string' && sessionId.startsWith(CODEX_SESSION_PREFIX);
15
- }
16
-
17
- export function codexSessionIdToThreadId(sessionId) {
18
- if (!isCodexThroughlineSessionId(sessionId)) return null;
19
- return sessionId.slice(CODEX_SESSION_PREFIX.length) || null;
20
- }
3
+ import {
4
+ CODEX_SESSION_PREFIX,
5
+ buildCodexThroughlineSessionId,
6
+ codexSessionIdToThreadId,
7
+ isCodexSessionId,
8
+ } from './hosts/identity.mjs';
9
+
10
+ // session identity の正本は hosts/identity.mjs。既存 import 面のため再 export する。
11
+ export {
12
+ CODEX_SESSION_PREFIX,
13
+ buildCodexThroughlineSessionId,
14
+ codexSessionIdToThreadId,
15
+ };
16
+ export const isCodexThroughlineSessionId = isCodexSessionId;
21
17
 
22
18
  export function captureCodexRolloutToDb(
23
19
  db,
@@ -1,4 +1,4 @@
1
- import { spawnPortableSync } from './portable-spawn-sync.mjs';
1
+ import { spawnPortableSync } from './os/portable-spawn-sync.mjs';
2
2
 
3
3
  export const CODEX_HANDOFF_MODEL_SMOKE_ENV = 'THROUGHLINE_EXPERIMENTAL_CODEX_HANDOFF_MODEL_SMOKE';
4
4
  export const DEFAULT_CODEX_HANDOFF_MODEL_SMOKE_TIMEOUT_MS = 120_000;
@@ -1,4 +1,5 @@
1
- import { spawnPortableSync } from './portable-spawn-sync.mjs';
1
+ import { spawnPortableSync } from './os/portable-spawn-sync.mjs';
2
+ import { isWin32Platform } from './os/paths.mjs';
2
3
 
3
4
  export const CODEX_SIDECAR_WORKFLOWS = Object.freeze([
4
5
  'review',
@@ -23,7 +24,7 @@ function resolveCommand({ command, env }) {
23
24
  }
24
25
 
25
26
  export function shouldShellWrapSidecarCommand(platform = process.platform) {
26
- return platform === 'win32';
27
+ return isWin32Platform(platform);
27
28
  }
28
29
 
29
30
  export function runCodexSidecarCommand(command, args, options = {}) {
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
+ import { CLAUDE_HOST } from './hosts/identity.mjs';
2
3
  import {
3
4
  chmodSync,
4
5
  lstatSync,
@@ -8,12 +9,12 @@ import {
8
9
  rmSync,
9
10
  writeFileSync,
10
11
  } from 'node:fs';
11
- import childProcess from 'node:child_process';
12
- import { homedir, platform as hostPlatform } from 'node:os';
13
12
  import { dirname, join } from 'node:path';
14
13
  import { DatabaseSync } from 'node:sqlite';
15
14
  import { hashAuditorBody } from './body-digest.mjs';
16
15
  import { normalizeProjectPathForCompare } from './project-path.mjs';
16
+ import { applyAndVerifyWindowsAcl, isWindows, verifyWindowsAcl } from './os/windows-acl.mjs';
17
+ import { windowsLocalAppData, xdgStateHome } from './os/app-dirs.mjs';
17
18
 
18
19
  export const COMPLETED_TURN_RECEIPT_STORE_SCHEMA = 'throughline.completed_turn_receipts.v1';
19
20
  export const COMPLETED_TURN_RECEIPT_SCHEMA_VERSION = '1.0';
@@ -21,15 +22,13 @@ export const COMPLETED_TURN_RECEIPT_LIMIT = 256;
21
22
 
22
23
  const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.completed-turn-receipt-directory');
23
24
  // CI実測でPowerShellコールドスタートが3.0〜3.2秒に達しflakeしたため15秒 (run 29586852389 / 29628634501)
24
- const WINDOWS_ACL_TIMEOUT_MS = 15_000;
25
25
 
26
26
  export function defaultCompletedTurnReceiptStorePath(projectSha256, env = process.env) {
27
27
  if (!isSha256(projectSha256)) throw new TypeError('projectSha256 must be a SHA-256 digest');
28
- const home = env.HOME || env.USERPROFILE || homedir();
29
28
  if (isWindows(env)) {
30
- return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'throughline', 'completed-turn-receipts', `${projectSha256}.json`);
29
+ return join(windowsLocalAppData(env), 'throughline', 'completed-turn-receipts', `${projectSha256}.json`);
31
30
  }
32
- return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'throughline', 'completed-turn-receipts', `${projectSha256}.json`);
31
+ return join(xdgStateHome(env), 'throughline', 'completed-turn-receipts', `${projectSha256}.json`);
33
32
  }
34
33
 
35
34
  /**
@@ -274,7 +273,7 @@ function validateReceipt(receipt, nextSequence) {
274
273
  ];
275
274
  if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) ||
276
275
  !exactKeys(receipt, keys) || receipt.schema_version !== COMPLETED_TURN_RECEIPT_SCHEMA_VERSION ||
277
- receipt.host !== 'claude' || !isSha256(receipt.project_sha256) ||
276
+ receipt.host !== CLAUDE_HOST || !isSha256(receipt.project_sha256) ||
278
277
  !isIdentity(receipt.target_session_id) || !isIdentity(receipt.origin_session_id) ||
279
278
  !isSha256(receipt.user_sha256) || !isSha256(receipt.assistant_sha256) ||
280
279
  !Number.isSafeInteger(receipt.completed_at) || receipt.completed_at < 0 ||
@@ -351,30 +350,4 @@ function assertExactOptions(options) {
351
350
  throw new TypeError('completed turn receipt options are invalid');
352
351
  }
353
352
  }
354
- function isWindows(env = process.env) { return env.OS === 'Windows_NT' || hostPlatform() === 'win32'; }
355
-
356
- function applyAndVerifyWindowsAcl(path, directory) { runWindowsAclScript(path, directory, WINDOWS_ACL_APPLY_SCRIPT); }
357
- function verifyWindowsAcl(path, directory) { runWindowsAclScript(path, directory, WINDOWS_ACL_VERIFY_SCRIPT); }
358
- function runWindowsAclScript(path, directory, script) {
359
- const result = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
360
- env: { ...process.env, THROUGHLINE_ACL_PATH: path, THROUGHLINE_ACL_DIRECTORY: directory ? '1' : '0' },
361
- stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
362
- });
363
- if (result.status !== 0) throw new Error('Windows owner-only ACL verification failed');
364
- }
365
353
 
366
- const WINDOWS_ACL_VERIFY_SCRIPT = String.raw`
367
- $p=$env:THROUGHLINE_ACL_PATH; $isDir=$env:THROUGHLINE_ACL_DIRECTORY -eq '1'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
368
- $acl=if($isDir){[System.IO.Directory]::GetAccessControl($p)}else{[System.IO.File]::GetAccessControl($p)}
369
- $owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
370
- if($owner -ne $sid){exit 41}; $rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])); if($rules.Count -ne 1){exit 42}
371
- $r=$rules[0]; if($r.IdentityReference.Value -ne $sid -or $r.AccessControlType -ne 'Allow' -or $r.IsInherited -or ($r.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
372
- `;
373
-
374
- const WINDOWS_ACL_APPLY_SCRIPT = String.raw`
375
- $p=$env:THROUGHLINE_ACL_PATH; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
376
- $isDir=$env:THROUGHLINE_ACL_DIRECTORY -eq '1'; $acl=if($isDir){New-Object System.Security.AccessControl.DirectorySecurity}else{New-Object System.Security.AccessControl.FileSecurity}; $acl.SetAccessRuleProtection($true,$false)
377
- $flags=if($isDir){[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'}else{[System.Security.AccessControl.InheritanceFlags]::None}
378
- $rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$flags,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)
379
- $acl.SetOwner($sid); $acl.AddAccessRule($rule); if($isDir){[System.IO.Directory]::SetAccessControl($p,$acl)}else{[System.IO.File]::SetAccessControl($p,$acl)}
380
- ` + WINDOWS_ACL_VERIFY_SCRIPT;
@@ -12,7 +12,7 @@ import {
12
12
  writeCompletedTurnReceipt,
13
13
  } from './completed-turn-receipts.mjs';
14
14
  import { seedCompletedTurnReceiptStore } from './completed-turn-receipts-test-fixture.mjs';
15
- import { verifyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
15
+ import { verifyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
16
16
 
17
17
  function sandbox() {
18
18
  const root = mkdtempSync(join(tmpdir(), 'throughline-completed-receipts-'));
@@ -50,7 +50,7 @@ import {
50
50
  CODEX_SIDECAR_STATUS,
51
51
  runCodexSidecarCommand,
52
52
  } from './codex-sidecar.mjs';
53
- import { spawnPortableSync } from './portable-spawn-sync.mjs';
53
+ import { spawnPortableSync } from './os/portable-spawn-sync.mjs';
54
54
 
55
55
  const MODEL = 'claude-haiku-4-5-20251001';
56
56
  // Codex CLI 要約の既定。gpt-5.6-luna@low@1/5 は 2026-07-17 の実測評価で選定
@@ -6,9 +6,11 @@
6
6
  * persisted to DB; adapters can render it for Claude, Codex, or diagnostics.
7
7
  */
8
8
 
9
+ import { CODEX_SESSION_PREFIX } from './hosts/identity.mjs';
10
+
9
11
  export const HANDOFF_RECORD_VERSION = 1;
10
12
  export const N_RECENT_L2 = 20;
11
- export const CODEX_SESSION_PREFIX = 'codex:';
13
+ export { CODEX_SESSION_PREFIX };
12
14
 
13
15
  const DEFAULT_INTENT = 'continue implementation';
14
16
  const DEFAULT_CONSTRAINTS = [
@@ -0,0 +1,27 @@
1
+ /**
2
+ * hosts/claude.mjs — Claude host adapter
3
+ *
4
+ * Claude は Throughline の基準 host。hook payload は snake_case のまま届き、
5
+ * 引き継ぎ注入は UserPromptSubmit hook の stdout でモデルへ渡る。
6
+ */
7
+ import { CLAUDE_HOST, hostOfSessionId } from './identity.mjs';
8
+
9
+ export const claudeHostAdapter = Object.freeze({
10
+ host: CLAUDE_HOST,
11
+ matchesSessionId: (sessionId) => hostOfSessionId(sessionId) === CLAUDE_HOST,
12
+ // Claude Stop payload の last_assistant_message を transcript 可視化の
13
+ // barrier に使う (ADR 0012)。
14
+ waitsForStopTranscriptFlush: true,
15
+ // Claude は UserPromptSubmit stdout がそのままモデル可視 context になる。
16
+ deliverHandoffInjection({ text, stdout = process.stdout }) {
17
+ stdout.write(text + '\n');
18
+ return { delivered: true };
19
+ },
20
+ // Claude の prompt は裸の slash command がそのまま届く。
21
+ resolveCommandPrompt({ prompt }) {
22
+ return prompt;
23
+ },
24
+ afterBatonWrite() {
25
+ return { launched: false };
26
+ },
27
+ });