throughline 0.16.16 → 0.16.18
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 +46 -1
- package/docs/adr/0047-codex-internal-compaction-is-stopped-without-error.md +15 -0
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +2 -1
- package/src/cli/doctor.mjs +1 -1
- package/src/codex-app-server.mjs +1 -0
- package/src/codex-auto-handoff.mjs +23 -1
- package/src/codex-auto-handoff.test.mjs +11 -1
- package/src/hook-failure-log.mjs +7 -0
- package/src/hook-failure-log.test.mjs +16 -0
- package/src/os/open-url.mjs +2 -1
- package/src/os/open-url.test.mjs +14 -0
- package/src/os/portable-spawn-sync.mjs +5 -1
- package/src/os/portable-spawn-sync.test.mjs +22 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,49 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.18] — 2026-10-11
|
|
14
|
+
|
|
15
|
+
### 変更
|
|
16
|
+
|
|
17
|
+
- Codexの`PreCompact(auto)`のhookが`handoff_hook_identity_invalid`で失敗した時、失敗ログ(`~/.throughline/logs/hook-failures.log`)の行へ、渡された合図の形(`identity`)を残す。0.16.14で内部のID(`auto-compact-N`)を失敗から外したが、0.16.16のmacOSの実機で同じ失敗が2回出た(2026-10-10 16:55:08と16:55:15、引き継ぎ先のタスクを作った直後)。内部の圧縮の記録は0行で、0.16.14が想定した形ではなかった。失敗ログには文面と位置しか無く、どの値が不正だったかを決められなかった。
|
|
18
|
+
- 残すのは、会話のID・ターンのID・agentのID・agentの種類・モデル名・`trigger`と、`transcript_path`・`cwd`が絶対pathかどうか。pathの中身と、記号の混じる値は残さない(長さだけ)。会話の本文は入らない。
|
|
19
|
+
- 失敗の扱い(圧縮を止める・失敗として数える)は変えていない。原因が決まるまで、この失敗は今までどおり記録される。
|
|
20
|
+
|
|
21
|
+
### 対応の範囲
|
|
22
|
+
|
|
23
|
+
| | macOS | Linux | Windows |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| Codex(Desktop) | 足した。単体試験と、切り離した置き場での本物のhookの命令(不正な合図を渡して、失敗ログの行に`identity`が出る事)。**本物のCodex Desktopがこの失敗を起こす場面は、狙って起こせないので未確認。次に起きた行で原因を決める** | 足した。単体試験と、切り離した置き場での本物のhookの命令。実機(同梱0.162.0-alpha.2)では、この失敗が起きていない | 足した。単体試験と、切り離した置き場での本物のhookの命令。実機では、この失敗の記録が無い |
|
|
26
|
+
| Codex(CLI・席) | 自動継続の対象外(Desktop以外は、この検査の前後で何もしない)。対象外の呼び出しがこの検査で失敗した時は、同じ形が残る | 同じ。BellTeamのコンテナとmain-serverは自動継続が無効で、この処理へ入らない | 同じ |
|
|
27
|
+
| Claude Code | 変更なし。この失敗はCodexの`PreCompact`だけが出す。失敗ログの部品は共通だが、`identity`を付けるのはCodexのこの失敗だけ | 同じ | 同じ |
|
|
28
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
29
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## [0.16.17] — 2026-10-10
|
|
33
|
+
|
|
34
|
+
### 修正
|
|
35
|
+
|
|
36
|
+
- Windowsで、Claude Codeの自動継続の後、Windows Terminalの窓が約2秒おきに開いては消えた。後継をClaude Desktopへ移す処理は、後継が手すきになるまで`claude agents --json --all`を繰り返し呼ぶ。この処理はconsoleを持たない切り離したprocessで動き、子の`pwsh.exe`を窓を隠さずに起こしていたので、呼ぶたびに新しいconsole(既定のターミナルの窓)が出ていた。後継が手すきにならない間、最長2分、ターンが終わるたびに続いた。
|
|
37
|
+
- 子processを起こす共通の部品(`src/os/portable-spawn-sync.mjs`)が、窓を隠す指定(`windowsHide`)を既定で付ける。`claude agents`・`claude stop`・後継の起動・L1の要約(`claude -p`・`codex exec`)・更新の処理が対象。
|
|
38
|
+
- URLを開く`cmd.exe /c start`と、手動の引き継ぎのapp-serverの起動にも付けた。開く先のアプリの窓は隠れない。
|
|
39
|
+
- 端末が要る`claude --desktop --resume`は、今までどおり最小化した新しいconsoleの中で呼ぶ(1回だけ)。
|
|
40
|
+
- 手すきを待つ間隔を1秒から3秒へ広げた。
|
|
41
|
+
|
|
42
|
+
### 対応の範囲
|
|
43
|
+
|
|
44
|
+
| | macOS | Linux | Windows |
|
|
45
|
+
|---|---|---|---|
|
|
46
|
+
| Claude Code | 窓の指定は効かないOS。手すきを待つ間隔だけ変わる(3秒)。単体試験とCI | 窓の指定は効かないOS。単体試験とCI。Desktopへ移す処理はLinuxでは動かない | 直した。実機の画面のあるsessionで、consoleを持たない切り離したprocessから`claude agents`を呼び、現れた窓を30msおきに数えた。0.16.16は3回の呼び出しでWindows Terminalの窓が3回、直した版は5回の呼び出しで0回。呼び出しの結果(一覧)は同じに読めた |
|
|
47
|
+
| Codex | 同じ(効かないOS) | 同じ | 直した。URLを開く所は、実機で窓を隠した`cmd.exe`からアプリが開く事を確かめた(電卓のURLで代用)。自動継続のworkerが起こすL1の要約(`codex exec`)は同じ部品を通るが、個別には確かめていない |
|
|
48
|
+
| Grok | 同じ(効かないOS) | 同じ | 直した。Stopのhookが起こすL1の要約が同じ部品を通る。個別には確かめていない |
|
|
49
|
+
| Cursor | 同じ(効かないOS) | 同じ | 直した。Grokと同じ |
|
|
50
|
+
|
|
51
|
+
### 確認していない範囲
|
|
52
|
+
|
|
53
|
+
- 本物の引き継ぎの後に窓が出ない事(公開して端末へ入れた後に確かめる)。
|
|
54
|
+
- 後継が手すきにならない理由(裏で動き続ける処理を持つ会話など)。その間、後継はClaude Desktopへ移らない。
|
|
55
|
+
|
|
13
56
|
## [0.16.16] — 2026-10-10
|
|
14
57
|
|
|
15
58
|
### 追加
|
|
@@ -2246,7 +2289,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2246
2289
|
|
|
2247
2290
|
---
|
|
2248
2291
|
|
|
2249
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2292
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.18...HEAD
|
|
2293
|
+
[0.16.18]: https://github.com/kitepon/Throughline/compare/v0.16.17...v0.16.18
|
|
2294
|
+
[0.16.17]: https://github.com/kitepon/Throughline/compare/v0.16.16...v0.16.17
|
|
2250
2295
|
[0.16.16]: https://github.com/kitepon/Throughline/compare/v0.16.15...v0.16.16
|
|
2251
2296
|
[0.16.15]: https://github.com/kitepon/Throughline/compare/v0.16.14...v0.16.15
|
|
2252
2297
|
[0.16.14]: https://github.com/kitepon/Throughline/compare/v0.16.13...v0.16.14
|
|
@@ -38,3 +38,18 @@ Codex は、利用者のターンとは別に、自分で作る内部の文脈
|
|
|
38
38
|
- 内部の文脈からの圧縮で、失敗の記録が増えなくなる。圧縮を止める動きは変わらない。
|
|
39
39
|
- 内部の文脈からの圧縮が何度も起きるタスクでも、圧縮は通らない。利用者のターンの `PreCompact` が来るまで、文脈はそのまま残る。
|
|
40
40
|
- Codex が内部の ID の形を変えると、また失敗として記録される。その時は記録の message で気づける。
|
|
41
|
+
|
|
42
|
+
## 追記(2026-10-11)
|
|
43
|
+
|
|
44
|
+
0.16.16 の macOS の実機で、同じ `handoff_hook_identity_invalid` が2回出た(2026-10-10 16:55:08 と 16:55:15)。どちらも、
|
|
45
|
+
引き継ぎ先のタスクを作った直後(作成の 1.4 秒後と、最初のターンの開始の 1.5 秒後)。`internal-compactions.jsonl` は
|
|
46
|
+
0行だった。この ADR が想定した形(ターンの ID が `auto-compact-N`)の呼び出しではなかった事になる。
|
|
47
|
+
|
|
48
|
+
- Context に書いた「内部の文脈から `PreCompact(auto)` を呼ぶ」は、Codex の source を読んだ推測で、実機では確かめていない。
|
|
49
|
+
`next_internal_sub_id` の ID は、`thread/inject_items` の文脈(`new_inject_items_context`)にも使われる。
|
|
50
|
+
- hook へ渡る `transcript_path` は null になり得る(`codex-rs/hooks/src/schema.rs` の `NullableString`)。ターンの中の
|
|
51
|
+
圧縮は、ターンの ID(UUID)をそのまま渡す(`with_model` は `sub_id` を引き継ぐ)。どの値が不正だったかは、失敗ログに
|
|
52
|
+
文面と位置しか無く、決められなかった。
|
|
53
|
+
- 0.16.18 で、この失敗の行へ合図の形(`identity`。ID・種類・絶対 path かどうか)を残す。次に起きた行を読んで、
|
|
54
|
+
扱いを決める。この ADR の Decision 1〜4 は、それまで変えない。
|
|
55
|
+
|
package/package.json
CHANGED
|
@@ -778,7 +778,8 @@ export async function runClaudeDesktopOpen(sourceSessionId, {
|
|
|
778
778
|
spawn = spawnPortableSync,
|
|
779
779
|
pty = runWithPty,
|
|
780
780
|
idleTimeoutMs = 120_000,
|
|
781
|
-
|
|
781
|
+
// 一覧を読むたびに `claude agents` を起こす(Windows では pwsh 経由)。手すきは急いで知る必要が無いので、間を空ける。
|
|
782
|
+
pollMs = 3_000,
|
|
782
783
|
} = {}) {
|
|
783
784
|
let record = readClaudeAutoHandoff(sourceSessionId, dir);
|
|
784
785
|
if (!record) throw new ClaudeHandoffError('handoff_not_found');
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -1014,7 +1014,7 @@ export async function run(argv = []) {
|
|
|
1014
1014
|
await check('throughline コマンドが PATH で見つかるか', () => {
|
|
1015
1015
|
try {
|
|
1016
1016
|
const which = process.platform === 'win32' ? 'where throughline' : 'which throughline';
|
|
1017
|
-
const result = execSync(which, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
1017
|
+
const result = execSync(which, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
|
|
1018
1018
|
return result.split(/\r?\n/)[0];
|
|
1019
1019
|
} catch {
|
|
1020
1020
|
throw new Error('見つからない — npm install -g throughline を実行してください');
|
package/src/codex-app-server.mjs
CHANGED
|
@@ -1649,6 +1649,7 @@ function startAppServerClient({ command, args, cwd, timeoutMs, requestTimeoutMs
|
|
|
1649
1649
|
const child = spawn(command, args, {
|
|
1650
1650
|
cwd,
|
|
1651
1651
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1652
|
+
windowsHide: true,
|
|
1652
1653
|
});
|
|
1653
1654
|
let stdoutBuffer = '';
|
|
1654
1655
|
let stderr = '';
|
|
@@ -106,6 +106,26 @@ function openSuccessor(operation) {
|
|
|
106
106
|
// Codex が内部の文脈へ付けるターンの ID(`codex-rs/core/src/session/mod.rs` の `next_internal_sub_id`)。
|
|
107
107
|
const INTERNAL_COMPACTION_TURN_ID_PATTERN = /^auto-compact-\d+$/;
|
|
108
108
|
|
|
109
|
+
const SAFE_IDENTITY_TEXT_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
110
|
+
const identityText = value => typeof value !== 'string' ? (value === null ? 'null' : typeof value)
|
|
111
|
+
: SAFE_IDENTITY_TEXT_PATTERN.test(value) ? value : `text(${value.length})`;
|
|
112
|
+
const identityPath = value => typeof value !== 'string' ? (value === null ? 'null' : typeof value)
|
|
113
|
+
: isAbsolute(value) ? 'absolute' : `relative(${value.length})`;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 引き継ぎの起点にできなかった合図の形。失敗ログへ残して、どの値が不正だったかを後から読めるようにする
|
|
117
|
+
* (2026-10-10、macOS で `handoff_hook_identity_invalid` が5回出たが、ログに形が無く原因を決められなかった)。
|
|
118
|
+
* ID・モデル名・種類だけを残す。path は絶対かどうかだけ。記号の混じる値は長さだけ。会話の本文は入らない。
|
|
119
|
+
*/
|
|
120
|
+
export function hookIdentityShape(payload = {}) {
|
|
121
|
+
return {
|
|
122
|
+
session_id: identityText(payload.session_id), turn_id: identityText(payload.turn_id),
|
|
123
|
+
transcript_path: identityPath(payload.transcript_path), cwd: identityPath(payload.cwd),
|
|
124
|
+
agent_id: identityText(payload.agent_id), agent_type: identityText(payload.agent_type),
|
|
125
|
+
model: identityText(payload.model), trigger: identityText(payload.trigger),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
109
129
|
/** ターンの外の自動圧縮を止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
110
130
|
function recordInternalCompactionStop(entry) {
|
|
111
131
|
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
@@ -179,7 +199,9 @@ export async function requestCodexAutoHandoff({ payload, db = null,
|
|
|
179
199
|
return { status: 'ok', inserted: false, internalCompaction: true, continue: false,
|
|
180
200
|
stopReason: 'Throughlineが、ターンの外の自動圧縮を止めました。文脈が上限に近づいたターンで、新しいタスクへ引き継ぎます。' };
|
|
181
201
|
}
|
|
182
|
-
if (!located || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? ''))
|
|
202
|
+
if (!located || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '')) {
|
|
203
|
+
throw Object.assign(error('handoff_hook_identity_invalid'), { hookIdentity: hookIdentityShape(payload) });
|
|
204
|
+
}
|
|
183
205
|
// JS実装のrealpathは、WindowsのCodex Desktopが起動したhookの中で `EISDIR: lstat 'C:'` で落ちる。OSのrealpathを使う。
|
|
184
206
|
const rolloutPath = realpathSync.native(payload.transcript_path);
|
|
185
207
|
const state = readCodexHandoffState(rolloutPath, { threadId, turnId });
|
|
@@ -15,7 +15,7 @@ import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePrepa
|
|
|
15
15
|
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
|
|
16
16
|
autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
|
|
17
17
|
markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
|
|
18
|
-
untrackedInheritedMemory, resolveAutoHandoffSuccessor } from './codex-auto-handoff.mjs';
|
|
18
|
+
untrackedInheritedMemory, resolveAutoHandoffSuccessor, hookIdentityShape } from './codex-auto-handoff.mjs';
|
|
19
19
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
20
20
|
|
|
21
21
|
async function withDb(fn) {
|
|
@@ -364,6 +364,16 @@ test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外に
|
|
|
364
364
|
await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, ...broken } }), /handoff_hook_identity_invalid/);
|
|
365
365
|
}
|
|
366
366
|
assert.equal(recorded.length, 1);
|
|
367
|
+
|
|
368
|
+
// 失敗には、どの値が不正だったかの形を付ける。path と記号の混じる値は、中身を残さない
|
|
369
|
+
const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path: null,
|
|
370
|
+
agent_type: 'reviewer', model: 'gpt-5.5' } }).catch(error => error);
|
|
371
|
+
assert.equal(shaped.code, 'handoff_hook_identity_invalid');
|
|
372
|
+
assert.deepEqual(shaped.hookIdentity, { session_id: thread, turn_id: 'text(14)', transcript_path: 'null', cwd: 'absolute',
|
|
373
|
+
agent_id: 'undefined', agent_type: 'reviewer', model: 'gpt-5.5', trigger: 'auto' });
|
|
374
|
+
assert.deepEqual(hookIdentityShape({ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative/rollout.jsonl', cwd: 7 }),
|
|
375
|
+
{ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative(22)', cwd: 'number',
|
|
376
|
+
agent_id: 'undefined', agent_type: 'undefined', model: 'undefined', trigger: 'undefined' });
|
|
367
377
|
}));
|
|
368
378
|
|
|
369
379
|
test('PreCompactの入口は、JS実装のrealpathが使えない環境(WindowsのCodex Desktopのhook)でも引き継ぎを作る', async () => withDb(async (db, home) => {
|
package/src/hook-failure-log.mjs
CHANGED
|
@@ -62,6 +62,13 @@ export function logHookFailure(code, error, { home, now = Date.now(), stderr = p
|
|
|
62
62
|
if (typeof context?.transcript_path === 'string' && context.transcript_path) {
|
|
63
63
|
entry.transcript_path = context.transcript_path;
|
|
64
64
|
}
|
|
65
|
+
// 不正な合図の形(errorが `hookIdentity` を持つ時)。文面が同じ失敗でも、どの値が不正だったかを残す。
|
|
66
|
+
// 値は呼び出し側が ID・種類・「絶対 path かどうか」へ直してある。ここでは短い文字列だけを通す。
|
|
67
|
+
const identity = error?.hookIdentity;
|
|
68
|
+
if (identity && typeof identity === 'object') {
|
|
69
|
+
const kept = Object.entries(identity).filter(([, value]) => typeof value === 'string' && value.length <= 80);
|
|
70
|
+
if (kept.length > 0) entry.identity = Object.fromEntries(kept);
|
|
71
|
+
}
|
|
65
72
|
// 外部 CLI の失敗は、message が終了 code だけになる。理由(reason)と CLI の stderr の末尾も残す
|
|
66
73
|
// (CLI は先頭に起動情報を出し、失敗の理由は末尾に出す)。
|
|
67
74
|
if (typeof error?.reason === 'string' && error.reason) entry.reason = error.reason;
|
|
@@ -50,6 +50,22 @@ test('logHookFailureは、外部CLIの失敗の理由とstderrの末尾を残す
|
|
|
50
50
|
}
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
test('logHookFailureは、不正な合図の形を残す', () => {
|
|
54
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
55
|
+
try {
|
|
56
|
+
const shaped = new Error('handoff_hook_identity_invalid');
|
|
57
|
+
shaped.hookIdentity = { turn_id: 'auto-compact-', transcript_path: 'null', cwd: 'absolute', long: 'x'.repeat(81), count: 3 };
|
|
58
|
+
logHookFailure('HOOK_CODEX_FAILED', shaped, { home });
|
|
59
|
+
logHookFailure('HOOK_CODEX_FAILED', new Error('plain'), { home });
|
|
60
|
+
const [first, second] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
61
|
+
// 短い文字列だけを通す(長い値と文字列でない値は落とす)。
|
|
62
|
+
assert.deepEqual(first.identity, { turn_id: 'auto-compact-', transcript_path: 'null', cwd: 'absolute' });
|
|
63
|
+
assert.equal('identity' in second, false);
|
|
64
|
+
} finally {
|
|
65
|
+
rmSync(home, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
53
69
|
test('logHookFailureは、どの会話の失敗かを残す', () => {
|
|
54
70
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
55
71
|
try {
|
package/src/os/open-url.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export function openUrlWithOsHandler(url, {
|
|
|
11
11
|
spawnImpl = spawnSync,
|
|
12
12
|
} = {}) {
|
|
13
13
|
if (platform === 'darwin') return spawnImpl('open', [url], { encoding: 'utf8' });
|
|
14
|
-
|
|
14
|
+
// cmd.exe 自身の窓は隠す(console を持たない worker から呼ぶと、窓が一瞬出る)。start が開く先の窓は、これでは隠れない。
|
|
15
|
+
if (platform === 'win32') return spawnImpl('cmd.exe', ['/c', 'start', '', url], { encoding: 'utf8', windowsHide: true });
|
|
15
16
|
return spawnImpl('xdg-open', [url], { encoding: 'utf8' });
|
|
16
17
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { openUrlWithOsHandler } from './open-url.mjs';
|
|
4
|
+
|
|
5
|
+
test('openUrlWithOsHandler: OS ごとの命令で開き、Windows では cmd.exe の窓を隠す', () => {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const spawnImpl = (command, args, options) => { calls.push({ command, args, options }); return { status: 0 }; };
|
|
8
|
+
openUrlWithOsHandler('codex://threads/abc', { platform: 'win32', spawnImpl });
|
|
9
|
+
openUrlWithOsHandler('codex://threads/abc', { platform: 'darwin', spawnImpl });
|
|
10
|
+
openUrlWithOsHandler('codex://threads/abc', { platform: 'linux', spawnImpl });
|
|
11
|
+
assert.deepEqual(calls[0], { command: 'cmd.exe', args: ['/c', 'start', '', 'codex://threads/abc'], options: { encoding: 'utf8', windowsHide: true } });
|
|
12
|
+
assert.deepEqual([calls[1].command, calls[1].args], ['open', ['codex://threads/abc']]);
|
|
13
|
+
assert.deepEqual([calls[2].command, calls[2].args], ['xdg-open', ['codex://threads/abc']]);
|
|
14
|
+
});
|
|
@@ -28,11 +28,15 @@ function resolveWindowsCommand(command, env) {
|
|
|
28
28
|
return command;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function portableInvocation(command, args, options = {}) {
|
|
31
|
+
export 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
|
+
// Windows では、console を持たない process(切り離した worker など)が console の子を起こすと、新しい console が作られ、
|
|
37
|
+
// 既定のターミナル(Windows Terminal)の窓が一瞬出る。出力は pipe で受けるので、窓は要らない。
|
|
38
|
+
// 窓が要る呼び出し(端末が要る命令)は os/pty-run.mjs が別に持つ。ほかの OS では無視される。
|
|
39
|
+
spawnOptions.windowsHide ??= true;
|
|
36
40
|
|
|
37
41
|
if (platform !== 'win32') return [command, args, spawnOptions];
|
|
38
42
|
|
|
@@ -3,7 +3,7 @@ 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 { spawnPortable, spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
6
|
+
import { portableInvocation, spawnPortable, spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
7
7
|
import { createInterface } from 'node:readline';
|
|
8
8
|
import { once } from 'node:events';
|
|
9
9
|
|
|
@@ -88,3 +88,24 @@ exit $LASTEXITCODE
|
|
|
88
88
|
rmSync(dir, { recursive: true, force: true });
|
|
89
89
|
}
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
test('portableInvocation: 窓を隠す指定を既定で付ける。呼び出し側が false を渡した時だけ外す', () => {
|
|
93
|
+
for (const platform of ['win32', 'linux', 'darwin']) {
|
|
94
|
+
const [, , hidden] = portableInvocation('claude', ['agents', '--json', '--all'], { platform, env: { PATH: '' }, encoding: 'utf8' });
|
|
95
|
+
assert.equal(hidden.windowsHide, true, platform);
|
|
96
|
+
assert.equal(hidden.shell, false); assert.equal(hidden.encoding, 'utf8'); assert.equal('platform' in hidden, false);
|
|
97
|
+
const [, , shown] = portableInvocation('claude', ['agents'], { platform, env: { PATH: '' }, windowsHide: false });
|
|
98
|
+
assert.equal(shown.windowsHide, false, platform);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('portableInvocation: Windows の .ps1 shim は pwsh.exe 経由で、窓を隠して起こす', () => {
|
|
103
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-portable-hide-'));
|
|
104
|
+
try {
|
|
105
|
+
writeFileSync(join(dir, 'claude.ps1'), 'exit 0\n');
|
|
106
|
+
const [command, args, options] = portableInvocation('claude', ['stop', 'abcd1234'], { platform: 'win32', env: { PATH: dir } });
|
|
107
|
+
assert.equal(command, 'pwsh.exe');
|
|
108
|
+
assert.ok(args.at(-1).includes(join(dir, 'claude.ps1')) && args.at(-1).includes("'stop' 'abcd1234'"));
|
|
109
|
+
assert.equal(options.windowsHide, true);
|
|
110
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
111
|
+
});
|