@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.
- package/README.md +22 -3
- package/package.json +3 -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 +44 -12
- 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 +252 -77
- 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 +45 -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 +126 -28
- package/src/session-supervision/owner-transport.js +23 -8
- package/src/session-supervision/progressive-discovery.js +79 -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/sqm/local-tests.js +2 -1
- 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 {
|
|
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,21 +159,41 @@ 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, 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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
252
|
-
//
|
|
253
|
-
|
|
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
|
|
424
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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,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
|
-
|
|
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
|
+
}
|