throughline 0.3.23 → 0.3.25

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 (111) hide show
  1. package/.claude/commands/tl-trim.md +42 -0
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +583 -0
  4. package/README.ja.md +42 -5
  5. package/README.md +400 -23
  6. package/bin/throughline.mjs +168 -4
  7. package/codex/skills/throughline/SKILL.md +157 -0
  8. package/codex/skills/throughline/agents/openai.yaml +7 -0
  9. package/docs/INHERITANCE_ON_CLEAR_ONLY.md +146 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +184 -0
  12. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  13. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  14. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  15. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  17. package/docs/archive/CONCEPT.md +476 -0
  18. package/docs/archive/EXPERIMENT.md +371 -0
  19. package/docs/archive/README.md +22 -0
  20. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  21. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  22. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  23. package/docs/throughline-handoff-context.example.json +57 -0
  24. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  25. package/package.json +6 -2
  26. package/src/cli/codex-capture.mjs +95 -0
  27. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  28. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  29. package/src/cli/codex-handoff-smoke.mjs +163 -0
  30. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  31. package/src/cli/codex-handoff-start.mjs +291 -0
  32. package/src/cli/codex-handoff-start.test.mjs +194 -0
  33. package/src/cli/codex-hook.mjs +276 -0
  34. package/src/cli/codex-hook.test.mjs +293 -0
  35. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  36. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  37. package/src/cli/codex-restore-smoke.mjs +357 -0
  38. package/src/cli/codex-restore-source-audit.mjs +304 -0
  39. package/src/cli/codex-resume.mjs +138 -0
  40. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  41. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  42. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  43. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  44. package/src/cli/codex-summarize.mjs +224 -0
  45. package/src/cli/codex-threads.mjs +89 -0
  46. package/src/cli/codex-visibility-smoke.mjs +196 -0
  47. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  48. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  49. package/src/cli/doctor.mjs +503 -1
  50. package/src/cli/doctor.test.mjs +542 -3
  51. package/src/cli/handoff-preview.mjs +78 -0
  52. package/src/cli/help.test.mjs +64 -0
  53. package/src/cli/install.mjs +227 -4
  54. package/src/cli/install.test.mjs +207 -4
  55. package/src/cli/trim.mjs +564 -0
  56. package/src/codex-app-server.mjs +1816 -0
  57. package/src/codex-app-server.test.mjs +512 -0
  58. package/src/codex-auto-refresh.mjs +194 -0
  59. package/src/codex-auto-refresh.test.mjs +182 -0
  60. package/src/codex-capture.mjs +235 -0
  61. package/src/codex-capture.test.mjs +393 -0
  62. package/src/codex-handoff-model-smoke.mjs +114 -0
  63. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  64. package/src/codex-handoff-smoke.mjs +124 -0
  65. package/src/codex-handoff-smoke.test.mjs +103 -0
  66. package/src/codex-handoff.mjs +331 -0
  67. package/src/codex-handoff.test.mjs +220 -0
  68. package/src/codex-host-primitive-audit.mjs +374 -0
  69. package/src/codex-host-primitive-audit.test.mjs +208 -0
  70. package/src/codex-restore-smoke.test.mjs +639 -0
  71. package/src/codex-restore-source-audit.mjs +1348 -0
  72. package/src/codex-restore-source-audit.test.mjs +623 -0
  73. package/src/codex-resume.test.mjs +242 -0
  74. package/src/codex-rollout-memory.mjs +711 -0
  75. package/src/codex-rollout-memory.test.mjs +610 -0
  76. package/src/codex-sidecar-cli.test.mjs +75 -0
  77. package/src/codex-sidecar.mjs +246 -0
  78. package/src/codex-sidecar.test.mjs +172 -0
  79. package/src/codex-summarize.test.mjs +143 -0
  80. package/src/codex-thread-identity.mjs +23 -0
  81. package/src/codex-thread-index.mjs +173 -0
  82. package/src/codex-thread-index.test.mjs +164 -0
  83. package/src/codex-usage.mjs +110 -0
  84. package/src/codex-usage.test.mjs +140 -0
  85. package/src/codex-visibility-smoke.test.mjs +222 -0
  86. package/src/codex-vscode-restore-smoke.mjs +206 -0
  87. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  88. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  89. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  90. package/src/db-schema.test.mjs +97 -0
  91. package/src/haiku-summarizer.mjs +267 -26
  92. package/src/haiku-summarizer.test.mjs +282 -0
  93. package/src/handoff-preview.test.mjs +108 -0
  94. package/src/handoff-record.mjs +294 -0
  95. package/src/handoff-record.test.mjs +226 -0
  96. package/src/hook-entrypoints.test.mjs +326 -0
  97. package/src/package-files.test.mjs +19 -0
  98. package/src/prompt-submit.mjs +9 -6
  99. package/src/resume-context.mjs +44 -140
  100. package/src/resume-context.test.mjs +172 -0
  101. package/src/session-start.mjs +8 -5
  102. package/src/state-file.mjs +50 -6
  103. package/src/state-file.test.mjs +50 -0
  104. package/src/token-monitor.mjs +14 -10
  105. package/src/token-monitor.test.mjs +27 -0
  106. package/src/trim-cli.test.mjs +1584 -0
  107. package/src/trim-model.mjs +584 -0
  108. package/src/trim-model.test.mjs +568 -0
  109. package/src/turn-processor.mjs +17 -10
  110. package/src/vscode-task.mjs +94 -6
  111. package/src/vscode-task.test.mjs +186 -6
@@ -13,10 +13,12 @@
13
13
  * - 各行頭に [HH:MM:SS] 時刻プレフィックス(created_at ベース、DB 永続)
14
14
  * - 末尾に /sc-detail <時刻> ガイドを追記
15
15
  * - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
16
- * - フレーミングを「過去の記憶」から「中断した作業の再開」に変更 (B 案)
16
+ * - フレーミングを「過去の記憶」から「中断した作業の再開」に変更する。
17
+ * 冒頭と末尾の両方に current-work instruction を置き、長文 context 内でも
18
+ * L1/L2 を現在タスク用の作業文脈として読むよう誘導する。
17
19
  */
18
20
 
19
- const N_RECENT_L2 = 20;
21
+ import { buildHandoffRecord, N_RECENT_L2 } from './handoff-record.mjs';
20
22
 
21
23
  const RESUME_HEADER_TEMPLATE = (turnCount) =>
22
24
  `## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
@@ -30,6 +32,19 @@ const RESUME_HEADER_TEMPLATE = (turnCount) =>
30
32
  `応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
31
33
  `作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
32
34
 
35
+ const ACTIVE_WORK_READING_CONTRACT =
36
+ `\n` +
37
+ `**読み方の契約:**\n` +
38
+ `- これは単なる過去ログではなく、現在進行中の作業を再開するための active work context です。\n` +
39
+ `- L2 は古い順に並んだ作業履歴です。後の発言・判断・TODO は前の仮説や作業方針を上書きし得ます。\n` +
40
+ `- すべての L2 行を現在も正しい事実として扱わず、最新の L2、in-flight メモ、最終ターン thinking を優先して現在状態を推定してください。\n` +
41
+ `- 不足している tool output / 詳細根拠が必要なときだけ、末尾の \`throughline detail <時刻>\` を使って L3 を取得してください。`;
42
+
43
+ const CONTINUATION_REMINDER =
44
+ '**再開指示:** 上記の L1 / L2 / thinking / in-flight メモを、現在タスクに使う作業コンテキストとして扱ってください。' +
45
+ '最新の L2、in-flight メモ、最終ターン thinking から次の一手を決め、中断地点から続行してください。' +
46
+ '古い仮説や未完了 TODO は、後続の判断で上書きされ得ます。';
47
+
33
48
  const NORMAL_HEADER = '## Throughline: セッション記憶';
34
49
 
35
50
  const FOOTER_GUIDE =
@@ -43,67 +58,6 @@ const FOOTER_GUIDE =
43
58
  '返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system / thinking 別にグループ化)。\n' +
44
59
  'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
45
60
 
46
- /**
47
- * Unix ms を HH:MM:SS 形式に変換する。
48
- */
49
- function formatTime(unixMs) {
50
- const d = new Date(unixMs);
51
- const hh = String(d.getHours()).padStart(2, '0');
52
- const mm = String(d.getMinutes()).padStart(2, '0');
53
- const ss = String(d.getSeconds()).padStart(2, '0');
54
- return `${hh}:${mm}:${ss}`;
55
- }
56
-
57
- /**
58
- * 最新ターン番号 (= 中断直前) の thinking ブロックを details から取り出す。
59
- * origin 除外がある場合はそれも考慮する。
60
- *
61
- * @param {import('node:sqlite').DatabaseSync} db
62
- * @param {string} sessionId
63
- * @param {string | null} excludeOriginId
64
- * @returns {Array<{ output_text: string, created_at: number }>}
65
- */
66
- function loadLatestThinking(db, sessionId, excludeOriginId) {
67
- const hasExclude = Boolean(excludeOriginId);
68
-
69
- // 最新 (origin_session_id, turn_number) を bodies から特定
70
- const latestQuery = hasExclude
71
- ? `SELECT origin_session_id, turn_number, created_at
72
- FROM bodies
73
- WHERE session_id = ? AND origin_session_id != ? AND role = 'assistant'
74
- ORDER BY created_at DESC
75
- LIMIT 1`
76
- : `SELECT origin_session_id, turn_number, created_at
77
- FROM bodies
78
- WHERE session_id = ? AND role = 'assistant'
79
- ORDER BY created_at DESC
80
- LIMIT 1`;
81
-
82
- let latest;
83
- try {
84
- latest = hasExclude
85
- ? db.prepare(latestQuery).get(sessionId, excludeOriginId)
86
- : db.prepare(latestQuery).get(sessionId);
87
- } catch {
88
- return [];
89
- }
90
- if (!latest) return [];
91
-
92
- // その (origin_session_id, turn_number) に紐づく kind='thinking' を取り出す
93
- try {
94
- const rows = db
95
- .prepare(
96
- `SELECT output_text, created_at FROM details
97
- WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND kind = 'thinking'
98
- ORDER BY created_at ASC`,
99
- )
100
- .all(sessionId, latest.origin_session_id, latest.turn_number);
101
- return rows.filter((r) => typeof r.output_text === 'string' && r.output_text.length > 0);
102
- } catch {
103
- return [];
104
- }
105
- }
106
-
107
61
  /**
108
62
  * L1+L2 注入テキストを組み立てる。
109
63
  *
@@ -120,107 +74,57 @@ export function buildResumeContext(
120
74
  db,
121
75
  { sessionId, isInheritance, excludeOriginId = null, inflightMemo = null },
122
76
  ) {
123
- if (!sessionId) return null;
124
-
125
- const hasExclude = Boolean(excludeOriginId);
126
-
127
- const bodiesQuery = hasExclude
128
- ? `SELECT origin_session_id, turn_number, role, text, created_at
129
- FROM bodies
130
- WHERE session_id = ? AND origin_session_id != ?
131
- ORDER BY created_at DESC
132
- LIMIT ?`
133
- : `SELECT origin_session_id, turn_number, role, text, created_at
134
- FROM bodies
135
- WHERE session_id = ?
136
- ORDER BY created_at DESC
137
- LIMIT ?`;
138
-
139
- const limitRows = N_RECENT_L2 * 2; // user/assistant の 2 ロール分
140
-
141
- let bodyRowsDesc = [];
142
- try {
143
- bodyRowsDesc = hasExclude
144
- ? db.prepare(bodiesQuery).all(sessionId, excludeOriginId, limitRows)
145
- : db.prepare(bodiesQuery).all(sessionId, limitRows);
146
- } catch {
147
- // bodies テーブル未作成(v3 DB)の場合は空
148
- bodyRowsDesc = [];
149
- }
150
- const bodyRows = bodyRowsDesc.reverse(); // ASC に戻す
151
-
152
- // 古い側の L1(bodies に既に含まれるターンを除いたもの)
153
- const bodySet = new Set(
154
- bodyRows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`),
155
- );
156
-
157
- const skelQuery = hasExclude
158
- ? `SELECT origin_session_id, turn_number, role, summary, created_at
159
- FROM skeletons
160
- WHERE session_id = ? AND origin_session_id != ?
161
- ORDER BY created_at ASC`
162
- : `SELECT origin_session_id, turn_number, role, summary, created_at
163
- FROM skeletons
164
- WHERE session_id = ?
165
- ORDER BY created_at ASC`;
166
-
167
- const allSkel = hasExclude
168
- ? db.prepare(skelQuery).all(sessionId, excludeOriginId)
169
- : db.prepare(skelQuery).all(sessionId);
170
-
171
- const l1Rows = allSkel.filter(
172
- (s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`),
173
- );
174
-
175
- const thinkingRows = loadLatestThinking(db, sessionId, excludeOriginId);
176
-
177
- if (
178
- bodyRows.length === 0 &&
179
- l1Rows.length === 0 &&
180
- thinkingRows.length === 0 &&
181
- !inflightMemo
182
- ) {
183
- return null;
184
- }
185
-
186
- const turnCount = bodyRows.length + l1Rows.length;
187
- const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
77
+ const record = buildHandoffRecord(db, {
78
+ sessionId,
79
+ isInheritance,
80
+ excludeOriginId,
81
+ inflightMemo,
82
+ });
83
+ if (!record) return null;
84
+
85
+ const turnCount = record.stats.preservedContextRows;
86
+ const header = isInheritance
87
+ ? RESUME_HEADER_TEMPLATE(turnCount) + ACTIVE_WORK_READING_CONTRACT
88
+ : NORMAL_HEADER;
188
89
  const lines = [header];
189
90
 
190
- if (inflightMemo && inflightMemo.trim().length > 0) {
91
+ if (record.memory.inflightMemo) {
191
92
  lines.push('');
192
93
  lines.push('### 中断直前の in-flight メモ(前セッションの Claude 自身による要約)');
193
- lines.push(inflightMemo.trim());
94
+ lines.push(record.memory.inflightMemo);
194
95
  }
195
96
 
196
- if (thinkingRows.length > 0) {
97
+ if (record.memory.latestThinking.length > 0) {
197
98
  lines.push('');
198
99
  lines.push('### 中断直前の思考 (最終ターンの extended thinking)');
199
- for (const r of thinkingRows) {
200
- lines.push(`[${formatTime(r.created_at)}] ${r.output_text}`);
100
+ for (const r of record.memory.latestThinking) {
101
+ lines.push(`[${r.time}] ${r.text}`);
201
102
  }
202
103
  }
203
104
 
204
- if (l1Rows.length > 0) {
105
+ if (record.memory.l1Summaries.length > 0) {
205
106
  lines.push('');
206
107
  lines.push('### それ以前の要約 (L1)');
207
- for (const r of l1Rows) {
108
+ for (const r of record.memory.l1Summaries) {
208
109
  if (!r.summary || r.summary === '(no content)') continue;
209
- lines.push(`[${formatTime(r.created_at)}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
110
+ lines.push(`[${r.time}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
210
111
  }
211
112
  }
212
113
 
213
- if (bodyRows.length > 0) {
114
+ if (record.memory.recentBodies.length > 0) {
214
115
  lines.push('');
215
- lines.push('### 直近のターン履歴 (L2)');
216
- for (const r of bodyRows) {
116
+ lines.push('### 現在進行中の作業履歴 (L2 / active work thread)');
117
+ lines.push('以下は古い順です。後の行ほど現在状態に近く、前の仮説を上書きし得ます。');
118
+ for (const r of record.memory.recentBodies) {
217
119
  if (!r.text) continue;
218
- lines.push(`[${formatTime(r.created_at)}] [${r.role}]: ${r.text}`);
120
+ lines.push(`[${r.time}] [${r.role}]: ${r.text}`);
219
121
  }
220
122
  }
221
123
 
222
124
  lines.push('');
223
125
  lines.push(FOOTER_GUIDE);
126
+ lines.push('');
127
+ lines.push(CONTINUATION_REMINDER);
224
128
 
225
129
  return lines.join('\n');
226
130
  }
@@ -0,0 +1,172 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { buildResumeContext } from './resume-context.mjs';
5
+
6
+ function makeDb() {
7
+ const db = new DatabaseSync(':memory:');
8
+ db.exec(`
9
+ CREATE TABLE skeletons (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ session_id TEXT NOT NULL,
12
+ origin_session_id TEXT,
13
+ turn_number INTEGER NOT NULL,
14
+ role TEXT NOT NULL,
15
+ summary TEXT NOT NULL,
16
+ created_at INTEGER NOT NULL
17
+ );
18
+ CREATE TABLE bodies (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ session_id TEXT NOT NULL,
21
+ origin_session_id TEXT NOT NULL,
22
+ turn_number INTEGER NOT NULL,
23
+ role TEXT NOT NULL,
24
+ text TEXT NOT NULL,
25
+ token_count INTEGER,
26
+ created_at INTEGER NOT NULL
27
+ );
28
+ CREATE TABLE details (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ session_id TEXT NOT NULL,
31
+ origin_session_id TEXT,
32
+ turn_number INTEGER,
33
+ tool_name TEXT NOT NULL,
34
+ input_text TEXT,
35
+ output_text TEXT,
36
+ token_count INTEGER NOT NULL DEFAULT 0,
37
+ created_at INTEGER NOT NULL,
38
+ kind TEXT,
39
+ source_id TEXT
40
+ );
41
+ `);
42
+ return db;
43
+ }
44
+
45
+ function insertSkeleton(db, row) {
46
+ db.prepare(
47
+ `INSERT INTO skeletons (session_id, origin_session_id, turn_number, role, summary, created_at)
48
+ VALUES (?, ?, ?, ?, ?, ?)`,
49
+ ).run(row.session, row.origin, row.turn, row.role, row.summary, row.createdAt);
50
+ }
51
+
52
+ function insertBody(db, row) {
53
+ db.prepare(
54
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
55
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
56
+ ).run(row.session, row.origin, row.turn, row.role, row.text, 1, row.createdAt);
57
+ }
58
+
59
+ function insertThinking(db, row) {
60
+ db.prepare(
61
+ `INSERT INTO details
62
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
63
+ token_count, created_at, kind, source_id)
64
+ VALUES (?, ?, ?, 'thinking', NULL, ?, 1, ?, 'thinking', ?)`,
65
+ ).run(row.session, row.origin, row.turn, row.text, row.createdAt, row.sourceId);
66
+ }
67
+
68
+ test('buildResumeContext: inheritance output order is memo -> thinking -> L1 -> L2 -> footer', () => {
69
+ const db = makeDb();
70
+
71
+ insertSkeleton(db, {
72
+ session: 'new',
73
+ origin: 'old',
74
+ turn: 1,
75
+ role: 'assistant',
76
+ summary: 'older L1 summary',
77
+ createdAt: 1000,
78
+ });
79
+ insertBody(db, {
80
+ session: 'new',
81
+ origin: 'old',
82
+ turn: 2,
83
+ role: 'user',
84
+ text: 'recent user body',
85
+ createdAt: 2000,
86
+ });
87
+ insertBody(db, {
88
+ session: 'new',
89
+ origin: 'old',
90
+ turn: 2,
91
+ role: 'assistant',
92
+ text: 'recent assistant body',
93
+ createdAt: 2100,
94
+ });
95
+ insertThinking(db, {
96
+ session: 'new',
97
+ origin: 'old',
98
+ turn: 2,
99
+ text: 'latest thinking block',
100
+ createdAt: 2200,
101
+ sourceId: 'asst:thinking:0',
102
+ });
103
+
104
+ const text = buildResumeContext(db, {
105
+ sessionId: 'new',
106
+ isInheritance: true,
107
+ inflightMemo: '**Next**: keep going',
108
+ });
109
+
110
+ assert.ok(text);
111
+ assert.match(text, /^## Throughline: 中断した作業の再開/);
112
+
113
+ const memoIdx = text.indexOf('### 中断直前の in-flight メモ');
114
+ const thinkingIdx = text.indexOf('### 中断直前の思考');
115
+ const l1Idx = text.indexOf('### それ以前の要約 (L1)');
116
+ const l2Idx = text.indexOf('### 現在進行中の作業履歴 (L2 / active work thread)');
117
+ const footerIdx = text.indexOf('**[Claude 向け — 記憶の使い方]**');
118
+
119
+ assert.ok(memoIdx > 0, 'in-flight memo section should be present');
120
+ assert.ok(thinkingIdx > memoIdx, 'thinking should follow in-flight memo');
121
+ assert.ok(l1Idx > thinkingIdx, 'L1 should follow thinking');
122
+ assert.ok(l2Idx > l1Idx, 'L2 should follow L1');
123
+ assert.ok(footerIdx > l2Idx, 'footer should follow L2');
124
+
125
+ assert.ok(text.includes('**Next**: keep going'));
126
+ assert.ok(text.includes('latest thinking block'));
127
+ assert.ok(text.includes('older L1 summary'));
128
+ assert.ok(text.includes('[user]: recent user body'));
129
+ assert.ok(text.includes('[assistant]: recent assistant body'));
130
+ assert.match(text, /単なる過去ログではなく、現在進行中の作業/);
131
+ assert.match(text, /後の行ほど現在状態に近く/);
132
+ assert.match(text, /上記の L1 \/ L2 \/ thinking \/ in-flight メモを、現在タスクに使う作業コンテキスト/);
133
+ });
134
+
135
+ test('buildResumeContext: returns null when no memory rows or inflight memo exist', () => {
136
+ const db = makeDb();
137
+ assert.equal(
138
+ buildResumeContext(db, { sessionId: 'empty', isInheritance: true }),
139
+ null,
140
+ );
141
+ });
142
+
143
+ test('buildResumeContext: excludeOriginId omits rows from the current origin', () => {
144
+ const db = makeDb();
145
+
146
+ insertBody(db, {
147
+ session: 'new',
148
+ origin: 'old',
149
+ turn: 1,
150
+ role: 'assistant',
151
+ text: 'old origin body',
152
+ createdAt: 1000,
153
+ });
154
+ insertBody(db, {
155
+ session: 'new',
156
+ origin: 'new',
157
+ turn: 1,
158
+ role: 'assistant',
159
+ text: 'current origin body',
160
+ createdAt: 2000,
161
+ });
162
+
163
+ const text = buildResumeContext(db, {
164
+ sessionId: 'new',
165
+ isInheritance: false,
166
+ excludeOriginId: 'new',
167
+ });
168
+
169
+ assert.ok(text);
170
+ assert.ok(text.includes('old origin body'));
171
+ assert.ok(!text.includes('current origin body'));
172
+ });
@@ -25,6 +25,7 @@ import { ensureMonitorTaskFile } from './vscode-task.mjs';
25
25
  import { appendFileSync, mkdirSync } from 'node:fs';
26
26
  import { join, dirname } from 'node:path';
27
27
  import { homedir } from 'node:os';
28
+ import { pathToFileURL } from 'node:url';
28
29
 
29
30
  function logDecision(entry) {
30
31
  const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
@@ -37,7 +38,7 @@ function logDecision(entry) {
37
38
  }
38
39
  }
39
40
 
40
- async function main() {
41
+ export async function run() {
41
42
  let raw = '';
42
43
  await new Promise((resolve) => {
43
44
  process.stdin.setEncoding('utf8');
@@ -118,7 +119,9 @@ async function main() {
118
119
  process.exit(0);
119
120
  }
120
121
 
121
- main().catch((err) => {
122
- process.stderr.write(`[session-start] error: ${err.message}\n`);
123
- process.exit(1);
124
- });
122
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
123
+ run().catch((err) => {
124
+ process.stderr.write(`[session-start] error: ${err.message}\n`);
125
+ process.exit(1);
126
+ });
127
+ }
@@ -2,12 +2,12 @@
2
2
  * state-file.mjs — セッション単位の状態ファイル管理(共有モジュール)
3
3
  *
4
4
  * パス: ~/.throughline/state/<session_id>.json
5
- * 書き手: turn-processor (Stop)
5
+ * 書き手: turn-processor (Claude Stop), codex-hook (Codex Stop)
6
6
  * 読み手: token-monitor
7
7
  *
8
8
  * 設計判断 (docs/PUBLIC_RELEASE_PLAN.md §4.5/4.6):
9
9
  * - ファイル単位分割で last-writer-wins 問題を解消
10
- * - PID 生存チェックで stale 削除(時間窓は使わない)
10
+ * - updatedAt ベースで stale 判定(短命 hook process の PID には依存しない)
11
11
  * - projectPath は path.resolve → / → 末尾 / 除去 → Windows lowercase で正規化
12
12
  */
13
13
 
@@ -37,21 +37,43 @@ export function normalizeProjectPath(p) {
37
37
 
38
38
  /**
39
39
  * セッション状態ファイルを書く
40
- * @param {{sessionId: string, projectPath: string, transcriptPath: string, pid: number, usage?: object|null}} data
40
+ * @param {{
41
+ * sessionId: string,
42
+ * projectPath: string,
43
+ * transcriptPath?: string|null,
44
+ * rolloutPath?: string|null,
45
+ * pid?: number,
46
+ * usage?: object|null,
47
+ * host?: 'claude'|'codex',
48
+ * }} data
41
49
  *
42
50
  * usage: monitor が表示する tokens/model/contextWindowSize をここに固定保存する。
43
51
  * Stop hook が readLatestUsage の結果を載せることで、monitor 側が毎フレーム JSONL を
44
52
  * 再スキャンする必要がなくなる。旧バージョン互換のため optional (無ければ monitor が
45
53
  * transcriptPath を読んでフォールバック)。
46
54
  */
47
- export function writeSessionState({ sessionId, projectPath, transcriptPath, pid, usage }) {
55
+ export function writeSessionState({
56
+ sessionId,
57
+ projectPath,
58
+ transcriptPath,
59
+ rolloutPath,
60
+ pid,
61
+ usage,
62
+ host,
63
+ }) {
48
64
  if (!sessionId) throw new Error('writeSessionState: sessionId is required');
65
+ const normalizedHost = normalizeHost(host);
66
+ if (host && normalizedHost === 'unknown') {
67
+ throw new Error(`writeSessionState: unsupported host ${host}`);
68
+ }
49
69
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
50
- const file = join(STATE_DIR, `${sessionId}.json`);
70
+ const file = join(STATE_DIR, stateFilename(sessionId));
51
71
  const payload = {
52
72
  sessionId,
73
+ host: normalizedHost === 'unknown' ? 'claude' : normalizedHost,
53
74
  projectPath: normalizeProjectPath(projectPath),
54
75
  transcriptPath: transcriptPath ?? null,
76
+ rolloutPath: rolloutPath ?? null,
55
77
  pid: pid ?? process.pid,
56
78
  updatedAt: Date.now(),
57
79
  };
@@ -69,7 +91,7 @@ export const STALE_DELETE_MS = 24 * 60 * 60 * 1000; // 24 時間: ファイル
69
91
  /**
70
92
  * 全セッション状態を読む。24 時間超のファイルは削除、壊れたファイルも削除する。
71
93
  * 15 分超のファイルは「stale」フラグを付けて返す(monitor 側で隠す判断をする)。
72
- * @returns {Array<{sessionId: string, projectPath: string, transcriptPath: string|null, updatedAt: number, stale: boolean}>}
94
+ * @returns {Array<{sessionId: string, host: string, projectPath: string, transcriptPath: string|null, rolloutPath: string|null, updatedAt: number, stale: boolean}>}
73
95
  */
74
96
  export function readAllSessionStates() {
75
97
  if (!existsSync(STATE_DIR)) return [];
@@ -112,6 +134,7 @@ export function readAllSessionStates() {
112
134
  }
113
135
  continue;
114
136
  }
137
+ parsed = normalizeState(parsed);
115
138
  const age = now - (parsed.updatedAt ?? 0);
116
139
  if (age > STALE_DELETE_MS) {
117
140
  // 24h 超: ハード削除(無制限蓄積防止)
@@ -128,6 +151,27 @@ export function readAllSessionStates() {
128
151
  return results;
129
152
  }
130
153
 
154
+ function stateFilename(sessionId) {
155
+ return `${encodeURIComponent(sessionId)}.json`;
156
+ }
157
+
158
+ function normalizeHost(host) {
159
+ if (host === undefined || host === null || host === '') return 'claude';
160
+ if (host === 'claude' || host === 'codex') return host;
161
+ return 'unknown';
162
+ }
163
+
164
+ function normalizeState(parsed) {
165
+ const host = normalizeHost(parsed?.host);
166
+ return {
167
+ ...parsed,
168
+ host,
169
+ projectPath: normalizeProjectPath(parsed?.projectPath ?? ''),
170
+ transcriptPath: parsed?.transcriptPath ?? null,
171
+ rolloutPath: parsed?.rolloutPath ?? null,
172
+ };
173
+ }
174
+
131
175
  /**
132
176
  * ファイル単位の mtime スナップショットを取る(差分検知用)
133
177
  * @returns {Map<string, number>}
@@ -146,11 +146,58 @@ test('writeSessionState: usage 付きで書くと JSON に含まれる', async (
146
146
  const results = mod.readAllSessionStates();
147
147
  assert.equal(results.length, 1);
148
148
  assert.ok(results[0].usage);
149
+ assert.equal(results[0].host, 'claude');
150
+ assert.equal(results[0].rolloutPath, null);
149
151
  assert.equal(results[0].usage.tokens, 123);
150
152
  assert.equal(results[0].usage.model, 'claude-opus-4-6');
151
153
  });
152
154
  });
153
155
 
156
+ test('writeSessionState: Codex state は host と rolloutPath を保持しファイル名を encode する', async () => {
157
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
158
+ mod.writeSessionState({
159
+ sessionId: 'codex:019dfaba-thread',
160
+ host: 'codex',
161
+ projectPath: '/tmp/x',
162
+ transcriptPath: null,
163
+ rolloutPath: '/tmp/codex/rollout.jsonl',
164
+ pid: 1,
165
+ usage: {
166
+ tokens: 123,
167
+ model: 'codex',
168
+ contextWindowSize: 258400,
169
+ contextWindowEstimated: false,
170
+ outputTokens: 10,
171
+ estimated: false,
172
+ source: 'codex-rollout-token-count',
173
+ },
174
+ });
175
+
176
+ assert.deepEqual(readdirSync(stateDir), ['codex%3A019dfaba-thread.json']);
177
+ const results = mod.readAllSessionStates();
178
+ assert.equal(results.length, 1);
179
+ assert.equal(results[0].sessionId, 'codex:019dfaba-thread');
180
+ assert.equal(results[0].host, 'codex');
181
+ assert.equal(results[0].transcriptPath, null);
182
+ assert.equal(results[0].rolloutPath, '/tmp/codex/rollout.jsonl');
183
+ assert.equal(results[0].usage.source, 'codex-rollout-token-count');
184
+ });
185
+ });
186
+
187
+ test('writeSessionState: unsupported host は throw する', async () => {
188
+ await withIsolatedStateDir(async ({ mod }) => {
189
+ assert.throws(
190
+ () =>
191
+ mod.writeSessionState({
192
+ sessionId: 'sess-bad-host',
193
+ host: 'unknown-host',
194
+ projectPath: '/tmp/x',
195
+ }),
196
+ /unsupported host/,
197
+ );
198
+ });
199
+ });
200
+
154
201
  test('writeSessionState: usage 無しで書いたらフィールド自体が無い (旧フォーマット互換)', async () => {
155
202
  await withIsolatedStateDir(async ({ stateDir, mod }) => {
156
203
  mod.writeSessionState({
@@ -162,6 +209,7 @@ test('writeSessionState: usage 無しで書いたらフィールド自体が無
162
209
  const results = mod.readAllSessionStates();
163
210
  assert.equal(results.length, 1);
164
211
  assert.equal(results[0].usage, undefined);
212
+ assert.equal(results[0].host, 'claude');
165
213
  });
166
214
  });
167
215
 
@@ -178,6 +226,8 @@ test('readAllSessionStates: 旧バージョンが書いた usage 無しの state
178
226
  }));
179
227
  const results = mod.readAllSessionStates();
180
228
  assert.equal(results.length, 1);
229
+ assert.equal(results[0].host, 'claude');
230
+ assert.equal(results[0].rolloutPath, null);
181
231
  assert.equal(results[0].usage, undefined);
182
232
  // usage 無しで読めること自体が互換性の証明
183
233
  });