@awak-app/simy-cli 0.7.29 → 0.7.32
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 +88 -3
- package/package.json +2 -2
- package/src/agent.js +8 -2
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
- package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +389 -51
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
- package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
- package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
- package/src/delivery-hooks/claude-workflow.md +3 -3
- package/src/delivery-hooks/command.js +8 -0
- package/src/delivery-hooks/desktop.js +31 -9
- package/src/delivery-hooks/supervisor.js +3 -2
- package/src/desktop-access.js +4 -1
- package/src/desktop-host.js +494 -72
- package/src/native-development-report-completion.js +26 -0
- package/src/native-development-reports.js +15 -5
- package/src/orchestrator/completion-contract.js +8 -2
- package/src/orchestrator/contract.js +3 -1
- package/src/orchestrator/instruction.js +1 -1
- package/src/runner.js +2 -1
- package/src/session-supervision/action-deadline.js +18 -1
- package/src/session-supervision/autonomous-upgrade.js +4 -2
- package/src/session-supervision/claude-desktop-archive.js +57 -0
- package/src/session-supervision/claude-desktop-native.js +50 -0
- package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
- package/src/session-supervision/claude-desktop-target.js +87 -0
- package/src/session-supervision/claude-owner-renewal.js +197 -0
- package/src/session-supervision/claude-sessions.js +143 -34
- package/src/session-supervision/codex-app-server.js +20 -2
- package/src/session-supervision/continuation-analysis.js +7 -7
- package/src/session-supervision/continuation-command.js +45 -0
- package/src/session-supervision/controller-store.js +48 -0
- package/src/session-supervision/conversation-presentation.js +241 -0
- package/src/session-supervision/decision-presentation.js +96 -35
- package/src/session-supervision/execution-context.js +39 -0
- package/src/session-supervision/index.js +36 -7
- package/src/session-supervision/manual-acceptance-command.js +30 -0
- package/src/session-supervision/notification-authority.js +9 -0
- package/src/session-supervision/notification-journal.js +40 -0
- package/src/session-supervision/operation-journal.js +42 -0
- package/src/session-supervision/owner-transport.js +146 -0
- package/src/session-supervision/purpose-recovery.js +10 -2
- package/src/session-supervision/remote-control.js +1 -1
- package/src/session-supervision/remote-entry.js +2 -0
- package/src/session-supervision/remote-errors.js +15 -0
- package/src/session-supervision/remote-hosts.js +344 -36
- package/src/session-supervision/remote-metadata.js +103 -0
- package/src/session-supervision/remote-session-list.js +2 -4
- package/src/session-supervision/session-archive-command.js +45 -0
- package/src/session-supervision/session-list.js +40 -4
- package/src/session-supervision/sqm-presentation.js +46 -3
- package/src/session-supervision/stop-notifications.js +42 -11
- package/src/session-supervision/work-state.js +106 -6
- package/src/sqm/agent-skill.js +3 -3
- package/src/sqm/claude-plugin.js +5 -2
- package/src/sqm/history-store.js +6 -0
- package/src/team-threads.js +4 -7
- package/src/update-detachment/coordinator.js +7 -1
- package/src/update-detachment/history-recovery.js +18 -0
- package/src/update-detachment/ledger-adapter.js +1 -0
- package/src/update-detachment/retained-paused.js +290 -0
- package/src/update-detachment/runtime.js +78 -15
- package/src/web-api.js +9 -0
|
@@ -7,7 +7,7 @@ import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/accepta
|
|
|
7
7
|
import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
8
8
|
import { realpath } from 'node:fs/promises';
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
-
import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, associationBlocksFollowup, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
10
|
+
import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, associationBlocksFollowup, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive, ownerRenewalPending } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
11
11
|
import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
|
|
12
12
|
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
13
13
|
import { loadPurposeRuleSet } from './purpose-instruction.js';
|
|
@@ -16,6 +16,7 @@ import { readCodexChildSessions } from './codex-children.js';
|
|
|
16
16
|
import { readCodexTitles, sessionTitle } from './session-titles.js';
|
|
17
17
|
import { recentSessionWindow, retainedSession } from './discovery-window.js';
|
|
18
18
|
import { requireProviderLink } from '../provider-links.js';
|
|
19
|
+
import { readDispatchGate, rootFromDelivery, sessionDispatchReadiness } from '../codex-plugins/simy/hooks/update-dispatch-gate.mjs';
|
|
19
20
|
|
|
20
21
|
// Only product-authored eligibility messages may pass through the desktop
|
|
21
22
|
// error sanitizer; native provider errors never acquire this type.
|
|
@@ -46,14 +47,26 @@ function codexContinuationChannel(source, model, issue = null) {
|
|
|
46
47
|
return { ready: true, code: 'ready', reason: null };
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
// The saved ON preference is consent, not execution readiness. Each blocker
|
|
51
|
+
// names its own recovery route instead of reading as provider activity.
|
|
52
|
+
const DISPATCH_BLOCKERS = {
|
|
53
|
+
update_recovery_required: 'Auto-continue is saved, but SIMY is recovering from an interrupted update and cannot send work. Open Settings > App & updates and choose Try again.',
|
|
54
|
+
writer_reconnect_required: 'Auto-continue is saved, but this conversation was opened before SIMY recovered from an update. Send one message in the original conversation so SIMY can verify the same process and reconnect it.',
|
|
55
|
+
dispatch_history_quarantined: 'Earlier unconfirmed work in this conversation was quarantined during update recovery and will not be resent. Review the original conversation and start the next request yourself.',
|
|
56
|
+
task_admission_blocked: 'This task needs fresh verified provider permissions before SIMY can send work. Continue it once in the original conversation.',
|
|
57
|
+
};
|
|
58
|
+
function autoContinueState(record, isRunning, availability, readiness = null) {
|
|
50
59
|
if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
|
|
51
60
|
const state = record.run?.state;
|
|
61
|
+
if (readiness?.state === 'blocked' && !TERMINAL_RUNS.has(state)) return { autoContinueStatus: 'needs_user',
|
|
62
|
+
autoContinueReason: DISPATCH_BLOCKERS[readiness.code] || 'Auto-continue is saved, but SIMY cannot send work for this conversation yet.' };
|
|
52
63
|
if (state === 'budget_exhausted') return { autoContinueStatus: 'needs_user', autoContinueReason: 'Local execution limits have been removed. Resume this same conversation to continue.' };
|
|
53
64
|
if (completionClaimUnbound(record)) return { autoContinueStatus: 'watching', autoContinueReason: 'Verification of the recorded completion remains pending.' };
|
|
54
|
-
if (
|
|
65
|
+
if (state === 'reported_done') return { autoContinueStatus: 'watching', autoContinueReason: 'The provider reported completion; the actual result has not yet been confirmed.' };
|
|
66
|
+
if (state === 'completed') return { autoContinueStatus: 'finished', autoContinueReason: 'Continuation finished for this conversation.' };
|
|
55
67
|
const managed = record.run?.managed;
|
|
56
68
|
const recoverableCause = record.run?.recoveryCause === 'process_exit' || managed?.resumeChannel === 'codex_app_server' && ['session_supervision', 'approved_action'].includes(record.run?.recoveryCause);
|
|
69
|
+
if (state === 'blocked' && managed?.controllerDecisionWait && !managed.nativeWait && !managed.userQuestion && !managed.understandingQuestion) return { autoContinueStatus: 'watching', autoContinueReason: 'Investigating existing authority and permitted next steps; no operation has been approved.' };
|
|
57
70
|
if (managed?.pendingApproval) return { autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention: review the pending approval in the original conversation before continuing.' };
|
|
58
71
|
if (state === 'recovery_required' && (!managed?.autoRecovery || !recoverableCause)) {
|
|
59
72
|
const recoveryReason = {
|
|
@@ -209,6 +222,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
209
222
|
assertActive();
|
|
210
223
|
const byKey = new Map();
|
|
211
224
|
const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
|
|
225
|
+
// One ledger read per discovery; an unreadable ledger blocks dispatch display.
|
|
226
|
+
let dispatchGate = null, dispatchGateError = null;
|
|
227
|
+
try { dispatchGate = readDispatchGate(rootFromDelivery(root)); } catch (error) { dispatchGateError = error.code || 'dispatch_gate_unreadable'; }
|
|
228
|
+
const readinessFor = (record, provider, session) => !record || record.autoContinue !== true && !record.run ? null
|
|
229
|
+
: dispatchGateError ? { state: 'blocked', code: dispatchGateError }
|
|
230
|
+
: sessionDispatchReadiness(dispatchGate, { provider, session, taskIds: [record.run?.managed?.taskId, record.run?.id].filter(Boolean) });
|
|
212
231
|
for (const item of [...discovered, ...claude, ...archivedCodex]) {
|
|
213
232
|
if (includeArchived && item.archived === true) { byKey.set(`${item.provider}:${item.session}`, item); continue; }
|
|
214
233
|
if (item.archived !== true && (item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
|
|
@@ -286,6 +305,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
286
305
|
const record = recordsByKey.get(`${item.provider}:${item.session}`);
|
|
287
306
|
const run = record?.run;
|
|
288
307
|
const archived = item.archived === true || record?.providerArchived === true;
|
|
308
|
+
const readiness = readinessFor(record, item.provider, item.session);
|
|
289
309
|
const mode = run?.managed ? run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' ? 'pending_takeover' : 'managed' : run ? 'standard_loop' : record?.monitor ? 'monitor' : 'unmanaged';
|
|
290
310
|
const codexChannel = codexContinuationChannel(item.observation.source, item.model, codexError ? { code: codexReasonCode, reason: codexError } : null);
|
|
291
311
|
const supported = item.provider === 'codex' && codexChannel.ready;
|
|
@@ -326,6 +346,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
326
346
|
...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
|
|
327
347
|
...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
|
|
328
348
|
tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
|
|
349
|
+
listPinned: record?.listPinned === true, listPinScopes: record?.listPinScopes || [],
|
|
329
350
|
providerArchived: archived, localHidden: record?.listHidden === true,
|
|
330
351
|
archived,
|
|
331
352
|
pullRequests: normalizePullRequests(item.pullRequests),
|
|
@@ -338,7 +359,9 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
338
359
|
run?.state === 'reported_done' && !run.managed && run.id && !record.automaticStartBlocked &&
|
|
339
360
|
!associationBlocksFollowup(record) && !(record.autoContinue === false && record.autoContinueEpoch)
|
|
340
361
|
? { previousRunId: run.id } : null,
|
|
341
|
-
...
|
|
362
|
+
...(readiness ? { dispatchReadiness: readiness } : {}),
|
|
363
|
+
...(record?.writerReconnect ? { writerReconnect: record.writerReconnect } : {}),
|
|
364
|
+
...autoContinueState(record, isRunning, availability, readiness),
|
|
342
365
|
lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
|
|
343
366
|
taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
344
367
|
binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
@@ -382,13 +405,18 @@ function hasAutoContinueAdmission(record, provider, session) {
|
|
|
382
405
|
(provider !== 'codex' || typeof admission.model === 'string' && admission.model.trim());
|
|
383
406
|
}
|
|
384
407
|
function requireAutoContinueTask(record) {
|
|
408
|
+
if (ownerRenewalPending(record)) throw new AutoContinueUnavailableError({ reasonCode: 'owner_renewal_pending', reason: 'Finish the original conversation connection recovery before enabling Auto Continue.' });
|
|
385
409
|
if (record?.run?.state === 'stopped') throw new AutoContinueUnavailableError({ reasonCode: 'task_stopped', reason: 'This task was stopped. Review the original conversation and explicitly request resumption before continuing.' });
|
|
386
410
|
if (record?.run && !record.run.managed && !canUpgradeDelivery(record.run)) throw Object.assign(new Error('This conversation already has a Delivery Loop. Use its existing controls.'), { code: 'existing_loop' });
|
|
387
411
|
if (TERMINAL_RUNS.has(record?.run?.state)) throw new AutoContinueUnavailableError({ reason: 'This supervised task has settled. Review its existing status before continuing.', reasonCode: 'task_settled' });
|
|
388
412
|
}
|
|
389
413
|
|
|
390
414
|
// Explicit execution authority: legacy monitor/tracked records stay read-only.
|
|
391
|
-
|
|
415
|
+
// `continuationPolicy` explicitly changes the policy and is rejected for an
|
|
416
|
+
// existing task. Desktop switches send `initialContinuationPolicy`: a default
|
|
417
|
+
// for first enrollment only, decided under the record lock so a stale catalog
|
|
418
|
+
// can neither fail ON for an ordinary loop nor change its policy or budgets.
|
|
419
|
+
export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, initialContinuationPolicy, ...options }) {
|
|
392
420
|
if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
|
|
393
421
|
const link = enabled ? await requireProviderLink({ root, provider, expectedEpoch: options.expectedProviderEpoch }) : null;
|
|
394
422
|
let discoveredAdmission;
|
|
@@ -438,8 +466,9 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
438
466
|
// Settings persisted by older versions may carry removed time, turn and
|
|
439
467
|
// token caps; only the fields that still govern a task are forwarded.
|
|
440
468
|
const { autonomyPolicy, maxFailures, continuationPolicy: savedPolicy } = sessionLoopOptions({ ...previous.autoContinueSettings, provider });
|
|
469
|
+
const requestedPolicy = continuationPolicy ?? (previous.run ? undefined : initialContinuationPolicy);
|
|
441
470
|
const settings = { autonomyPolicy, ...(maxFailures === undefined ? {} : { maxFailures }), ...(provider === 'codex' ? { purposeDriven: true } : {}),
|
|
442
|
-
...(
|
|
471
|
+
...(requestedPolicy ?? savedPolicy) === undefined ? {} : { continuationPolicy: requestedPolicy ?? savedPolicy } };
|
|
443
472
|
if (settings.continuationPolicy !== undefined && !['bounded', 'progress'].includes(settings.continuationPolicy)) throw new Error('Choose a continuation policy.');
|
|
444
473
|
if (previous.run && continuationPolicy !== undefined) throw new Error('Change an existing task through its explicit continuation controls.');
|
|
445
474
|
const next = { ...previous, ...(enabled && link.consentEpoch ? { providerConsentEpoch: link.consentEpoch } : {}), autoContinue: enabled, autoContinueEpoch: epoch,
|
|
@@ -593,7 +622,7 @@ export async function continueSession(options) {
|
|
|
593
622
|
// rather than enrolling the generic context-bootstrap goal as a new run.
|
|
594
623
|
return enrollSession({ ...options, objective: record.run.objective, upgradeRunId: record.run.id,
|
|
595
624
|
maxFailures: record.run.maxFailures, contextBootstrap: false,
|
|
596
|
-
acceptanceCriteria: [{ id: 'original-goal', description: record.run.objective.slice(0, 500), check: { type:
|
|
625
|
+
acceptanceCriteria: [{ id: 'original-goal', description: record.run.objective.slice(0, 500), check: { type: 'semantic' } }] });
|
|
597
626
|
}
|
|
598
627
|
return enrollSession({ ...options, objective: 'Understand and continue the selected conversation’s current user request.',
|
|
599
628
|
acceptanceCriteria: [], contextBootstrap: true });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Native and remote callers send the same frozen criterion controls. Never
|
|
2
|
+
// forward a caller's approval, objective, or permission fields with feedback.
|
|
3
|
+
const identityFields = ['provider', 'session', 'taskId', 'expectedRunId', 'expectedRevision', 'expectedWorkspace', 'criterionId', 'expectedCriterionDigest'];
|
|
4
|
+
const boundedText = (value, limit) => typeof value === 'string' && value.trim().length > 0 && value.length <= limit && !/[\x00-\x1f\x7f]/.test(value);
|
|
5
|
+
export function manualAcceptanceCommand(command) {
|
|
6
|
+
if (!['codex', 'claude'].includes(command?.provider) || !['accept', 'not_yet'].includes(command?.decision) ||
|
|
7
|
+
!['session', 'taskId', 'expectedRunId', 'criterionId'].every(key => boundedText(command[key], 200)) ||
|
|
8
|
+
!boundedText(command.expectedWorkspace, 4000) || !Number.isSafeInteger(command.expectedRevision) || command.expectedRevision < 0 ||
|
|
9
|
+
!/^[a-f0-9]{64}$/.test(command.expectedCriterionDigest || '') ||
|
|
10
|
+
command.decision === 'not_yet' && (typeof command.reason !== 'string' || !command.reason.trim() || command.reason.length > 1000 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(command.reason))) {
|
|
11
|
+
throw new Error('Refresh the manual acceptance controls before deciding.');
|
|
12
|
+
}
|
|
13
|
+
return { ...Object.fromEntries(identityFields.map(key => [key, command[key]])), decision: command.decision,
|
|
14
|
+
...(command.decision === 'not_yet' ? { reason: command.reason.trim() } : {}) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function matchesManualAcceptanceControls(command, controls) {
|
|
18
|
+
return Boolean(controls && identityFields.every(key => controls[key] === command[key]));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const continuationReasons = {
|
|
22
|
+
none: [null],
|
|
23
|
+
queued: ['original_read_diagnosis_queued'],
|
|
24
|
+
original_turn_required: ['passive_hook_channel'],
|
|
25
|
+
deferred: ['existing_execution_or_authority_boundary', 'current_work_kept', 'explicit_resume_required', 'existing_task_state_kept'],
|
|
26
|
+
};
|
|
27
|
+
export function manualAcceptanceContinuation(value) {
|
|
28
|
+
return Object.hasOwn(continuationReasons, value?.state) && continuationReasons[value.state].includes(value.reasonCode)
|
|
29
|
+
? { state: value.state, reasonCode: value.reasonCode } : null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Read-only attention binding. This is not execution authority or a consent store.
|
|
2
|
+
// Use the current repair receipt's real requestId/status; legacy guesses stay unknown.
|
|
3
|
+
export function repairNotificationAuthority(run) {
|
|
4
|
+
const receipt = run?.managed?.repairRequest;
|
|
5
|
+
if (typeof receipt?.requestId !== 'string' || !receipt.requestId.trim() || receipt.requestId.length > 200 ||
|
|
6
|
+
/[\u0000-\u001f\u007f]/u.test(receipt.requestId) || receipt.revokedAt !== undefined ||
|
|
7
|
+
!['running', 'resolved', 'unresolved', 'needs_human'].includes(receipt.status)) return null;
|
|
8
|
+
return { kind: 'repair', id: receipt.requestId };
|
|
9
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// Stable notification IDs let controllers recover events after a disconnected
|
|
6
|
+
// observation. This is internal delivery state, never user-supplied evidence.
|
|
7
|
+
export function createNotificationJournal(root) {
|
|
8
|
+
const path = join(root, 'controller-notifications.json');
|
|
9
|
+
const cursors = new Map();
|
|
10
|
+
let events = [], queue = readFile(path, 'utf8').then(text => {
|
|
11
|
+
const value = JSON.parse(text);
|
|
12
|
+
if (!Array.isArray(value)) throw new Error('Invalid notification journal.');
|
|
13
|
+
events = value;
|
|
14
|
+
}).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
15
|
+
return {
|
|
16
|
+
append(event) {
|
|
17
|
+
const work = queue.then(async () => {
|
|
18
|
+
if (events.some(item => item.id === event.id && item.scope === event.scope)) return;
|
|
19
|
+
const next = [...events, event];
|
|
20
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
21
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
22
|
+
await writeFile(temp, JSON.stringify(next), { mode: 0o600 });
|
|
23
|
+
await rename(temp, path); events = next;
|
|
24
|
+
});
|
|
25
|
+
queue = work; return work;
|
|
26
|
+
},
|
|
27
|
+
async snapshot(scope, after) {
|
|
28
|
+
await queue;
|
|
29
|
+
if (after !== undefined) {
|
|
30
|
+
if (!Number.isSafeInteger(after) || after < 0) throw new Error('Invalid event cursor.');
|
|
31
|
+
return structuredClone(events.map((event, index) => ({ ...event, sequence: index + 1 })).filter(event => event.scope === scope && event.sequence > after).slice(0, 512));
|
|
32
|
+
}
|
|
33
|
+
const rows = scope === undefined ? events : events.filter(event => event.scope === scope);
|
|
34
|
+
const offset = cursors.get(scope) || 0;
|
|
35
|
+
const batch = rows.slice(offset, offset + 512);
|
|
36
|
+
cursors.set(scope, offset + batch.length >= rows.length ? 0 : offset + batch.length);
|
|
37
|
+
return structuredClone(batch);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createControllerStore } from './controller-store.js';
|
|
3
|
+
export const operationDigest = command => {
|
|
4
|
+
const { id, requestId, ...body } = command;
|
|
5
|
+
return createHash('sha256').update(JSON.stringify(Object.fromEntries(Object.entries(body).sort(([a],[b]) => a.localeCompare(b))))).digest('hex');
|
|
6
|
+
};
|
|
7
|
+
export function createOperationJournal(root) {
|
|
8
|
+
const store = createControllerStore(root);
|
|
9
|
+
return {
|
|
10
|
+
begin(scope, command, { reconcileReceived = false } = {}) {
|
|
11
|
+
if (typeof command.requestId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(command.requestId)) throw new Error('Invalid remote request identity.');
|
|
12
|
+
return store.change(scope, state => {
|
|
13
|
+
state.operations ||= {};
|
|
14
|
+
const key = command.requestId, digest = operationDigest(command), previous = state.operations[key];
|
|
15
|
+
if (previous && previous.digest !== digest) throw new Error('Request identity conflicts with another operation.');
|
|
16
|
+
if (previous) {
|
|
17
|
+
// Only a controller forwarding to a v2 owner may reconcile the same
|
|
18
|
+
// intent. The execution owner must never rerun an unsettled command.
|
|
19
|
+
const unknown = !previous.result || previous.result.ok === false &&
|
|
20
|
+
['remote_operation_unknown', 'remote_host_unavailable'].includes(previous.result.code);
|
|
21
|
+
if (reconcileReceived && unknown) {
|
|
22
|
+
delete previous.result;
|
|
23
|
+
previous.state = 'received';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return previous.result || { ok: false, code: 'remote_operation_unknown', message: 'The operation was received; its final result has not been reconciled.' };
|
|
27
|
+
}
|
|
28
|
+
state.operations[key] = { digest, state: 'received', protocolVersion: 2, requestId: key, receivedAt: Date.now(),
|
|
29
|
+
target: { hostId: command.hostId || 'local', provider: command.provider, session: command.session, action: command.action, taskId: command.taskId, expectedRevision: command.expectedRevision } }; return null;
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
finish(scope, command, result) {
|
|
33
|
+
return store.change(scope, state => {
|
|
34
|
+
const item = state.operations?.[command.requestId];
|
|
35
|
+
if (!item || item.digest !== operationDigest(command)) throw new Error('Unknown remote request.');
|
|
36
|
+
if (result.ok === false && result.code === 'remote_operation_unknown') return;
|
|
37
|
+
const { id, ...value } = result; item.result = value; item.state = 'settled'; item.settledAt = Date.now();
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
close: () => store.close(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createServer, createConnection } from 'node:net';
|
|
2
|
+
import { chmod, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
|
|
7
|
+
export const ownerSocketPath = (root, platform = process.platform) => platform === 'win32'
|
|
8
|
+
? '\\\\.\\pipe\\simy-controller-' + createHash('sha256').update(root).digest('hex').slice(0, 32)
|
|
9
|
+
: join(root, 'desktop-app.lock', 'controller.sock');
|
|
10
|
+
const socketPath = ownerSocketPath;
|
|
11
|
+
// The socket and nonce are accessible only to the local OS account. Application
|
|
12
|
+
// account/provider checks still run in the owner's ordinary command handler.
|
|
13
|
+
export async function createOwnerTransport({ root, nonce, allowedActions, dispatch, allowControllerArchive = false }) {
|
|
14
|
+
const peers = new Set(), requests = new Map();
|
|
15
|
+
let latest;
|
|
16
|
+
const send = (peer, frame) => {
|
|
17
|
+
if (peer.destroyed) return;
|
|
18
|
+
if (peer.writableLength > 8 * 1024 * 1024) { peer.destroy(); return; }
|
|
19
|
+
peer.write(JSON.stringify(frame) + '\n');
|
|
20
|
+
};
|
|
21
|
+
const server = createServer(peer => {
|
|
22
|
+
peer.setEncoding('utf8');
|
|
23
|
+
let authenticated = false, controllerArchive = false, buffer = '';
|
|
24
|
+
const timeout = setTimeout(() => peer.destroy(), 5000);
|
|
25
|
+
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); });
|
|
27
|
+
peer.on('data', chunk => {
|
|
28
|
+
buffer += chunk.toString();
|
|
29
|
+
if (buffer.length > 16384) { peer.destroy(); return; }
|
|
30
|
+
let end;
|
|
31
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
32
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
33
|
+
let command; try { command = JSON.parse(line); } catch { peer.destroy(); return; }
|
|
34
|
+
if (!command || typeof command !== 'object' || Array.isArray(command)) { peer.destroy(); return; }
|
|
35
|
+
if (!authenticated) {
|
|
36
|
+
if (command.nonce !== nonce) { peer.destroy(); return; }
|
|
37
|
+
authenticated = true; controllerArchive = allowControllerArchive && command.client === 'archive-controller'; clearTimeout(timeout); peers.add(peer);
|
|
38
|
+
if (latest) send(peer, latest);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
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))) {
|
|
42
|
+
send(peer, { type: 'result', id: command.id, ok: false, code: 'unsupported_remote_action' }); continue;
|
|
43
|
+
}
|
|
44
|
+
if (requests.size >= 256) { peer.destroy(); return; }
|
|
45
|
+
const id = randomUUID(); requests.set(id, { peer, id: command.id });
|
|
46
|
+
dispatch({ ...command, id });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath(root), resolve); });
|
|
51
|
+
if (process.platform !== 'win32') await chmod(socketPath(root), 0o600);
|
|
52
|
+
return {
|
|
53
|
+
publish(frame) {
|
|
54
|
+
if (frame.type === 'status') latest = frame;
|
|
55
|
+
if (frame.type === 'task_detail') {
|
|
56
|
+
const item = requests.get(frame.id);
|
|
57
|
+
if (item) send(item.peer, { ...frame, id: item.id });
|
|
58
|
+
} else if (frame.type === 'result') {
|
|
59
|
+
const item = requests.get(frame.id);
|
|
60
|
+
if (item) { requests.delete(frame.id); send(item.peer, { ...frame, id: item.id }); }
|
|
61
|
+
} else if (frame.type === 'status' || frame.type === 'notify' && (!frame.hostId || frame.hostId === 'local')) {
|
|
62
|
+
for (const peer of peers) send(peer, frame);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
async close() { for (const peer of peers) peer.destroy(); await new Promise(resolve => server.close(resolve)); },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Returns false only when no compatible owner endpoint exists. Never starts a
|
|
70
|
+
// 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 }) {
|
|
72
|
+
let owner;
|
|
73
|
+
try { owner = JSON.parse(await readFile(join(root, 'desktop-app.lock', 'owner.json'), 'utf8')); }
|
|
74
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
75
|
+
const peer = createConnection(socketPath(root));
|
|
76
|
+
const connected = await new Promise((resolve, reject) => {
|
|
77
|
+
const timer = setTimeout(() => { peer.destroy(); reject(new Error('Owner connection timed out.')); }, 5000);
|
|
78
|
+
peer.once('connect', () => { clearTimeout(timer); resolve(true); });
|
|
79
|
+
peer.once('error', error => { clearTimeout(timer); if (['ENOENT','ECONNREFUSED'].includes(error.code)) resolve(false); else reject(error); });
|
|
80
|
+
});
|
|
81
|
+
if (!connected) return false;
|
|
82
|
+
peer.write(JSON.stringify({ nonce: owner.nonce }) + '\n');
|
|
83
|
+
peer.pipe(output, { end: false });
|
|
84
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
85
|
+
lines.on('line', line => { if (line.length <= 16384 && !peer.destroyed) peer.write(line + '\n'); });
|
|
86
|
+
lines.on('close', () => peer.end());
|
|
87
|
+
await new Promise((resolve, reject) => { peer.once('close', resolve); peer.once('error', reject); });
|
|
88
|
+
lines.close(); input.pause();
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A terminal command attaches to the existing execution owner. It never starts
|
|
93
|
+
// another supervisor or retries a mutation after a lost response.
|
|
94
|
+
export async function requestOwnerArchive({ root, command, signal, timeoutMs = 55_000 }) {
|
|
95
|
+
if (!['session_archive', 'session_restore', 'session_provider_setup'].includes(command?.action)) throw new Error('Unsupported archive command.');
|
|
96
|
+
return requestOwnerCommand({ root, command, signal, timeoutMs, client: 'archive-controller' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Use the authenticated owner and controls already used by the app. Chat
|
|
100
|
+
// delegation is local; never fall back to a different host or execution owner.
|
|
101
|
+
export async function requestOwnerContinuation({ root, command, signal, timeoutMs = 55_000 }) {
|
|
102
|
+
if (!['session_set_auto_continue', 'session_get_status'].includes(command?.action) || command.hostId !== 'local')
|
|
103
|
+
throw new Error('Unsupported continuation command.');
|
|
104
|
+
return requestOwnerCommand({ root, command, signal, timeoutMs });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function requestOwnerCommand({ root, command, signal, timeoutMs, client }) {
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
const unavailable = () => Object.assign(new Error('Start or update the SIMY app on this computer, then retry.'), { code: 'desktop_owner_unavailable' });
|
|
110
|
+
const denied = () => Object.assign(new Error('The current provider permissions cannot access the local SIMY controller. Check the provider’s native permission request; do not change the original task or start another controller.'), { code: 'desktop_owner_access_denied' });
|
|
111
|
+
const connectionFailure = error => ['EACCES', 'EPERM'].includes(error?.code) ? denied() : unavailable();
|
|
112
|
+
let owner;
|
|
113
|
+
try { owner = JSON.parse(await readFile(join(root, 'desktop-app.lock', 'owner.json'), 'utf8')); } catch (error) { throw connectionFailure(error); }
|
|
114
|
+
if (typeof owner?.nonce !== 'string' || !owner.nonce) throw unavailable();
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const peer = createConnection(socketPath(root));
|
|
117
|
+
let buffer = '', settled = false, sent = false;
|
|
118
|
+
const unknown = () => Object.assign(new Error('SIMY has not confirmed the result. Refresh the conversation before retrying.'), { code: 'remote_operation_unknown' });
|
|
119
|
+
const finish = (error, result) => {
|
|
120
|
+
if (settled) return; settled = true;
|
|
121
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort); peer.destroy();
|
|
122
|
+
if (error) reject(error); else resolve(result);
|
|
123
|
+
};
|
|
124
|
+
const abort = () => finish(signal.reason);
|
|
125
|
+
const timer = setTimeout(() => finish(unknown()), timeoutMs);
|
|
126
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
127
|
+
peer.setEncoding('utf8');
|
|
128
|
+
peer.on('connect', () => {
|
|
129
|
+
if (signal?.aborted) { abort(); return; }
|
|
130
|
+
sent = true;
|
|
131
|
+
peer.write(JSON.stringify({ nonce: owner.nonce, ...(client ? { client } : {}) }) + '\n' + JSON.stringify(command) + '\n');
|
|
132
|
+
});
|
|
133
|
+
peer.on('error', error => finish(sent ? unknown() : connectionFailure(error)));
|
|
134
|
+
peer.on('close', () => finish(Object.assign(new Error('SIMY disconnected before confirming the result. Refresh before retrying.'), { code: 'remote_operation_unknown' })));
|
|
135
|
+
peer.on('data', chunk => {
|
|
136
|
+
buffer += chunk;
|
|
137
|
+
if (buffer.length > 8 * 1024 * 1024) { finish(new Error('SIMY response exceeded its supported size.')); return; }
|
|
138
|
+
let end;
|
|
139
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
140
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
141
|
+
let frame; try { frame = JSON.parse(line); } catch { finish(new Error('SIMY returned an invalid response.')); return; }
|
|
142
|
+
if (frame?.type === 'result' && frame.id === command.id) { finish(null, frame); return; }
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -4,7 +4,7 @@ import { finalResponseRecoveryInstruction } from './final-response-recovery.js';
|
|
|
4
4
|
import { requireProviderLink } from '../provider-links.js';
|
|
5
5
|
import { searchEnabled, purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
-
import { listRecords, readRecord, updateRecord, automaticContinuationAllowed, canResearchReplan, queuePurposeDiagnosis, queuePurposeVerification, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
7
|
+
import { listRecords, readRecord, updateRecord, automaticContinuationAllowed, canResearchReplan, queuePurposeDiagnosis, queuePurposeVerification, managedBudgetReason, controllerApprovalDiagnosisEligible, diagnoseControllerApproval } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
8
8
|
import { openCodexAppServer, readCodexSession, observeCodexAuthentication } from './codex-app-server.js';
|
|
9
9
|
import { realpath } from 'node:fs/promises';
|
|
10
10
|
import { loadPurposeRuleSet } from './purpose-instruction.js';
|
|
@@ -64,8 +64,16 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
64
64
|
if (record.run?.managed?.nativeHandoff) continue;
|
|
65
65
|
try {
|
|
66
66
|
let run = record.run;
|
|
67
|
+
if (run?.state === 'blocked' && run.managed?.controllerDecisionWait && !controllerApprovalDiagnosisEligible(record, now)) {
|
|
68
|
+
record = await writeRecord(root, record.provider, record.session, current => {
|
|
69
|
+
if (current?.run?.id === run.id && current.run.revision === run.revision && current.run.state === 'blocked' &&
|
|
70
|
+
current.run.managed?.controllerDecisionWait && !controllerApprovalDiagnosisEligible(current, now)) diagnoseControllerApproval(current, now);
|
|
71
|
+
return current;
|
|
72
|
+
});
|
|
73
|
+
run = record.run;
|
|
74
|
+
}
|
|
67
75
|
if (run?.managed?.providerCapabilityWait) continue;
|
|
68
|
-
if (upgradeEligible(record) && (policyAdoptionPending(record) || run.managed.purpose?.enabled !== true || run.managed.purpose?.version !== 2 || run.managed.purpose?.decisionPolicy !== 'llm-first-hc102' || run.managed.controllerContractVersion !== 2 || run.state === 'paused' || run.state === 'verification_pending')) {
|
|
76
|
+
if (upgradeEligible(record) && (run.state === 'blocked' && controllerApprovalDiagnosisEligible(record, now) || policyAdoptionPending(record) || run.managed.purpose?.enabled !== true || run.managed.purpose?.version !== 2 || run.managed.purpose?.decisionPolicy !== 'llm-first-hc102' || run.managed.controllerContractVersion !== 2 || run.state === 'paused' || run.state === 'verification_pending')) {
|
|
69
77
|
let metadata, migrationLink;
|
|
70
78
|
try { migrationLink = await requireProviderLink({ root, provider: record.provider }); metadata = await observeMetadata(record); }
|
|
71
79
|
catch { metadata = null; }
|
|
@@ -23,7 +23,7 @@ export function createControl({ authorize, select, set, followup, save, revoke,
|
|
|
23
23
|
if (command.enabled && (candidate.autoContinueAvailability?.canEnable ?? candidate.capabilities?.canAutoContinue) !== true) throw Object.assign(new Error(candidate.autoContinueAvailability?.reason || 'Continuation unavailable'), {code:candidate.autoContinueAvailability?.reasonCode});
|
|
24
24
|
await authorize(scope);
|
|
25
25
|
await set({root, provider:'codex',session:command.session,enabled:command.enabled,
|
|
26
|
-
|
|
26
|
+
initialContinuationPolicy:command.continuationPolicy, admission:candidate.continuationAdmission,expectedProviderEpoch:scope.providerEpoch});
|
|
27
27
|
if (!command.enabled) revoke('codex',command.session);
|
|
28
28
|
await save(command,scope);
|
|
29
29
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SSH-only entry point. Keep the normal signed/runtime-pinned desktop startup,
|
|
2
2
|
// account/organization checks, provider consent and native execution boundaries.
|
|
3
3
|
// No device credential is relayed from the Mac and hosts cannot recurse.
|
|
4
|
+
import { relayToOwner } from './owner-transport.js';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { pathToFileURL } from 'node:url';
|
|
@@ -9,6 +10,7 @@ process.env.SIMY_REMOTE_CHILD = '1';
|
|
|
9
10
|
process.env.SIMY_AUTO_UPDATE = '0';
|
|
10
11
|
const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
|
|
11
12
|
try {
|
|
13
|
+
if (await relayToOwner({ root })) process.exit(0);
|
|
12
14
|
await adoptInstalledRuntime({ root });
|
|
13
15
|
const selected = await pinnedWorkflowSource({ root });
|
|
14
16
|
const host = await import(selected ? pathToFileURL(join(selected, 'src/desktop-host.js')).href : '../desktop-host.js');
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
// Remote diagnostics are selected from product-owned text. Never display
|
|
2
2
|
// arbitrary SSH stderr, provider exceptions, paths or remote frame messages.
|
|
3
3
|
const messages = {
|
|
4
|
+
claude_desktop_platform_unsupported: 'Claude Desktop archive control requires a macOS conversation host.',
|
|
5
|
+
claude_desktop_not_installed: 'Install Claude Desktop on the conversation host and sign in to its Code tab.',
|
|
6
|
+
claude_desktop_accessibility_required: 'Enable macOS Accessibility for the app running SIMY on the conversation host. Use simy sessions provider-setup --provider claude --host ALIAS --session UUID to open that host’s setting.',
|
|
7
|
+
claude_desktop_ui_unavailable: 'Open the exact Claude Desktop Code conversation on the selected host and retry.',
|
|
8
|
+
claude_desktop_ui_changed: 'The Claude Desktop window changed or is covered. Retry when the selected conversation is visible.',
|
|
9
|
+
claude_desktop_running: 'Stop this conversation and turn off automatic continuation before organizing it.',
|
|
10
|
+
claude_desktop_unsaved_input: 'Claude Desktop contains unsent input. Preserve it on the conversation host before retrying.',
|
|
11
|
+
claude_desktop_identity_unverified: 'Claude Desktop conversation identity could not be verified. Refresh the exact conversation.',
|
|
12
|
+
claude_desktop_identity_changed: 'Claude Desktop account or conversation changed. Refresh before retrying.',
|
|
13
|
+
claude_desktop_state_unconfirmed: 'Claude Desktop has not confirmed the archive state. Refresh before retrying.',
|
|
14
|
+
claude_desktop_native_unconfirmed: 'The native Claude result is unknown. Refresh its archive state before retrying.',
|
|
15
|
+
|
|
16
|
+
remote_stop_pending: 'The stop request is awaiting confirmation. New work will remain paused until it is reconciled.',
|
|
17
|
+
remote_operation_unknown: 'The remote operation was received but its result is not yet known. Refresh its state before starting another operation.',
|
|
18
|
+
remote_operation_unsupported: 'Update SIMY on the selected host to use this operation.',
|
|
4
19
|
process_conflict: 'Another SIMY service owns this remote host. Reconcile its existing service before connecting; do not stop unrelated work.',
|
|
5
20
|
desktop_access_required: 'Sign in to the same SIMY account and organization on the remote host and verify its access.',
|
|
6
21
|
provider_link_disconnected: 'Reconnect this AI provider to SIMY on the remote host.',
|