@awak-app/simy-cli 0.7.20 → 0.7.22
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 +73 -12
- package/package.json +2 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +9 -1
- package/src/codex-work-pattern-sync.js +243 -43
- package/src/console/sessions.js +6 -6
- package/src/delivery-hooks/command.js +8 -6
- package/src/delivery-hooks/lifecycle-recovery.js +82 -0
- package/src/delivery-hooks/local-supervisor.js +25 -2
- package/src/delivery-hooks/supervisor-activation.js +25 -0
- package/src/delivery-hooks/supervisor-service.js +2 -2
- package/src/delivery-hooks/supervisor-watchdog.js +11 -6
- package/src/desktop-host.js +84 -11
- package/src/index.js +1 -1
- package/src/session-supervision/claude-sessions.js +7 -7
- package/src/session-supervision/codex-app-server.js +19 -9
- package/src/session-supervision/codex-peer-opinion.js +128 -0
- package/src/session-supervision/index.js +21 -12
- package/src/session-supervision/remote-control.js +27 -0
- package/src/session-supervision/remote-errors.js +22 -0
- package/src/session-supervision/remote-hosts.js +17 -13
- package/src/session-supervision/remote-session-list.js +56 -0
- package/src/session-supervision/session-list.js +83 -23
- package/src/sqm/agent-skill.js +4 -2
- package/src/sqm/provider-command.js +1 -1
- package/src/sqm/provider-plugin-repair.js +105 -0
- package/src/sqm/provider-plugin-status.js +20 -8
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Routine purpose monitoring uses the same authenticated app-server transport
|
|
2
|
+
// and model as its task. A standalone `codex exec` has a separate login path.
|
|
3
|
+
// This context supplies advice only: it has no tools or acceptance authority.
|
|
4
|
+
const disabledFeatures = [
|
|
5
|
+
'shell_tool', 'unified_exec', 'shell_snapshot', 'view_image', 'apps', 'plugins',
|
|
6
|
+
'hooks', 'memories', 'multi_agent', 'browser_use', 'computer_use',
|
|
7
|
+
'image_generation', 'code_mode', 'code_mode_host', 'skill_search',
|
|
8
|
+
'skill_mcp_dependency_install', 'workspace_dependencies', 'goals',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export async function runCodexPeerOpinion(provider, request, {
|
|
12
|
+
model, cwd, openClient, readAuthentication, signal,
|
|
13
|
+
beforeDispatch = async () => {}, dispatchTransport = initiate => initiate(),
|
|
14
|
+
onContext = () => {}, onUsage = () => {}, timeoutMs = 120_000,
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (provider !== 'codex' || !model || !cwd || !openClient || !readAuthentication) {
|
|
17
|
+
throw new Error('Peer opinion requires the task’s Codex transport, model and workspace.');
|
|
18
|
+
}
|
|
19
|
+
signal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)]);
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
const client = await openClient({ signal, cancelOnAbort: true, isolateConnectors: true, maxLineBytes: 1024 * 1024 });
|
|
22
|
+
let threadId, turnId, completed = false, unsubscribe, abort, closed;
|
|
23
|
+
let startPromise;
|
|
24
|
+
try {
|
|
25
|
+
if (await readAuthentication(client) === 'missing') {
|
|
26
|
+
throw Object.assign(new Error('The task’s Codex account is not signed in.'), { code: 'authentication_required' });
|
|
27
|
+
}
|
|
28
|
+
const config = await client.request('config/read', { cwd, includeLayers: false });
|
|
29
|
+
if (!config?.config) throw new Error('Peer configuration is unavailable.');
|
|
30
|
+
const created = await client.request('thread/start', {
|
|
31
|
+
cwd, model, ephemeral: true, sandbox: 'read-only', approvalPolicy: 'never',
|
|
32
|
+
developerInstructions: request.system,
|
|
33
|
+
config: {
|
|
34
|
+
web_search: 'disabled',
|
|
35
|
+
features: {
|
|
36
|
+
...Object.fromEntries(disabledFeatures.map(name => [name, false])),
|
|
37
|
+
enable_fanout: false, multi_agent_v2: { enabled: false },
|
|
38
|
+
skip_host_skill_discovery: true,
|
|
39
|
+
},
|
|
40
|
+
mcp_servers: Object.fromEntries(Object.keys(config.config.mcp_servers || {}).map(name => [name, { enabled: false }])),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
threadId = created.thread?.id;
|
|
44
|
+
if (!threadId || created.model !== model || created.cwd !== cwd ||
|
|
45
|
+
created.sandbox?.type !== 'readOnly' || created.approvalPolicy !== 'never') {
|
|
46
|
+
throw new Error('Peer context does not match the task’s isolated configuration.');
|
|
47
|
+
}
|
|
48
|
+
let cursor = null;
|
|
49
|
+
const cursors = new Set();
|
|
50
|
+
do {
|
|
51
|
+
const inventory = await client.request('mcpServerStatus/list', { threadId, cursor, limit: 1 });
|
|
52
|
+
if (!Array.isArray(inventory.data)) throw new Error('Peer connector inventory is unavailable.');
|
|
53
|
+
if (inventory.data?.some(server => server.toolsError || Object.keys(server.tools || {}).length ||
|
|
54
|
+
server.resources?.length || server.resourceTemplates?.length)) {
|
|
55
|
+
throw new Error('Peer context still has connector capabilities.');
|
|
56
|
+
}
|
|
57
|
+
cursor = inventory.nextCursor;
|
|
58
|
+
if (cursor && (cursors.has(cursor) || cursors.size >= 100)) throw new Error('Peer connector inventory is incomplete.');
|
|
59
|
+
if (cursor) cursors.add(cursor);
|
|
60
|
+
} while (cursor);
|
|
61
|
+
await onContext(threadId);
|
|
62
|
+
const messages = new Map(), turns = new Map(), usage = new Map();
|
|
63
|
+
let resolveDone, rejectDone, settled = false, lastUsage;
|
|
64
|
+
const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
|
|
65
|
+
// Preparation/dispatch can fail before the completion promise is awaited.
|
|
66
|
+
done.catch(() => {});
|
|
67
|
+
const fail = error => { if (!settled) { settled = true; rejectDone(error); } };
|
|
68
|
+
const reportUsage = () => {
|
|
69
|
+
const observed = usage.get(turnId);
|
|
70
|
+
if (observed && observed !== lastUsage) {
|
|
71
|
+
lastUsage = observed;
|
|
72
|
+
try { onUsage(observed); } catch (error) { fail(error); }
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const finish = () => {
|
|
76
|
+
if (settled || !turnId || !turns.has(turnId)) return;
|
|
77
|
+
const turn = turns.get(turnId);
|
|
78
|
+
completed = true;
|
|
79
|
+
if (turn.status !== 'completed') return fail(Object.assign(new Error('Peer turn did not complete.'), { code: 'turn_failed' }));
|
|
80
|
+
let output;
|
|
81
|
+
try { output = JSON.parse(messages.get(turnId) || ''); } catch {
|
|
82
|
+
return fail(new Error('Peer opinion did not return structured output.'));
|
|
83
|
+
}
|
|
84
|
+
if (typeof output.opinion !== 'string' || !output.opinion.trim()) return fail(new Error('Peer opinion was empty.'));
|
|
85
|
+
reportUsage();
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
resolveDone({ output: { opinion: output.opinion }, model });
|
|
89
|
+
};
|
|
90
|
+
unsubscribe = client.onNotification((method, params, requestId) => {
|
|
91
|
+
if (params.threadId !== threadId) return;
|
|
92
|
+
if (requestId !== undefined) return fail(Object.assign(new Error('Peer requested a tool or approval.'), { code: 'provider_approval' }));
|
|
93
|
+
if (method === 'item/completed' && params.item?.type === 'agentMessage') messages.set(params.turnId, params.item.text);
|
|
94
|
+
if (['item/started', 'item/completed'].includes(method) && ['commandExecution', 'mcpToolCall', 'fileChange', 'webSearch', 'dynamicToolCall'].includes(params.item?.type)) {
|
|
95
|
+
return fail(new Error('Peer attempted a disabled tool.'));
|
|
96
|
+
}
|
|
97
|
+
if (method === 'thread/tokenUsage/updated') {
|
|
98
|
+
const tokens = params.tokenUsage?.total?.totalTokens;
|
|
99
|
+
if (Number.isSafeInteger(tokens) && tokens >= 0) usage.set(params.turnId, { source: 'codex_app_server', tokens, observedAt: Date.now() });
|
|
100
|
+
reportUsage();
|
|
101
|
+
}
|
|
102
|
+
if (method === 'turn/completed' && params.turn?.id) turns.set(params.turn.id, params.turn);
|
|
103
|
+
finish();
|
|
104
|
+
});
|
|
105
|
+
abort = () => fail(Object.assign(new Error('Peer inspection cancelled.'), { code: 'cancelled' }));
|
|
106
|
+
closed = () => fail(Object.assign(new Error('Peer connection closed.'), { code: 'closed' }));
|
|
107
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
108
|
+
client.child?.once('close', closed);
|
|
109
|
+
await beforeDispatch();
|
|
110
|
+
signal?.throwIfAborted();
|
|
111
|
+
startPromise = Promise.resolve(dispatchTransport(() => client.request('turn/start', {
|
|
112
|
+
threadId, model, cwd, input: [{ type: 'text', text: request.prompt }],
|
|
113
|
+
outputSchema: request.output_schema, sandboxPolicy: { type: 'readOnly', networkAccess: false },
|
|
114
|
+
})));
|
|
115
|
+
const started = await Promise.race([startPromise, done]);
|
|
116
|
+
turnId = started.turn?.id;
|
|
117
|
+
if (!turnId) throw new Error('Peer turn did not start.');
|
|
118
|
+
reportUsage();
|
|
119
|
+
finish();
|
|
120
|
+
return await done;
|
|
121
|
+
} finally {
|
|
122
|
+
signal?.removeEventListener('abort', abort);
|
|
123
|
+
if (closed) client.child?.removeListener('close', closed);
|
|
124
|
+
unsubscribe?.();
|
|
125
|
+
if (!completed && turnId) await client.request('turn/interrupt', { threadId, turnId }, 2000).catch(() => {});
|
|
126
|
+
await client.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -14,7 +14,7 @@ import { loadPurposeRuleSet } from './purpose-instruction.js';
|
|
|
14
14
|
import { originalSessionTarget } from './original-session-target.js';
|
|
15
15
|
import { readCodexChildSessions } from './codex-children.js';
|
|
16
16
|
import { readCodexTitles, sessionTitle } from './session-titles.js';
|
|
17
|
-
import {
|
|
17
|
+
import { recentSessionWindow, retainedSession } from './discovery-window.js';
|
|
18
18
|
import { requireProviderLink } from '../provider-links.js';
|
|
19
19
|
|
|
20
20
|
// Only product-authored eligibility messages may pass through the desktop
|
|
@@ -29,7 +29,8 @@ export class AutoContinueUnavailableError extends Error {
|
|
|
29
29
|
// Opt-in is independent of a busy provider turn. Dispatch still requires a
|
|
30
30
|
// settled context and the original provider's execution boundary.
|
|
31
31
|
function autoContinueAvailability(run, channel) {
|
|
32
|
-
const issue = !channel.ready ? channel : run
|
|
32
|
+
const issue = !channel.ready ? channel : run?.state === 'stopped'
|
|
33
|
+
? { code: 'task_stopped', reason: 'This task was stopped. Review the original conversation and explicitly request resumption before continuing.' } : run && !run.managed && !canUpgradeDelivery(run)
|
|
33
34
|
? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
|
|
34
35
|
: TERMINAL_RUNS.has(run?.state)
|
|
35
36
|
? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null;
|
|
@@ -139,7 +140,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
139
140
|
await Promise.all([
|
|
140
141
|
(async () => {
|
|
141
142
|
try { if (connected.has('codex')) discovered = await withClient(openClient, async client => {
|
|
142
|
-
const active = await listCodexSessions({ client,
|
|
143
|
+
const active = await listCodexSessions({ client, all: true, signal, inspectState: true, codexHome,
|
|
143
144
|
prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
|
|
144
145
|
if (includeArchived) {
|
|
145
146
|
const observedArchives = new Map();
|
|
@@ -153,7 +154,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
153
154
|
}
|
|
154
155
|
return response;
|
|
155
156
|
} };
|
|
156
|
-
try { await listCodexSessions({ client: archiveClient,
|
|
157
|
+
try { await listCodexSessions({ client: archiveClient, all: true, signal, archived: true }); }
|
|
157
158
|
catch { /* Keep positive archive pages even if a later page fails. */ }
|
|
158
159
|
archivedCodex = [...observedArchives.values()];
|
|
159
160
|
}
|
|
@@ -168,7 +169,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
168
169
|
}
|
|
169
170
|
})(),
|
|
170
171
|
(async () => {
|
|
171
|
-
try { if (connected.has('claude')) claude = await listClaude({ signal,
|
|
172
|
+
try { if (connected.has('claude')) claude = await listClaude({ signal, all: true,
|
|
172
173
|
prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
|
|
173
174
|
catch { claudeError = 'Claude conversation discovery unavailable.'; }
|
|
174
175
|
})(),
|
|
@@ -237,16 +238,17 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
237
238
|
availability: 'unknown', model: record.admission?.model || null,
|
|
238
239
|
});
|
|
239
240
|
}
|
|
240
|
-
//
|
|
241
|
-
// visible even during a truncated discovery or provider outage.
|
|
241
|
+
// Catalog visibility is complete; only live process probes use a recent window.
|
|
242
242
|
const catalog = [...byKey.values()].map(item => {
|
|
243
243
|
const record = recordsByKey.get(`${item.provider}:${item.session}`);
|
|
244
244
|
const parentSession = navigationParent(item, record);
|
|
245
245
|
return { ...item, localHidden: record?.listHidden === true, ...(parentSession ? { parentSession } : {}) };
|
|
246
246
|
});
|
|
247
|
-
const
|
|
247
|
+
const liveItems = [...recentSessionWindow(catalog.filter(item => item.archived !== true && !item.localHidden), {
|
|
248
248
|
priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
|
|
249
249
|
}), ...recentSessionWindow(catalog.filter(item => item.archived === true || item.localHidden), { priority: item => item.localHidden })];
|
|
250
|
+
const liveKeys = new Set(liveItems.map(item => `${item.provider}:${item.session}`));
|
|
251
|
+
const items = [...catalog];
|
|
250
252
|
// Include archived ancestors of active rows even when archive visibility is off.
|
|
251
253
|
const catalogByKey = new Map(catalog.map(item => [`${item.provider}:${item.session}`, item]));
|
|
252
254
|
const includedKeys = new Set(items.map(item => `${item.provider}:${item.session}`));
|
|
@@ -285,7 +287,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
285
287
|
const supported = item.provider === 'codex' && codexChannel.ready;
|
|
286
288
|
const claudeChannel = item.provider === 'claude' ? claudeError
|
|
287
289
|
? { ready: false, code: 'session_metadata_unavailable', reason: claudeError }
|
|
288
|
-
: await liveClaudeChannel(record, item, inspectProcess) : null;
|
|
290
|
+
: await liveClaudeChannel(record, item, liveKeys.has(`${item.provider}:${item.session}`) ? inspectProcess : async () => null) : null;
|
|
289
291
|
const liveHook = claudeChannel?.ready === true;
|
|
290
292
|
let continuationAdmission;
|
|
291
293
|
if (supported || liveHook) {
|
|
@@ -308,7 +310,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
308
310
|
// A hook newer than the persisted provider activity may describe a
|
|
309
311
|
// new turn. Require its same live process; older hooks cannot override
|
|
310
312
|
// a more recent completed provider turn.
|
|
311
|
-
if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) { isRunning = true; observation = { ...item.observation, observedAt: record.runtime.observedAt }; }
|
|
313
|
+
if (liveKeys.has(`${item.provider}:${item.session}`) && record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) { isRunning = true; observation = { ...item.observation, observedAt: record.runtime.observedAt }; }
|
|
312
314
|
else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
|
|
313
315
|
} catch { /* Unverified process leaves runtime status unknown. */ }
|
|
314
316
|
}
|
|
@@ -326,6 +328,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
326
328
|
autoContinue: !archived && record?.autoContinue === true,
|
|
327
329
|
autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
|
|
328
330
|
autoContinueAvailability: availability,
|
|
331
|
+
// A finished ordinary report needs an explicit current goal, not an
|
|
332
|
+
// implicit reset through the auto-continue preference.
|
|
333
|
+
followup: !archived && supported && item.provider === 'codex' &&
|
|
334
|
+
run?.state === 'reported_done' && !run.managed && run.id && !record.automaticStartBlocked &&
|
|
335
|
+
!record.associationTaskId && !(record.autoContinue === false && record.autoContinueEpoch)
|
|
336
|
+
? { previousRunId: run.id } : null,
|
|
329
337
|
...autoContinueState(record, isRunning, availability),
|
|
330
338
|
lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
|
|
331
339
|
taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
@@ -369,6 +377,7 @@ function hasAutoContinueAdmission(record, provider, session) {
|
|
|
369
377
|
(provider !== 'codex' || typeof admission.model === 'string' && admission.model.trim());
|
|
370
378
|
}
|
|
371
379
|
function requireAutoContinueTask(record) {
|
|
380
|
+
if (record?.run?.state === 'stopped') throw new AutoContinueUnavailableError({ reasonCode: 'task_stopped', reason: 'This task was stopped. Review the original conversation and explicitly request resumption before continuing.' });
|
|
372
381
|
if (record?.run && !record.run.managed && !canUpgradeDelivery(record.run)) throw Object.assign(new Error('This conversation already has a Delivery Loop. Use its existing controls.'), { code: 'existing_loop' });
|
|
373
382
|
if (TERMINAL_RUNS.has(record?.run?.state)) throw new AutoContinueUnavailableError({ reason: 'This supervised task has settled. Review its existing status before continuing.', reasonCode: 'task_settled' });
|
|
374
383
|
}
|
|
@@ -513,7 +522,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
|
|
|
513
522
|
|
|
514
523
|
export async function enrollSession(options) {
|
|
515
524
|
const { root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy,
|
|
516
|
-
maxMinutes, maxContinuations, maxTokens, purposeDriven, purposeVersion, maxFailures, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, executionCwd, upgradeRunId, previousRunId, reconcileRunId, expectedProviderEpoch, openClient = openCodexAppServer,
|
|
525
|
+
maxMinutes, maxContinuations, maxTokens, purposeDriven, purposeVersion, maxFailures, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, executionCwd, upgradeRunId, previousRunId, reconcileRunId, followupAutoContinue, expectedProviderEpoch, openClient = openCodexAppServer,
|
|
517
526
|
readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() } = sessionLoopOptions(options);
|
|
518
527
|
if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
|
|
519
528
|
const link = await requireProviderLink({ root, provider, expectedEpoch: expectedProviderEpoch });
|
|
@@ -541,7 +550,7 @@ export async function enrollSession(options) {
|
|
|
541
550
|
const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
|
|
542
551
|
await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
|
|
543
552
|
const rules = purposeDriven ? await loadPurposeRuleSet() : null;
|
|
544
|
-
const run = await startDelivery({ root, provider, session, cwd, executionCwd, upgradeRunId, previousRunId, reconcileRunId, providerLinkEpoch: link.epoch, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
553
|
+
const run = await startDelivery({ root, provider, session, cwd, executionCwd, upgradeRunId, previousRunId, reconcileRunId, followupAutoContinue, providerLinkEpoch: link.epoch, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
545
554
|
maxMinutes, maxContinuations, maxFailures, continuationPolicy, now, admission, inspectProcess, autoContinueEpoch,
|
|
546
555
|
liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
|
|
547
556
|
managed: { ...(rules ? { purpose: { enabled: true, version: purposeVersion, ...(purposeVersion === 2 ? { decisionPolicy: 'llm-first-hc102' } : {}), failureBudgetMode: maxFailures === undefined ? 'strategy' : 'hard', ruleDigest: rules.digest, ruleIds: rules.rules.map(rule => rule.id), recoveries: [] } } : {}), maxTokens, acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Only host-discovered admission may reach the native mutation API.
|
|
2
|
+
export function createControl({ authorize, select, set, followup, save, revoke, root }) {
|
|
3
|
+
return async command => {
|
|
4
|
+
const isFollowup = command?.action === 'session_followup';
|
|
5
|
+
if (!['session_set_auto_continue', 'session_followup'].includes(command?.action) || command.provider !== 'codex' ||
|
|
6
|
+
typeof command.session !== 'string' || !/^[a-f0-9-]{36}$/.test(command.session) ||
|
|
7
|
+
(!isFollowup && typeof command.enabled !== 'boolean') ||
|
|
8
|
+
(isFollowup && (typeof command.objective !== 'string' || !command.objective.trim() || command.objective.length > 4000)) ||
|
|
9
|
+
command.continuationPolicy !== undefined && !['bounded','progress'].includes(command.continuationPolicy)) throw new Error('Unsupported remote command');
|
|
10
|
+
const scope = await authorize();
|
|
11
|
+
const candidate = await select(command);
|
|
12
|
+
if (!candidate || candidate.provider !== 'codex' || candidate.session !== command.session) throw new Error('Select a known conversation');
|
|
13
|
+
if (isFollowup) {
|
|
14
|
+
if (typeof followup !== 'function' || !candidate.followup?.previousRunId || command.previousRunId !== candidate.followup.previousRunId) throw new Error('Refresh the finished conversation before requesting follow-up.');
|
|
15
|
+
await authorize(scope);
|
|
16
|
+
await followup({root,provider:'codex',session:command.session,previousRunId:command.previousRunId,objective:command.objective,followupAutoContinue:true,expectedProviderEpoch:scope.providerEpoch});
|
|
17
|
+
await save({...command,enabled:true},scope);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (command.enabled && (candidate.autoContinueAvailability?.canEnable ?? candidate.capabilities?.canAutoContinue) !== true) throw Object.assign(new Error(candidate.autoContinueAvailability?.reason || 'Continuation unavailable'), {code:candidate.autoContinueAvailability?.reasonCode});
|
|
21
|
+
await authorize(scope);
|
|
22
|
+
await set({root, provider:'codex',session:command.session,enabled:command.enabled,
|
|
23
|
+
continuationPolicy:command.continuationPolicy, admission:candidate.continuationAdmission,expectedProviderEpoch:scope.providerEpoch});
|
|
24
|
+
if (!command.enabled) revoke('codex',command.session);
|
|
25
|
+
await save(command,scope);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Remote diagnostics are selected from product-owned text. Never display
|
|
2
|
+
// arbitrary SSH stderr, provider exceptions, paths or remote frame messages.
|
|
3
|
+
const messages = {
|
|
4
|
+
process_conflict: 'Another SIMY service owns this remote host. Reconcile its existing service before connecting; do not stop unrelated work.',
|
|
5
|
+
desktop_access_required: 'Sign in to the same SIMY account and organization on the remote host and verify its access.',
|
|
6
|
+
provider_link_disconnected: 'Reconnect this AI provider to SIMY on the remote host.',
|
|
7
|
+
provider_link_changed: 'The remote provider connection changed. Refresh and select the conversation again.',
|
|
8
|
+
session_metadata_unavailable: 'Refresh the remote conversation list; its exact session metadata is unavailable.',
|
|
9
|
+
session_model_unavailable: 'Select a model in the original remote conversation, then refresh.',
|
|
10
|
+
model_metadata_unavailable: 'Select a model in the original remote conversation, then refresh.',
|
|
11
|
+
session_workspace_changed: 'The remote workspace changed. Refresh and select the conversation again.',
|
|
12
|
+
session_archived: 'Restore the original remote conversation before enabling Auto-continue.',
|
|
13
|
+
existing_loop: 'Use this remote conversation’s existing task controls.',
|
|
14
|
+
task_stopped: 'This remote task was stopped. Review the original conversation and explicitly request resumption.',
|
|
15
|
+
task_settled: 'The remote task has finished. Refresh and select its explicit follow-up controls.',
|
|
16
|
+
cancelled: 'The remote operation was cancelled.',
|
|
17
|
+
};
|
|
18
|
+
export function remoteOperationFailure(code) {
|
|
19
|
+
return typeof code === 'string' && Object.hasOwn(messages, code)
|
|
20
|
+
? { code, message: messages[code] }
|
|
21
|
+
: { message: 'Remote operation could not finish. Check the original conversation and refresh before retrying.' };
|
|
22
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remoteOperationFailure } from './remote-errors.js';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { spawn } from 'node:child_process';
|
|
3
4
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
@@ -14,7 +15,7 @@ const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
|
14
15
|
const bootstrap = 'import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; const entry = join(dirname(realpathSync(process.argv[1])), "session-supervision", "remote-entry.js"); await import(pathToFileURL(entry).href);';
|
|
15
16
|
export const REMOTE_HOST_COMMAND = `exec sh -lc ${shellQuote(`exec env SIMY_REMOTE_CHILD=1 SIMY_AUTO_UPDATE=0 node --input-type=module -e ${shellQuote(bootstrap)} "$(command -v simy)"`)}`;
|
|
16
17
|
const allowedActions = new Set(['refresh', 'session_set_pinned', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
|
|
17
|
-
'session_monitor', 'session_unmonitor', 'session_enroll', 'session_set_auto_continue', 'session_analyze', 'session_continue', 'session_answer',
|
|
18
|
+
'session_monitor', 'session_unmonitor', 'session_enroll', 'session_followup', 'session_set_auto_continue', 'session_analyze', 'session_continue', 'session_answer',
|
|
18
19
|
'session_deadline_decide', 'session_action_refresh', 'session_action_select', 'session_action_unlink',
|
|
19
20
|
'delivery_pause', 'delivery_stop', 'delivery_reject', 'delivery_resume', 'delivery_replan', 'delivery_approve',
|
|
20
21
|
'delivery_dependency_ready', 'delivery_set_policy', 'delivery_continuation_policy', 'delivery_verify']);
|
|
@@ -25,7 +26,7 @@ function normalizedHost(value) {
|
|
|
25
26
|
if (label.length > 80 || /[\x00-\x1f\x7f]/.test(label)) throw error('Choose a shorter host name.', 'remote_host_invalid');
|
|
26
27
|
return { alias: value.alias, label: label || value.alias, id: remoteHostId(value.alias) };
|
|
27
28
|
}
|
|
28
|
-
async function readHosts(root) {
|
|
29
|
+
export async function readHosts(root) {
|
|
29
30
|
let value;
|
|
30
31
|
try { value = JSON.parse(await readFile(join(root, 'remote-hosts.json'), 'utf8')); }
|
|
31
32
|
catch (e) { if (e.code === 'ENOENT') return []; throw error('Remote host settings could not be read.', 'remote_host_settings'); }
|
|
@@ -42,7 +43,7 @@ function stamp(value, host, depth = 0) {
|
|
|
42
43
|
if (Array.isArray(value)) return value.map(item => stamp(item, host, depth + 1));
|
|
43
44
|
if (!value || typeof value !== 'object') return value;
|
|
44
45
|
const result = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, stamp(item, host, depth + 1)]));
|
|
45
|
-
if (result.provider
|
|
46
|
+
if (['codex', 'claude'].includes(result.provider) && typeof result.session === 'string') result.hostId = host.id;
|
|
46
47
|
return result;
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -113,13 +114,13 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
113
114
|
}
|
|
114
115
|
changed();
|
|
115
116
|
} else if (frame.type === 'error') {
|
|
116
|
-
disconnect(connection,
|
|
117
|
+
disconnect(connection, remoteOperationFailure(frame.code).message); changed(); return;
|
|
117
118
|
} else if (frame.type === 'result') {
|
|
118
119
|
const pending = connection.pending.get(frame.id);
|
|
119
120
|
if (!pending) continue;
|
|
120
121
|
connection.pending.delete(frame.id); clearTimeout(pending.timer);
|
|
121
122
|
pending.resolve({ type: 'result', id: pending.id, ok: frame.ok === true,
|
|
122
|
-
...(frame.ok !== true ?
|
|
123
|
+
...(frame.ok !== true ? remoteOperationFailure(frame.code) : {}) });
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
});
|
|
@@ -167,10 +168,11 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
167
168
|
}
|
|
168
169
|
async function forward(command, status) {
|
|
169
170
|
if (closed || !usable(status) || scopeOf(status) !== scope) throw error('SIMY account changed. Refresh before using the remote host.', 'remote_host_access');
|
|
171
|
+
if (status.providerLinks?.[command.provider]?.connected === false) throw error('Provider is disconnected.', 'remote_host_access');
|
|
170
172
|
const c = connections.get(command.hostId);
|
|
171
173
|
if (!c?.ready || !c.child || now() - c.receivedAt > 45_000) throw error('The remote host is disconnected. Reconnect before using this conversation.');
|
|
172
|
-
if (!allowedActions.has(command.action) || command.provider
|
|
173
|
-
!c.status?.sessionSupervision?.sessions?.some(s => s?.provider ===
|
|
174
|
+
if (!allowedActions.has(command.action) || !['codex', 'claude'].includes(command.provider) || typeof command.session !== 'string' ||
|
|
175
|
+
!c.status?.sessionSupervision?.sessions?.some(s => s?.provider === command.provider && s.session === command.session)) throw error('Select an available remote conversation.', 'remote_host_invalid');
|
|
174
176
|
const id = randomUUID();
|
|
175
177
|
const { hostId, ...payload } = command;
|
|
176
178
|
return new Promise((resolve, reject) => {
|
|
@@ -181,7 +183,7 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
181
183
|
});
|
|
182
184
|
});
|
|
183
185
|
}
|
|
184
|
-
function project(status) {
|
|
186
|
+
function project(status, { providerLinks = status.providerLinks } = {}) {
|
|
185
187
|
if (!usable(status) || scopeOf(status) !== scope) return status;
|
|
186
188
|
const hosts = [...connections.values()];
|
|
187
189
|
const sessions = [], runs = [];
|
|
@@ -189,14 +191,16 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
189
191
|
const ready = c.ready && now() - c.receivedAt <= 45_000;
|
|
190
192
|
const unavailableReason = c.reason || 'Remote status is stale.';
|
|
191
193
|
for (const row of c.status?.sessionSupervision?.sessions || []) {
|
|
192
|
-
if (!row || row.provider
|
|
194
|
+
if (!row || !['codex', 'claude'].includes(row.provider) || typeof row.session !== 'string' ||
|
|
195
|
+
providerLinks?.[row.provider]?.connected === false) continue;
|
|
193
196
|
const item = stamp(row, c);
|
|
194
197
|
item.remoteHost = { id: c.id, label: c.label, state: ready ? 'ready' : c.state === 'ready' ? 'unavailable' : c.state, reason: ready ? null : c.reason || 'Remote status is stale.' };
|
|
195
198
|
// Codex's host-specific deep link contract is unverified. Preserve exact
|
|
196
199
|
// navigation context instead of silently opening a cloned local UUID.
|
|
197
|
-
item.originalSessionTarget = { kind: 'manual', url: null, label: 'Find in remote
|
|
198
|
-
reason: `Select ${c.label} in Codex, then find conversation ${row.session}.` };
|
|
200
|
+
item.originalSessionTarget = { kind: 'manual', url: null, label: 'Find in remote provider', verifiedOnPlatform: false,
|
|
201
|
+
reason: `Select ${c.label} in ${row.provider === 'claude' ? 'Claude Code' : 'Codex'}, then find conversation ${row.session}.` };
|
|
199
202
|
if (!ready) {
|
|
203
|
+
item.followup = null;
|
|
200
204
|
item.capabilities = Object.fromEntries(Object.keys(item.capabilities || {}).map(key => [key, false]));
|
|
201
205
|
item.autoContinueAvailability = { canEnable: false, reasonCode: 'remote_host_unavailable', reason: unavailableReason };
|
|
202
206
|
item.autoContinueStatus = 'unavailable'; item.autoContinueReason = unavailableReason;
|
|
@@ -206,13 +210,13 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
206
210
|
}
|
|
207
211
|
sessions.push(item);
|
|
208
212
|
}
|
|
209
|
-
if (ready) for (const run of c.status?.deliveryHooks?.runs || []) if (run?.provider
|
|
213
|
+
if (ready) for (const run of c.status?.deliveryHooks?.runs || []) if (['codex', 'claude'].includes(run?.provider) && providerLinks?.[run.provider]?.connected !== false) runs.push({ ...stamp(run, c), originalSessionTarget: { kind: 'manual', url: null, verifiedOnPlatform: false, reason: `Select ${c.label} in ${run.provider === 'claude' ? 'Claude Code' : 'Codex'}, then find conversation ${run.session}.` } });
|
|
210
214
|
}
|
|
211
215
|
return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, state: c.state, reason: c.reason })) },
|
|
212
216
|
sessionSupervision: { ...status.sessionSupervision, sessions: [...(status.sessionSupervision?.sessions || []).filter(row => !row.hostId || row.hostId === 'local'), ...sessions].sort(compareSessionWork) },
|
|
213
217
|
deliveryHooks: { ...status.deliveryHooks, runs: [...(status.deliveryHooks?.runs || []).filter(row => !row.hostId || row.hostId === 'local'), ...runs] } };
|
|
214
218
|
}
|
|
215
219
|
return { update, save, forward, project,
|
|
216
|
-
hasSession: session => [...connections.values()].some(c => c.status?.sessionSupervision?.sessions?.some(s => s?.provider
|
|
220
|
+
hasSession: session => [...connections.values()].some(c => c.status?.sessionSupervision?.sessions?.some(s => ['codex', 'claude'].includes(s?.provider) && s.session === session)),
|
|
217
221
|
async close() { closed = true; for (const c of connections.values()) disconnect(c); connections.clear(); await writes.catch(() => {}); } };
|
|
218
222
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readHosts } from './remote-hosts.js';
|
|
5
|
+
|
|
6
|
+
// This read-only command never boots a remote desktop host or supervisor.
|
|
7
|
+
export async function readRemoteSessionLists({ root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'),
|
|
8
|
+
providers, includeArchived = false, signal, spawnImpl = spawn, timeoutMs = 30_000 } = {}) {
|
|
9
|
+
const hosts = await readHosts(root);
|
|
10
|
+
return Promise.all(hosts.map(async host => {
|
|
11
|
+
try {
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
const provider = providers.length === 2 ? 'all' : providers[0];
|
|
14
|
+
if (!['all', 'codex', 'claude'].includes(provider)) throw new Error('No connected provider.');
|
|
15
|
+
const command = `exec sh -lc 'exec simy sessions --json --local-only --provider ${provider}${includeArchived ? ' --include-archived' : ''}'`;
|
|
16
|
+
const catalog = await new Promise((resolve, reject) => {
|
|
17
|
+
const child = spawnImpl('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
|
|
18
|
+
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2',
|
|
19
|
+
'--', host.alias, command], { stdio: ['ignore', 'pipe', 'pipe'], shell: false });
|
|
20
|
+
let chunks = [], size = 0, done = false;
|
|
21
|
+
const finish = (error, value) => {
|
|
22
|
+
if (done) return; done = true;
|
|
23
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
|
24
|
+
if (error) { child.kill('SIGTERM'); reject(error); } else resolve(value);
|
|
25
|
+
};
|
|
26
|
+
const abort = () => finish(new Error('Remote listing cancelled.'));
|
|
27
|
+
const timer = setTimeout(() => finish(new Error('Remote listing timed out.')), timeoutMs);
|
|
28
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
29
|
+
if (signal?.aborted) abort();
|
|
30
|
+
child.stderr.resume();
|
|
31
|
+
child.on('error', () => finish(new Error('Could not start SSH.')));
|
|
32
|
+
child.stdout.on('data', chunk => {
|
|
33
|
+
if (done) return;
|
|
34
|
+
size += chunk.length;
|
|
35
|
+
if (size > 16 * 1024 * 1024) return finish(new Error('Remote catalog exceeds the supported response size.'));
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
});
|
|
38
|
+
child.on('close', code => {
|
|
39
|
+
if (done) return;
|
|
40
|
+
if (code !== 0) return finish(new Error('Remote listing failed. Check SSH and update SIMY on this host.'));
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); chunks = [];
|
|
43
|
+
if (!Array.isArray(value.sessions) || value.sessions.some(row => !row ||
|
|
44
|
+
!providers.includes(row.provider) || typeof row.session !== 'string' || !row.session ||
|
|
45
|
+
typeof row.cwd !== 'string' || row.hostId && row.hostId !== 'local')) throw new Error();
|
|
46
|
+
finish(null, value);
|
|
47
|
+
} catch { finish(new Error('Remote SIMY returned an invalid conversation catalog.')); }
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
return { host, catalog };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
signal?.throwIfAborted();
|
|
53
|
+
return { host, error: error.message };
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
}
|