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 +28 -1
- package/README.md +5 -3
- package/bin/throughline.mjs +1 -1
- package/docs/05_codex_first_roadmap.md +1 -1
- package/package.json +1 -1
- package/src/cli/codex-handoff-start.mjs +38 -13
- package/src/cli/codex-handoff-start.test.mjs +38 -0
- package/src/cli/help.test.mjs +1 -1
- package/src/cli/install.mjs +11 -3
- package/src/cli/install.test.mjs +22 -0
- package/src/db-contention.test.mjs +95 -0
- package/src/db.mjs +16 -7
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.
|
|
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
|
|
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`.
|
|
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 |
|
package/bin/throughline.mjs
CHANGED
|
@@ -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`
|
|
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
|
@@ -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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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', [
|
|
317
|
+
? spawnSync('open', [url], { encoding: 'utf8' })
|
|
302
318
|
: process.platform === 'win32'
|
|
303
|
-
? spawnSync('cmd.exe', ['/c', 'start', '',
|
|
304
|
-
: spawnSync('xdg-open', [
|
|
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:
|
|
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:
|
|
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
|
|
package/src/cli/help.test.mjs
CHANGED
|
@@ -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/);
|
package/src/cli/install.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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) {
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
}
|