@awak-app/simy-cli 0.7.31 → 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.
- package/README.md +22 -3
- package/package.json +3 -2
- package/src/agent.js +7 -2
- package/src/auto-update.js +10 -1
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +27 -3
- package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +13 -4
- package/src/delivery-hooks/desktop.js +1 -1
- package/src/desktop-access.js +1 -1
- package/src/desktop-frame-writer.js +29 -0
- package/src/desktop-host.js +176 -74
- package/src/orchestrator/loop.js +2 -0
- package/src/session-supervision/analysis-manager.js +83 -18
- package/src/session-supervision/claude-desktop-lineage.js +1 -1
- package/src/session-supervision/claude-sessions.js +16 -4
- package/src/session-supervision/codex-app-server.js +39 -30
- package/src/session-supervision/continuation-readiness.js +58 -0
- package/src/session-supervision/decision-presentation.js +11 -3
- package/src/session-supervision/index.js +101 -24
- package/src/session-supervision/owner-transport.js +23 -8
- package/src/session-supervision/progressive-discovery.js +66 -0
- package/src/session-supervision/remote-entry.js +7 -4
- package/src/session-supervision/remote-host-recovery.js +21 -0
- package/src/session-supervision/remote-hosts.js +28 -8
- package/src/session-supervision/remote-owner-entry.js +20 -0
- package/src/session-supervision/remote-service.js +36 -0
- package/src/session-supervision/statistics.js +19 -3
- package/src/session-supervision/work-state.js +7 -1
- package/src/team-threads.js +3 -6
- package/src/update-connection.js +16 -0
- package/src/web-api.js +9 -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 {
|
|
11
|
-
import {
|
|
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(
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
252
|
-
//
|
|
253
|
-
|
|
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
|
|
424
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|