throughline 0.10.14 → 0.10.16
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 +21 -1
- package/README.ja.md +6 -0
- package/README.md +25 -1
- package/bin/throughline.mjs +0 -0
- package/docs/04_public_release_plan.md +5 -2
- package/docs/05_codex_first_roadmap.md +5 -0
- package/docs/08_codex_dual_support.md +5 -2
- package/package.json +2 -2
- package/src/cli/codex-handoff-start.mjs +9 -2
- package/src/cli/codex-handoff-start.test.mjs +3 -0
- package/src/codex-app-server.mjs +1 -1
- package/src/codex-host-primitive-audit.mjs +1 -1
- package/src/docs-contract.test.mjs +5 -2
- package/src/os/codex-runtime.mjs +36 -0
- package/src/os/codex-runtime.test.mjs +31 -0
- package/src/os/portable-spawn-sync.mjs +21 -10
- package/src/os/portable-spawn-sync.test.mjs +51 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.10.16] — 2026-09-07
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- WindowsのDesktop引き継ぎで、稼働中のアプリが使うCodex実行ファイルを選ぶようにした。
|
|
18
|
+
PATH上の古いCodexがデスクトップ版の設定を読めず、新規タスクの作成に失敗する問題を修正した。
|
|
19
|
+
- 引き継ぎ結果へCodex実行元を表示する。アプリの実体を特定できない場合は理由を出して停止する。
|
|
20
|
+
|
|
21
|
+
## [0.10.15] — 2026-09-06
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Windowsでnpmから導入したCodexをThroughlineの新規タスク引き継ぎと診断から起動できるようにした。
|
|
26
|
+
OS境界の起動処理を通し、PowerShell 7経由の標準入出力をUTF-8で受け渡す。
|
|
27
|
+
- Windowsの起動試験を通常のテストと製品CIへ含め、日本語と接続中の要求・応答を検証する。
|
|
28
|
+
- Windowsの起動条件、Codexフックの承認、既存ログの回収を文書化した。
|
|
29
|
+
自動refreshの古い案内を直し、公開版と開発中の版の参照先を分けた。
|
|
30
|
+
|
|
13
31
|
## [0.10.14] — 2026-09-02
|
|
14
32
|
|
|
15
33
|
### Added
|
|
@@ -1439,7 +1457,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1439
1457
|
|
|
1440
1458
|
---
|
|
1441
1459
|
|
|
1442
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.
|
|
1460
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.16...HEAD
|
|
1461
|
+
[0.10.16]: https://github.com/kitepon/Throughline/compare/v0.10.15...v0.10.16
|
|
1462
|
+
[0.10.15]: https://github.com/kitepon/Throughline/compare/v0.10.14...v0.10.15
|
|
1443
1463
|
[0.10.14]: https://github.com/kitepon/Throughline/compare/v0.10.13...v0.10.14
|
|
1444
1464
|
[0.10.13]: https://github.com/kitepon/Throughline/compare/v0.10.12...v0.10.13
|
|
1445
1465
|
[0.10.12]: https://github.com/kitepon/Throughline/compare/v0.10.11...v0.10.12
|
package/README.ja.md
CHANGED
|
@@ -102,6 +102,11 @@ current-thread rollback
|
|
|
102
102
|
診断が要る時だけ明示的に `trim --execute --host codex` を使う。既存の
|
|
103
103
|
非 Throughline Codex hook は保持される。
|
|
104
104
|
|
|
105
|
+
登録後は `throughline doctor --codex` で承認状態も確認する。未承認のフックは
|
|
106
|
+
Codexのフック承認画面で利用者が承認する。Throughlineは承認状態を書き換えない。
|
|
107
|
+
Windowsの起動条件と、記録が見つからない場合の確認手順は
|
|
108
|
+
[Codexの診断と復旧](README.md#codex-windows)を参照。
|
|
109
|
+
|
|
105
110
|
</details>
|
|
106
111
|
|
|
107
112
|
## 他の手段との比較
|
|
@@ -464,6 +469,7 @@ DB作成・migration・baton消費・session merge・
|
|
|
464
469
|
- **Codex CLI ログイン**(既定の L1 要約 backend、`gpt-5.6-luna`)または
|
|
465
470
|
**Claude Max サブスクリプション**(`claude -p` 経由の Haiku fallback)— どちらも API キー不要
|
|
466
471
|
- 対応 OS: **Windows / macOS / Linux**
|
|
472
|
+
- Windowsでnpm版Codexを起動する場合は **PowerShell 7**(`pwsh.exe`)がPATHに必要。
|
|
467
473
|
|
|
468
474
|
ランタイム依存 **ゼロ**。npm パッケージは純 `.mjs` ファイルのみで構成されています。
|
|
469
475
|
|
package/README.md
CHANGED
|
@@ -72,6 +72,8 @@ DB handoff memory as a developer item, and opens that thread in the selected
|
|
|
72
72
|
host; ask explicitly for current-thread rollback diagnostics when you want the
|
|
73
73
|
guarded `trim --execute --host codex` surface.
|
|
74
74
|
|
|
75
|
+
フック登録後の承認とWindowsでの起動条件は、[Codexの診断と復旧](#codex-windows)を参照。
|
|
76
|
+
|
|
75
77
|
</details>
|
|
76
78
|
|
|
77
79
|
<details>
|
|
@@ -636,6 +638,10 @@ opens the new task in Codex Desktop when invoked there, while preserving the
|
|
|
636
638
|
existing VS Code and CLI routes. The result reports both the requested and
|
|
637
639
|
resolved host. The installed `$throughline` skill passes the current Codex
|
|
638
640
|
surface explicitly so a persistent shell or PTY cannot redirect the handoff.
|
|
641
|
+
WindowsのDesktop引き継ぎは、稼働中のCodex Desktopが使う実行ファイルを選びます。
|
|
642
|
+
PATH上の別バージョンによる設定読取りエラーを避け、選んだ実行元を結果へ表示します。
|
|
643
|
+
Desktopを起動してから実行してください。実行ファイルが見つからない場合や、異なる実行ファイルの
|
|
644
|
+
Desktopが複数稼働している場合は理由を出して停止します。明示した`--codex-app-server-bin`は優先します。
|
|
639
645
|
The
|
|
640
646
|
individual commands remain available: validate the fresh-thread handoff with
|
|
641
647
|
`throughline codex-handoff-smoke --session codex:<thread-id>`, optionally audit
|
|
@@ -817,7 +823,7 @@ entry to the `tasks` array yourself:
|
|
|
817
823
|
|
|
818
824
|
## Commands
|
|
819
825
|
|
|
820
|
-
|
|
826
|
+
公開版は冒頭のnpmバッジ、開発中の版は `package.json`、版ごとの変更は [CHANGELOG.md](CHANGELOG.md) を参照。Throughline supports Claude Code, Codex,
|
|
821
827
|
Grok, and Cursor as documented host adapters. The versioned, read-only
|
|
822
828
|
`handoff-context` boundary opens only an existing database and leaves baton
|
|
823
829
|
state, session ownership, and memory rows unchanged. Factory diagnostics,
|
|
@@ -1118,6 +1124,24 @@ See [`src/haiku-summarizer.mjs`](src/haiku-summarizer.mjs) for the implementatio
|
|
|
1118
1124
|
|
|
1119
1125
|
## Troubleshooting
|
|
1120
1126
|
|
|
1127
|
+
<a id="codex-windows"></a>
|
|
1128
|
+
|
|
1129
|
+
### Codexの診断と復旧(Windowsを含む)
|
|
1130
|
+
|
|
1131
|
+
Windowsでnpm版Codexを使う場合、`codex` とPowerShell 7の `pwsh.exe` がPATHに必要。
|
|
1132
|
+
Throughlineはnpmが生成したPowerShell起動ファイルを使い、引数と標準入出力を
|
|
1133
|
+
UTF-8で渡す。macOS/Linuxの起動方法は変わらない。
|
|
1134
|
+
|
|
1135
|
+
引き継ぐ記録が見つからない場合は、対象プロジェクトで `throughline doctor --codex` を実行する。
|
|
1136
|
+
登録済みでも未承認のフックは動作しないため、Codexのフック承認画面で利用者が承認する。
|
|
1137
|
+
Throughlineのinstallや更新は、その承認を代行しない。
|
|
1138
|
+
|
|
1139
|
+
旧版を使っている場合は、[更新手順](docs/04_public_release_plan.md#製品単独運用)で更新する。
|
|
1140
|
+
未承認期間の既存ログを回収する必要がある場合は、対象IDを確定して
|
|
1141
|
+
`throughline codex-capture --codex-thread-id <id> --json` を実行する。
|
|
1142
|
+
その後、通常のターン終了で記録が増えることを確認する。一度の手動回収を
|
|
1143
|
+
自動記録の復旧とは扱わない。現在のタスクへのrollbackや注入は復旧手順に含めない。
|
|
1144
|
+
|
|
1121
1145
|
### Claude Code Desktop `/clear`
|
|
1122
1146
|
|
|
1123
1147
|
Automatic inheritance (the auto path) does not fire for `/clear` in Claude Code Desktop: the client does not send `source="clear"` and also mislabels the SessionEnd `reason` as `"other"`. This is reported upstream in [anthropics/claude-code#76704](https://github.com/anthropics/claude-code/issues/76704). On Desktop, run `/tl` before `/clear` (the baton lasts one hour). The 0.6.0 backfill work means this `/tl` handoff carries complete L2 through the immediately preceding turn. The VS Code extension's `/clear` auto-handoff works normally.
|
package/bin/throughline.mjs
CHANGED
|
File without changes
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 現行状態
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- 候補の版は `package.json`、公開版はnpm registryを正とする。tagとnpmの一致はpublish後に確認する。
|
|
10
10
|
- Claude Code、Codex、Grok、Cursorをfirst-class hostとして扱う。
|
|
11
11
|
- 現行DB schemaはv9。schemaの正本は [src/db.mjs](../src/db.mjs) の
|
|
12
12
|
`CURRENT_VERSION`、二相handoffの判断は
|
|
@@ -58,7 +58,8 @@ Windows PowerShell 5.1へ切り替えない。更新前CLIを再利用せず、
|
|
|
58
58
|
|
|
59
59
|
release候補は次の全条件を満たしたときだけ公開する。
|
|
60
60
|
|
|
61
|
-
1. `package.json
|
|
61
|
+
1. `package.json` と `CHANGELOG.md` の候補版が一致し、READMEとCLAUDE.mdが版の正本を参照する。
|
|
62
|
+
schema・host契約も実装と一致し、`npm run verify:docs` が全Markdownと梱包内リンクを検査する。
|
|
62
63
|
2. 変更に直結するfocused testがgreenで、その後に `npm test` を最終確認として1回通す。
|
|
63
64
|
3. `npm run verify:release-commit` が、clean working treeかつHEADが`origin`既定ブランチの
|
|
64
65
|
祖先であることを確認する。既定ブランチへ着地していないcommitからpublishしない。
|
|
@@ -76,6 +77,8 @@ publish済みでも、上の確認が終わるまではrelease完了としない
|
|
|
76
77
|
- Claude Code: user/project hooksとslash command。PATH解決型CLIを使う。
|
|
77
78
|
- Codex: UserPromptSubmit / PostToolUse / Stop hook、feature flags、`$throughline` skill。
|
|
78
79
|
絶対Node + installed CLI pathを使う。
|
|
80
|
+
登録と利用者の承認は別であり、未承認は `doctor --codex` で確認する。
|
|
81
|
+
Windowsのnpm版Codex起動は `src/os/portable-spawn-sync.mjs` が所有し、PowerShell 7を使う。
|
|
79
82
|
- Grok: `~/.grok/hooks/throughline.json`と`grok-continue`。絶対pathを使う。
|
|
80
83
|
Grok予約環境変数でhostを確定し、camelCase/snake_caseの属性名を同じ境界で正規化する。
|
|
81
84
|
- Cursor: `~/.cursor/hooks.json`へsessionStart / beforeSubmitPrompt / stopをupsertし、
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
|
|
23
23
|
## 現状認識
|
|
24
24
|
|
|
25
|
+
Windowsの通常の新規タスク引き継ぎは実機確認済み。
|
|
26
|
+
起動条件とフック承認は [README](../README.md#codex-windows)、
|
|
27
|
+
当時の検証と未完了事項は [実測記録](https://github.com/kitepon/Throughline/blob/main/evidence/2026-09-06-windows-codex-handoff.md) を参照する。
|
|
28
|
+
この確認は同一タスクのrollbackや自動記録の承認を保証しない。
|
|
29
|
+
|
|
25
30
|
- Throughline 本体は、ほとんどが hook から起動されるローカル Node.js コードベースで動く。
|
|
26
31
|
- 現行 Claude path は、Claude hooks、slash command、handoff baton、DB、resume context、L1 / L2 / L3 persistence で成立している。
|
|
27
32
|
- 外部モデル的に呼ぶ主要箇所は L2 -> L1 要約。
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
|
|
22
22
|
Throughline は agent-neutral な handoff / context compression infrastructure になるべきです。
|
|
23
23
|
|
|
24
|
-
Claude Code transcript と handoff behavior
|
|
24
|
+
Claude Code transcript と handoff behavior を維持し、Codex対応はadapterとして追加する。
|
|
25
|
+
Codexの保存元は実装済みの `src/codex-capture.mjs` が読むrolloutであり、
|
|
26
|
+
`codex:<thread_id>` の記憶を保存する。通常の引き継ぎは新規タスクへdeveloper itemを注入する。
|
|
27
|
+
フック承認とWindows起動条件は [README](../README.md#codex-windows) を正とする。
|
|
25
28
|
|
|
26
29
|
目指す形:
|
|
27
30
|
|
|
@@ -33,7 +36,7 @@ Claude Code transcript と handoff behavior は守りつつ、Codex primary brid
|
|
|
33
36
|
## 優先順位
|
|
34
37
|
|
|
35
38
|
1. Throughline を Codex primary で使えるようにする。Codex primary の L2 -> L1 backend は Codex CLI を本線にする。
|
|
36
|
-
2. Codex
|
|
39
|
+
2. Codexの同一タスクのtrimは明示的な診断として維持する。自動refreshは無効であり、通常の `$throughline` は新規タスクへ引き継ぐ。同一タスクでの継続的な使用量削減は保証しない。
|
|
37
40
|
3. そのあと Claude 側の `/rewind` UX / 自動化 surface を詰める。
|
|
38
41
|
|
|
39
42
|
Claude transcript handling の置き換えから始めないでください。Codex 対応は adapter / bridge / Codex primary entrypoint として追加します。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "throughline",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
|
|
6
6
|
"keywords": [
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"LICENSE"
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
|
-
"test": "node --import ./src/test-env.mjs --test scripts/verify-release-commit.test.mjs src/*.test.mjs src/cli/*.test.mjs src/hosts/*.test.mjs",
|
|
44
|
+
"test": "node --import ./src/test-env.mjs --test scripts/verify-release-commit.test.mjs src/*.test.mjs src/cli/*.test.mjs src/hosts/*.test.mjs src/os/*.test.mjs",
|
|
45
45
|
"verify:docs": "node scripts/verify-docs.mjs && node scripts/verify-pack-markdown-links.mjs",
|
|
46
46
|
"verify:release-commit": "node scripts/verify-release-commit.mjs",
|
|
47
47
|
"prepublishOnly": "npm run verify:release-commit && npm test"
|
|
@@ -9,6 +9,7 @@ import { sameProjectPath } from '../project-path.mjs';
|
|
|
9
9
|
import { shQuote } from '../os/shell.mjs';
|
|
10
10
|
import { openUrlWithOsHandler } from '../os/open-url.mjs';
|
|
11
11
|
import { runTerminalDoScript } from '../os/macos-terminal.mjs';
|
|
12
|
+
import { resolveCodexRuntime } from '../os/codex-runtime.mjs';
|
|
12
13
|
|
|
13
14
|
async function readStdin() {
|
|
14
15
|
let raw = '';
|
|
@@ -242,6 +243,7 @@ function renderTextResult(result) {
|
|
|
242
243
|
lines.push(` execute: ${result.execute ? 'yes' : 'no'}`);
|
|
243
244
|
lines.push(` open requested: ${result.requestedOpenHost ?? result.openHost}`);
|
|
244
245
|
lines.push(` open resolved: ${result.resolvedOpenHost ?? result.open?.host ?? result.openHost}`);
|
|
246
|
+
if (result.runtime) lines.push(` Codex実行元: ${result.runtime.command} (${result.runtime.source})`);
|
|
245
247
|
lines.push(` handoff smoke: ${result.handoffSmoke.status}`);
|
|
246
248
|
lines.push(` prompt chars: ${result.handoffSmoke.promptChars}/${result.handoffSmoke.maxPromptChars}`);
|
|
247
249
|
lines.push(` model prompt: ${result.modelPromptChars}`);
|
|
@@ -452,11 +454,16 @@ export async function run(args) {
|
|
|
452
454
|
}
|
|
453
455
|
|
|
454
456
|
if (parsed.execute && result.status === 'ready') {
|
|
457
|
+
const runtime = resolveCodexRuntime({
|
|
458
|
+
host: result.resolvedOpenHost,
|
|
459
|
+
override: parsed.codexAppServerBin,
|
|
460
|
+
});
|
|
461
|
+
result.runtime = runtime;
|
|
455
462
|
const startResult = await runCodexNewThreadHandoff({
|
|
456
463
|
cwd: process.cwd(),
|
|
457
464
|
prompt: handoffPrompt,
|
|
458
|
-
command:
|
|
459
|
-
commandArgs:
|
|
465
|
+
command: runtime.command,
|
|
466
|
+
commandArgs: runtime.commandArgs,
|
|
460
467
|
timeoutMs: parsed.timeoutMs,
|
|
461
468
|
requestTimeoutMs: parsed.requestTimeoutMs,
|
|
462
469
|
waitForTurn: false,
|
|
@@ -310,6 +310,9 @@ test('codex-handoff-start execute creates a new app-server thread and can skip o
|
|
|
310
310
|
assert.equal(payload.startThreadManually, false);
|
|
311
311
|
assert.equal(payload.newThread.threadId, '019e2000-0000-7000-8000-000000000001');
|
|
312
312
|
assert.equal(payload.newThread.delivery, 'developer-item');
|
|
313
|
+
assert.equal(payload.runtime.command, script);
|
|
314
|
+
assert.equal(payload.runtime.source, 'explicit');
|
|
315
|
+
assert.deepEqual(payload.runtime.commandArgs, []);
|
|
313
316
|
assert.equal(payload.newThread.injectSent, true);
|
|
314
317
|
assert.equal(payload.newThread.turnStatus, 'not-started');
|
|
315
318
|
assert.equal(payload.open.status, 'skipped');
|
package/src/codex-app-server.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawnSync } from '
|
|
1
|
+
import { spawnPortableSync as spawnSync } from './os/portable-spawn-sync.mjs';
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -48,7 +48,7 @@ test('Markdown-only CI runs the product-owned documentation contract', () => {
|
|
|
48
48
|
assert.match(result.stdout, /packed Markdown links:/);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
test('
|
|
51
|
+
test('現行文書は版の正本を参照し、変更履歴は候補版と一致する', () => {
|
|
52
52
|
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
53
53
|
const version = packageJson.version;
|
|
54
54
|
const currentSources = [
|
|
@@ -56,7 +56,10 @@ test('release candidate version is synchronized across current product documents
|
|
|
56
56
|
readFileSync(new URL('../CLAUDE.md', import.meta.url), 'utf8'),
|
|
57
57
|
readFileSync(new URL('../docs/04_public_release_plan.md', import.meta.url), 'utf8'),
|
|
58
58
|
];
|
|
59
|
-
for (const source of currentSources)
|
|
59
|
+
for (const source of currentSources) {
|
|
60
|
+
assert.match(source, /`package\.json`/);
|
|
61
|
+
assert.match(source, /公開版はnpm|公開版は冒頭のnpm/);
|
|
62
|
+
}
|
|
60
63
|
|
|
61
64
|
const changelog = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8');
|
|
62
65
|
assert.match(changelog, new RegExp(`^## \\[${version.replaceAll('.', '\\.')}\\]`, 'm'));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
2
|
+
|
|
3
|
+
function discoverWindowsDesktopCodex() {
|
|
4
|
+
const result = spawnPortableSync('pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `
|
|
5
|
+
$ErrorActionPreference = 'Stop'
|
|
6
|
+
$processes = @(Get-CimInstance Win32_Process)
|
|
7
|
+
$paths = @($processes | Where-Object { $_.Name -eq 'codex.exe' } | ForEach-Object {
|
|
8
|
+
$child = $_
|
|
9
|
+
$parent = $processes | Where-Object { $_.ProcessId -eq $child.ParentProcessId }
|
|
10
|
+
if ($parent.ExecutablePath -match '[\\\\/]OpenAI\\.Codex_[^\\\\/]+[\\\\/]app[\\\\/]ChatGPT\\.exe$' -and $child.ExecutablePath) {
|
|
11
|
+
$child.ExecutablePath
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
ConvertTo-Json -InputObject $paths -Compress
|
|
15
|
+
`], { encoding: 'utf8', timeout: 15000 });
|
|
16
|
+
if (result.error || result.status !== 0) {
|
|
17
|
+
throw new Error(`Codex Desktopの実行ファイルを取得できません: ${result.error?.message ?? result.stderr.trim()}`);
|
|
18
|
+
}
|
|
19
|
+
return JSON.parse(result.stdout.trim());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveCodexRuntime({ host, override = null, platform = process.platform,
|
|
23
|
+
discover = discoverWindowsDesktopCodex } = {}) {
|
|
24
|
+
if (override) return { command: override, commandArgs: [], source: 'explicit' };
|
|
25
|
+
const commandArgs = ['app-server', '--listen', 'stdio://'];
|
|
26
|
+
if (platform !== 'win32' || host !== 'desktop') {
|
|
27
|
+
return { command: 'codex', commandArgs, source: 'path' };
|
|
28
|
+
}
|
|
29
|
+
const candidates = [...new Set(discover())];
|
|
30
|
+
if (candidates.length !== 1) {
|
|
31
|
+
throw new Error(candidates.length === 0
|
|
32
|
+
? '稼働中のCodex Desktopの実行ファイルが見つかりません。アプリを起動してから再実行してください。'
|
|
33
|
+
: '複数のCodex Desktop実行ファイルが稼働しています。使用するアプリだけを残して再実行してください。');
|
|
34
|
+
}
|
|
35
|
+
return { command: candidates[0], commandArgs, source: 'desktop-process' };
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resolveCodexRuntime } from './codex-runtime.mjs';
|
|
4
|
+
|
|
5
|
+
test('Windows Desktopは稼働中アプリのCodexを使う', () => {
|
|
6
|
+
const runtime = resolveCodexRuntime({ platform: 'win32', host: 'desktop',
|
|
7
|
+
discover: () => ['C:\\Codex\\codex.exe', 'C:\\Codex\\codex.exe'] });
|
|
8
|
+
assert.equal(runtime.command, 'C:\\Codex\\codex.exe');
|
|
9
|
+
assert.deepEqual(runtime.commandArgs, ['app-server', '--listen', 'stdio://']);
|
|
10
|
+
assert.equal(runtime.source, 'desktop-process');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('明示したapp-serverはDesktopの探索より優先する', () => {
|
|
14
|
+
const runtime = resolveCodexRuntime({ platform: 'win32', host: 'desktop', override: 'fake.mjs',
|
|
15
|
+
discover: () => { throw new Error('探索してはいけない'); } });
|
|
16
|
+
assert.equal(runtime.command, 'fake.mjs');
|
|
17
|
+
assert.deepEqual(runtime.commandArgs, []);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('対象外のOSとhostは従来のCodexを使う', () => {
|
|
21
|
+
for (const [platform, host] of [['darwin', 'desktop'], ['win32', 'cli'], ['win32', 'vscode']]) {
|
|
22
|
+
assert.equal(resolveCodexRuntime({ platform, host }).command, 'codex');
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('Desktopの実体がない場合や複数ある場合は古いCLIへ切り替えない', () => {
|
|
27
|
+
for (const candidates of [[], ['C:\\one\\codex.exe', 'C:\\two\\codex.exe']]) {
|
|
28
|
+
assert.throws(() => resolveCodexRuntime({ platform: 'win32', host: 'desktop',
|
|
29
|
+
discover: () => candidates }), /Codex Desktop/);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { basename, delimiter, dirname, extname, isAbsolute, join } from 'node:path';
|
|
4
4
|
|
|
@@ -28,31 +28,42 @@ function resolveWindowsCommand(command, env) {
|
|
|
28
28
|
return command;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
function portableInvocation(command, args, options = {}) {
|
|
32
32
|
const platform = options.platform ?? process.platform;
|
|
33
33
|
const spawnOptions = { ...options };
|
|
34
34
|
delete spawnOptions.platform;
|
|
35
35
|
spawnOptions.shell = false;
|
|
36
36
|
|
|
37
|
-
if (platform !== 'win32') return
|
|
37
|
+
if (platform !== 'win32') return [command, args, spawnOptions];
|
|
38
38
|
|
|
39
39
|
const env = spawnOptions.env ?? process.env;
|
|
40
40
|
const resolved = resolveWindowsCommand(command, env);
|
|
41
41
|
const extension = extname(resolved).toLowerCase();
|
|
42
42
|
if (['.js', '.cjs', '.mjs'].includes(extension)) {
|
|
43
|
-
return
|
|
43
|
+
return [process.execPath, [resolved, ...args], spawnOptions];
|
|
44
44
|
}
|
|
45
45
|
if (extension === '.ps1') {
|
|
46
|
-
|
|
46
|
+
const invocation = [resolved, ...args]
|
|
47
|
+
.map(value => `'${value.replaceAll("'", "''")}'`).join(' ');
|
|
48
|
+
return ['pwsh.exe', [
|
|
47
49
|
'-NoLogo',
|
|
48
50
|
'-NoProfile',
|
|
49
51
|
'-NonInteractive',
|
|
50
52
|
'-ExecutionPolicy',
|
|
51
53
|
'Bypass',
|
|
52
|
-
'-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
'-Command',
|
|
55
|
+
'[Console]::InputEncoding = [Console]::OutputEncoding = $OutputEncoding = ' +
|
|
56
|
+
'[System.Text.UTF8Encoding]::new($false); ' +
|
|
57
|
+
`& ${invocation}; exit $LASTEXITCODE`,
|
|
58
|
+
], spawnOptions];
|
|
56
59
|
}
|
|
57
|
-
return
|
|
60
|
+
return [resolved, args, spawnOptions];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function spawnPortableSync(command, args, options = {}) {
|
|
64
|
+
return spawnSync(...portableInvocation(command, args, options));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function spawnPortable(command, args, options = {}) {
|
|
68
|
+
return spawn(...portableInvocation(command, args, options));
|
|
58
69
|
}
|
|
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
6
|
+
import { spawnPortable, spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { once } from 'node:events';
|
|
7
9
|
|
|
8
10
|
test('spawnPortableSync: Windows cmd shim preserves argv boundaries and stdin', {
|
|
9
11
|
skip: process.platform !== 'win32' ? 'Windows cmd shim contract' : undefined,
|
|
@@ -20,6 +22,7 @@ process.stdin.on('data', (chunk) => { input += chunk; });
|
|
|
20
22
|
process.stdin.on('end', () => {
|
|
21
23
|
process.stdout.write(JSON.stringify({ args: process.argv.slice(2), input }));
|
|
22
24
|
});
|
|
25
|
+
|
|
23
26
|
`);
|
|
24
27
|
writeFileSync(command, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(child)} %*\r\n`);
|
|
25
28
|
writeFileSync(powerShellShim, `& ${JSON.stringify(process.execPath)} ${JSON.stringify(child)} @args\nexit $LASTEXITCODE\n`);
|
|
@@ -38,3 +41,50 @@ process.stdin.on('end', () => {
|
|
|
38
41
|
rmSync(dir, { recursive: true, force: true });
|
|
39
42
|
}
|
|
40
43
|
});
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
test('WindowsのPATHにあるnpm起動ファイルと標準入出力で往復できる', {
|
|
47
|
+
skip: process.platform !== 'win32',
|
|
48
|
+
timeout: 10_000,
|
|
49
|
+
}, async () => {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-npm-stream-'));
|
|
51
|
+
let child;
|
|
52
|
+
try {
|
|
53
|
+
const script = join(dir, 'rpc.mjs');
|
|
54
|
+
writeFileSync(script, `
|
|
55
|
+
import { createInterface } from 'node:readline';
|
|
56
|
+
createInterface({ input: process.stdin }).on('line', (line) => {
|
|
57
|
+
process.stdout.write(JSON.stringify({ line, args: process.argv.slice(2) }) + '\\n');
|
|
58
|
+
});
|
|
59
|
+
`);
|
|
60
|
+
// npmのPowerShell起動ファイルと同じ入力分岐で、EOF前の応答を確かめる。
|
|
61
|
+
writeFileSync(join(dir, 'tl-rpc.ps1'), `
|
|
62
|
+
if ($MyInvocation.ExpectingInput) {
|
|
63
|
+
$input | & ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args
|
|
64
|
+
} else {
|
|
65
|
+
& ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args
|
|
66
|
+
}
|
|
67
|
+
exit $LASTEXITCODE
|
|
68
|
+
`);
|
|
69
|
+
const env = { ...process.env };
|
|
70
|
+
const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path');
|
|
71
|
+
env[pathKey] = `${dir};${env[pathKey]}`;
|
|
72
|
+
const args = ['空白 を含む', 'a&b', '100%'];
|
|
73
|
+
const sync = spawnPortableSync('tl-rpc', args, { env, encoding: 'utf8', input: '同期\n' });
|
|
74
|
+
assert.equal(sync.status, 0, sync.stderr || sync.error?.message);
|
|
75
|
+
assert.deepEqual(JSON.parse(sync.stdout), { line: '同期', args });
|
|
76
|
+
child = spawnPortable('tl-rpc', args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
77
|
+
const lines = createInterface({ input: child.stdout });
|
|
78
|
+
for (const line of ['初期化', '次の要求']) {
|
|
79
|
+
const response = once(lines, 'line');
|
|
80
|
+
child.stdin.write(`${line}\n`);
|
|
81
|
+
assert.deepEqual(JSON.parse((await response)[0]), { line, args });
|
|
82
|
+
}
|
|
83
|
+
const closed = once(child, 'close');
|
|
84
|
+
child.stdin.end();
|
|
85
|
+
assert.equal((await closed)[0], 0);
|
|
86
|
+
} finally {
|
|
87
|
+
child?.kill();
|
|
88
|
+
rmSync(dir, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
});
|