throughline 0.8.1 → 0.8.3

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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,30 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.8.3] — 2026-07-20
14
+
15
+ ### Fixed
16
+
17
+ - Codex fresh-thread handoffs now detect when they were launched from Codex
18
+ Desktop and open the new local task with the app's
19
+ `codex://threads/<thread-id>` deep link instead of spawning a Terminal
20
+ `codex resume` session. `--open-host desktop` is available explicitly;
21
+ VS Code and CLI opening behavior is unchanged.
22
+ - Concurrent CLI and Codex hook processes now configure a bounded SQLite busy
23
+ timeout and avoid reapplying WAL mode when it is already active. A transient
24
+ writer or WAL recovery lock no longer makes DB initialization fail
25
+ immediately, and a failed initialization is never retained as the singleton.
26
+
27
+ ## [0.8.2] — 2026-07-20
28
+
29
+ ### Fixed
30
+
31
+ - Windows native Codex hook commands now prefix quoted Node executables with the
32
+ PowerShell call operator `&`. POSIX command strings are unchanged, and the
33
+ existing managed-hook detector continues to recognize the canonical commands.
34
+
35
+ ## [0.8.1] — 2026-07-19
36
+
13
37
  ### Fixed
14
38
 
15
39
  - **Native factory diagnostics now report the database compatibility label from
@@ -1109,7 +1133,10 @@ two attempts, instrument first instead of patching again.
1109
1133
 
1110
1134
  ---
1111
1135
 
1112
- [Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.0...HEAD
1136
+ [Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.3...HEAD
1137
+ [0.8.3]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.2...v0.8.3
1138
+ [0.8.2]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.1...v0.8.2
1139
+ [0.8.1]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.0...v0.8.1
1113
1140
  [0.8.0]: https://github.com/kitepon-rgb/Throughline/compare/v0.7.0...v0.8.0
1114
1141
  [0.7.0]: https://github.com/kitepon-rgb/Throughline/compare/v0.6.3...v0.7.0
1115
1142
  [0.6.3]: https://github.com/kitepon-rgb/Throughline/compare/v0.6.2...v0.6.3
package/README.md CHANGED
@@ -304,7 +304,7 @@ throughline codex-summarize --session codex:<thread-id> --json
304
304
  throughline codex-resume --session codex:<thread-id>
305
305
  throughline codex-resume --session codex:<thread-id> --format handoff
306
306
  throughline codex-handoff-start --session codex:<thread-id>
307
- throughline codex-handoff-start --session codex:<thread-id> --execute --open-host vscode
307
+ throughline codex-handoff-start --session codex:<thread-id> --execute --open-host desktop
308
308
  throughline codex-handoff-smoke --session codex:<thread-id>
309
309
  throughline codex-handoff-model-smoke --session codex:<thread-id> --dry-run --json
310
310
  THROUGHLINE_EXPERIMENTAL_CODEX_HANDOFF_MODEL_SMOKE=1 \
@@ -567,7 +567,9 @@ live model smoke, and can include the prompt with `--print-prompt`. With
567
567
  `--memo-stdin`, it also propagates `--memo-stdin` into the replay commands and
568
568
  reminds you to pipe the same memo when using them separately. Add `--execute`
569
569
  to create a new Codex app-server thread, inject the handoff memory as a
570
- developer item, and open it with `--open-host auto|vscode|cli|none`. The
570
+ developer item, and open it with `--open-host auto|desktop|vscode|cli|none`. `auto`
571
+ opens the new task in Codex Desktop when invoked there, while preserving the
572
+ existing VS Code and CLI routes. The
571
573
  individual commands remain available: validate the fresh-thread handoff with
572
574
  `throughline codex-handoff-smoke --session codex:<thread-id>`, optionally audit
573
575
  the model-smoke boundary with
@@ -785,7 +787,7 @@ aggregate は collection が既定OFFで、canonical dotagents config の
785
787
  | `throughline codex-summarize --session codex:<id>` | Summarize captured Codex L2 into L1 with the Codex CLI backend |
786
788
  | `throughline codex-resume --session codex:<id>` | Render Codex active-work context from a captured Codex session |
787
789
  | `throughline codex-resume --session codex:<id> --format handoff` | Render a concise fresh-thread handoff prompt without mutating the current thread |
788
- | `throughline codex-handoff-start --session codex:<id>` | Guided start plan for moving handoff memory into a new Codex thread; add `--execute` to create the thread through app-server, inject developer memory, and open it with `--open-host auto\|vscode\|cli\|none`; use `--print-prompt` to include the prompt and `--memo-stdin` to carry a current-work memo |
790
+ | `throughline codex-handoff-start --session codex:<id>` | Guided start plan for moving handoff memory into a new Codex thread; add `--execute` to create the thread through app-server, inject developer memory, and open it with `--open-host auto\|desktop\|vscode\|cli\|none`; `auto` keeps Codex Desktop handoffs in the Desktop app; use `--print-prompt` to include the prompt and `--memo-stdin` to carry a current-work memo |
789
791
  | `throughline codex-handoff-smoke --session codex:<id>` | Read-only validation that the fresh-thread handoff prompt is pasteable before starting a new thread |
790
792
  | `throughline codex-handoff-model-smoke --session codex:<id>` | Experimental marker smoke for the handoff prompt. `--dry-run` checks readiness / command boundary without starting Codex exec; `--memo-stdin` carries a current-work memo; live `codex exec --ephemeral --sandbox read-only` requires explicit env opt-in |
791
793
  | `throughline codex-visibility-smoke --session codex:<id>` | Experimental Codex app-server marker smoke; injects memory and starts a model turn |
@@ -248,7 +248,7 @@ Usage:
248
248
  render command, optional --print-prompt, and
249
249
  --memo-stdin replay guidance. Use --execute to
250
250
  create a new app-server thread, inject handoff
251
- memory, and open it with --open-host auto|vscode|cli|none
251
+ memory, and open it with --open-host auto|desktop|vscode|cli|none
252
252
  throughline codex-visibility-smoke
253
253
  Experimental: inject Codex active-work memory and
254
254
  start a marker-check model turn. Requires
@@ -271,7 +271,7 @@ Phase 3 implementation result (2026-05-06):
271
271
  - [x] `throughline codex-resume --format handoff` は current thread を mutate せず、新規 Codex thread に貼る短い handoff prompt を返す。handoff view は L2 件数 / 本文長 / detail refs を cap し、full active-work context は通常 text renderer に残す。
272
272
  - [x] `throughline codex-handoff-smoke --session codex:<thread_id>` は新規 thread handoff prompt を read-only に検査し、prompt size / required sections / mutation boundary / detail command dedupe を固定する。
273
273
  - [x] `throughline codex-handoff-model-smoke --session codex:<thread_id>` は明示 opt-in 時だけ `codex exec --ephemeral --ignore-user-config --ignore-rules --sandbox read-only` で handoff prompt の marker model smoke を行う。`--dry-run` は env なしで readiness / command boundary を監査し、`--print-prompt` で結合 prompt を出せる。`--memo-stdin` で Codex-primary current-work memo も同じ prompt に含める。structural handoff smoke が ready でなければ拒否し、current thread は mutate しない。
274
- - [x] `throughline codex-handoff-start --session codex:<thread_id>` は safe continuation の guided entrypoint として、structural smoke / model smoke dry-run / handoff render / optional live smoke / `--print-prompt` をまとめて表示する。`--execute` では app-server `thread/start` + `thread/inject_items` で新 thread に developer memory を注入し、`--open-host auto|vscode|cli|none` で表示を開く。`--memo-stdin` 時は replay 用コマンドにも `--memo-stdin` を伝播し、same memo を pipe する注意を出す。
274
+ - [x] `throughline codex-handoff-start --session codex:<thread_id>` は safe continuation の guided entrypoint として、structural smoke / model smoke dry-run / handoff render / optional live smoke / `--print-prompt` をまとめて表示する。`--execute` では app-server `thread/start` + `thread/inject_items` で新 thread に developer memory を注入し、`--open-host auto|desktop|vscode|cli|none` で表示を開く。`auto` は Codex Desktop 起点(`CODEX_INTERNAL_ORIGINATOR_OVERRIDE="Codex Desktop"` または `__CFBundleIdentifier=com.openai.codex`)を CLI より先に識別し、Desktop の `codex://threads/<thread-id>` deep link で新 thread を同アプリ内に開く。VS Code と CLI の既存経路は維持する。`--memo-stdin` 時は replay 用コマンドにも `--memo-stdin` を伝播し、same memo を pipe する注意を出す。
275
275
  - [x] `throughline codex-resume --format item-json` は Codex developer message item JSON を返す。
276
276
  - [x] `codex-resume` は explicit session を受け取れる。省略時は cwd の最新 `codex:%` session だけを対象にし、Claude session を混ぜない。
277
277
  - [x] `src/codex-handoff.test.mjs` / `src/codex-resume.test.mjs` で renderer と CLI shape を固定した。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -79,8 +79,8 @@ function parseArgs(args) {
79
79
  out.execute = true;
80
80
  } else if (arg === '--open-host') {
81
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');
82
+ if (!['auto', 'desktop', 'vscode', 'cli', 'none'].includes(value)) {
83
+ throw new Error('--open-host must be auto, desktop, vscode, cli, or none');
84
84
  }
85
85
  out.openHost = value;
86
86
  } else if (arg === '--codex-app-server-bin') {
@@ -247,6 +247,7 @@ function renderTextResult(result) {
247
247
  }
248
248
  if (result.open) {
249
249
  lines.push(` open status: ${result.open.status}`);
250
+ lines.push(` desktop url: ${result.open.desktopUrl}`);
250
251
  lines.push(` vscode url: ${result.open.vscodeUrl}`);
251
252
  lines.push(` resume command: ${result.open.resumeCommand}`);
252
253
  }
@@ -268,21 +269,34 @@ function renderTextResult(result) {
268
269
  return lines.join('\n');
269
270
  }
270
271
 
271
- function resolveOpenHost(host) {
272
+ function isCodexDesktopEnvironment(env) {
273
+ const originator = env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE?.trim()
274
+ .toLowerCase()
275
+ .replace(/[_-]+/g, ' ');
276
+ return originator === 'codex desktop' || env.__CFBundleIdentifier === 'com.openai.codex';
277
+ }
278
+
279
+ function resolveOpenHost(host, env = process.env) {
272
280
  if (host !== 'auto') return host;
281
+ if (isCodexDesktopEnvironment(env)) return 'desktop';
273
282
  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'
283
+ env.VSCODE_IPC_HOOK_CLI ||
284
+ env.VSCODE_IPC_HOOK ||
285
+ env.TERM_PROGRAM === 'vscode' ||
286
+ env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE === 'codex_vscode'
278
287
  ) {
279
288
  return 'vscode';
280
289
  }
281
290
  return 'cli';
282
291
  }
283
292
 
293
+ function buildCodexDesktopUrl(threadId) {
294
+ return `codex://threads/${encodeURIComponent(threadId)}`;
295
+ }
296
+
284
297
  function openStartedCodexThread({ threadId, host, cwd }) {
285
298
  const resolvedHost = resolveOpenHost(host);
299
+ const desktopUrl = buildCodexDesktopUrl(threadId);
286
300
  const vscodeUrl = `vscode://openai.chatgpt/local/${encodeURIComponent(threadId)}`;
287
301
  const resumeCommand = `codex resume ${threadId} --no-alt-screen`;
288
302
  if (resolvedHost === 'none') {
@@ -290,23 +304,26 @@ function openStartedCodexThread({ threadId, host, cwd }) {
290
304
  status: 'skipped',
291
305
  reason: 'open_host_none',
292
306
  host: resolvedHost,
307
+ desktopUrl,
293
308
  vscodeUrl,
294
309
  resumeCommand,
295
310
  };
296
311
  }
297
312
 
298
- if (resolvedHost === 'vscode') {
313
+ if (resolvedHost === 'desktop' || resolvedHost === 'vscode') {
314
+ const url = resolvedHost === 'desktop' ? desktopUrl : vscodeUrl;
299
315
  const result =
300
316
  process.platform === 'darwin'
301
- ? spawnSync('open', [vscodeUrl], { encoding: 'utf8' })
317
+ ? spawnSync('open', [url], { encoding: 'utf8' })
302
318
  : process.platform === 'win32'
303
- ? spawnSync('cmd.exe', ['/c', 'start', '', vscodeUrl], { encoding: 'utf8' })
304
- : spawnSync('xdg-open', [vscodeUrl], { encoding: 'utf8' });
319
+ ? spawnSync('cmd.exe', ['/c', 'start', '', url], { encoding: 'utf8' })
320
+ : spawnSync('xdg-open', [url], { encoding: 'utf8' });
305
321
  if (result.status !== 0) {
306
322
  return {
307
323
  status: 'failed',
308
- reason: 'vscode_deep_link_open_failed',
324
+ reason: `${resolvedHost}_deep_link_open_failed`,
309
325
  host: resolvedHost,
326
+ desktopUrl,
310
327
  vscodeUrl,
311
328
  resumeCommand,
312
329
  error: (result.stderr || result.stdout || '').trim(),
@@ -314,8 +331,9 @@ function openStartedCodexThread({ threadId, host, cwd }) {
314
331
  }
315
332
  return {
316
333
  status: 'opened',
317
- reason: 'vscode_deep_link_opened',
334
+ reason: `${resolvedHost}_deep_link_opened`,
318
335
  host: resolvedHost,
336
+ desktopUrl,
319
337
  vscodeUrl,
320
338
  resumeCommand,
321
339
  };
@@ -337,6 +355,7 @@ end tell
337
355
  status: 'failed',
338
356
  reason: 'terminal_open_failed',
339
357
  host: resolvedHost,
358
+ desktopUrl,
340
359
  vscodeUrl,
341
360
  resumeCommand,
342
361
  error: (result.stderr || result.stdout || '').trim(),
@@ -346,6 +365,7 @@ end tell
346
365
  status: 'opened',
347
366
  reason: 'terminal_resume_opened',
348
367
  host: resolvedHost,
368
+ desktopUrl,
349
369
  vscodeUrl,
350
370
  resumeCommand,
351
371
  };
@@ -354,6 +374,7 @@ end tell
354
374
  status: 'manual',
355
375
  reason: 'cli_auto_open_unsupported_on_platform',
356
376
  host: resolvedHost,
377
+ desktopUrl,
357
378
  vscodeUrl,
358
379
  resumeCommand,
359
380
  };
@@ -363,6 +384,7 @@ end tell
363
384
  status: 'failed',
364
385
  reason: 'unsupported_open_host',
365
386
  host: resolvedHost,
387
+ desktopUrl,
366
388
  vscodeUrl,
367
389
  resumeCommand,
368
390
  };
@@ -460,6 +482,9 @@ export async function run(args) {
460
482
  }
461
483
 
462
484
  export const _internal = {
485
+ buildCodexDesktopUrl,
486
+ isCodexDesktopEnvironment,
463
487
  parseArgs,
464
488
  renderTextResult,
489
+ resolveOpenHost,
465
490
  };
@@ -5,9 +5,43 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { _internal } from './codex-handoff-start.mjs';
8
9
 
9
10
  const REPO_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
10
11
 
12
+ test('codex-handoff-start recognizes desktop as an explicit open host', () => {
13
+ assert.equal(_internal.parseArgs(['--open-host', 'desktop']).openHost, 'desktop');
14
+ });
15
+
16
+ test('codex-handoff-start auto-selects Codex Desktop before inherited VS Code signals', () => {
17
+ assert.equal(
18
+ _internal.resolveOpenHost('auto', {
19
+ CODEX_INTERNAL_ORIGINATOR_OVERRIDE: 'Codex Desktop',
20
+ VSCODE_IPC_HOOK_CLI: '/tmp/stale-vscode.sock',
21
+ }),
22
+ 'desktop',
23
+ );
24
+ assert.equal(
25
+ _internal.resolveOpenHost('auto', { __CFBundleIdentifier: 'com.openai.codex' }),
26
+ 'desktop',
27
+ );
28
+ });
29
+
30
+ test('codex-handoff-start keeps VS Code and CLI auto-open behavior', () => {
31
+ assert.equal(
32
+ _internal.resolveOpenHost('auto', { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: 'codex_vscode' }),
33
+ 'vscode',
34
+ );
35
+ assert.equal(_internal.resolveOpenHost('auto', {}), 'cli');
36
+ });
37
+
38
+ test('codex-handoff-start builds the Codex Desktop local thread deep link', () => {
39
+ assert.equal(
40
+ _internal.buildCodexDesktopUrl('019f78e8-77ec-7d73-aee4-fb8e3869200a'),
41
+ 'codex://threads/019f78e8-77ec-7d73-aee4-fb8e3869200a',
42
+ );
43
+ });
44
+
11
45
  function makeTempHome() {
12
46
  return mkdtempSync(join(tmpdir(), 'tl-codex-handoff-start-home-'));
13
47
  }
@@ -256,6 +290,10 @@ test('codex-handoff-start execute creates a new app-server thread and can skip o
256
290
  assert.equal(payload.newThread.injectSent, true);
257
291
  assert.equal(payload.newThread.turnStatus, 'not-started');
258
292
  assert.equal(payload.open.status, 'skipped');
293
+ assert.equal(
294
+ payload.open.desktopUrl,
295
+ 'codex://threads/019e2000-0000-7000-8000-000000000001',
296
+ );
259
297
  assert.equal(payload.open.vscodeUrl, 'vscode://openai.chatgpt/local/019e2000-0000-7000-8000-000000000001');
260
298
  assert.match(payload.open.resumeCommand, /codex resume 019e2000-0000-7000-8000-000000000001/);
261
299
 
@@ -49,7 +49,7 @@ test('CLI help exposes guided Codex handoff and guarded execute semantics', () =
49
49
  }
50
50
  assert.match(result.stdout, /Fresh-thread Codex handoff start plan/);
51
51
  assert.match(result.stdout, /Use --execute to/);
52
- assert.match(result.stdout, /--open-host auto\|vscode\|cli\|none/);
52
+ assert.match(result.stdout, /--open-host auto\|desktop\|vscode\|cli\|none/);
53
53
  assert.match(result.stdout, /throughline trim --execute/);
54
54
  assert.match(result.stdout, /injectable DB memory/);
55
55
  assert.match(result.stdout, /matching/);
@@ -101,22 +101,30 @@ export function resolveCodexHookNodePath({
101
101
  export function buildCodexStopHookCommand({
102
102
  nodePath = resolveCodexHookNodePath(),
103
103
  cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
104
+ platform = process.platform,
104
105
  } = {}) {
105
- return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook stop`;
106
+ return buildCodexHookCommand('stop', { nodePath, cliScriptPath, platform });
106
107
  }
107
108
 
108
109
  export function buildCodexUserPromptSubmitHookCommand({
109
110
  nodePath = resolveCodexHookNodePath(),
110
111
  cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
112
+ platform = process.platform,
111
113
  } = {}) {
112
- return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook user-prompt-submit`;
114
+ return buildCodexHookCommand('user-prompt-submit', { nodePath, cliScriptPath, platform });
113
115
  }
114
116
 
115
117
  export function buildCodexPostToolUseHookCommand({
116
118
  nodePath = resolveCodexHookNodePath(),
117
119
  cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
120
+ platform = process.platform,
118
121
  } = {}) {
119
- return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook post-tool-use`;
122
+ return buildCodexHookCommand('post-tool-use', { nodePath, cliScriptPath, platform });
123
+ }
124
+
125
+ function buildCodexHookCommand(event, { nodePath, cliScriptPath, platform }) {
126
+ const prefix = platform === 'win32' ? '& ' : '';
127
+ return `${prefix}${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook ${event}`;
120
128
  }
121
129
 
122
130
  export function isThroughlineCodexHookCommand(command) {
@@ -8,6 +8,7 @@ import {
8
8
  buildCodexPostToolUseHookCommand,
9
9
  buildCodexStopHookCommand,
10
10
  buildCodexUserPromptSubmitHookCommand,
11
+ isThroughlineCodexHookCommand,
11
12
  resolveCodexHookNodePath,
12
13
  run,
13
14
  resolveThroughlineOnPath,
@@ -141,6 +142,27 @@ test('global install registers Codex session hooks and enables hooks features',
141
142
  }
142
143
  });
143
144
 
145
+ test('Codex hook builders use the PowerShell call operator on Windows only', () => {
146
+ const options = {
147
+ nodePath: String.raw`C:\Program Files\nodejs\node.exe`,
148
+ cliScriptPath: String.raw`C:\Users\Kite\App Data\Roaming\npm\node_modules\throughline\bin\throughline.mjs`,
149
+ };
150
+ assert.equal(
151
+ buildCodexStopHookCommand({ ...options, platform: 'win32' }),
152
+ String.raw`& "C:\Program Files\nodejs\node.exe" "C:\Users\Kite\App Data\Roaming\npm\node_modules\throughline\bin\throughline.mjs" codex-hook stop`,
153
+ );
154
+ assert.equal(
155
+ buildCodexUserPromptSubmitHookCommand({ ...options, platform: 'win32' }),
156
+ String.raw`& "C:\Program Files\nodejs\node.exe" "C:\Users\Kite\App Data\Roaming\npm\node_modules\throughline\bin\throughline.mjs" codex-hook user-prompt-submit`,
157
+ );
158
+ assert.equal(
159
+ buildCodexPostToolUseHookCommand({ ...options, platform: 'win32' }),
160
+ String.raw`& "C:\Program Files\nodejs\node.exe" "C:\Users\Kite\App Data\Roaming\npm\node_modules\throughline\bin\throughline.mjs" codex-hook post-tool-use`,
161
+ );
162
+ assert.equal(buildCodexStopHookCommand({ ...options, platform: 'linux' }).startsWith('& '), false);
163
+ assert.equal(isThroughlineCodexHookCommand(buildCodexStopHookCommand({ ...options, platform: 'win32' })), true);
164
+ });
165
+
144
166
  test('global install copies Throughline Codex skill to ~/.codex/skills/', async () => {
145
167
  const home = makeTempHome();
146
168
  if (home.resolved !== home.dir) {
@@ -0,0 +1,95 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ const DB_MODULE_URL = pathToFileURL(fileURLToPath(new URL('./db.mjs', import.meta.url))).href;
10
+
11
+ function waitForLine(stream, expected) {
12
+ return new Promise((resolve, reject) => {
13
+ let output = '';
14
+ const onData = (chunk) => {
15
+ output += chunk;
16
+ if (output.includes(expected)) {
17
+ stream.off('data', onData);
18
+ resolve();
19
+ }
20
+ };
21
+ stream.on('data', onData);
22
+ stream.once('error', reject);
23
+ });
24
+ }
25
+
26
+ function waitForExit(child) {
27
+ return new Promise((resolve, reject) => {
28
+ let stderr = '';
29
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
30
+ child.once('error', reject);
31
+ child.once('close', (code) => resolve({ code, stderr }));
32
+ });
33
+ }
34
+
35
+ test('getDb configures a bounded SQLite busy timeout for concurrent hook processes', () => {
36
+ const home = mkdtempSync(join(tmpdir(), 'throughline-db-timeout-'));
37
+ try {
38
+ const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
39
+ import { getDb } from ${JSON.stringify(DB_MODULE_URL)};
40
+ const db = getDb();
41
+ process.stdout.write(String(db.prepare('PRAGMA busy_timeout').get().timeout));
42
+ db.close();
43
+ `], {
44
+ env: { ...process.env, HOME: home, USERPROFILE: home },
45
+ encoding: 'utf8',
46
+ });
47
+ assert.equal(result.status, 0, result.stderr);
48
+ assert.equal(result.stdout, '5000');
49
+ } finally {
50
+ rmSync(home, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test('getDb reuses an existing WAL database while another process owns the writer lock', async () => {
55
+ const home = mkdtempSync(join(tmpdir(), 'throughline-db-contention-'));
56
+ const env = { ...process.env, HOME: home, USERPROFILE: home };
57
+ const initialize = spawn(process.execPath, ['--input-type=module', '-e', `
58
+ import { getDb } from ${JSON.stringify(DB_MODULE_URL)};
59
+ getDb().close();
60
+ `], { env, stdio: ['ignore', 'ignore', 'pipe'] });
61
+ assert.deepEqual(await waitForExit(initialize), { code: 0, stderr: '' });
62
+
63
+ const holder = spawn(process.execPath, ['--input-type=module', '-e', `
64
+ import { DatabaseSync } from 'node:sqlite';
65
+ import { join } from 'node:path';
66
+ const db = new DatabaseSync(join(process.env.HOME, '.throughline', 'throughline.db'));
67
+ db.exec('CREATE TABLE IF NOT EXISTS contention_probe (value TEXT)');
68
+ db.exec("BEGIN IMMEDIATE; INSERT INTO contention_probe VALUES ('held')");
69
+ process.stdout.write('locked\\n');
70
+ setTimeout(() => {
71
+ db.exec('COMMIT');
72
+ db.close();
73
+ }, 500);
74
+ `], { env, stdio: ['ignore', 'pipe', 'pipe'] });
75
+ await waitForLine(holder.stdout, 'locked\n');
76
+
77
+ const contender = spawn(process.execPath, ['--input-type=module', '-e', `
78
+ import { getDb } from ${JSON.stringify(DB_MODULE_URL)};
79
+ const db = getDb();
80
+ const mode = db.prepare('PRAGMA journal_mode').get().journal_mode;
81
+ db.close();
82
+ if (String(mode).toLowerCase() !== 'wal') process.exit(2);
83
+ `], { env, stdio: ['ignore', 'ignore', 'pipe'] });
84
+
85
+ const [holderResult, contenderResult] = await Promise.all([
86
+ waitForExit(holder),
87
+ waitForExit(contender),
88
+ ]);
89
+ try {
90
+ assert.deepEqual(holderResult, { code: 0, stderr: '' });
91
+ assert.deepEqual(contenderResult, { code: 0, stderr: '' });
92
+ } finally {
93
+ rmSync(home, { recursive: true, force: true });
94
+ }
95
+ });
package/src/db.mjs CHANGED
@@ -9,6 +9,7 @@ import { join } from 'path';
9
9
 
10
10
  const DB_DIR = join(homedir(), '.throughline');
11
11
  const DB_PATH = join(DB_DIR, 'throughline.db');
12
+ const DB_BUSY_TIMEOUT_MS = 5_000;
12
13
  export const CURRENT_VERSION = 9;
13
14
 
14
15
  let _db = null;
@@ -248,11 +249,19 @@ export function getDb() {
248
249
 
249
250
  mkdirSync(DB_DIR, { recursive: true });
250
251
 
251
- _db = new DatabaseSync(DB_PATH);
252
- _db.exec('PRAGMA journal_mode = WAL');
253
- _db.exec('PRAGMA foreign_keys = ON');
254
-
255
- initSchema(_db);
256
-
257
- return _db;
252
+ const db = new DatabaseSync(DB_PATH);
253
+ try {
254
+ db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
255
+ const journalMode = db.prepare('PRAGMA journal_mode').get().journal_mode;
256
+ if (String(journalMode).toLowerCase() !== 'wal') {
257
+ db.exec('PRAGMA journal_mode = WAL');
258
+ }
259
+ db.exec('PRAGMA foreign_keys = ON');
260
+ initSchema(db);
261
+ _db = db;
262
+ return _db;
263
+ } catch (error) {
264
+ db.close();
265
+ throw error;
266
+ }
258
267
  }