throughline 0.3.24 → 0.4.0

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 (116) hide show
  1. package/.claude/commands/tl.md +6 -21
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +632 -0
  4. package/README.ja.md +71 -46
  5. package/README.md +420 -76
  6. package/bin/throughline.mjs +169 -7
  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 +159 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
  12. package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
  13. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  14. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  15. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  17. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  18. package/docs/archive/CONCEPT.md +476 -0
  19. package/docs/archive/EXPERIMENT.md +371 -0
  20. package/docs/archive/README.md +22 -0
  21. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  22. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  23. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  24. package/docs/throughline-handoff-context.example.json +57 -0
  25. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  26. package/package.json +6 -2
  27. package/src/baton.mjs +17 -45
  28. package/src/baton.test.mjs +4 -41
  29. package/src/cli/codex-capture.mjs +95 -0
  30. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  31. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  32. package/src/cli/codex-handoff-smoke.mjs +163 -0
  33. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  34. package/src/cli/codex-handoff-start.mjs +291 -0
  35. package/src/cli/codex-handoff-start.test.mjs +194 -0
  36. package/src/cli/codex-hook.mjs +276 -0
  37. package/src/cli/codex-hook.test.mjs +293 -0
  38. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  39. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  40. package/src/cli/codex-restore-smoke.mjs +357 -0
  41. package/src/cli/codex-restore-source-audit.mjs +304 -0
  42. package/src/cli/codex-resume.mjs +138 -0
  43. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  44. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  45. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  46. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  47. package/src/cli/codex-summarize.mjs +224 -0
  48. package/src/cli/codex-threads.mjs +89 -0
  49. package/src/cli/codex-visibility-smoke.mjs +196 -0
  50. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  51. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  52. package/src/cli/doctor.mjs +503 -1
  53. package/src/cli/doctor.test.mjs +542 -3
  54. package/src/cli/handoff-preview.mjs +78 -0
  55. package/src/cli/help.test.mjs +64 -0
  56. package/src/cli/install.mjs +226 -3
  57. package/src/cli/install.test.mjs +205 -4
  58. package/src/cli/trim.mjs +564 -0
  59. package/src/codex-app-server.mjs +1816 -0
  60. package/src/codex-app-server.test.mjs +512 -0
  61. package/src/codex-auto-refresh.mjs +194 -0
  62. package/src/codex-auto-refresh.test.mjs +182 -0
  63. package/src/codex-capture.mjs +235 -0
  64. package/src/codex-capture.test.mjs +393 -0
  65. package/src/codex-handoff-model-smoke.mjs +114 -0
  66. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  67. package/src/codex-handoff-smoke.mjs +124 -0
  68. package/src/codex-handoff-smoke.test.mjs +103 -0
  69. package/src/codex-handoff.mjs +331 -0
  70. package/src/codex-handoff.test.mjs +220 -0
  71. package/src/codex-host-primitive-audit.mjs +374 -0
  72. package/src/codex-host-primitive-audit.test.mjs +208 -0
  73. package/src/codex-restore-smoke.test.mjs +639 -0
  74. package/src/codex-restore-source-audit.mjs +1348 -0
  75. package/src/codex-restore-source-audit.test.mjs +623 -0
  76. package/src/codex-resume.test.mjs +242 -0
  77. package/src/codex-rollout-memory.mjs +711 -0
  78. package/src/codex-rollout-memory.test.mjs +610 -0
  79. package/src/codex-sidecar-cli.test.mjs +75 -0
  80. package/src/codex-sidecar.mjs +246 -0
  81. package/src/codex-sidecar.test.mjs +172 -0
  82. package/src/codex-summarize.test.mjs +143 -0
  83. package/src/codex-thread-identity.mjs +23 -0
  84. package/src/codex-thread-index.mjs +173 -0
  85. package/src/codex-thread-index.test.mjs +164 -0
  86. package/src/codex-usage.mjs +110 -0
  87. package/src/codex-usage.test.mjs +140 -0
  88. package/src/codex-visibility-smoke.test.mjs +222 -0
  89. package/src/codex-vscode-restore-smoke.mjs +206 -0
  90. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  91. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  92. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  93. package/src/db-schema.test.mjs +96 -0
  94. package/src/db.mjs +14 -1
  95. package/src/haiku-summarizer.mjs +267 -26
  96. package/src/haiku-summarizer.test.mjs +282 -0
  97. package/src/handoff-preview.test.mjs +108 -0
  98. package/src/handoff-record.mjs +294 -0
  99. package/src/handoff-record.test.mjs +226 -0
  100. package/src/hook-entrypoints.test.mjs +286 -0
  101. package/src/package-files.test.mjs +19 -0
  102. package/src/prompt-submit.mjs +9 -6
  103. package/src/resume-context.mjs +58 -171
  104. package/src/resume-context.test.mjs +177 -0
  105. package/src/session-start.mjs +85 -26
  106. package/src/state-file.mjs +50 -6
  107. package/src/state-file.test.mjs +50 -0
  108. package/src/token-monitor.mjs +14 -10
  109. package/src/token-monitor.test.mjs +27 -0
  110. package/src/trim-cli.test.mjs +1584 -0
  111. package/src/trim-model.mjs +584 -0
  112. package/src/trim-model.test.mjs +568 -0
  113. package/src/turn-processor.mjs +17 -10
  114. package/src/vscode-task.mjs +33 -10
  115. package/src/vscode-task.test.mjs +19 -9
  116. package/src/cli/save-inflight.mjs +0 -81
@@ -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
  });
@@ -14,7 +14,8 @@
14
14
  * - setInterval (1s) + mtime 差分検知で更新を捕捉
15
15
  * - updatedAt 降順ソート、先頭行を ▶ でハイライト
16
16
  * - stale は PID 生存チェックで判定
17
- * - トークン数は transcript JSONL の最新 assistant usage を直読
17
+ * - Claude transcript JSONL の最新 assistant usage を直読
18
+ * - Codex は Stop hook が state.usage に固定した rollout usage / estimate を表示
18
19
  */
19
20
 
20
21
  import { basename, dirname, join } from 'node:path';
@@ -191,7 +192,7 @@ function parseArgs(argv) {
191
192
  }
192
193
 
193
194
  // --- 表示 ---
194
- function renderBar(ratio, width = 20) {
195
+ function renderBar(ratio, width = 10) {
195
196
  // NaN は 0、+Infinity は 1(オーバーフロー = 満タン表示)、負値 / -Infinity は 0 にクランプ
196
197
  let safe;
197
198
  if (Number.isNaN(ratio)) safe = 0;
@@ -288,11 +289,10 @@ export function resolveColumns() {
288
289
  function formatLine({ state, usage, isActive, now = Date.now() }) {
289
290
  const project = basename(state.projectPath || '?');
290
291
  const shortId = state.sessionId.slice(0, 8);
292
+ const host = state.host === 'codex' ? 'Codex' : state.host === 'unknown' ? 'Unknown' : 'Claude';
291
293
  const tokens = usage?.tokens ?? 0;
292
294
  const max = usage?.contextWindowSize ?? 200_000;
293
295
  const ratio = max > 0 ? tokens / max : 0;
294
- const pct = Math.round(ratio * 100);
295
- const remaining = Math.max(0, max - tokens);
296
296
 
297
297
  const bar = renderBar(ratio);
298
298
  const barColor =
@@ -310,11 +310,15 @@ function formatLine({ state, usage, isActive, now = Date.now() }) {
310
310
 
311
311
  const marker = isActive ? color(ANSI.bold + ANSI.cyan, '▶') : ' ';
312
312
  const projectCol = padCellsEnd(project, 18);
313
+ const hostCol = color(ANSI.dim, padCellsEnd(host, 6));
313
314
  const idCol = color(ANSI.dim, shortId);
314
315
  const barCol = color(barColor, bar);
315
- const tokCol = `${formatNumber(tokens).padStart(6)} / ${pct.toString().padStart(3)}%`;
316
- const remCol = color(ANSI.dim, `残 ${formatNumber(remaining)}`);
317
- const modelCol = usage?.model ? color(ANSI.dim, usage.model) : color(ANSI.dim, '(未取得)');
316
+ const tokCol = `${formatNumber(tokens).padStart(6)} / ${formatNumber(max).padStart(6)}`;
317
+ const estimateMark = usage?.estimated ? ' est' : '';
318
+ const windowMark = usage?.contextWindowEstimated ? ' win?' : '';
319
+ const modelCol = usage?.model
320
+ ? color(ANSI.dim, `${usage.model}${estimateMark}${windowMark}`)
321
+ : color(ANSI.dim, '(未取得)');
318
322
  // 最終更新からの経過: 表示が「止まって見える」とき、それが idle なのか障害なのかを
319
323
  // 即座に判別できるようにする。updatedAt は state.writeSessionState 時の Date.now()。
320
324
  // 位置は session id の直後(左寄せ固定幅)。狭いターミナルでもモデル名より先に
@@ -325,7 +329,7 @@ function formatLine({ state, usage, isActive, now = Date.now() }) {
325
329
  // 8 セル固定: "just now" が最長 (8 セル)、"99d ago" は 7 セル。括弧なしで OK
326
330
  const agoCol = color(ANSI.dim, padCellsEnd(agoText, 8));
327
331
 
328
- return `${marker} ${projectCol} ${idCol} ${agoCol} ${barCol} ${tokCol} ${remCol} ${modelCol}${warn}`;
332
+ return `${marker} ${projectCol} ${hostCol} ${idCol} ${agoCol} ${barCol} ${tokCol} ${modelCol}${warn}`;
329
333
  }
330
334
 
331
335
  // --- フィルタ ---
@@ -428,8 +432,8 @@ function renderFrame(args) {
428
432
  for (let i = 0; i < filtered.length; i++) {
429
433
  const state = filtered[i];
430
434
  // Stop hook が state.usage に固定値を入れていればそれを使う(JSONL 再スキャン不要)。
431
- // 旧バージョンが書いた state や usage スナップショットが取れなかったターンでは
432
- // transcriptPath を直読してフォールバック。state 側の情報が 1 本化されると
435
+ // 旧バージョンが書いた Claude state や usage スナップショットが取れなかったターンでは
436
+ // transcriptPath を直読。state 側の情報が 1 本化されると
433
437
  // 「state が古い JSONL を指している」時の表示ブレが減る。
434
438
  const usage = state.usage
435
439
  ?? (state.transcriptPath ? readLatestUsage(state.transcriptPath) : null);
@@ -254,6 +254,10 @@ test('renderBar: ratio=0 は全部 ░', () => {
254
254
  assert.equal(renderBar(0, 5), '░░░░░');
255
255
  });
256
256
 
257
+ test('renderBar: default width は 10 セル', () => {
258
+ assert.equal(renderBar(0.5), '█████░░░░░');
259
+ });
260
+
257
261
  test('renderBar: ratio=1 は全部 █', () => {
258
262
  assert.equal(renderBar(1, 5), '█████');
259
263
  });
@@ -287,6 +291,7 @@ function makeLineArgs(ratio) {
287
291
  return {
288
292
  state: {
289
293
  sessionId: 'abc12345-xxxx',
294
+ host: 'claude',
290
295
  projectPath: '/tmp/foo',
291
296
  transcriptPath: null,
292
297
  updatedAt: Date.now(),
@@ -303,11 +308,33 @@ function makeLineArgs(ratio) {
303
308
 
304
309
  test('formatLine: 70% 未満は警告テキストなし', () => {
305
310
  const out = stripColors(formatLine(makeLineArgs(0.5)));
311
+ assert.ok(out.includes('Claude'));
312
+ assert.ok(out.includes('100.0k / 200.0k'));
313
+ assert.ok(!out.includes('残'));
314
+ assert.ok(!out.includes('/ 50%'));
306
315
  assert.ok(!out.includes('!!'));
307
316
  assert.ok(!out.includes('! '));
308
317
  assert.ok(!out.includes('/tl'));
309
318
  });
310
319
 
320
+ test('formatLine: Codex estimated usage は host と est marker を表示する', () => {
321
+ const args = makeLineArgs(0.5);
322
+ args.state.host = 'codex';
323
+ args.usage = {
324
+ tokens: 100_000,
325
+ model: 'codex',
326
+ contextWindowSize: 200_000,
327
+ contextWindowEstimated: true,
328
+ outputTokens: 0,
329
+ estimated: true,
330
+ source: 'codex-rollout-chars-div-4',
331
+ };
332
+ const out = stripColors(formatLine(args));
333
+ assert.ok(out.includes('Codex'));
334
+ assert.ok(out.includes('100.0k / 200.0k'));
335
+ assert.ok(out.includes('codex est win?'));
336
+ });
337
+
311
338
  test('formatLine: 70% 以上で "!" マーカーと弱めの文言', () => {
312
339
  const out = stripColors(formatLine(makeLineArgs(0.75)));
313
340
  assert.ok(out.includes('!'), 'should include ! marker');