@awak-app/simy-cli 0.7.32 → 0.7.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +22 -3
  2. package/package.json +3 -2
  3. package/src/auto-update.js +10 -1
  4. package/src/cli-contract.js +1 -1
  5. package/src/cli-observability.js +1 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +44 -12
  7. package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +13 -4
  8. package/src/delivery-hooks/desktop.js +1 -1
  9. package/src/desktop-access.js +1 -1
  10. package/src/desktop-frame-writer.js +29 -0
  11. package/src/desktop-host.js +252 -77
  12. package/src/orchestrator/loop.js +2 -0
  13. package/src/session-supervision/analysis-manager.js +83 -18
  14. package/src/session-supervision/claude-desktop-lineage.js +1 -1
  15. package/src/session-supervision/claude-sessions.js +16 -4
  16. package/src/session-supervision/codex-app-server.js +45 -30
  17. package/src/session-supervision/continuation-readiness.js +58 -0
  18. package/src/session-supervision/decision-presentation.js +11 -3
  19. package/src/session-supervision/index.js +126 -28
  20. package/src/session-supervision/owner-transport.js +23 -8
  21. package/src/session-supervision/progressive-discovery.js +79 -0
  22. package/src/session-supervision/remote-entry.js +7 -4
  23. package/src/session-supervision/remote-host-recovery.js +21 -0
  24. package/src/session-supervision/remote-hosts.js +28 -8
  25. package/src/session-supervision/remote-owner-entry.js +20 -0
  26. package/src/session-supervision/remote-service.js +36 -0
  27. package/src/session-supervision/statistics.js +19 -3
  28. package/src/session-supervision/work-state.js +7 -1
  29. package/src/sqm/local-tests.js +2 -1
  30. package/src/update-connection.js +16 -0
@@ -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,21 +159,41 @@ 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, recordSnapshot, archiveReconciliations = new Map(), readRecords = listRecords, 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();
147
172
  const connected = new Set(connectedProviders);
148
- const records = (await listRecords(root)).filter(record => connected.has(record.provider));
173
+ const records = (recordSnapshot || await readRecords(root)).filter(record => connected.has(record.provider));
149
174
  const priorities = records.filter(retainedSession);
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 observed = new Set([...(snapshot.discovered || []), ...(snapshot.claude || []), ...(snapshot.archivedCodex || [])]
181
+ .map(item => `${item.provider}:${item.session}`));
182
+ const value = await discoverSessionCatalog({ root, inspectProcess, now: requestedNow, signal, connectedProviders, includeArchived, codexHome,
183
+ // Pages reuse this attempt's records. Do not rescan or project the entire
184
+ // retained corpus for every page; the complete catalog still includes it.
185
+ catalogSnapshot: snapshot, recordSnapshot: records.filter(record => observed.has(`${record.provider}:${record.session}`)), archiveReconciliations,
186
+ listCodexChildren: async () => [], listCodexTitles: async () => new Map() });
187
+ if (!signal?.aborted) await onProgress(value);
188
+ };
189
+ if (catalogSnapshot) {
190
+ discovered = catalogSnapshot.discovered || []; claude = catalogSnapshot.claude || []; archivedCodex = catalogSnapshot.archivedCodex || [];
191
+ for (const item of archivedCodex) archiveStates.set(item.session, true);
192
+ } else await Promise.all([
154
193
  (async () => {
155
194
  try { if (connected.has('codex')) discovered = await withClient(openClient, async client => {
156
195
  const active = await listCodexSessions({ client, all: true, signal, inspectState: true, codexHome,
196
+ onPage: async items => { discovered = [...items]; await progress({ discovered, archivedCodex }); },
157
197
  prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
158
198
  if (includeArchived) {
159
199
  const observedArchives = new Map();
@@ -167,8 +207,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
167
207
  }
168
208
  return response;
169
209
  } };
170
- try { await listCodexSessions({ client: archiveClient, all: true, signal, archived: true }); }
171
- catch { /* Keep positive archive pages even if a later page fails. */ }
210
+ try { await listCodexSessions({ client: archiveClient, all: true, signal, archived: true,
211
+ onPage: async () => { archivedCodex = [...observedArchives.values()]; await progress({ discovered, archivedCodex }); } }); }
212
+ catch (error) {
213
+ codexError = 'Codex archive discovery incomplete. Refresh to retry.';
214
+ codexReasonCode = error?.code || 'session_metadata_unavailable';
215
+ }
172
216
  archivedCodex = [...observedArchives.values()];
173
217
  }
174
218
  const ids = new Set(active.map(item => item.session));
@@ -182,7 +226,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
182
226
  }
183
227
  })(),
184
228
  (async () => {
185
- try { if (connected.has('claude')) claude = await listClaude({ signal, all: true,
229
+ try { if (connected.has('claude')) claude = await listClaude({ signal, all: true, onProgress: items => { claude = items; return progress({ claude }); },
186
230
  prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
187
231
  catch { claudeError = 'Claude conversation discovery unavailable.'; }
188
232
  })(),
@@ -195,11 +239,22 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
195
239
  const claudeItem = record.provider === 'claude' ? claude.find(item => item.session === record.session && item.cwd === record.cwd) : null;
196
240
  const archiveState = record.provider === 'codex' ? archiveStates.get(record.session) ?? (activeCodex.has(record.session) ? false : undefined) : claudeItem?.archived;
197
241
  if (typeof archiveState !== 'boolean') continue;
242
+ const key = `${record.provider}:${record.session}`;
243
+ const reconciled = archiveReconciliations.get(key);
244
+ if (reconciled?.archived === archiveState) {
245
+ records[index] = reconciled.record;
246
+ continue;
247
+ }
248
+ // Pages repeat an ever-growing prefix. Persist positive archive fencing
249
+ // before exposing it once, rather than repeatedly refreshing the same old
250
+ // observation and contending with hook writers on every page. This map is
251
+ // private to one scoped attempt; execution still reads fresh durable state.
252
+ const remember = () => archiveReconciliations.set(key, { archived: archiveState, record: records[index] });
198
253
  const isArchived = archiveState;
199
254
  if (isArchived) await recordProviderArchive(root, record.provider, record.session, true, now);
200
- if (isArchived && record.providerArchived === true) continue;
201
- if (!isArchived && !(record.providerArchived === true && archiveState === false)) continue;
202
- try { records[index] = await updateRecord(root, record.provider, record.session, current => {
255
+ if (isArchived && record.providerArchived === true) { remember(); continue; }
256
+ if (!isArchived && !(record.providerArchived === true && archiveState === false)) { remember(); continue; }
257
+ try { const updated = await updateRecord(root, record.provider, record.session, current => {
203
258
  if (!current) return current;
204
259
  current.providerArchived = isArchived;
205
260
  if (isArchived) {
@@ -212,11 +267,16 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
212
267
  }
213
268
  return current;
214
269
  });
270
+ if (updated) records[index] = updated;
215
271
  if (!isArchived) await recordProviderArchive(root, record.provider, record.session, false, now);
272
+ remember();
216
273
  } catch {
217
274
  // Reconciliation can contend with a hook writer or an unreclaimable
218
275
  // legacy lock. Preserve stored state and retry on the next refresh;
219
276
  // one record must not discard either provider's entire catalog.
277
+ // A successfully persisted positive fence already revokes execution.
278
+ // Retry its JSON reconciliation on the next attempt, not each old prefix.
279
+ if (isArchived) remember();
220
280
  }
221
281
  }
222
282
  assertActive();
@@ -248,9 +308,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
248
308
  const archived = record.providerArchived === true || record.provider === 'codex' && archiveStates.get(record.session) === true;
249
309
  if (archived && !includeArchived) continue;
250
310
  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, {
311
+ // A saved stop must remain visible when the provider catalog is unavailable.
312
+ // This restores navigation only: the stopped run and unavailable provider
313
+ // still prohibit continuation. Bare hook observations remain insufficient.
314
+ const savedStopDuringOutage = record.run?.state === 'stopped' && Boolean(record.provider === 'codex' ? codexError : claudeError);
315
+ if (!byKey.has(key) && (retainedSession(record) || savedStopDuringOutage || includeArchived && record.listHidden === true || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
254
316
  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
317
  lastActivityAt: record.listMetadata?.lastActivityAt ?? record.observedAt ?? record.admission?.validatedAt ?? record.run?.updatedAt ?? null,
256
318
  observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
@@ -345,6 +407,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
345
407
  ...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
346
408
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
347
409
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
410
+ // Consent can be saved while runtime checks are pending or failing.
411
+ controllerCanSetAutoContinue: !archived,
348
412
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
349
413
  listPinned: record?.listPinned === true, listPinScopes: record?.listPinScopes || [],
350
414
  providerArchived: archived, localHidden: record?.listHidden === true,
@@ -416,12 +480,21 @@ function requireAutoContinueTask(record) {
416
480
  // existing task. Desktop switches send `initialContinuationPolicy`: a default
417
481
  // for first enrollment only, decided under the record lock so a stale catalog
418
482
  // can neither fail ON for an ordinary loop nor change its policy or budgets.
483
+ // Desktop consent is independent of execution readiness. The target is selected
484
+ // by the host from its catalog, never accepted from a native command payload.
485
+ export async function saveSessionAutoContinuePreference(options) {
486
+ return setSessionAutoContinue({ ...options, preferenceOnly: true });
487
+ }
488
+
419
489
  export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, initialContinuationPolicy, ...options }) {
420
490
  if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
421
491
  const link = enabled ? await requireProviderLink({ root, provider, expectedEpoch: options.expectedProviderEpoch }) : null;
422
492
  let discoveredAdmission;
423
- const purposeRules = enabled && provider === 'codex' ? await loadPurposeRuleSet() : null;
424
- if (enabled) {
493
+ const preferenceOnly = options.preferenceOnly === true;
494
+ const target = options.target;
495
+ const validTarget = target?.provider === provider && target?.session === session && typeof target.cwd === 'string' && isAbsolute(target.cwd);
496
+ const purposeRules = enabled && provider === 'codex' && !preferenceOnly ? await loadPurposeRuleSet() : null;
497
+ if (enabled && !preferenceOnly) {
425
498
  const record = await readRecord(root, provider, session);
426
499
  if (record?.providerArchived === true) throw Object.assign(new Error('Restore the original provider conversation before enabling automatic continuation.'), { code: 'session_archived' });
427
500
  requireAutoContinueTask(record);
@@ -445,6 +518,15 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
445
518
  }
446
519
  const updated = await updateRecord(root, provider, session, async previous => {
447
520
  if (enabled) await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
521
+ if (enabled && preferenceOnly) {
522
+ if (previous && validTarget && previous.cwd !== target.cwd) throw Object.assign(new Error('Session workspace changed; refresh its metadata.'), { code: 'session_workspace_changed' });
523
+ if (!previous) {
524
+ if (!validTarget) throw Object.assign(new Error('Select a known conversation before saving its preference.'), { code: 'session_metadata_unavailable' });
525
+ // No filesystem/process/model probe and no provider call at save time.
526
+ previous = { schemaVersion: 1, provider, session, cwd: target.cwd, run: null };
527
+ if (hasAutoContinueAdmission({ ...previous, admission: options.admission }, provider, session)) previous.admission = { ...options.admission };
528
+ }
529
+ }
448
530
  if (enabled && discoveredAdmission) {
449
531
  if (previous && previous.cwd !== discoveredAdmission.cwd) throw Object.assign(new Error('Session workspace changed; refresh its metadata.'), { code: 'session_workspace_changed' });
450
532
  // An admission written while saving is authoritative. Never overwrite its
@@ -452,8 +534,8 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
452
534
  previous = previous?.admission ? previous : { ...(previous || { schemaVersion: 1, provider, session, cwd: discoveredAdmission.cwd, run: null }), admission: discoveredAdmission };
453
535
  }
454
536
  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) {
537
+ if (enabled && previous.providerArchived === true) throw Object.assign(new Error('Restore the original provider conversation before enabling automatic continuation.'), { code: 'session_archived' });
538
+ if (enabled && !preferenceOnly) {
457
539
  const channel = provider === 'claude' ? await liveClaudeChannel(previous,
458
540
  { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)
459
541
  : codexContinuationChannel(previous.admission?.source, previous.admission?.model);
@@ -474,12 +556,14 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
474
556
  const next = { ...previous, ...(enabled && link.consentEpoch ? { providerConsentEpoch: link.consentEpoch } : {}), autoContinue: enabled, autoContinueEpoch: epoch,
475
557
  ...(enabled ? { tracked: true, monitor: true, autoContinueSettings: settings } : { tracked: false, monitor: false }) };
476
558
  // Adopt existing supervision without restarting it or renewing its budgets.
477
- if (next.run?.managed) {
559
+ const preserveTerminal = preferenceOnly && (enabled || TERMINAL_RUNS.has(next.run?.state));
560
+ if (next.run?.managed && !preserveTerminal) {
478
561
  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',
562
+ if (enabled && next.run.managed.resumeChannel === 'codex_app_server') {
563
+ if (purposeRules) next.run.managed.purpose = { ...next.run.managed.purpose, enabled: true, version: 2, decisionPolicy: 'llm-first-hc102',
481
564
  failureBudgetMode: purposeFailureMode(next),
482
565
  ruleDigest: purposeRules.digest, ruleIds: purposeRules.rules.map(rule => rule.id) };
566
+ else if (next.run.managed.purpose) next.run.managed.purpose.enabled = true;
483
567
  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
568
  // OFF keeps the current native turn alive. Re-enabling during that
485
569
  // same turn restores future continuation without starting another turn.
@@ -489,7 +573,7 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
489
573
  }
490
574
  // The desktop preference is an explicit human control too: an old decision
491
575
  // card must not undo this switch, even when no provider checkpoint arrived.
492
- if (next.run) advanceDeliveryControlRevision(next.run);
576
+ if (next.run && !preserveTerminal) advanceDeliveryControlRevision(next.run);
493
577
  return next;
494
578
  });
495
579
  // Consent is durable now. Provider reads and ordinary-loop promotion belong
@@ -636,19 +720,33 @@ export async function answerSession({ root, provider, session, taskId, expectedR
636
720
  // selection changed in Codex is configuration, not a different task or authority.
637
721
  export async function resumeSession({ openClient = openCodexAppServer, ...options }) {
638
722
  const record = await readRecord(options.root, options.provider, options.session);
639
- if (options.provider !== 'codex' || record?.run?.managed?.resumeChannel !== 'codex_app_server')
723
+ const stoppedStandard = options.provider === 'codex' && !record?.run?.managed &&
724
+ canExplicitlyResumeStopped(record?.run, { standard: true });
725
+ if (options.provider !== 'codex' || !stoppedStandard && record?.run?.managed?.resumeChannel !== 'codex_app_server')
640
726
  return controlDelivery({ ...options, action: 'resume' });
641
- if (record.run.managed.taskId !== options.taskId) throw new Error('Managed task identity changed; refresh its status.');
727
+ if (!stoppedStandard && record.run.managed.taskId !== options.taskId) throw new Error('Managed task identity changed; refresh its status.');
642
728
  const link = await requireProviderLink(options);
643
729
  const client = await openClient({ isolateConnectors: true });
644
730
  let metadata;
645
- try { metadata = await readCodexSession(client, options.session); }
731
+ try {
732
+ metadata = await readCodexSession(client, options.session);
733
+ // thread/read may omit runtime status. Observe the latest native turn
734
+ // instead of treating the omitted field as either idle or an error.
735
+ if (stoppedStandard && metadata.isRunning === undefined) {
736
+ const observation = await readCodexObservation(client, options.session);
737
+ if (typeof observation.isRunning === 'boolean') metadata.isRunning = observation.isRunning;
738
+ }
739
+ }
646
740
  finally { await client.close(); }
647
741
  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.');
742
+ 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.');
743
+ const rules = stoppedStandard ? await loadPurposeRuleSet() : null;
649
744
  await requireProviderLink({ ...options, expectedEpoch: link.epoch });
650
745
  return controlDelivery({ ...options, action: 'resume',
651
746
  expectedRunId: options.expectedRunId ?? record.run.id,
652
747
  expectedRevision: options.expectedRevision ?? deliveryDecisionRevision(record.run),
653
- resumeObservation: { session: metadata.session, cwd, model: metadata.model, source: 'codex_app_server', providerEpoch: link.epoch } });
748
+ resumeObservation: { session: metadata.session, cwd, model: metadata.model, source: 'codex_app_server', providerEpoch: link.epoch,
749
+ ...(stoppedStandard && metadata.nativeSandbox ? { nativeSandbox: metadata.nativeSandbox, nativeSandboxSource: metadata.nativeSandboxSource } : {}),
750
+ ...(rules ? { purpose: { enabled: true, version: 2, decisionPolicy: 'llm-first-hc102', failureBudgetMode: 'hard',
751
+ ruleDigest: rules.digest, ruleIds: rules.rules.map(rule => rule.id) } } : {}) } });
654
752
  }
@@ -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,79 @@
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, historyTimeoutMs = timeoutMs, onDrain } = {}) {
4
+ const controller = new AbortController();
5
+ const startedAt = Date.now();
6
+ let historyStarted = false;
7
+ let timer, rejectStop;
8
+ const stopped = new Promise((_, reject) => { rejectStop = reject; });
9
+ const cancel = () => {
10
+ const error = signal?.reason || Object.assign(new Error('Conversation discovery cancelled.'), { code: 'cancelled' });
11
+ controller.abort(error); rejectStop(error);
12
+ };
13
+ signal?.addEventListener('abort', cancel, { once: true });
14
+ const expire = () => {
15
+ const error = Object.assign(new Error('Conversation discovery timed out. Refresh to retry.'), { code: 'discovery_timeout' });
16
+ controller.abort(error); rejectStop(error);
17
+ };
18
+ timer = setTimeout(expire, timeoutMs);
19
+ const startHistory = () => {
20
+ if (historyStarted || controller.signal.aborted) return;
21
+ historyStarted = true;
22
+ clearTimeout(timer);
23
+ // The background budget is measured from the original request. Repeated
24
+ // pages cannot postpone the absolute deadline indefinitely.
25
+ timer = setTimeout(expire, Math.max(0, startedAt + Math.max(timeoutMs, historyTimeoutMs) - Date.now()));
26
+ };
27
+ try {
28
+ if (signal?.aborted) cancel();
29
+ const operation = Promise.resolve().then(() => {
30
+ controller.signal.throwIfAborted(); return work(controller.signal, startHistory);
31
+ });
32
+ onDrain?.(operation.then(() => {}, () => {}));
33
+ return await Promise.race([stopped, operation]);
34
+ } finally {
35
+ clearTimeout(timer); signal?.removeEventListener('abort', cancel);
36
+ // Revoke callbacks retained by an adapter after its result settled.
37
+ if (!controller.signal.aborted) controller.abort();
38
+ }
39
+ }
40
+
41
+ export async function discoverProgressively({ discover, onProgress, signal, timeoutMs = 30_000, historyTimeoutMs = timeoutMs,
42
+ connectedProviders = ['codex', 'claude'], onDrain, ...options }) {
43
+ const values = new Map(), complete = new Set(), settled = new Set();
44
+ let notifications = Promise.resolve();
45
+ const snapshot = () => ({
46
+ sessions: [...values.values()].flatMap(value => value.sessions || []),
47
+ selectionCandidates: [...values.values()].flatMap(value => value.selectionCandidates || []),
48
+ providers: Object.fromEntries(['codex', 'claude'].map(provider => [provider,
49
+ values.get(provider)?.providers?.[provider] || { discovery: connectedProviders.includes(provider) ? 'loading' : 'disconnected', reason: null }])),
50
+ completeProviders: [...complete], historyLoading: settled.size < connectedProviders.length,
51
+ refreshedAt: Date.now(),
52
+ });
53
+ const notify = () => {
54
+ const value = snapshot();
55
+ notifications = notifications.then(() => { signal?.throwIfAborted(); return onProgress(value); });
56
+ return notifications;
57
+ };
58
+ await Promise.all(connectedProviders.map(async provider => {
59
+ try {
60
+ const value = await boundedDiscovery((childSignal, startHistory) => discover({ ...options, signal: childSignal,
61
+ connectedProviders: [provider], onProgress: async value => {
62
+ childSignal.throwIfAborted();
63
+ if (value.sessions?.length) startHistory();
64
+ values.set(provider, value); await notify();
65
+ } }), { signal, timeoutMs, historyTimeoutMs, onDrain });
66
+ signal?.throwIfAborted();
67
+ values.set(provider, value);
68
+ if (['ready', 'local_index'].includes(value.providers?.[provider]?.discovery)) complete.add(provider);
69
+ } catch (error) {
70
+ if (signal?.aborted) throw error;
71
+ const previous = values.get(provider) || { sessions: [] };
72
+ values.set(provider, { ...previous, providers: { [provider]: { discovery: 'unavailable',
73
+ reasonCode: error.code || 'discovery_failed', reason: error.message } } });
74
+ }
75
+ settled.add(provider); await notify();
76
+ }));
77
+ await notifications;
78
+ return snapshot();
79
+ }
@@ -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
+ }