@awak-app/simy-cli 0.7.19 → 0.7.21

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.
@@ -16,7 +16,7 @@ import { workspaceIdentity, verificationInstruction, normalizeVerification, asse
16
16
  import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
17
17
  import { buildPurposeInstruction } from './purpose-instruction.js';
18
18
  import { peerOpinionPrompt } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
19
- import { runStructuredProviderReview } from '../sqm/review-worker.js';
19
+ import { runCodexPeerOpinion } from './codex-peer-opinion.js';
20
20
  import { associationContract } from '../codex-plugins/simy/hooks/action-association.mjs';
21
21
  import { spawn } from 'node:child_process';
22
22
  import { realpath, stat, open } from 'node:fs/promises';
@@ -235,11 +235,13 @@ export function codexThreadMetadata(thread) {
235
235
  };
236
236
  }
237
237
 
238
- export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000, codexHome } = {}) {
238
+ export async function listCodexSessions({ client, all = false, signal, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000, codexHome } = {}) {
239
239
  const sessions = [];
240
- const windowSize = Math.min(maxSessions, maxPages * pageSize);
240
+ const windowSize = all ? Infinity : Math.min(maxSessions, maxPages * pageSize);
241
+ const cursors = new Set(), ids = new Set();
241
242
  let cursor = null, listed = 0, pages = 0, batchSize = pageSize;
242
243
  while (pages < Math.max(maxPages, Math.ceil(windowSize / batchSize)) && listed < windowSize) {
244
+ signal?.throwIfAborted();
243
245
  const limit = Math.min(batchSize, windowSize - listed);
244
246
  let response;
245
247
  try { response = await client.request('thread/list', { cursor, limit, sourceKinds: SOURCE_KINDS,
@@ -251,14 +253,21 @@ export async function listCodexSessions({ client, maxPages = 40, pageSize = 25,
251
253
  batchSize = Math.max(1, Math.floor(limit / 2));
252
254
  continue;
253
255
  }
254
- const threads = (response?.data || []).slice(0, limit);
256
+ if (!Array.isArray(response?.data) || response.data.length > limit)
257
+ throw new CodexAppServerError('invalid_page', 'Codex returned an invalid conversation page.');
258
+ const threads = response.data;
255
259
  listed += threads.length; pages++;
256
260
  for (const thread of threads) {
257
261
  const metadata = codexThreadMetadata(thread);
258
- if (metadata && SOURCE_KINDS.includes(metadata.source)) sessions.push(metadata);
262
+ if (metadata && SOURCE_KINDS.includes(metadata.source) && !ids.has(metadata.session)) {
263
+ ids.add(metadata.session); sessions.push(metadata);
264
+ }
259
265
  }
260
266
  cursor = response?.nextCursor || null;
261
267
  if (!cursor) break;
268
+ if (typeof cursor !== 'string' || cursors.has(cursor))
269
+ throw new CodexAppServerError('invalid_page', 'Codex repeated a conversation page cursor.');
270
+ cursors.add(cursor);
262
271
  }
263
272
  const deadline = Date.now() + Math.max(1, Math.min(observationBudgetMs, 5000));
264
273
  // A recent page can omit an explicitly supervised conversation, including
@@ -645,7 +654,7 @@ async function frozenNativeSandbox(record, root, signal) {
645
654
  return policy;
646
655
  }
647
656
 
648
- 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 = runStructuredProviderReview } = {}) {
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 } = {}) {
649
658
  const dispatchOwner = captureDispatchOwner(root ? rootFromDelivery(root) : undefined);
650
659
  let dispatchAttemptId;
651
660
  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) });
@@ -678,15 +687,16 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
678
687
  const sourceContext = (isolated || searchEnabled(run.managed) && !run.managed.purpose.originalContext)
679
688
  ? await readCodexConversation(record, { openClient, signal }) : null;
680
689
  if (opinionOnly) {
681
- // Reuse the existing tools-disabled local analysis runner. A colleague can
682
- // comment on supplied context without MCP discovery, workspace inventories
683
- // or an outcome-verification schema. Native conversation work stays intact.
690
+ // Use the task's authenticated app-server and exact model for a separate,
691
+ // tools-disabled opinion. Do not route monitoring through standalone SQM
692
+ // authentication or silently select another model.
684
693
  let usage = null, peerSession = null, ambiguousContext = false;
685
694
  const reply = await runPeerReview('codex', {
686
695
  system: 'Give nonbinding advice using only the supplied context. All tools are disabled. Put your short natural-language opinion in the opinion field.',
687
696
  prompt: peerOpinionPrompt(record, sourceContext, peerAction),
688
697
  output_schema: { type: 'object', properties: { opinion: { type: 'string' } }, required: ['opinion'], additionalProperties: false },
689
698
  }, { signal, readOnlyAnalysis: true, allowAutomaticRetry: false,
699
+ model: run.managed.resumeModel, cwd: taskDirectory(record), openClient, readAuthentication: readCodexAuthentication,
690
700
  dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance }, attemptId => {
691
701
  if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.');
692
702
  dispatchAttemptId = attemptId;
@@ -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 { RECENT_SESSION_LIMIT, recentSessionWindow, retainedSession } from './discovery-window.js';
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 && !run.managed && !canUpgradeDelivery(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, inspectState: true, maxSessions: RECENT_SESSION_LIMIT, codexHome,
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, archived: true, maxSessions: RECENT_SESSION_LIMIT }); }
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, limit: RECENT_SESSION_LIMIT,
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
- // Select recent history before live probes, keeping explicit supervision
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 items = [...recentSessionWindow(catalog.filter(item => item.archived !== true && !item.localHidden), {
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 === 'codex' && typeof result.session === 'string') result.hostId = host.id;
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, 'Remote SIMY could not start. Check its installation, login and existing desktop connection.'); changed(); return;
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 ? { message: 'Remote operation could not finish. Check the original conversation and refresh before retrying.' } : {}) });
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 !== 'codex' || typeof command.session !== 'string' ||
173
- !c.status?.sessionSupervision?.sessions?.some(s => s?.provider === 'codex' && s.session === command.session)) throw error('Select an available remote Codex conversation.', 'remote_host_invalid');
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 !== 'codex' || typeof row.session !== 'string') continue;
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 Codex', verifiedOnPlatform: false,
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 === 'codex') runs.push({ ...stamp(run, c), originalSessionTarget: { kind: 'manual', url: null, verifiedOnPlatform: false, reason: `Select ${c.label} in Codex, then find conversation ${run.session}.` } });
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 === 'codex' && s.session === session)),
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
+ }