@awak-app/simy-cli 0.7.25 → 0.7.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +8 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +22 -0
  4. package/src/cli-contract.js +1 -1
  5. package/src/cli-observability.js +262 -0
  6. package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
  7. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +76 -11
  8. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
  9. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
  10. package/src/connection-health.js +253 -0
  11. package/src/connection-presentation.js +85 -0
  12. package/src/connection-recovery.js +102 -0
  13. package/src/console/app.js +60 -6
  14. package/src/console/commands.js +3 -0
  15. package/src/console/index.js +1 -0
  16. package/src/delivery-hooks/command.js +8 -0
  17. package/src/delivery-hooks/legacy-hook-routing.js +24 -0
  18. package/src/delivery-hooks/supervisor.js +14 -14
  19. package/src/desktop-host.js +49 -10
  20. package/src/direct-executor.js +8 -2
  21. package/src/doctor.js +1 -0
  22. package/src/index.js +12 -2
  23. package/src/remote-connection-guide.js +222 -0
  24. package/src/session-supervision/analysis-manager.js +24 -4
  25. package/src/session-supervision/automatic-continuation.js +69 -18
  26. package/src/session-supervision/codex-app-server.js +52 -7
  27. package/src/session-supervision/codex-peer-opinion.js +27 -5
  28. package/src/session-supervision/consistency-auditor.js +1 -1
  29. package/src/session-supervision/continuation-analysis.js +7 -3
  30. package/src/session-supervision/decision-presentation.js +1 -1
  31. package/src/session-supervision/index.js +23 -15
  32. package/src/session-supervision/outcome-plan.js +76 -0
  33. package/src/session-supervision/purpose-instruction.js +28 -0
  34. package/src/session-supervision/recovery-frontier.js +14 -4
  35. package/src/session-supervision/remote-command-queue.js +15 -0
  36. package/src/session-supervision/remote-control.js +3 -0
  37. package/src/session-supervision/remote-hosts.js +70 -19
  38. package/src/session-supervision/remote-managed-delivery.js +94 -0
  39. package/src/session-supervision/remote-session-list.js +1 -1
  40. package/src/session-supervision/statistics.js +10 -3
  41. package/src/session-supervision/stop-notifications.js +26 -10
  42. package/src/session-supervision/work-state.js +5 -0
  43. package/src/sqm/claude-plugin.js +8 -1
@@ -0,0 +1,222 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { homedir, hostname, userInfo } from 'node:os';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { friendlyCheck } from './connection-presentation.js';
7
+
8
+ const execute = promisify(execFile);
9
+ const aliasPattern = /^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/;
10
+ const quote = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
11
+ const safeText = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, '');
12
+ const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=8'];
13
+
14
+ export function assertRemoteAlias(alias) {
15
+ if (typeof alias !== 'string' || !aliasPattern.test(alias)) throw new Error('一覧に表示された接続先名を指定してください。一覧を見るには simy connections を実行してください。');
16
+ return alias;
17
+ }
18
+
19
+ // Discovery reads local configuration only. It never probes hosts, executes
20
+ // Match/ProxyCommand directives, imports credentials, or changes registrations.
21
+ export async function discoverRemoteConnections({ home = homedir(), root = process.env.SIMY_HOME?.trim() || join(home, '.simy') } = {}) {
22
+ const hosts = new Map(), warnings = [], visited = new Set();
23
+ const add = (alias, source, label = alias, enabled = true) => {
24
+ if (!aliasPattern.test(alias) || hosts.size >= 128) return;
25
+ const current = hosts.get(alias);
26
+ if (current) { if (!current.sources.includes(source)) current.sources.push(source); return; }
27
+ hosts.set(alias, { alias, label: safeText(label).slice(0, 80), enabled, sources: [source] });
28
+ };
29
+ try {
30
+ const saved = JSON.parse(await readFile(join(root, 'remote-hosts.json'), 'utf8'));
31
+ if (saved.schemaVersion !== 1 || !Array.isArray(saved.hosts) || saved.hosts.length > 128) throw new Error('invalid');
32
+ for (const host of saved.hosts) {
33
+ if (!host || typeof host.alias !== 'string' || !aliasPattern.test(host.alias) ||
34
+ (host.enabled !== undefined && typeof host.enabled !== 'boolean')) throw new Error('invalid');
35
+ add(host.alias, 'SIMY', typeof host.label === 'string' ? host.label : host.alias, host.enabled !== false);
36
+ }
37
+ } catch (error) { if (error.code !== 'ENOENT') warnings.push('SIMYの端末設定を読み取れません。設定を確認してください。'); }
38
+ async function scan(file) {
39
+ const location = resolve(file);
40
+ if (visited.has(location)) return;
41
+ if (visited.size >= 32) { warnings.push('接続設定が多いため、一部のパソコンを省略しました。見つからない場合は設定を手伝う人に確認してください。'); return; }
42
+ visited.add(location);
43
+ let content;
44
+ try { content = await readFile(location, 'utf8'); }
45
+ catch (error) { if (error.code !== 'ENOENT') warnings.push('パソコンへの接続設定の一部を読み取れません。'); return; }
46
+ if (Buffer.byteLength(content) > 256 * 1024) { warnings.push('接続設定のファイルが大きすぎるため、一部のパソコンを省略しました。'); return; }
47
+ for (const line of content.split(/\r?\n/)) {
48
+ const tokens = sshTokens(line);
49
+ const key = tokens.shift()?.toLowerCase();
50
+ if (key === 'host') for (const alias of tokens) add(alias, 'SSH');
51
+ if (key === 'include') {
52
+ for (const token of tokens) {
53
+ const expanded = token.startsWith('~/') ? join(home, token.slice(2)) : isAbsolute(token) ? token : join(home, '.ssh', token);
54
+ for (const included of await expandIncludes(expanded)) await scan(included);
55
+ }
56
+ }
57
+ }
58
+ }
59
+ await scan(join(home, '.ssh', 'config'));
60
+ return { hosts: [...hosts.values()], warnings: [...new Set(warnings)] };
61
+ }
62
+
63
+ function sshTokens(line) {
64
+ const result = []; let token = '', quoted = false, escaped = false;
65
+ for (const character of line) {
66
+ if (escaped) { token += character; escaped = false; continue; }
67
+ if (character === '\\') { escaped = true; continue; }
68
+ if (character === '"') { quoted = !quoted; continue; }
69
+ if (!quoted && character === '#') break;
70
+ if (!quoted && (/\s/.test(character) || (character === '=' && result.length === 0))) {
71
+ if (token) { result.push(token); token = ''; }
72
+ } else token += character;
73
+ }
74
+ if (token) result.push(token);
75
+ return result;
76
+ }
77
+
78
+ async function expandIncludes(pattern) {
79
+ if (!/[?*\[]/.test(pattern)) return [pattern];
80
+ const parent = dirname(pattern);
81
+ const parents = /[?*\[]/.test(parent) ? await expandIncludes(parent) : [parent];
82
+ const name = pattern.slice(parent.length + 1);
83
+ // OpenSSH commonly uses Include config.d/*.conf. Unsupported bracket globs
84
+ // are not interpreted as literal host names or executed by a shell.
85
+ if (name.includes('[')) return [];
86
+ const matcher = new RegExp('^' + name.split('').map(c => c === '*' ? '.*' : c === '?' ? '.' : c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('') + '$');
87
+ const found = [];
88
+ for (const directory of parents.slice(0, 32)) {
89
+ try {
90
+ for (const entry of (await readdir(directory)).sort()) {
91
+ if (matcher.test(entry)) found.push(join(directory, entry));
92
+ if (found.length >= 32) return found;
93
+ }
94
+ } catch { /* An unmatched Include contributes no candidates. */ }
95
+ }
96
+ return found;
97
+ }
98
+
99
+ export function connectionStartupHint(discovery) {
100
+ if (!discovery.hosts.length && !discovery.warnings.length) return '';
101
+ return `保存されているパソコン: ${discovery.hosts.length}件(まだ接続できるかは未確認)。SIMYの入力欄で /connections、または別のターミナルで simy connections を入力してください。`;
102
+ }
103
+
104
+ export function connectionGuideLines(alias) {
105
+ assertRemoteAlias(alias);
106
+ return [
107
+ `接続先のパソコン: ${alias}`,
108
+ 'このパソコンでターミナルをもう1つ開き、次の1行をコピーして実行してください。',
109
+ `simy connections check ${alias}`,
110
+ '「相手のパソコンにつながりました」と表示されたら、次の1行で相手のSIMYを開けます。',
111
+ `simy connections connect ${alias}`,
112
+ '相手側のSIMY画面が表示されたら接続完了です。相手のSIMYを終了すると、このパソコンに戻ります。',
113
+ 'つながらない場合は、このパソコンで次の1行を実行してください。順番に対処方法をご案内します。',
114
+ `simy connections recover ${alias}`,
115
+ '相手からこちらへ接続する方法もあります。上の案内で「相手のパソコンからつなぐ方法を見る」を選んでください。',
116
+ 'その方法では、相手側で simy connections tunnel 接続先名 を使います。「接続先名」には相手側の一覧でこのパソコンを指す名前を入れます。',
117
+ '接続中は相手側のターミナルを開いたままにします。詳しい手順は上の対処方法で確認できます。',
118
+ '接続先の住所(IPアドレス)が変わった場合は、接続設定をした人に設定の更新を依頼してください。',
119
+ '設定に名前を使っている場合は、接続のたびに現在の住所を調べます。'
120
+ ];
121
+ }
122
+
123
+ export function reverseCliCommand(port, { name = hostname(), user = userInfo().username } = {}) {
124
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid tunnel port.');
125
+ return `ssh -t -o ${quote(`HostKeyAlias=${name}`)} -p ${port} -l ${quote(user)} 127.0.0.1 "exec sh -lc 'exec simy'"`;
126
+ }
127
+
128
+ export async function runRemoteConnectionsCommand(argv, { discovery, output = console.log, errorOutput = console.error,
129
+ executeImpl = execute, spawnImpl = spawn, home, root, webOrigin, identity = { name: hostname(), user: userInfo().username } } = {}) {
130
+ if (['diagnose', 'recover'].includes(argv[0])) {
131
+ const { runConnectionRecovery } = await import('./connection-recovery.js');
132
+ return runConnectionRecovery(argv, { output, root, webOrigin });
133
+ }
134
+ const [action, alias, ...extra] = argv;
135
+ const localPort = extra.length === 2 && extra[0] === '--local-port' && /^\d+$/.test(extra[1]) ? Number(extra[1]) : 22;
136
+ if ((extra.length && !(action === 'tunnel' && extra.length === 2 && extra[0] === '--local-port' && /^\d+$/.test(extra[1]) && localPort >= 1 && localPort <= 65535)) || (['guide', 'check', 'connect', 'tunnel'].includes(action) && !alias) ||
137
+ (action && !['guide', 'check', 'connect', 'tunnel', '--help', '-h'].includes(action)) ||
138
+ (['--help', '-h'].includes(action) && alias)) throw new Error('入力内容を確認してください。simy connections を実行すると接続先の一覧と使い方が表示されます。');
139
+ if (!action || ['--help', '-h'].includes(action)) {
140
+ const candidates = discovery || await discoverRemoteConnections({ home, root });
141
+ output('別のパソコンのSIMYにつなぐ');
142
+ output('保存されている接続先です。まだ接続できるかは確認していません。');
143
+ for (const host of candidates.hosts) output(` ${host.alias} — ${host.label}${host.enabled ? '' : '(SIMYとの接続解除済み)'}`);
144
+ for (const warning of candidates.warnings) output(warning);
145
+ if (!candidates.hosts.length) output('保存されているパソコンがありません。接続先を用意した人に、接続設定と接続先名を確認してください。');
146
+ else {
147
+ output('このパソコンのターミナルで、次の1行をコピーして実行すると手順が表示されます。');
148
+ output(`simy connections guide ${candidates.hosts[0].alias}`);
149
+ output('別のパソコンを選ぶ場合は、最後の名前を上の一覧の接続先名に置き換えてください。');
150
+ }
151
+ output('SIMYがつながらないとき: simy connections recover');
152
+ output('Codexのログインや連携を確認: simy connections recover provider:codex');
153
+ output('Claudeのログインや連携を確認: simy connections recover provider:claude');
154
+ output('Webサービスを確認: simy connections recover に続けて、半角スペースと確認したいWebアドレスを入力してください。');
155
+ output('確認だけをする場合は recover を diagnose に置き換えてください。');
156
+ output('設定を手伝う人向け: diagnose / recover に --details で技術情報、--json で機械向けの結果を表示できます。');
157
+ output('外部サービスの接続キーは --token-env 環境変数名 で指定できます。設定方法はサービスの担当者に確認してください。');
158
+ return 0;
159
+ }
160
+ assertRemoteAlias(alias);
161
+ if (action === 'guide') { connectionGuideLines(alias).forEach(line => output(line)); return 0; }
162
+ if (action === 'check') {
163
+ try {
164
+ const result = await executeImpl('ssh', ['-T', ...SSH_OPTIONS, '-o', 'BatchMode=yes', '--', alias, "sh -lc 'command -v simy'"],
165
+ { timeout: 15_000, maxBuffer: 64 * 1024, windowsHide: true });
166
+ if (!result.stdout?.trim()) throw new Error('CLI not found');
167
+ output(`${alias}: 相手のパソコンにつながりました。SIMYも見つかりました。相手側のSIMYへのログインは、開いてから確認してください。`);
168
+ output(`接続: simy connections connect ${alias}`);
169
+ return 0;
170
+ } catch (error) {
171
+ const { classifySshFailure, issueFor } = await import('./connection-health.js');
172
+ const issue = friendlyCheck(issueFor(classifySshFailure(error)));
173
+ errorOutput(`${alias}: ${issue.message}`);
174
+ issue.steps.forEach((step, i) => output(`${i + 1}. ${step}`));
175
+ output('このパソコンのターミナルで、次の1行をコピーして実行してください。順番に対処方法をご案内します。');
176
+ output(`simy connections recover ${alias}`);
177
+ return 1;
178
+ }
179
+ }
180
+ if (action === 'connect') {
181
+ output(`${alias} のSIMYを開きます。相手側のSIMY画面が表示されたら接続完了です。相手のSIMYを終了すると、このパソコンに戻ります。`);
182
+ return await waitForSsh(spawnImpl('ssh', ['-t', ...SSH_OPTIONS, '--', alias, "exec sh -lc 'exec simy'"], { stdio: 'inherit', shell: false }), errorOutput);
183
+ }
184
+ output(`${alias} からこのパソコンへつなぐための、一時的な接続経路を作ります。このターミナルは開いたままにしてください。`);
185
+ output('次のコマンドが表示されるまでお待ちください。中止するにはCtrlキーを押しながらCキーを押してください。');
186
+ const child = spawnImpl('ssh', ['-v', '-N', ...SSH_OPTIONS, '-o', 'ExitOnForwardFailure=yes',
187
+ '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3', '-R', `127.0.0.1:0:127.0.0.1:${localPort}`, '--', alias],
188
+ { stdio: ['inherit', 'inherit', 'pipe'], shell: false });
189
+ let buffer = '', announced = false;
190
+ child.stderr.on('data', data => {
191
+ // Keep verbose SSH output private; show a plain-language remedy on failure.
192
+ buffer = (buffer + data.toString('utf8')).slice(-8192);
193
+ const match = buffer.match(/Allocated port (\d+) for remote forward/);
194
+ if (!announced && match) {
195
+ const port = Number(match[1]);
196
+ if (port < 1 || port > 65535) return;
197
+ announced = true;
198
+ output(`接続経路ができました。${alias} のパソコンでターミナルを開き、下の1行をコピーして実行してください:`);
199
+ output(reverseCliCommand(port, identity));
200
+ output('相手の身元を確認する英語の質問が出たら、分からないまま許可せず、接続設定をした人に確認してください。担当者は、今この経路を作ったパソコンで次を実行して確認情報を照合できます:');
201
+ output('ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub');
202
+ output('相手からこのパソコンのSIMYが開けば接続完了です。接続中はこのターミナルを開いたままにしてください。閉じると接続も切れます。');
203
+ }
204
+ });
205
+ const result = await waitForSsh(child, errorOutput);
206
+ if (!announced) {
207
+ const { classifySshFailure } = await import('./connection-health.js');
208
+ const plain = friendlyCheck({ code: classifySshFailure({ stderr: buffer }) });
209
+ errorOutput('接続経路を作れたか確認できませんでした。' + plain.message);
210
+ plain.steps.forEach(step => output(step));
211
+ output(`このパソコンで次を実行すると、順番に対処方法をご案内します: simy connections recover ${alias}`);
212
+ return result || 1;
213
+ }
214
+ return result;
215
+ }
216
+
217
+ function waitForSsh(child, errorOutput) {
218
+ return new Promise(resolve => {
219
+ child.once('error', () => { errorOutput('パソコンへの接続を始められませんでした。設定を手伝う人に、OpenSSHという接続用ソフトが使えるか確認してもらってください。'); resolve(1); });
220
+ child.once('close', (code, signal) => resolve(Number.isInteger(code) ? code : signal === 'SIGINT' ? 130 : 1));
221
+ });
222
+ }
@@ -6,6 +6,7 @@ import { providerGoalEvidence } from './conversation-context.js';
6
6
 
7
7
  const BACKFILL_LIMIT = 10;
8
8
  const MAX_CACHE_ENTRIES = 2000;
9
+ const MAX_CACHE_BYTES = 4 * 1024 * 1024;
9
10
  const AUTH_RECOVERY_COOLDOWN_MS = 60_000;
10
11
  const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting', 'uncertain']);
11
12
  const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
@@ -23,7 +24,10 @@ export const continuationAnalysisRevision = candidate => createHash('sha256')
23
24
  .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, turnOf(candidate) ? ['turn', turnOf(candidate)] : activityOf(candidate),
24
25
  ...(candidate.isRunning === true ? ['running'] : []), ...(goalOf(candidate) === null ? [] : ['goal', goalOf(candidate)])]))
25
26
  .digest('hex');
26
- const eligible = candidate => candidate.capabilities?.canAnalyze === true &&
27
+ // Route analysis is read-only for bound loops too; execution remains owned by
28
+ // their existing controller. Never admit them through requireRecommendation().
29
+ const eligible = candidate => candidate.capabilities?.canAnalyze === true;
30
+ const canDispatchRecommendation = candidate => eligible(candidate) &&
27
31
  ['unmanaged', 'monitor'].includes(candidate.binding?.mode || 'unmanaged');
28
32
 
29
33
  // Recommendations are metadata only. This worker never enrolls a conversation
@@ -38,7 +42,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
38
42
  let document, cacheInfo;
39
43
  try {
40
44
  cacheInfo = await lstat(path);
41
- if (!cacheInfo.isFile() || cacheInfo.isSymbolicLink() || cacheInfo.size > 4 * 1024 * 1024) throw new Error('Invalid session analysis cache.');
45
+ if (!cacheInfo.isFile() || cacheInfo.isSymbolicLink() || cacheInfo.size > MAX_CACHE_BYTES) throw new Error('Invalid session analysis cache.');
42
46
  document = JSON.parse(await readFile(path, 'utf8'));
43
47
  const pairs = list => Array.isArray(list) && list.every(pair => Array.isArray(pair) && pair.length === 2 && typeof pair[0] === 'string' && pair[1] && typeof pair[1] === 'object');
44
48
  if (document.schemaVersion !== 1 || typeof document.initialized !== 'boolean' || !pairs(document.known) || !pairs(document.entries) ||
@@ -78,9 +82,25 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
78
82
  };
79
83
  const persist = async () => {
80
84
  for (const map of [known, entries]) while (map.size > MAX_CACHE_ENTRIES) map.delete(map.keys().next().value);
85
+ // Bound the serialized snapshot too: a count limit alone can produce a
86
+ // cache that this same reader rejects after restart. Keep in-flight jobs
87
+ // in memory; dropping optional cached recommendations must not cancel them.
88
+ const snapshot = { ...document, known: [...known], entries: [...entries] };
89
+ const encode = () => JSON.stringify(snapshot) + '\n';
90
+ let bytes = Buffer.byteLength(encode());
91
+ for (const list of [snapshot.entries, snapshot.known]) {
92
+ let remove = 0;
93
+ while (bytes > MAX_CACHE_BYTES && remove < list.length) {
94
+ bytes -= Buffer.byteLength(JSON.stringify(list[remove])) + (list.length - remove > 1 ? 1 : 0);
95
+ remove++;
96
+ }
97
+ list.splice(0, remove);
98
+ }
99
+ const payload = encode();
100
+ if (Buffer.byteLength(payload) > MAX_CACHE_BYTES) throw new Error('Session analysis metadata exceeds cache capacity.');
81
101
  const temporary = `${path}.${randomUUID()}.tmp`;
82
102
  try {
83
- await writeFile(temporary, JSON.stringify({ ...document, known: [...known], entries: [...entries] }) + '\n', { mode: 0o600 });
103
+ await writeFile(temporary, payload, { mode: 0o600 });
84
104
  await rename(temporary, path);
85
105
  } finally { await rm(temporary, { force: true }); }
86
106
  };
@@ -345,7 +365,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
345
365
  requireRecommendation(candidate, { allowDiagnosis = false } = {}) {
346
366
  if (closed || suspended) throw new Error('Session analysis is unavailable.');
347
367
  const analysis = this.project(candidate).analysis;
348
- if (!eligible(candidate) || candidate.isRunning === true || analysis.awaitingTurn === true || !(analysis.state === 'continue' || allowDiagnosis && candidate.provider === 'codex' && candidate.autoContinue === true && !analysis.incomplete && analysis.contextDigest && ['needs_user', 'uncertain', 'waiting'].includes(analysis.state))) throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
368
+ if (!canDispatchRecommendation(candidate) || candidate.isRunning === true || analysis.awaitingTurn === true || !(analysis.state === 'continue' || allowDiagnosis && candidate.provider === 'codex' && candidate.autoContinue === true && !analysis.incomplete && analysis.contextDigest && ['needs_user', 'uncertain', 'waiting'].includes(analysis.state))) throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
349
369
  return analysis;
350
370
  },
351
371
  async whenIdle() {
@@ -12,7 +12,7 @@ function continuationFailureReason(error) {
12
12
  unsupported: 'Update the original AI provider to a compatible version, then retry auto-continue.',
13
13
  process_exited: 'The original Claude process has exited. Return to the original conversation, then refresh.',
14
14
  session_ended: 'This Claude session has ended. Return to the original conversation, then refresh.',
15
- hook_missing: 'SIMY has not observed the original Claude session-start hook. Check the plugin and hook execution, then refresh.',
15
+ hook_missing: 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.',
16
16
  };
17
17
  return Object.hasOwn(reasons, error?.code) ? reasons[error.code] : 'Could not start auto-continue. Check the original conversation and retry.';
18
18
  }
@@ -38,12 +38,24 @@ export async function continueAnalyzedSession({ candidate, manager, root, settin
38
38
  return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings, expectedProviderEpoch: link.epoch });
39
39
  }
40
40
 
41
- // One pending enrollment per identity. A failed attempt is retried only after
42
- // context/opt-in changes; an existing (including terminal) run is never reset.
41
+ const transientStartFailure = code => ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN',
42
+ 'timeout', 'unavailable', 'closed'].includes(code);
43
+
44
+ function startingPresentation(candidate, status, reason) {
45
+ const work = candidate.workState;
46
+ const canPresent = work && !work.needsAttention && !['running', 'stopped', 'archived', 'approval', 'question'].includes(work.state);
47
+ return { ...candidate, autoContinueStatus: status, autoContinueReason: reason,
48
+ ...(canPresent ? { workState: { ...work, state: status,
49
+ label: status === 'starting' ? '自動継続を準備中' : '自動復旧中', reason,
50
+ nextAction: null, needsAttention: false } } : {}) };
51
+ }
52
+
53
+ // One pending start per identity. Only known transient failures may retry the
54
+ // same context, with backoff and fresh consent/task checks before each attempt.
43
55
  export function createAutomaticContinuation({ root, getManager, selectCandidate,
44
56
  readSetting = readSessionAutoContinue, readContext = readContinuationContext, enroll = continueSession,
45
- beforeEnroll = async () => {}, onChange = () => {} }) {
46
- const pending = new Map(), attempted = new Map(), errors = new Map();
57
+ beforeEnroll = async () => {}, onChange = () => {}, now = Date.now }) {
58
+ const pending = new Map(), attempted = new Map(), errors = new Map(), retries = new Map();
47
59
  let closed = false;
48
60
  const tick = candidates => {
49
61
  if (closed) return;
@@ -53,59 +65,98 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
53
65
  for (const candidate of candidates) {
54
66
  const key = `${candidate.provider}:${candidate.session}`;
55
67
  const attempt = attempted.get(key);
56
- const bound = ['managed', 'pending_takeover', 'standard_loop'].includes(candidate.binding?.mode);
68
+ const bound = ['managed', 'pending_takeover'].includes(candidate.binding?.mode);
57
69
  if (candidate.autoContinue !== true || bound ||
58
70
  attempt && !attempt.startsWith(`${candidate.autoContinueEpoch}:`)) {
59
- attempted.delete(key); errors.delete(key);
71
+ attempted.delete(key); errors.delete(key); retries.delete(key);
60
72
  }
61
73
  }
62
74
  for (const candidate of candidates) {
63
75
  if (pending.size >= 3) break;
76
+ const upgradeRunId = candidate.binding?.mode === 'standard_loop' ? candidate.binding.upgradeRunId : null;
64
77
  if (candidate.autoContinue !== true || !candidate.autoContinueEpoch || candidate.isRunning ||
65
- candidate.capabilities?.canAutoContinue !== true || candidate.capabilities?.canContinue !== true) continue;
78
+ upgradeRunId && candidate.isRunning !== false ||
79
+ ['managed', 'pending_takeover'].includes(candidate.binding?.mode) ||
80
+ candidate.capabilities?.canAutoContinue !== true || !upgradeRunId && candidate.capabilities?.canContinue !== true) continue;
66
81
  const key = `${candidate.provider}:${candidate.session}`;
67
82
  if (pending.has(key)) continue;
68
83
  let recommendation;
69
84
  const manager = getManager();
70
- try { recommendation = manager.requireRecommendation(candidate, { allowDiagnosis: candidate.provider === 'codex' && candidate.autoContinue === true }); } catch { continue; }
85
+ if (upgradeRunId) recommendation = { contextDigest: `upgrade:${upgradeRunId}` };
86
+ else try { recommendation = manager.requireRecommendation(candidate, { allowDiagnosis: candidate.provider === 'codex' && candidate.autoContinue === true }); } catch { continue; }
71
87
  const attempt = `${candidate.autoContinueEpoch}:${recommendation.contextDigest}`;
72
- if (attempted.get(key) === attempt) continue;
88
+ const retry = retries.get(key);
89
+ if (attempted.get(key) === attempt && (!retry || retry.attempt !== attempt || now() < retry.at)) continue;
90
+ if (retry?.attempt !== attempt) retries.delete(key);
73
91
  attempted.set(key, attempt);
74
92
  errors.delete(key);
75
93
  const controller = new AbortController();
76
94
  const guard = async () => {
77
- await requireProviderLink({ root, provider: candidate.provider });
95
+ const link = await requireProviderLink({ root, provider: candidate.provider });
78
96
  const setting = await readSetting({ root, provider: candidate.provider, session: candidate.session });
79
- if (closed || controller.signal.aborted || !setting.enabled || setting.epoch !== candidate.autoContinueEpoch || setting.hasRun) throw new Error('Automatic continuation was disabled or already enrolled.');
80
- return setting;
97
+ if (closed || controller.signal.aborted || !setting.enabled || setting.epoch !== candidate.autoContinueEpoch ||
98
+ (upgradeRunId ? setting.upgradeRunId !== upgradeRunId : setting.hasRun)) throw new Error('Automatic continuation was disabled or already enrolled.');
99
+ return { ...setting, expectedProviderEpoch: link.epoch };
81
100
  };
82
101
  const job = Promise.resolve().then(async () => {
83
102
  const setting = await guard();
84
103
  const fresh = await selectCandidate(candidate);
85
104
  if (!fresh.autoContinue || fresh.autoContinueEpoch !== candidate.autoContinueEpoch) throw new Error('Automatic continuation setting changed.');
105
+ if (upgradeRunId) {
106
+ if (fresh.isRunning !== false || fresh.binding?.upgradeRunId !== upgradeRunId || fresh.capabilities?.canAutoContinue !== true) {
107
+ attempted.delete(key);
108
+ return;
109
+ }
110
+ await beforeEnroll(fresh);
111
+ await guard();
112
+ await requireProviderLink({ root, provider: candidate.provider, expectedEpoch: setting.expectedProviderEpoch });
113
+ await enroll({ root, provider: fresh.provider, session: fresh.session, ...setting.settings,
114
+ ...(fresh.provider === 'codex' ? { purposeDriven: true } : {}),
115
+ upgradeRunId, autoContinueEpoch: candidate.autoContinueEpoch, expectedProviderEpoch: setting.expectedProviderEpoch });
116
+ return;
117
+ }
86
118
  await continueAnalyzedSession({ root, candidate: fresh, manager, signal: controller.signal,
87
119
  readContext, enroll, beforeEnroll: async selected => { await guard(); await beforeEnroll(selected); },
88
120
  settings: { ...setting.settings, ...(fresh.provider === 'codex' ? { purposeDriven: true } : {}), autoContinueEpoch: candidate.autoContinueEpoch } });
89
121
  }).catch(error => {
90
122
  if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
91
- errors.set(key, { epoch: candidate.autoContinueEpoch, reason: continuationFailureReason(error) });
123
+ const recoverable = transientStartFailure(error?.code);
124
+ if (recoverable) {
125
+ const count = (retries.get(key)?.count || 0) + 1;
126
+ retries.set(key, { attempt, count, at: now() + Math.min(60_000, 1000 * 2 ** Math.min(count - 1, 6)) });
127
+ } else retries.delete(key);
128
+ errors.set(key, { epoch: candidate.autoContinueEpoch, recoverable,
129
+ reason: recoverable ? 'The AI connection is temporarily unavailable. Automatic continuation will retry when the connection is available.' : continuationFailureReason(error) });
92
130
  }
93
- }).finally(() => { pending.delete(key); if (!closed) onChange(); });
131
+ }).finally(() => {
132
+ if (controller.signal.aborted) { attempted.delete(key); retries.delete(key); }
133
+ pending.delete(key); if (!closed) onChange();
134
+ });
94
135
  pending.set(key, { job, controller });
95
136
  }
96
137
  };
97
138
  return { tick,
98
139
  retry(provider, session) {
99
140
  const key = `${provider}:${session}`;
100
- if (!closed && !pending.has(key)) { attempted.delete(key); errors.delete(key); }
141
+ if (!closed && !pending.has(key)) { attempted.delete(key); errors.delete(key); retries.delete(key); }
101
142
  },
102
143
  project(candidate) {
144
+ const key = `${candidate.provider}:${candidate.session}`;
145
+ if (candidate.autoContinue === true && candidate.capabilities?.canAutoContinue === true &&
146
+ !['managed', 'pending_takeover'].includes(candidate.binding?.mode)) {
147
+ if (pending.has(key)) return startingPresentation(candidate, 'starting',
148
+ 'Auto-continue is saved. Preparing to continue this conversation in the background.');
149
+ const failure = errors.get(key);
150
+ if (failure?.recoverable && failure.epoch === candidate.autoContinueEpoch)
151
+ return startingPresentation(candidate, 'recovering', failure.reason);
152
+ }
103
153
  if (candidate.autoContinue === true && candidate.isRunning !== true &&
104
154
  ['unmanaged', 'monitor'].includes(candidate.binding?.mode || 'unmanaged') && candidate.analysis?.state === 'needs_user') return {
105
155
  ...candidate, autoContinueStatus: 'needs_user', autoContinueReason: candidate.analysis.reason || 'Your decision is needed before automatic continuation can proceed.',
106
156
  };
107
157
  const error = errors.get(`${candidate.provider}:${candidate.session}`);
108
- if (candidate.autoContinue === true && candidate.autoContinueEpoch === error?.epoch && candidate.capabilities?.canContinue === true &&
158
+ if (error && candidate.autoContinue === true && candidate.autoContinueEpoch === error.epoch &&
159
+ (candidate.capabilities?.canContinue === true || candidate.binding?.upgradeRunId) &&
109
160
  !['no_action', 'needs_user', 'waiting', 'uncertain'].includes(candidate.analysis?.state)) return {
110
161
  ...candidate, autoContinueStatus: 'error', autoContinueReason: error.reason,
111
162
  };
@@ -122,7 +173,7 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
122
173
  closed = true;
123
174
  for (const entry of pending.values()) entry.controller.abort();
124
175
  await Promise.allSettled([...pending.values()].map(entry => entry.job));
125
- attempted.clear(); errors.clear();
176
+ attempted.clear(); errors.clear(); retries.clear();
126
177
  },
127
178
  };
128
179
  }
@@ -2,7 +2,7 @@ import { agentRole } from '../codex-plugins/simy/hooks/loop-kernel.mjs';
2
2
  import { captureAcceptanceReads } from './acceptance-reads.js';
3
3
  import { providerFailureDiagnostic } from './provider-failure.js';
4
4
  import { captureDispatchProvenance } from '../update-detachment/provenance.js';
5
- import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery } from '../update-detachment/dispatch-gate.js';
5
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, queueProviderTerminalReceipt, rootFromDelivery } from '../update-detachment/dispatch-gate.js';
6
6
  import { normalizePullRequests } from './pull-requests.js';
7
7
  import { delegatedNativeSandbox, canWriteWorkspace, authorizedWorktreeGitRoots } from './native-delegation.js';
8
8
  import { flexibleReasoning } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
@@ -541,7 +541,16 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
541
541
  let cursor = null;
542
542
  do {
543
543
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
544
- const page = await readPage(cursor, ANALYSIS_MAX_TURNS - turns.filter(terminal).length);
544
+ let page;
545
+ try { page = await readPage(cursor, ANALYSIS_MAX_TURNS - turns.filter(terminal).length); }
546
+ catch (error) {
547
+ // An oversized older turn (often attachments) must not discard an
548
+ // already complete recent exchange. This is omitted history, not a
549
+ // fabricated turn. Latest-page overflow still fails closed, and the
550
+ // classifier must judge whether the bounded context is sufficient.
551
+ if (cursor && turns.some(terminal) && error?.code === 'response_too_large') break;
552
+ throw error;
553
+ }
545
554
  for (const turn of page.data) {
546
555
  if (typeof turn?.id !== 'string' || !turn.id || turnIds.has(turn.id)) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
547
556
  turnIds.add(turn.id); turns.push(turn);
@@ -654,9 +663,10 @@ async function frozenNativeSandbox(record, root, signal) {
654
663
  return policy;
655
664
  }
656
665
 
657
- export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runCodexPeerOpinion } = {}) {
666
+ export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, dispatchAttemptId: requestedAttemptId, runPeerReview = runCodexPeerOpinion } = {}) {
658
667
  const dispatchOwner = captureDispatchOwner(root ? rootFromDelivery(root) : undefined);
659
668
  let dispatchAttemptId;
669
+ if (requestedAttemptId && (!auditOnly || record.run?.managed?.outcomeAudit?.lease?.id !== requestedAttemptId)) throw new CodexAppServerError('identity', 'Independent audit dispatch must use its original lease.');
660
670
  const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: record.session, worktree: taskDirectory(record), taskId: record.run.managed.taskId, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
661
671
  assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
662
672
  const run = record.run, decision = run.managed?.decision;
@@ -697,13 +707,29 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
697
707
  output_schema: { type: 'object', properties: { opinion: { type: 'string' } }, required: ['opinion'], additionalProperties: false },
698
708
  }, { signal, readOnlyAnalysis: true, allowAutomaticRetry: false,
699
709
  model: run.managed.resumeModel, cwd: taskDirectory(record), openClient, readAuthentication: readCodexAuthentication,
700
- dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance }, attemptId => {
710
+ dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: requestedAttemptId || run.managed.resumeLease?.id,
711
+ provenance: captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: peerSession,
712
+ worktree: taskDirectory(record), taskId: run.managed.taskId || record.session, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) }) }, attemptId => {
713
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
701
714
  if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.');
702
715
  dispatchAttemptId = attemptId;
703
716
  return initiate();
704
717
  }),
705
718
  beforeDispatch: async () => { await authorizePreparation(); await beforeDispatch({ type: 'readOnly', networkAccess: false }); },
706
- onContext: session => { if (peerSession && peerSession !== session) ambiguousContext = true; peerSession = session; },
719
+ onContext: async session => {
720
+ if (!session || session === record.session || peerSession && peerSession !== session) { ambiguousContext = true; throw new CodexAppServerError('identity', 'Independent peer context changed.'); }
721
+ peerSession = session;
722
+ await onEvidence({ source: 'adapter_capture', actionId: decision.id, workerContext: { session, originalSession: record.session, cwd: taskDirectory(record), model: run.managed.resumeModel, ephemeralRequested: true } });
723
+ },
724
+ onStarted: ({ session, turnId }) => {
725
+ if (!dispatchAttemptId || session !== peerSession) throw new CodexAppServerError('identity', 'Independent peer identity changed.');
726
+ queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `provider:${dispatchAttemptId}:${turnId}`, providerExecutionId: turnId });
727
+ onActivity();
728
+ },
729
+ onTerminal: ({ session, turnId, status, response }) => {
730
+ if (!dispatchAttemptId || session !== peerSession || session === record.session) throw new CodexAppServerError('identity', 'Independent peer identity changed.');
731
+ queueProviderTerminalReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, providerSession: session, providerExecutionId: turnId, status, response });
732
+ },
707
733
  onUsage: observed => { usage = observed; onUsage(observed); } });
708
734
  if (!peerSession || peerSession === record.session || ambiguousContext || typeof reply.output?.opinion !== 'string' || !reply.output.opinion.trim()) throw new CodexAppServerError('identity', 'Independent peer context or opinion unavailable.');
709
735
  return { code: 0, dispatchAttemptId, opinion: { text: evidenceExcerpt(reply.output.opinion, 2000).text, source: 'independent_codex_session', session: peerSession, model: reply.model }, usage, toolEvidence: [] };
@@ -722,6 +748,8 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
722
748
  const beforeEvidence = searchEnabled(run.managed) && !reviewing ? await captureWorkspace() : null;
723
749
  let evidenceQueue = Promise.resolve(), evidenceFailure = null;
724
750
  let activeTurnId = null, turnCompleted = false, turnStart = null, startReceipt = null;
751
+ let releaseTurnNotifications, terminalResolve;
752
+ const terminalObserved = new Promise(resolve => { terminalResolve = resolve; });
725
753
  let usage = null, lastTotal = null;
726
754
  try {
727
755
  // An unavailable login is a connection dependency, not an action approval.
@@ -870,13 +898,18 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
870
898
  lastTotal = Math.max(lastTotal ?? 0, total);
871
899
  onUsage({ ...usage });
872
900
  };
873
- const cleanup = () => { unsubscribe(); signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
901
+ const cleanup = () => { releaseTurnNotifications = unsubscribe; signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
874
902
  const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); };
875
903
  const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
876
904
  const closed = () => finish(new CodexAppServerError('closed', 'Codex app-server closed before turn completion.'));
877
905
  const complete = () => {
878
906
  if (!completion || !turnId || !startRecorded || completion.turn?.id !== turnId) return;
879
907
  turnCompleted = true;
908
+ try { queueProviderTerminalReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, providerSession: targetSession,
909
+ providerExecutionId: turnId, status: completion.turn.status, response: messages.get(turnId) || '' }); }
910
+ catch (error) { terminalResolve(); return finish(error); }
911
+ terminalResolve();
912
+ if (settled) return;
880
913
  if (completion.turn.status !== 'completed') return finish(new CodexAppServerError('turn_failed', 'Codex turn did not complete.'));
881
914
  const message = messages.get(turnId);
882
915
  if (!message || Buffer.byteLength(message) > (run.managed.purpose?.enabled ? 128 : 16) * 1024) return finish(new CodexAppServerError('checkpoint', 'Codex did not return a bounded checkpoint.'));
@@ -887,6 +920,13 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
887
920
  }
888
921
  };
889
922
  const unsubscribe = client.onNotification((method, params, requestId) => {
923
+ if (settled) {
924
+ if (requestId === undefined && params.threadId === targetSession) {
925
+ if (method === 'item/completed' && params.turnId === turnId && params.item?.type === 'agentMessage') messages.set(turnId, params.item.text);
926
+ if (method === 'turn/completed' && params.turn?.id === turnId) { completion = params; complete(); }
927
+ }
928
+ return;
929
+ }
890
930
  if (requestId !== undefined && params.threadId === targetSession && /requestApproval|requestPermissions/i.test(method)) return finish(Object.assign(new CodexAppServerError('provider_approval', 'Codex requires provider approval or user input.'), { providerTurnId: params.turnId || turnId }));
891
931
  if (requestId !== undefined && params.threadId === targetSession && /requestUserInput/i.test(method)) return finish(Object.assign(new CodexAppServerError(searchEnabled(run.managed) ? 'provider_question' : 'provider_approval', 'Provider asked a question; investigate before requesting a human decision.'), { providerTurnId: params.turnId || turnId, question: evidenceExcerpt(params.questions || [], 2000).text }));
892
932
  if (params.threadId !== targetSession) return;
@@ -905,7 +945,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
905
945
  signal?.addEventListener('abort', cancelled, { once: true });
906
946
  client.child.once('close', closed);
907
947
  const targetProvenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: targetSession, worktree: isolated ? executionCwd : record.cwd, taskId: run.managed.taskId || record.session, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
908
- turnStart = dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance: targetProvenance }, attemptId => { assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance }); if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.'); dispatchAttemptId = attemptId; return client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
948
+ turnStart = dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: requestedAttemptId || run.managed.resumeLease?.id, provenance: targetProvenance }, attemptId => { assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance }); if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.'); dispatchAttemptId = attemptId; return client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
909
949
  model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) }); })
910
950
  .then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (turnId) queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `provider:${dispatchAttemptId}:${turnId}`, providerExecutionId: turnId }); if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
911
951
  startReceipt = Promise.resolve().then(() => onStarted({ turnId, session: targetSession }));
@@ -991,6 +1031,11 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
991
1031
  if (turnStart && !activeTurnId) await Promise.race([turnStart, new Promise(resolve => setTimeout(resolve, 2_000))]).catch(() => {});
992
1032
  if (!turnCompleted && activeTurnId) await client.request('turn/interrupt', { threadId: targetSession, turnId: activeTurnId }, 2_000).catch(() => {});
993
1033
  }
1034
+ if (activeTurnId && !turnCompleted && !client.closed) await new Promise(resolve => {
1035
+ const timer = setTimeout(resolve, 2_000);
1036
+ terminalObserved.then(() => { clearTimeout(timer); resolve(); });
1037
+ });
1038
+ releaseTurnNotifications?.();
994
1039
  await client.close();
995
1040
 
996
1041
  }