@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.
- package/README.md +8 -0
- package/package.json +2 -2
- package/src/agent.js +22 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +262 -0
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +76 -11
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
- package/src/connection-health.js +253 -0
- package/src/connection-presentation.js +85 -0
- package/src/connection-recovery.js +102 -0
- package/src/console/app.js +60 -6
- package/src/console/commands.js +3 -0
- package/src/console/index.js +1 -0
- package/src/delivery-hooks/command.js +8 -0
- package/src/delivery-hooks/legacy-hook-routing.js +24 -0
- package/src/delivery-hooks/supervisor.js +14 -14
- package/src/desktop-host.js +49 -10
- package/src/direct-executor.js +8 -2
- package/src/doctor.js +1 -0
- package/src/index.js +12 -2
- package/src/remote-connection-guide.js +222 -0
- package/src/session-supervision/analysis-manager.js +24 -4
- package/src/session-supervision/automatic-continuation.js +69 -18
- package/src/session-supervision/codex-app-server.js +52 -7
- package/src/session-supervision/codex-peer-opinion.js +27 -5
- package/src/session-supervision/consistency-auditor.js +1 -1
- package/src/session-supervision/continuation-analysis.js +7 -3
- package/src/session-supervision/decision-presentation.js +1 -1
- package/src/session-supervision/index.js +23 -15
- package/src/session-supervision/outcome-plan.js +76 -0
- package/src/session-supervision/purpose-instruction.js +28 -0
- package/src/session-supervision/recovery-frontier.js +14 -4
- package/src/session-supervision/remote-command-queue.js +15 -0
- package/src/session-supervision/remote-control.js +3 -0
- package/src/session-supervision/remote-hosts.js +70 -19
- package/src/session-supervision/remote-managed-delivery.js +94 -0
- package/src/session-supervision/remote-session-list.js +1 -1
- package/src/session-supervision/statistics.js +10 -3
- package/src/session-supervision/stop-notifications.js +26 -10
- package/src/session-supervision/work-state.js +5 -0
- 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
|
-
|
|
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 >
|
|
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,
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
80
|
-
|
|
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
|
-
|
|
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(() => {
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 => {
|
|
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
|
|
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
|
}
|