@awak-app/simy-cli 0.7.32 → 0.7.33

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.
@@ -1,3 +1,4 @@
1
+ import { discoverProgressively } from './progressive-discovery.js';
1
2
  import { sessionLoopOptions } from '../codex-plugins/simy/hooks/loop-kernel.mjs';
2
3
  import { sessionWorkState, compareSessionWork } from './work-state.js';
3
4
  import { normalizePullRequests } from './pull-requests.js';
@@ -7,8 +8,9 @@ import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/accepta
7
8
  import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
8
9
  import { realpath } from 'node:fs/promises';
9
10
  import { randomUUID } from 'node:crypto';
10
- import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, associationBlocksFollowup, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive, ownerRenewalPending } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
11
- import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
11
+ import { isAbsolute } from 'node:path';
12
+ import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, associationBlocksFollowup, canUpgradeDelivery, canExplicitlyResumeStopped, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive, ownerRenewalPending } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
13
+ import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexObservation, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
12
14
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
13
15
  import { loadPurposeRuleSet } from './purpose-instruction.js';
14
16
  import { originalSessionTarget } from './original-session-target.js';
@@ -30,6 +32,8 @@ export class AutoContinueUnavailableError extends Error {
30
32
  // Opt-in is independent of a busy provider turn. Dispatch still requires a
31
33
  // settled context and the original provider's execution boundary.
32
34
  function autoContinueAvailability(run, channel) {
35
+ if (run?.state === 'stopped' && channel.ready) return { canEnable: false, reasonCode: 'task_stopped',
36
+ reason: 'この会話は中断・停止中です。画面の「再開を依頼する」で、同じ目的と履歴を引き継いで続けられます。' };
33
37
  const issue = !channel.ready ? channel : run?.state === 'stopped'
34
38
  ? { code: 'task_stopped', reason: 'This task was stopped. Review the original conversation and explicitly request resumption before continuing.' } : run && !run.managed && !canUpgradeDelivery(run)
35
39
  ? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
@@ -100,6 +104,22 @@ function autoContinueState(record, isRunning, availability, readiness = null) {
100
104
  autoContinueReason: record.run ? 'Continuing the original task with progress and approval checks.' : isRunning ? 'Waiting for the original turn to finish.' : 'Checking settled requests for worthwhile remaining work.' };
101
105
  }
102
106
 
107
+ // Local consent changes independently of provider history pagination. Reuse the
108
+ // verified provider capabilities; a saved preference grants no new admission.
109
+ export function projectSessionAutoContinue(candidate, record) {
110
+ if (!record || record.provider !== candidate.provider || record.session !== candidate.session ||
111
+ record.cwd !== candidate.cwd || candidate.hostId && candidate.hostId !== 'local') return candidate;
112
+ const availability = candidate.provider === 'codex' && record.admission?.source
113
+ ? autoContinueAvailability(record.run, codexContinuationChannel(record.admission.source, record.admission.model || candidate.model))
114
+ : candidate.autoContinueAvailability || { canEnable: candidate.capabilities?.canAutoContinue === true };
115
+ const enabled = candidate.archived !== true && record.providerArchived !== true && record.autoContinue === true;
116
+ const setting = enabled ? record : { ...record, autoContinue: false };
117
+ return { ...candidate, autoContinueAvailability: availability, autoContinue: enabled, autoContinueEpoch: enabled ? record.autoContinueEpoch : null,
118
+ tracked: typeof record.tracked === 'boolean' ? record.tracked : record.monitor === true, monitor: record.monitor === true,
119
+ ...autoContinueState(setting, candidate.isRunning,
120
+ availability, candidate.dispatchReadiness) };
121
+ }
122
+
103
123
  function initialDiagnosisPlan(objective, contextBootstrap = false) {
104
124
  const action = { kind: 'read', scope: 'task', summary: contextBootstrap ? 'Understand the original user request and current progress without changing files' : 'Inspect the selected conversation and acceptance evidence without changing files' };
105
125
  return {
@@ -139,8 +159,13 @@ function navigationParent(item, record) {
139
159
  : record?.parentSession || item.parentSession;
140
160
  }
141
161
 
142
- export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
143
- inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false, includeSelectionCandidates = false } = {}) {
162
+ export async function discoverSessions(options = {}) {
163
+ if (options.onProgress && !options.catalogSnapshot) return discoverProgressively({ ...options, discover: discoverSessionCatalog });
164
+ return discoverSessionCatalog(options);
165
+ }
166
+
167
+ async function discoverSessionCatalog({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
168
+ inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false, includeSelectionCandidates = false, catalogSnapshot, onProgress } = {}) {
144
169
  const now = requestedNow ?? Date.now();
145
170
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
146
171
  assertActive();
@@ -150,10 +175,20 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
150
175
  let archivedCodex = [];
151
176
  let discovered = [], codexError = null, codexReasonCode = null, claude = [], claudeError = null;
152
177
  let archiveStates = new Map();
153
- await Promise.all([
178
+ const progress = async snapshot => {
179
+ if (!onProgress || signal?.aborted) return;
180
+ const value = await discoverSessionCatalog({ root, inspectProcess, now: requestedNow, signal, connectedProviders, includeArchived, codexHome,
181
+ catalogSnapshot: snapshot, listCodexChildren: async () => [], listCodexTitles: async () => new Map() });
182
+ if (!signal?.aborted) await onProgress(value);
183
+ };
184
+ if (catalogSnapshot) {
185
+ discovered = catalogSnapshot.discovered || []; claude = catalogSnapshot.claude || []; archivedCodex = catalogSnapshot.archivedCodex || [];
186
+ for (const item of archivedCodex) archiveStates.set(item.session, true);
187
+ } else await Promise.all([
154
188
  (async () => {
155
189
  try { if (connected.has('codex')) discovered = await withClient(openClient, async client => {
156
190
  const active = await listCodexSessions({ client, all: true, signal, inspectState: true, codexHome,
191
+ onPage: async items => { discovered = [...items]; await progress({ discovered, archivedCodex }); },
157
192
  prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
158
193
  if (includeArchived) {
159
194
  const observedArchives = new Map();
@@ -167,8 +202,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
167
202
  }
168
203
  return response;
169
204
  } };
170
- try { await listCodexSessions({ client: archiveClient, all: true, signal, archived: true }); }
171
- catch { /* Keep positive archive pages even if a later page fails. */ }
205
+ try { await listCodexSessions({ client: archiveClient, all: true, signal, archived: true,
206
+ onPage: async () => { archivedCodex = [...observedArchives.values()]; await progress({ discovered, archivedCodex }); } }); }
207
+ catch (error) {
208
+ codexError = 'Codex archive discovery incomplete. Refresh to retry.';
209
+ codexReasonCode = error?.code || 'session_metadata_unavailable';
210
+ }
172
211
  archivedCodex = [...observedArchives.values()];
173
212
  }
174
213
  const ids = new Set(active.map(item => item.session));
@@ -182,7 +221,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
182
221
  }
183
222
  })(),
184
223
  (async () => {
185
- try { if (connected.has('claude')) claude = await listClaude({ signal, all: true,
224
+ try { if (connected.has('claude')) claude = await listClaude({ signal, all: true, onProgress: items => { claude = items; return progress({ claude }); },
186
225
  prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
187
226
  catch { claudeError = 'Claude conversation discovery unavailable.'; }
188
227
  })(),
@@ -248,9 +287,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
248
287
  const archived = record.providerArchived === true || record.provider === 'codex' && archiveStates.get(record.session) === true;
249
288
  if (archived && !includeArchived) continue;
250
289
  const key = `${record.provider}:${record.session}`;
251
- // A hook observation can belong to an AI-created worker. Only explicit
252
- // supervision may restore a conversation absent from provider discovery.
253
- if (!byKey.has(key) && (retainedSession(record) || includeArchived && record.listHidden === true || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
290
+ // A saved stop must remain visible when the provider catalog is unavailable.
291
+ // This restores navigation only: the stopped run and unavailable provider
292
+ // still prohibit continuation. Bare hook observations remain insufficient.
293
+ const savedStopDuringOutage = record.run?.state === 'stopped' && Boolean(record.provider === 'codex' ? codexError : claudeError);
294
+ if (!byKey.has(key) && (retainedSession(record) || savedStopDuringOutage || includeArchived && record.listHidden === true || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
254
295
  provider: record.provider, session: record.session, cwd: record.cwd, title: record.listMetadata?.title || '', createdAt: record.listMetadata?.createdAt ?? null, archived, source: record.admission?.source || 'hook',
255
296
  lastActivityAt: record.listMetadata?.lastActivityAt ?? record.observedAt ?? record.admission?.validatedAt ?? record.run?.updatedAt ?? null,
256
297
  observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
@@ -345,6 +386,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
345
386
  ...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
346
387
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
347
388
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
389
+ // Consent can be saved while runtime checks are pending or failing.
390
+ controllerCanSetAutoContinue: !archived,
348
391
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
349
392
  listPinned: record?.listPinned === true, listPinScopes: record?.listPinScopes || [],
350
393
  providerArchived: archived, localHidden: record?.listHidden === true,
@@ -416,12 +459,21 @@ function requireAutoContinueTask(record) {
416
459
  // existing task. Desktop switches send `initialContinuationPolicy`: a default
417
460
  // for first enrollment only, decided under the record lock so a stale catalog
418
461
  // can neither fail ON for an ordinary loop nor change its policy or budgets.
462
+ // Desktop consent is independent of execution readiness. The target is selected
463
+ // by the host from its catalog, never accepted from a native command payload.
464
+ export async function saveSessionAutoContinuePreference(options) {
465
+ return setSessionAutoContinue({ ...options, preferenceOnly: true });
466
+ }
467
+
419
468
  export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, initialContinuationPolicy, ...options }) {
420
469
  if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
421
470
  const link = enabled ? await requireProviderLink({ root, provider, expectedEpoch: options.expectedProviderEpoch }) : null;
422
471
  let discoveredAdmission;
423
- const purposeRules = enabled && provider === 'codex' ? await loadPurposeRuleSet() : null;
424
- if (enabled) {
472
+ const preferenceOnly = options.preferenceOnly === true;
473
+ const target = options.target;
474
+ const validTarget = target?.provider === provider && target?.session === session && typeof target.cwd === 'string' && isAbsolute(target.cwd);
475
+ const purposeRules = enabled && provider === 'codex' && !preferenceOnly ? await loadPurposeRuleSet() : null;
476
+ if (enabled && !preferenceOnly) {
425
477
  const record = await readRecord(root, provider, session);
426
478
  if (record?.providerArchived === true) throw Object.assign(new Error('Restore the original provider conversation before enabling automatic continuation.'), { code: 'session_archived' });
427
479
  requireAutoContinueTask(record);
@@ -445,6 +497,15 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
445
497
  }
446
498
  const updated = await updateRecord(root, provider, session, async previous => {
447
499
  if (enabled) await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
500
+ if (enabled && preferenceOnly) {
501
+ if (previous && validTarget && previous.cwd !== target.cwd) throw Object.assign(new Error('Session workspace changed; refresh its metadata.'), { code: 'session_workspace_changed' });
502
+ if (!previous) {
503
+ if (!validTarget) throw Object.assign(new Error('Select a known conversation before saving its preference.'), { code: 'session_metadata_unavailable' });
504
+ // No filesystem/process/model probe and no provider call at save time.
505
+ previous = { schemaVersion: 1, provider, session, cwd: target.cwd, run: null };
506
+ if (hasAutoContinueAdmission({ ...previous, admission: options.admission }, provider, session)) previous.admission = { ...options.admission };
507
+ }
508
+ }
448
509
  if (enabled && discoveredAdmission) {
449
510
  if (previous && previous.cwd !== discoveredAdmission.cwd) throw Object.assign(new Error('Session workspace changed; refresh its metadata.'), { code: 'session_workspace_changed' });
450
511
  // An admission written while saving is authoritative. Never overwrite its
@@ -452,8 +513,8 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
452
513
  previous = previous?.admission ? previous : { ...(previous || { schemaVersion: 1, provider, session, cwd: discoveredAdmission.cwd, run: null }), admission: discoveredAdmission };
453
514
  }
454
515
  if (!previous) throw new Error('Refresh this conversation before changing automatic continuation.');
455
- if (enabled && previous.providerArchived === true) throw new Error('Restore the original provider conversation before enabling automatic continuation.');
456
- if (enabled) {
516
+ if (enabled && previous.providerArchived === true) throw Object.assign(new Error('Restore the original provider conversation before enabling automatic continuation.'), { code: 'session_archived' });
517
+ if (enabled && !preferenceOnly) {
457
518
  const channel = provider === 'claude' ? await liveClaudeChannel(previous,
458
519
  { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)
459
520
  : codexContinuationChannel(previous.admission?.source, previous.admission?.model);
@@ -474,12 +535,14 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
474
535
  const next = { ...previous, ...(enabled && link.consentEpoch ? { providerConsentEpoch: link.consentEpoch } : {}), autoContinue: enabled, autoContinueEpoch: epoch,
475
536
  ...(enabled ? { tracked: true, monitor: true, autoContinueSettings: settings } : { tracked: false, monitor: false }) };
476
537
  // Adopt existing supervision without restarting it or renewing its budgets.
477
- if (next.run?.managed) {
538
+ const preserveTerminal = preferenceOnly && (enabled || TERMINAL_RUNS.has(next.run?.state));
539
+ if (next.run?.managed && !preserveTerminal) {
478
540
  next.run.managed.autoContinueEpoch = epoch;
479
- if (purposeRules && next.run.managed.resumeChannel === 'codex_app_server') {
480
- next.run.managed.purpose = { ...next.run.managed.purpose, enabled: true, version: 2, decisionPolicy: 'llm-first-hc102',
541
+ if (enabled && next.run.managed.resumeChannel === 'codex_app_server') {
542
+ if (purposeRules) next.run.managed.purpose = { ...next.run.managed.purpose, enabled: true, version: 2, decisionPolicy: 'llm-first-hc102',
481
543
  failureBudgetMode: purposeFailureMode(next),
482
544
  ruleDigest: purposeRules.digest, ruleIds: purposeRules.rules.map(rule => rule.id) };
545
+ else if (next.run.managed.purpose) next.run.managed.purpose.enabled = true;
483
546
  if (next.run.state === 'paused' && next.run.reason === 'Automatic continuation paused by user.') { next.run.state = 'recovery_required'; next.run.recoveryCause = 'session_supervision'; next.run.managed.autoRecovery = true; }
484
547
  // OFF keeps the current native turn alive. Re-enabling during that
485
548
  // same turn restores future continuation without starting another turn.
@@ -489,7 +552,7 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
489
552
  }
490
553
  // The desktop preference is an explicit human control too: an old decision
491
554
  // card must not undo this switch, even when no provider checkpoint arrived.
492
- if (next.run) advanceDeliveryControlRevision(next.run);
555
+ if (next.run && !preserveTerminal) advanceDeliveryControlRevision(next.run);
493
556
  return next;
494
557
  });
495
558
  // Consent is durable now. Provider reads and ordinary-loop promotion belong
@@ -636,19 +699,33 @@ export async function answerSession({ root, provider, session, taskId, expectedR
636
699
  // selection changed in Codex is configuration, not a different task or authority.
637
700
  export async function resumeSession({ openClient = openCodexAppServer, ...options }) {
638
701
  const record = await readRecord(options.root, options.provider, options.session);
639
- if (options.provider !== 'codex' || record?.run?.managed?.resumeChannel !== 'codex_app_server')
702
+ const stoppedStandard = options.provider === 'codex' && !record?.run?.managed &&
703
+ canExplicitlyResumeStopped(record?.run, { standard: true });
704
+ if (options.provider !== 'codex' || !stoppedStandard && record?.run?.managed?.resumeChannel !== 'codex_app_server')
640
705
  return controlDelivery({ ...options, action: 'resume' });
641
- if (record.run.managed.taskId !== options.taskId) throw new Error('Managed task identity changed; refresh its status.');
706
+ if (!stoppedStandard && record.run.managed.taskId !== options.taskId) throw new Error('Managed task identity changed; refresh its status.');
642
707
  const link = await requireProviderLink(options);
643
708
  const client = await openClient({ isolateConnectors: true });
644
709
  let metadata;
645
- try { metadata = await readCodexSession(client, options.session); }
710
+ try {
711
+ metadata = await readCodexSession(client, options.session);
712
+ // thread/read may omit runtime status. Observe the latest native turn
713
+ // instead of treating the omitted field as either idle or an error.
714
+ if (stoppedStandard && metadata.isRunning === undefined) {
715
+ const observation = await readCodexObservation(client, options.session);
716
+ if (typeof observation.isRunning === 'boolean') metadata.isRunning = observation.isRunning;
717
+ }
718
+ }
646
719
  finally { await client.close(); }
647
720
  const cwd = await realpath(metadata.cwd);
648
- if (cwd !== record.cwd || !metadata.model) throw new Error('Original Codex workspace or model is unavailable; inspect before resuming.');
721
+ if (cwd !== record.cwd || !metadata.model || stoppedStandard && (metadata.isRunning !== false || metadata.archived || metadata.observation?.source !== 'codex_app_server')) throw new Error('Original Codex workspace, idle observation or model is unavailable; inspect before resuming.');
722
+ const rules = stoppedStandard ? await loadPurposeRuleSet() : null;
649
723
  await requireProviderLink({ ...options, expectedEpoch: link.epoch });
650
724
  return controlDelivery({ ...options, action: 'resume',
651
725
  expectedRunId: options.expectedRunId ?? record.run.id,
652
726
  expectedRevision: options.expectedRevision ?? deliveryDecisionRevision(record.run),
653
- resumeObservation: { session: metadata.session, cwd, model: metadata.model, source: 'codex_app_server', providerEpoch: link.epoch } });
727
+ resumeObservation: { session: metadata.session, cwd, model: metadata.model, source: 'codex_app_server', providerEpoch: link.epoch,
728
+ ...(stoppedStandard && metadata.nativeSandbox ? { nativeSandbox: metadata.nativeSandbox, nativeSandboxSource: metadata.nativeSandboxSource } : {}),
729
+ ...(rules ? { purpose: { enabled: true, version: 2, decisionPolicy: 'llm-first-hc102', failureBudgetMode: 'hard',
730
+ ruleDigest: rules.digest, ruleIds: rules.rules.map(rule => rule.id) } } : {}) } });
654
731
  }
@@ -1,8 +1,10 @@
1
+ import { createDesktopFrameWriter } from '../desktop-frame-writer.js';
1
2
  import { createServer, createConnection } from 'node:net';
2
3
  import { chmod, readFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { randomUUID, createHash } from 'node:crypto';
5
6
  import { createInterface } from 'node:readline';
7
+ import { normalizeWebOrigin } from '../web-origin.js';
6
8
 
7
9
  export const ownerSocketPath = (root, platform = process.platform) => platform === 'win32'
8
10
  ? '\\\\.\\pipe\\simy-controller-' + createHash('sha256').update(root).digest('hex').slice(0, 32)
@@ -11,19 +13,22 @@ const socketPath = ownerSocketPath;
11
13
  // The socket and nonce are accessible only to the local OS account. Application
12
14
  // account/provider checks still run in the owner's ordinary command handler.
13
15
  export async function createOwnerTransport({ root, nonce, allowedActions, dispatch, allowControllerArchive = false }) {
14
- const peers = new Set(), requests = new Map();
16
+ const writers = new WeakMap();
17
+ const peers = new Set(), subscribers = new Set(), requests = new Map();
15
18
  let latest;
16
19
  const send = (peer, frame) => {
17
20
  if (peer.destroyed) return;
18
- if (peer.writableLength > 8 * 1024 * 1024) { peer.destroy(); return; }
19
- peer.write(JSON.stringify(frame) + '\n');
21
+ if (frame.type !== 'status' && peer.writableLength > 8 * 1024 * 1024) { peer.destroy(); return; }
22
+ writers.get(peer).write(JSON.stringify(frame) + '\n', frame.type === 'status');
20
23
  };
21
24
  const server = createServer(peer => {
22
25
  peer.setEncoding('utf8');
26
+ const writer = createDesktopFrameWriter(peer);
27
+ writers.set(peer, writer);
23
28
  let authenticated = false, controllerArchive = false, buffer = '';
24
29
  const timeout = setTimeout(() => peer.destroy(), 5000);
25
30
  peer.on('error', () => {});
26
- peer.on('close', () => { clearTimeout(timeout); peers.delete(peer); for (const [id, item] of requests) if (item.peer === peer) requests.delete(id); });
31
+ peer.on('close', () => { writer.close(); clearTimeout(timeout); peers.delete(peer); subscribers.delete(peer); for (const [id, item] of requests) if (item.peer === peer) requests.delete(id); });
27
32
  peer.on('data', chunk => {
28
33
  buffer += chunk.toString();
29
34
  if (buffer.length > 16384) { peer.destroy(); return; }
@@ -35,7 +40,12 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
35
40
  if (!authenticated) {
36
41
  if (command.nonce !== nonce) { peer.destroy(); return; }
37
42
  authenticated = true; controllerArchive = allowControllerArchive && command.client === 'archive-controller'; clearTimeout(timeout); peers.add(peer);
38
- if (latest) send(peer, latest);
43
+ // One-shot controls need their result, not multi-megabyte broadcasts
44
+ // that can fill the write queue before the result is sent.
45
+ if (command.subscribeStatus !== false) {
46
+ subscribers.add(peer);
47
+ if (latest) send(peer, latest);
48
+ }
39
49
  continue;
40
50
  }
41
51
  if (!allowedActions.has(command.action) || typeof command.id !== 'string' || command.id.length > 128 || command.hostId && command.hostId !== 'local' && !(controllerArchive && ['session_archive', 'session_restore', 'session_provider_setup'].includes(command.action))) {
@@ -59,7 +69,7 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
59
69
  const item = requests.get(frame.id);
60
70
  if (item) { requests.delete(frame.id); send(item.peer, { ...frame, id: item.id }); }
61
71
  } else if (frame.type === 'status' || frame.type === 'notify' && (!frame.hostId || frame.hostId === 'local')) {
62
- for (const peer of peers) send(peer, frame);
72
+ for (const peer of subscribers) send(peer, frame);
63
73
  }
64
74
  },
65
75
  async close() { for (const peer of peers) peer.destroy(); await new Promise(resolve => server.close(resolve)); },
@@ -68,7 +78,7 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
68
78
 
69
79
  // Returns false only when no compatible owner endpoint exists. Never starts a
70
80
  // second owner, kills a process, or converts a disconnect into an execution retry.
71
- export async function relayToOwner({ root, input = process.stdin, output = process.stdout }) {
81
+ export async function relayToOwner({ root, webOrigin, input = process.stdin, output = process.stdout }) {
72
82
  let owner;
73
83
  try { owner = JSON.parse(await readFile(join(root, 'desktop-app.lock', 'owner.json'), 'utf8')); }
74
84
  catch (error) { if (error.code === 'ENOENT') return false; throw error; }
@@ -79,6 +89,11 @@ export async function relayToOwner({ root, input = process.stdin, output = proce
79
89
  peer.once('error', error => { clearTimeout(timer); if (['ENOENT','ECONNREFUSED'].includes(error.code)) resolve(false); else reject(error); });
80
90
  });
81
91
  if (!connected) return false;
92
+ try {
93
+ if (typeof owner?.nonce !== 'string' || !owner.nonce) throw new Error('Remote owner identity is unavailable.');
94
+ if (webOrigin && (!owner.webOrigin || normalizeWebOrigin(owner.webOrigin) !== normalizeWebOrigin(webOrigin)))
95
+ throw Object.assign(new Error('The existing SIMY owner belongs to another Web address. Keep its tasks and reconnect using its saved environment.'), { code: 'remote_owner_origin_mismatch' });
96
+ } catch (error) { peer.destroy(); throw error; }
82
97
  peer.write(JSON.stringify({ nonce: owner.nonce }) + '\n');
83
98
  peer.pipe(output, { end: false });
84
99
  const lines = createInterface({ input, crlfDelay: Infinity });
@@ -128,7 +143,7 @@ async function requestOwnerCommand({ root, command, signal, timeoutMs, client })
128
143
  peer.on('connect', () => {
129
144
  if (signal?.aborted) { abort(); return; }
130
145
  sent = true;
131
- peer.write(JSON.stringify({ nonce: owner.nonce, ...(client ? { client } : {}) }) + '\n' + JSON.stringify(command) + '\n');
146
+ peer.write(JSON.stringify({ nonce: owner.nonce, subscribeStatus: false, ...(client ? { client } : {}) }) + '\n' + JSON.stringify(command) + '\n');
132
147
  });
133
148
  peer.on('error', error => finish(sent ? unknown() : connectionFailure(error)));
134
149
  peer.on('close', () => finish(Object.assign(new Error('SIMY disconnected before confirming the result. Refresh before retrying.'), { code: 'remote_operation_unknown' })));
@@ -0,0 +1,66 @@
1
+ // A deadline releases the caller even when an adapter ignores cancellation.
2
+ // The adapter still receives an abort signal; late values never reach the UI.
3
+ export async function boundedDiscovery(work, { signal, timeoutMs = 30_000, onDrain } = {}) {
4
+ const controller = new AbortController();
5
+ let timer, rejectStop;
6
+ const stopped = new Promise((_, reject) => { rejectStop = reject; });
7
+ const cancel = () => {
8
+ const error = signal?.reason || Object.assign(new Error('Conversation discovery cancelled.'), { code: 'cancelled' });
9
+ controller.abort(error); rejectStop(error);
10
+ };
11
+ signal?.addEventListener('abort', cancel, { once: true });
12
+ timer = setTimeout(() => {
13
+ const error = Object.assign(new Error('Conversation discovery timed out. Refresh to retry.'), { code: 'discovery_timeout' });
14
+ controller.abort(error); rejectStop(error);
15
+ }, timeoutMs);
16
+ try {
17
+ if (signal?.aborted) cancel();
18
+ const operation = Promise.resolve().then(() => {
19
+ controller.signal.throwIfAborted(); return work(controller.signal);
20
+ });
21
+ onDrain?.(operation.then(() => {}, () => {}));
22
+ return await Promise.race([stopped, operation]);
23
+ } finally {
24
+ clearTimeout(timer); signal?.removeEventListener('abort', cancel);
25
+ // Revoke callbacks retained by an adapter after its result settled.
26
+ if (!controller.signal.aborted) controller.abort();
27
+ }
28
+ }
29
+
30
+ export async function discoverProgressively({ discover, onProgress, signal, timeoutMs = 30_000,
31
+ connectedProviders = ['codex', 'claude'], onDrain, ...options }) {
32
+ const values = new Map(), complete = new Set(), settled = new Set();
33
+ let notifications = Promise.resolve();
34
+ const snapshot = () => ({
35
+ sessions: [...values.values()].flatMap(value => value.sessions || []),
36
+ selectionCandidates: [...values.values()].flatMap(value => value.selectionCandidates || []),
37
+ providers: Object.fromEntries(['codex', 'claude'].map(provider => [provider,
38
+ values.get(provider)?.providers?.[provider] || { discovery: connectedProviders.includes(provider) ? 'loading' : 'disconnected', reason: null }])),
39
+ completeProviders: [...complete], historyLoading: settled.size < connectedProviders.length,
40
+ refreshedAt: Date.now(),
41
+ });
42
+ const notify = () => {
43
+ const value = snapshot();
44
+ notifications = notifications.then(() => { signal?.throwIfAborted(); return onProgress(value); });
45
+ return notifications;
46
+ };
47
+ await Promise.all(connectedProviders.map(async provider => {
48
+ try {
49
+ const value = await boundedDiscovery(childSignal => discover({ ...options, signal: childSignal,
50
+ connectedProviders: [provider], onProgress: async value => {
51
+ childSignal.throwIfAborted(); values.set(provider, value); await notify();
52
+ } }), { signal, timeoutMs, onDrain });
53
+ signal?.throwIfAborted();
54
+ values.set(provider, value);
55
+ if (['ready', 'local_index'].includes(value.providers?.[provider]?.discovery)) complete.add(provider);
56
+ } catch (error) {
57
+ if (signal?.aborted) throw error;
58
+ const previous = values.get(provider) || { sessions: [] };
59
+ values.set(provider, { ...previous, providers: { [provider]: { discovery: 'unavailable',
60
+ reasonCode: error.code || 'discovery_failed', reason: error.message } } });
61
+ }
62
+ settled.add(provider); await notify();
63
+ }));
64
+ await notifications;
65
+ return snapshot();
66
+ }
@@ -6,17 +6,20 @@ import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
8
  import { adoptInstalledRuntime, pinnedWorkflowSource } from '../delivery-hooks/local-runtime.js';
9
+ import { attachRemoteService } from './remote-service.js';
10
+ import { resolveWebOrigin } from '../web-origin.js';
9
11
  process.env.SIMY_REMOTE_CHILD = '1';
10
12
  process.env.SIMY_AUTO_UPDATE = '0';
11
13
  const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
14
+ const webOrigin = resolveWebOrigin();
12
15
  try {
13
- if (await relayToOwner({ root })) process.exit(0);
16
+ if (await relayToOwner({ root, webOrigin })) process.exit(0);
14
17
  await adoptInstalledRuntime({ root });
15
18
  const selected = await pinnedWorkflowSource({ root });
16
19
  const host = await import(selected ? pathToFileURL(join(selected, 'src/desktop-host.js')).href : '../desktop-host.js');
17
20
  if (host.remoteSessionProtocol !== 1) throw new Error('Update the remote SIMY runtime.');
18
- host.startDesktopEntry();
19
- } catch {
20
- process.stdout.write(JSON.stringify({ type: 'error', message: 'Remote SIMY could not start. Update its runtime and check its existing desktop connection.' }) + '\n');
21
+ await attachRemoteService({ root, webOrigin });
22
+ } catch (error) {
23
+ process.stdout.write(JSON.stringify({ type: 'error', ...(error.code === 'remote_owner_origin_mismatch' ? { code: error.code } : {}), message: 'Remote SIMY could not start. Update its runtime and check its existing desktop connection.' }) + '\n');
21
24
  process.exitCode = 1;
22
25
  }
@@ -0,0 +1,21 @@
1
+ // A live transport and an executable AI provider are separate observations.
2
+ // Never use cached private provider status to diagnose a disconnected host.
3
+ export function remoteHostRecovery({ state, enabled = true, status, ready = false, fresh = false, errorCode } = {}) {
4
+ if (enabled === false) return { code: 'disconnected', action: 'connect' };
5
+ if (errorCode === 'update_required') return { code: 'update_required', action: 'settings' };
6
+ if (errorCode === 'simy_auth_required') return { code: 'simy_auth_required', action: 'login' };
7
+ if (errorCode === 'access_required') return { code: 'access_required', action: 'login' };
8
+ if (state === 'connecting') return { code: 'connecting', action: null };
9
+ if (!fresh || !status || state === 'unavailable') return { code: 'transport_unavailable', action: 'reconnect' };
10
+ if (status.auth?.state !== 'signed_in') return { code: 'simy_auth_required', action: 'login' };
11
+ if (!ready || status.access?.state !== 'ready') return { code: 'access_required', action: 'login' };
12
+ for (const provider of ['codex', 'claude']) {
13
+ // Only providers explicitly connected by this host's user need recovery.
14
+ if (status.providerLinks?.[provider]?.connected !== true) continue;
15
+ const observed = status.providers?.[provider] || status[provider];
16
+ if (['missing', 'outdated'].includes(observed?.state)) return { code: 'provider_setup_required', action: 'settings', provider };
17
+ if (observed?.state === 'signed_out') return { code: 'provider_login_required', action: 'settings', provider };
18
+ if (observed?.state !== 'ready') return { code: 'provider_unverified', action: 'refresh', provider };
19
+ }
20
+ return { code: 'ready', action: null };
21
+ }
@@ -7,6 +7,7 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import { compareSessionWork } from './work-state.js';
9
9
  import { manualAcceptanceCommand, matchesManualAcceptanceControls, manualAcceptanceContinuation } from './manual-acceptance-command.js';
10
+ import { remoteHostRecovery } from './remote-host-recovery.js';
10
11
 
11
12
  const MAX_FRAME = 8 * 1024 * 1024;
12
13
  const MAX_HOSTS = 8;
@@ -83,9 +84,10 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
83
84
  let preferenceScope = null;
84
85
  let scope = null, local = null, closed = false, settingsError = null, writes = Promise.resolve(), generation = 0;
85
86
  const changed = () => { if (!closed) onChange(); };
86
- function disconnect(connection, reason = 'Remote connection closed.') {
87
+ function disconnect(connection, reason = 'Remote connection closed.', errorCode = 'transport_unavailable') {
87
88
  const child = connection.child;
88
89
  connection.child = null; connection.ready = false; connection.reason = reason; connection.state = 'unavailable'; connection.retryAt = now() + retryMs;
90
+ connection.errorCode = errorCode;
89
91
  clearTimeout(connection.handshake);
90
92
  for (const job of connection.pending.values()) { clearTimeout(job.timer); job.reject(error('Remote operation result is unknown. Check the original conversation before retrying.', 'remote_operation_unknown')); }
91
93
  connection.pending.clear();
@@ -106,7 +108,8 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
106
108
  const id = `${connection.id}:${frame.id}`;
107
109
  return preferences.change(expectedScope, state => {
108
110
  if (state.events[id] || closed || preferenceScope !== expectedScope || !connection.ready) return;
109
- onNotify({ ...stamp(event, connection), hostId: connection.id, id });
111
+ onNotify({ ...stamp(event, connection), hostId: connection.id, id,
112
+ ...(event.continuationWaitId ? { sessionIdentity: `${event.provider}|${event.session}|${connection.id}` } : {}) });
110
113
  state.events[id] = now();
111
114
  });
112
115
  }
@@ -139,7 +142,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
139
142
  }
140
143
  function connect(connection) {
141
144
  if (closed || connection.enabled === false || connection.child || now() < connection.retryAt) return;
142
- connection.state = 'connecting'; connection.reason = null;
145
+ connection.state = 'connecting'; connection.reason = null; connection.errorCode = null;
143
146
  let child;
144
147
  try { child = spawnImpl('ssh', remoteSshArgs(connection.alias, REMOTE_HOST_COMMAND), { stdio: ['pipe', 'pipe', 'pipe'], shell: false }); }
145
148
  catch { disconnect(connection, 'Could not start SSH.'); return; }
@@ -175,10 +178,11 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
175
178
  connection.capabilities = Array.isArray(status?.remoteControl?.actions)
176
179
  ? status.remoteControl.actions.filter(action => typeof action === 'string' && allowedActions.has(action))
177
180
  : status?.remoteControl === undefined && status?.remoteSessionProtocol === 1 ? [...legacyActions] : [];
178
- if (status?.remoteSessionProtocol !== 1) { disconnect(connection, 'Update SIMY on the remote host to use remote sessions.'); changed(); return; }
181
+ if (status?.remoteSessionProtocol !== 1) { disconnect(connection, 'Update SIMY on the remote host to use remote sessions.', 'update_required'); changed(); return; }
179
182
  if (!usable(status) || status.auth.userId !== local?.auth?.userId || status.access.organizationId !== local?.access?.organizationId) {
180
183
  clearTimeout(connection.handshake);
181
184
  connection.ready = false; connection.state = 'access_required'; connection.reason = 'Use Remote connections in this CLI to sign in to this host.';
185
+ connection.errorCode = status?.auth?.state !== 'signed_in' ? 'simy_auth_required' : 'access_required';
182
186
  connection.status = null;
183
187
  for (const [id, job] of connection.pending) if (!job.setup) { clearTimeout(job.timer); job.reject(error(connection.reason, 'remote_operation_unknown')); connection.pending.delete(id); }
184
188
  } else if (!Array.isArray(status.sessionSupervision?.sessions) || !Array.isArray(status.deliveryHooks?.runs)) {
@@ -186,6 +190,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
186
190
  } else {
187
191
  clearTimeout(connection.handshake);
188
192
  connection.status = status; connection.ready = true; connection.state = 'ready'; connection.reason = null; connection.receivedAt = now();
193
+ connection.errorCode = null;
189
194
  if (status.controllerEventsVersion === 1) void pullEvents(connection).catch(() => {});
190
195
  else for (const event of status.controllerEvents || []) void receiveNotification(connection, event)?.catch(() => {});
191
196
  const expectedScope = preferenceScope;
@@ -210,7 +215,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
210
215
  } else if (frame.type === 'notify' && connection.ready && local && typeof frame.id === 'string' && ['finished','failed','paused','needs_decision'].includes(frame.kind)) {
211
216
  void receiveNotification(connection, frame)?.catch(() => {});
212
217
  } else if (frame.type === 'error') {
213
- disconnect(connection, remoteOperationFailure(frame.code).message); changed(); return;
218
+ disconnect(connection, remoteOperationFailure(frame.code).message, frame.code === 'remote_owner_origin_mismatch' ? 'access_required' : 'transport_unavailable'); changed(); return;
214
219
  } else if (frame.type === 'task_detail') {
215
220
  const pending = connection.pending.get(frame.id);
216
221
  pending?.onDetail?.({ ...frame, id: pending.id, taskKey: pending.taskKey });
@@ -252,7 +257,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
252
257
  c.label = host.label; c.enabled = host.enabled !== false;
253
258
  if (!c.enabled) {
254
259
  if (c.child) disconnect(c);
255
- c.ready = false; c.status = null; c.state = 'disconnected'; c.reason = null;
260
+ c.ready = false; c.status = null; c.state = 'disconnected'; c.reason = null; c.errorCode = null;
256
261
  continue;
257
262
  }
258
263
  if (c.state === 'disconnected') c.retryAt = 0;
@@ -339,8 +344,22 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
339
344
  if (!c) return;
340
345
  c.enabled = enabled;
341
346
  if (!enabled) { disconnect(c); c.status = null; c.state = 'disconnected'; c.reason = null; }
342
- else { c.retryAt = 0; if (c.state === 'disconnected') c.state = 'connecting'; }
347
+ else {
348
+ // Explicit Connect also retries a stale or unauthorized live transport.
349
+ // Resetting backoff alone cannot replace a child that is still present.
350
+ // Keep healthy connections and an in-flight handshake intact.
351
+ if (c.child && c.state !== 'connecting' && (!c.ready || now() - c.receivedAt > 45_000)) disconnect(c);
352
+ c.retryAt = 0;
353
+ if (!c.child) c.state = 'connecting';
354
+ }
343
355
  });
356
+ const c = connections.get(host.id);
357
+ if (enabled && c?.child && c.capabilities?.includes('refresh')) {
358
+ // Explicit Connect/Check again must request a new host observation even
359
+ // when SSH is healthy. Keep its owner and in-flight mutation receipts:
360
+ // refreshing providers is read-only and must never replay execution.
361
+ c.child.stdin.write(JSON.stringify({ id: randomUUID(), action: 'refresh' }) + '\n');
362
+ }
344
363
  }
345
364
  async function forward(command, status) {
346
365
  if (closed || !usable(status) || scopeOf(status) !== scope) throw error('SIMY account changed. Refresh before using the remote host.', 'remote_host_access');
@@ -478,7 +497,8 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
478
497
  const remoteActiveCount = hosts.filter(c => c.ready).reduce((n,c) => n + (c.status?.[key]?.activeCount || 0), 0);
479
498
  return { ...status[key], items: [...localItems, ...rows], localActiveCount: Math.max(0, (status[key]?.activeCount || 0) - priorRemote), activeCount: Math.max(0, (status[key]?.activeCount || 0) - priorRemote) + remoteActiveCount, remoteActiveCount, unknownHosts: hosts.filter(c => !c.ready).map(c => c.id) };
480
499
  }
481
- return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, enabled: c.enabled !== false, state: c.ready && now() - c.receivedAt > 45_000 ? 'unavailable' : c.state, transportId: c.ready ? c.transportId : null, reason: c.reason })) },
500
+ return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, enabled: c.enabled !== false, state: c.ready && now() - c.receivedAt > 45_000 ? 'unavailable' : c.state, transportId: c.ready ? c.transportId : null, reason: c.reason,
501
+ recovery: remoteHostRecovery({ state: c.state, enabled: c.enabled, status: c.status, ready: c.ready, fresh: now() - c.receivedAt <= 45_000, errorCode: c.errorCode }) })) },
482
502
  tasks: aggregate('tasks', tasks), sqmTasks: aggregate('sqmTasks', sqmTasks),
483
503
  sessionSupervision: { ...status.sessionSupervision, selectionCandidates: [...(status.sessionSupervision?.selectionCandidates || []).filter(row => !row.hostId || row.hostId === 'local'), ...selectionCandidates], sessions: [...(status.sessionSupervision?.sessions || []).filter(row => !row.hostId || row.hostId === 'local'), ...sessions].sort(compareSessionWork) },
484
504
  deliveryHooks: { ...status.deliveryHooks, runs: [...(status.deliveryHooks?.runs || []).filter(row => !row.hostId || row.hostId === 'local'), ...runs] } };
@@ -0,0 +1,20 @@
1
+ // Separate from the SSH entry so reconnects only attach and never replace work.
2
+ import { PassThrough, Writable } from 'node:stream';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { adoptInstalledRuntime, pinnedWorkflowSource } from '../delivery-hooks/local-runtime.js';
7
+
8
+ process.env.SIMY_REMOTE_CHILD = '1';
9
+ process.env.SIMY_AUTO_UPDATE = '0';
10
+ const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
11
+ try {
12
+ await adoptInstalledRuntime({ root });
13
+ const selected = await pinnedWorkflowSource({ root });
14
+ const host = await import(selected ? pathToFileURL(join(selected, 'src/desktop-host.js')).href : '../desktop-host.js');
15
+ if (host.remoteSessionProtocol !== 1) throw new Error('Update the remote SIMY runtime.');
16
+ host.startDesktopEntry({ input: new PassThrough(), output: new Writable({ write(_chunk, _encoding, done) { done(); } }) });
17
+ } catch (error) {
18
+ process.stderr.write(`Remote SIMY owner could not start: ${error.message}\n`);
19
+ process.exitCode = 1;
20
+ }