@awak-app/simy-cli 0.7.23 → 0.7.26
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 +6 -0
- package/package.json +2 -2
- package/src/agent.js +16 -1
- package/src/auto-update.js +3 -2
- package/src/browser-handoff.js +14 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
- package/src/connection-health.js +253 -0
- package/src/connection-overview.js +58 -0
- package/src/connection-presentation.js +85 -0
- package/src/connection-recovery.js +102 -0
- package/src/console/app.js +60 -6
- package/src/console/commands.js +3 -0
- package/src/console/index.js +1 -0
- package/src/delivery-hooks/legacy-hook-routing.js +24 -0
- package/src/delivery-hooks/supervisor.js +14 -14
- package/src/desktop-host.js +43 -8
- package/src/desktop-status.js +9 -0
- package/src/direct-executor.js +8 -2
- package/src/doctor.js +1 -0
- package/src/index.js +12 -2
- package/src/remote-connection-guide.js +222 -0
- package/src/session-supervision/analysis-manager.js +24 -4
- package/src/session-supervision/automatic-continuation.js +69 -18
- package/src/session-supervision/codex-app-server.js +52 -7
- package/src/session-supervision/codex-peer-opinion.js +27 -5
- package/src/session-supervision/consistency-auditor.js +1 -1
- package/src/session-supervision/continuation-analysis.js +7 -3
- package/src/session-supervision/decision-presentation.js +1 -1
- package/src/session-supervision/index.js +21 -13
- package/src/session-supervision/outcome-plan.js +76 -0
- package/src/session-supervision/purpose-instruction.js +28 -0
- package/src/session-supervision/recovery-frontier.js +14 -4
- package/src/session-supervision/remote-command-queue.js +15 -0
- package/src/session-supervision/remote-control.js +3 -0
- package/src/session-supervision/remote-hosts.js +70 -19
- package/src/session-supervision/remote-managed-delivery.js +94 -0
- package/src/session-supervision/remote-session-list.js +1 -1
- package/src/session-supervision/statistics.js +10 -3
- package/src/session-supervision/work-state.js +5 -0
- package/src/sqm/bundle-store.js +30 -2
- package/src/sqm/provider-plugin-status.js +21 -3
- package/src/web-origin.js +7 -0
|
@@ -11,16 +11,22 @@ const disabledFeatures = [
|
|
|
11
11
|
export async function runCodexPeerOpinion(provider, request, {
|
|
12
12
|
model, cwd, openClient, readAuthentication, signal,
|
|
13
13
|
beforeDispatch = async () => {}, dispatchTransport = initiate => initiate(),
|
|
14
|
-
onContext = () => {}, onUsage = () => {}, timeoutMs = 120_000,
|
|
14
|
+
onContext = () => {}, onUsage = () => {}, onStarted = () => {}, onTerminal = () => {}, timeoutMs = 120_000,
|
|
15
15
|
} = {}) {
|
|
16
16
|
if (provider !== 'codex' || !model || !cwd || !openClient || !readAuthentication) {
|
|
17
17
|
throw new Error('Peer opinion requires the task’s Codex transport, model and workspace.');
|
|
18
18
|
}
|
|
19
19
|
signal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)]);
|
|
20
20
|
signal.throwIfAborted();
|
|
21
|
-
const client = await openClient({ signal, cancelOnAbort:
|
|
21
|
+
const client = await openClient({ signal, cancelOnAbort: false, isolateConnectors: true, maxLineBytes: 1024 * 1024 });
|
|
22
22
|
let threadId, turnId, completed = false, unsubscribe, abort, closed;
|
|
23
|
-
let startPromise;
|
|
23
|
+
let startPromise, terminalResolve;
|
|
24
|
+
const terminalObserved = new Promise(resolve => { terminalResolve = resolve; });
|
|
25
|
+
// Preparation is abortable, but an owned turn must get a bounded chance to
|
|
26
|
+
// report interruption before its transport is closed.
|
|
27
|
+
let preparing = true;
|
|
28
|
+
const cancelPreparation = () => { if (preparing) void client.close(); };
|
|
29
|
+
signal.addEventListener('abort', cancelPreparation, { once: true });
|
|
24
30
|
try {
|
|
25
31
|
if (await readAuthentication(client) === 'missing') {
|
|
26
32
|
throw Object.assign(new Error('The task’s Codex account is not signed in.'), { code: 'authentication_required' });
|
|
@@ -73,9 +79,14 @@ export async function runCodexPeerOpinion(provider, request, {
|
|
|
73
79
|
}
|
|
74
80
|
};
|
|
75
81
|
const finish = () => {
|
|
76
|
-
if (
|
|
82
|
+
if (!turnId || !turns.has(turnId)) return;
|
|
77
83
|
const turn = turns.get(turnId);
|
|
84
|
+
if (completed) return;
|
|
78
85
|
completed = true;
|
|
86
|
+
try { onTerminal({ session: threadId, turnId, status: turn.status, response: messages.get(turnId) || '' }); }
|
|
87
|
+
catch (error) { terminalResolve(); return fail(error); }
|
|
88
|
+
terminalResolve();
|
|
89
|
+
if (settled) return;
|
|
79
90
|
if (turn.status !== 'completed') return fail(Object.assign(new Error('Peer turn did not complete.'), { code: 'turn_failed' }));
|
|
80
91
|
let output;
|
|
81
92
|
try { output = JSON.parse(messages.get(turnId) || ''); } catch {
|
|
@@ -89,6 +100,7 @@ export async function runCodexPeerOpinion(provider, request, {
|
|
|
89
100
|
};
|
|
90
101
|
unsubscribe = client.onNotification((method, params, requestId) => {
|
|
91
102
|
if (params.threadId !== threadId) return;
|
|
103
|
+
if (settled && !['item/completed', 'turn/completed'].includes(method)) return;
|
|
92
104
|
if (requestId !== undefined) return fail(Object.assign(new Error('Peer requested a tool or approval.'), { code: 'provider_approval' }));
|
|
93
105
|
if (method === 'item/completed' && params.item?.type === 'agentMessage') messages.set(params.turnId, params.item.text);
|
|
94
106
|
if (['item/started', 'item/completed'].includes(method) && ['commandExecution', 'mcpToolCall', 'fileChange', 'webSearch', 'dynamicToolCall'].includes(params.item?.type)) {
|
|
@@ -108,6 +120,8 @@ export async function runCodexPeerOpinion(provider, request, {
|
|
|
108
120
|
client.child?.once('close', closed);
|
|
109
121
|
await beforeDispatch();
|
|
110
122
|
signal?.throwIfAborted();
|
|
123
|
+
preparing = false;
|
|
124
|
+
signal.removeEventListener('abort', cancelPreparation);
|
|
111
125
|
startPromise = Promise.resolve(dispatchTransport(() => client.request('turn/start', {
|
|
112
126
|
threadId, model, cwd, input: [{ type: 'text', text: request.prompt }],
|
|
113
127
|
outputSchema: request.output_schema, sandboxPolicy: { type: 'readOnly', networkAccess: false },
|
|
@@ -115,14 +129,22 @@ export async function runCodexPeerOpinion(provider, request, {
|
|
|
115
129
|
const started = await Promise.race([startPromise, done]);
|
|
116
130
|
turnId = started.turn?.id;
|
|
117
131
|
if (!turnId) throw new Error('Peer turn did not start.');
|
|
132
|
+
await onStarted({ session: threadId, turnId });
|
|
118
133
|
reportUsage();
|
|
119
134
|
finish();
|
|
120
135
|
return await done;
|
|
121
136
|
} finally {
|
|
122
137
|
signal?.removeEventListener('abort', abort);
|
|
123
138
|
if (closed) client.child?.removeListener('close', closed);
|
|
139
|
+
signal.removeEventListener('abort', cancelPreparation);
|
|
140
|
+
if (!completed && turnId && !client.closed) {
|
|
141
|
+
await client.request('turn/interrupt', { threadId, turnId }, 2000).catch(() => {});
|
|
142
|
+
if (!completed && !client.closed) await new Promise(resolve => {
|
|
143
|
+
const timer = setTimeout(resolve, 2000);
|
|
144
|
+
terminalObserved.then(() => { clearTimeout(timer); resolve(); });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
124
147
|
unsubscribe?.();
|
|
125
|
-
if (!completed && turnId) await client.request('turn/interrupt', { threadId, turnId }, 2000).catch(() => {});
|
|
126
148
|
await client.close();
|
|
127
149
|
}
|
|
128
150
|
}
|
|
@@ -121,7 +121,7 @@ async function auditConsistencyAttempt(record, findings, request, { root, signal
|
|
|
121
121
|
};
|
|
122
122
|
let usage = null, usageWrites = Promise.resolve();
|
|
123
123
|
try {
|
|
124
|
-
result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true, opinionOnly, peerAction: record.run.managed.decision?.action, authorizePreparation:beforeDispatch,
|
|
124
|
+
result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true, opinionOnly, dispatchAttemptId: id, peerAction: record.run.managed.decision?.action, authorizePreparation:beforeDispatch,
|
|
125
125
|
beforeDispatch: async () => { await beforeDispatch(); await retainDiagnostic('dispatching'); },
|
|
126
126
|
onEvidence: evidence => {
|
|
127
127
|
const context = evidence?.workerContext;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OUTCOME_PLAN_SCHEMA, OUTCOME_PLAN_INSTRUCTION, validateOutcomePlan } from './outcome-plan.js';
|
|
1
2
|
import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
|
|
2
3
|
import { correctiveFollowupInstruction } from '../codex-plugins/simy/hooks/result-cycle.mjs';
|
|
3
4
|
import { readCodexConversation } from './codex-app-server.js';
|
|
@@ -7,7 +8,7 @@ import { runStructuredProviderReview } from '../sqm/review-worker.js';
|
|
|
7
8
|
import { inspectProviderStatus } from '../desktop-status.js';
|
|
8
9
|
import { createHash } from 'node:crypto';
|
|
9
10
|
|
|
10
|
-
export const CONTINUATION_ANALYSIS_VERSION =
|
|
11
|
+
export const CONTINUATION_ANALYSIS_VERSION = 11;
|
|
11
12
|
const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
|
|
12
13
|
const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
|
|
13
14
|
const string = maximum => ({ type: 'string', maxLength: maximum });
|
|
@@ -17,8 +18,9 @@ const EVIDENCE_SCHEMA = {
|
|
|
17
18
|
};
|
|
18
19
|
const OUTPUT_SCHEMA = {
|
|
19
20
|
type: 'object', additionalProperties: false,
|
|
20
|
-
required: ['state', 'reason', 'objective', 'remaining', 'nextAction', 'basis', 'assessment', 'blocker', 'pendingActions', 'nextActionAuthorization'],
|
|
21
|
+
required: ['state', 'reason', 'objective', 'remaining', 'nextAction', 'basis', 'assessment', 'blocker', 'pendingActions', 'nextActionAuthorization', 'outcomePlan'],
|
|
21
22
|
properties: {
|
|
23
|
+
outcomePlan: { anyOf: [{ type: 'null' }, OUTCOME_PLAN_SCHEMA] },
|
|
22
24
|
state: { type: 'string', enum: STATES }, reason: string(600), objective: string(600),
|
|
23
25
|
remaining: { type: 'array', maxItems: 8, items: string(400) },
|
|
24
26
|
nextAction: { anyOf: [string(600), { type: 'null' }] },
|
|
@@ -53,6 +55,7 @@ Determine whether the ORIGINAL user request contains concrete unfinished work wi
|
|
|
53
55
|
${correctiveFollowupInstruction} In this read-only classifier, select and ground the next action; do not perform it or answer the user yourself. A corrective question can establish investigation intent in context; use earlier still-valid work authorization for repairs and tests. Mere uncertainty about a completed result does not prove a defect: investigate the specific disputed outcome without inventing implementation work.
|
|
54
56
|
When supplied, originalProviderGoal is read-only goal metadata from this exact original conversation. Consider its objective together with the latest finished conversation evidence. An active goal can span several turns: a finished subtask does not necessarily finish the whole goal. Active status alone is NOT evidence of remaining actionable work; require concrete unfinished work in the finished messages. Later user corrections, cancellation and permissions still govern. A complete goal adds no new work or acceptance obligations; a distinct later explicit user request can be assessed on its own. Never resume a paused/blocked/usageLimited/budgetLimited goal or reset its limits. Goal evidence must use role=goal, turnRef=provider-goal and a verbatim excerpt from its objective; never present goal metadata as a human message. A truncated goal is incomplete evidence.
|
|
55
57
|
Write objective in the original user's language as the intended user experience: who should be able to do what, in which situation. Preserve the actual requested scope; do not replace the outcome with implementation tasks, test commands or constraints. Keep constraints in the reasoning and authorization checks. Do not invent an experience when the user only requested technical work.
|
|
58
|
+
${OUTCOME_PLAN_INSTRUCTION}
|
|
56
59
|
State rules:
|
|
57
60
|
- A final answer or missing plan/checkpoint alone is not completion. If original requested implementation remains explicitly unfinished, generate a concrete authorized next action even when the assistant supplied none; use enabling conditions, alternative routes or independent work. If only comparison and a Word document were requested and delivered, unimplemented features described in that document are outside scope. Do not let a generic completion phrase override concrete contradictory unfinished-work evidence, or invent gaps when the requested outcome is reported complete.
|
|
58
61
|
- continue: the user requested concrete work, work remains unfinished, and a specific feasible next step can be safely taken within existing authority. A continued local investigation/edit/test can qualify. Require user evidence for the objective AND evidence of concrete remaining work. Do not invent additional acceptance checks after the assistant says the requested work is done.
|
|
@@ -181,7 +184,8 @@ export function validateContinuationAnalysis(output, context) {
|
|
|
181
184
|
if (blocker && (!assessment.userRequestedWork || !assessment.unfinished || assessment.optionalFollowUp ||
|
|
182
185
|
!['needs_user', 'waiting', 'uncertain'].includes(state))) throw new Error('Blocker contradicts task status');
|
|
183
186
|
if (validatedPendingActions.length && (!assessment.userRequestedWork || !assessment.unfinished || assessment.optionalFollowUp || state === 'no_action')) throw new Error('Pending action contradicts task status');
|
|
184
|
-
|
|
187
|
+
const outcomePlan = validateOutcomePlan(output.outcomePlan, { objective, state, nextAction: output.nextAction, validateEvidence });
|
|
188
|
+
return { state, reason, objective, remaining, outcomePlan, nextAction: state === 'continue' ? output.nextAction : null, basis, blocker,
|
|
185
189
|
...(Object.hasOwn(output, 'pendingActions') ? { pendingActions: validatedPendingActions } : {}),
|
|
186
190
|
...(Object.hasOwn(output, 'nextActionAuthorization') ? { nextActionAuthorization: state === 'continue' ? nextActionAuthorization : null } : {}),
|
|
187
191
|
analysisVersion: CONTINUATION_ANALYSIS_VERSION };
|
|
@@ -30,7 +30,7 @@ export function sessionDecisionPresentation(candidate, run) {
|
|
|
30
30
|
choices: [],
|
|
31
31
|
};
|
|
32
32
|
if (run?.dependencyWait || run?.autoRecovery && ['recovery_required', 'auto_resuming'].includes(run.state) ||
|
|
33
|
-
['supervising', 'watching', 'waiting'].includes(candidate.autoContinueStatus)) return null;
|
|
33
|
+
['supervising', 'watching', 'waiting', 'starting', 'recovering'].includes(candidate.autoContinueStatus)) return null;
|
|
34
34
|
if (run && ['recovery_required', 'paused', 'failed', 'blocked'].includes(run.state)) {
|
|
35
35
|
const providerName = candidate.provider === 'claude' ? 'Claude' : candidate.provider === 'codex' ? 'Codex' : 'AI';
|
|
36
36
|
const context = run.state === 'paused' ? 'この作業は停止中です。停止の指示は自動で解除しません。' : ({ session_end: `元の${providerName}会話が終了し、自動継続が止まっています。`,
|
|
@@ -127,7 +127,7 @@ function navigationParent(item, record) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
130
|
-
inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
|
|
130
|
+
inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false, includeSelectionCandidates = false } = {}) {
|
|
131
131
|
const now = requestedNow ?? Date.now();
|
|
132
132
|
const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
|
|
133
133
|
assertActive();
|
|
@@ -269,7 +269,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
const projected = new Array(items.length);
|
|
272
|
-
const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title))
|
|
272
|
+
const codexTitles = connected.has('codex') && (includeSelectionCandidates || items.some(item => item.provider === 'codex' && !sessionTitle(item.title)))
|
|
273
|
+
? await listCodexTitles({ codexHome }) : new Map();
|
|
274
|
+
// Title-index entries are navigation hints, not verified session metadata.
|
|
275
|
+
// Manual addition still reads the exact provider session before persisting.
|
|
276
|
+
const selectionCandidates = includeSelectionCandidates ? [...codexTitles].map(([session, title]) => ({ provider: 'codex', session, title })) : undefined;
|
|
273
277
|
assertActive();
|
|
274
278
|
let nextItem = 0;
|
|
275
279
|
const project = async item => {
|
|
@@ -337,14 +341,15 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
337
341
|
...autoContinueState(record, isRunning, availability),
|
|
338
342
|
lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
|
|
339
343
|
taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
340
|
-
binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null
|
|
344
|
+
binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
345
|
+
...(upgradeable ? { upgradeRunId: run.id } : {}) },
|
|
341
346
|
originalSessionTarget: originalSessionTarget(item.provider, item.session),
|
|
342
347
|
continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
|
|
343
348
|
...(!archived && continuationAdmission ? { continuationAdmission } : {}),
|
|
344
349
|
continuationReasonCode: availability.reasonCode || claudeChannel?.code || 'ready',
|
|
345
350
|
capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
|
|
346
351
|
canPurposeContinue: !archived && supported && (!run || !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(run.state)),
|
|
347
|
-
canAnalyze: !archived &&
|
|
352
|
+
canAnalyze: !archived && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
|
|
348
353
|
canEnroll: !archived && (supported || liveHook) && !run, canContinue: !archived && (supported || liveHook) && !run,
|
|
349
354
|
canAutoContinue: !archived && (supported || liveHook) && (!run || upgradeable || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
|
|
350
355
|
reason: item.provider === 'claude' ? continuationIssue?.reason || claudeChannel.reason :
|
|
@@ -364,7 +369,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
364
369
|
const stateNow = requestedNow ?? Date.now();
|
|
365
370
|
for (const candidate of projected) candidate.workState = sessionWorkState(candidate, recordsByKey.get(`${candidate.provider}:${candidate.session}`)?.run, { now: stateNow });
|
|
366
371
|
projected.sort(compareSessionWork);
|
|
367
|
-
return { sessions: includeArchived ? projected : projected.filter(item => !item.archived && !item.localHidden), providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
372
|
+
return { ...(selectionCandidates ? { selectionCandidates } : {}), sessions: includeArchived ? projected : projected.filter(item => !item.archived && !item.localHidden), providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
368
373
|
}
|
|
369
374
|
|
|
370
375
|
// Saving consent needs an exact admitted identity, not another provider RPC.
|
|
@@ -447,6 +452,10 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
447
452
|
failureBudgetMode: purposeFailureMode(next),
|
|
448
453
|
ruleDigest: purposeRules.digest, ruleIds: purposeRules.rules.map(rule => rule.id) };
|
|
449
454
|
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; }
|
|
455
|
+
// OFF keeps the current native turn alive. Re-enabling during that
|
|
456
|
+
// same turn restores future continuation without starting another turn.
|
|
457
|
+
if (previous.autoContinue === false && next.run.state === 'auto_resuming' && next.run.stopReason?.actor !== 'user')
|
|
458
|
+
next.run.managed.autoRecovery = true;
|
|
450
459
|
} else if (!enabled && next.run.managed.purpose) { if (next.run.state !== 'auto_resuming') next.run.managed.purpose.enabled = false; next.run.managed.autoRecovery = false; if (!TERMINAL_RUNS.has(next.run.state) && next.run.state !== 'auto_resuming') { next.run.state = 'paused'; next.run.reason = 'Automatic continuation paused by user.'; } }
|
|
451
460
|
}
|
|
452
461
|
// The desktop preference is an explicit human control too: an old decision
|
|
@@ -454,20 +463,17 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
454
463
|
if (next.run) advanceDeliveryControlRevision(next.run);
|
|
455
464
|
return next;
|
|
456
465
|
});
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
// overlapping Off action prevents the promotion from dispatching work.
|
|
460
|
-
await continueSession({ ...options, root, provider, session,
|
|
461
|
-
...updated.autoContinueSettings, autoContinueEpoch: updated.autoContinueEpoch, expectedProviderEpoch: link.epoch });
|
|
462
|
-
return readRecord(root, provider, session);
|
|
463
|
-
}
|
|
466
|
+
// Consent is durable now. Provider reads and ordinary-loop promotion belong
|
|
467
|
+
// to the background coordinator, so their failure cannot undo this response.
|
|
464
468
|
return updated;
|
|
465
469
|
}
|
|
466
470
|
|
|
467
471
|
export async function readSessionAutoContinue({ root, provider, session }) {
|
|
468
472
|
const record = await readRecord(root, provider, session);
|
|
469
473
|
return { enabled: record?.autoContinue === true, epoch: record?.autoContinueEpoch || null,
|
|
470
|
-
settings: record?.autoContinueSettings, hasRun: Boolean(record?.run)
|
|
474
|
+
settings: record?.autoContinueSettings, hasRun: Boolean(record?.run),
|
|
475
|
+
upgradeRunId: canUpgradeDelivery(record?.run, { automatic: true })
|
|
476
|
+
? record.run.id : null };
|
|
471
477
|
}
|
|
472
478
|
|
|
473
479
|
// Tracking is local observation metadata. It neither starts nor stops the
|
|
@@ -577,6 +583,8 @@ export async function followupSession(options) {
|
|
|
577
583
|
export async function continueSession(options) {
|
|
578
584
|
options = sessionLoopOptions(options);
|
|
579
585
|
const record = await readRecord(options.root, options.provider, options.session);
|
|
586
|
+
if (options.upgradeRunId && (record?.run?.id !== options.upgradeRunId || record.run.managed))
|
|
587
|
+
throw Object.assign(new Error('The original task changed before automatic continuation started.'), { code: 'session_task_changed' });
|
|
580
588
|
if (record?.run && !record.run.managed) {
|
|
581
589
|
// The user asked to continue this task. Adopt its goal and failure budget,
|
|
582
590
|
// rather than enrolling the generic context-bootstrap goal as a new run.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { sanitizedText } from './conversation-context.js';
|
|
2
|
+
|
|
3
|
+
// A read-only route to the requested outcome. Never an execution permission or
|
|
4
|
+
// acceptance certificate. Old/partial analyses intentionally have no plan.
|
|
5
|
+
const text = { type: 'string', minLength: 1, maxLength: 600 };
|
|
6
|
+
const id = { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,60}$' };
|
|
7
|
+
const evidence = { type: 'array', maxItems: 6, items: {
|
|
8
|
+
type: 'object', additionalProperties: false, required: ['role', 'turnRef', 'snippet'],
|
|
9
|
+
properties: { role: { type: 'string', enum: ['user', 'assistant', 'goal'] },
|
|
10
|
+
turnRef: { type: 'string', maxLength: 160 }, snippet: { type: 'string', maxLength: 280 } },
|
|
11
|
+
} };
|
|
12
|
+
export const OUTCOME_PLAN_SCHEMA = {
|
|
13
|
+
type: 'object', additionalProperties: false, required: ['destination', 'criteria', 'steps'],
|
|
14
|
+
properties: {
|
|
15
|
+
destination: text,
|
|
16
|
+
criteria: { type: 'array', minItems: 1, maxItems: 8, items: {
|
|
17
|
+
type: 'object', additionalProperties: false, required: ['id', 'description', 'basis'],
|
|
18
|
+
properties: { id, description: text, basis: evidence },
|
|
19
|
+
} },
|
|
20
|
+
steps: { type: 'array', minItems: 1, maxItems: 12, items: {
|
|
21
|
+
type: 'object', additionalProperties: false,
|
|
22
|
+
required: ['id', 'title', 'criterionIds', 'dependsOn', 'exitCondition', 'state', 'evidence'],
|
|
23
|
+
properties: { id, title: text, exitCondition: text,
|
|
24
|
+
criterionIds: { type: 'array', minItems: 1, maxItems: 8, items: id },
|
|
25
|
+
dependsOn: { type: 'array', maxItems: 11, items: id },
|
|
26
|
+
state: { type: 'string', enum: ['planned', 'current', 'reported'] }, evidence },
|
|
27
|
+
} },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const OUTCOME_PLAN_INSTRUCTION = `Return outcomePlan as a destination-driven proposed route, separate from chronological work reports. First identify the user's requested destination (exactly objective) and observable completion criteria grounded in direct user or active original-goal evidence. Work BACKWARDS from each criterion: what result must exist immediately before it, and what prerequisites enable that result? Then return those necessary steps in dependency/execution order. Every step must link to criterionIds and have a concrete observable exitCondition; cover every criterion. Do not turn paths, HEAD hashes, commands executed or investigation diary entries into milestones. Infer reasonable prerequisite steps within scope, but never add publication, deployment or other outcomes the user did not request. A plan grants no authority. Preserve blocked/future stages even when one safe action can proceed.
|
|
32
|
+
For each criterion include verbatim basis citations establishing requested scope; assistant suggestions alone are not requirements. For each step use planned unless the conversation explicitly reports its result (reported, with verbatim user/assistant evidence). Reported never means independently verified. At most one step is current; only for state=continue, and its title must exactly equal nextAction. Current depends only on stages reported accomplished; otherwise include the next enabling prerequisite first. Never mark an inferred step accomplished. For answered questions, casual chat, insufficient destination/criteria evidence or a route you cannot ground, return outcomePlan=null rather than inventing a full workflow. Do not treat an absent plan as a reason to continue completed work.`;
|
|
33
|
+
|
|
34
|
+
export function validateOutcomePlan(value, { objective, state, nextAction, validateEvidence }) {
|
|
35
|
+
if (value == null) return null;
|
|
36
|
+
try {
|
|
37
|
+
const string = value => {
|
|
38
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 600 || sanitizedText(value) !== value || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(value)) throw Error('Invalid text');
|
|
39
|
+
return value;
|
|
40
|
+
};
|
|
41
|
+
const identifier = value => { if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,60}$/.test(value)) throw Error('Invalid id'); return value; };
|
|
42
|
+
const list = (value, max, min = 0) => { if (!Array.isArray(value) || value.length < min || value.length > max) throw Error('Invalid list'); return value; };
|
|
43
|
+
if (value.destination !== objective || !objective) throw Error('Wrong destination');
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
const criteria = list(value.criteria, 8, 1).map(c => {
|
|
46
|
+
const id = identifier(c.id);
|
|
47
|
+
if (ids.has(id)) throw Error('Duplicate criterion');
|
|
48
|
+
ids.add(id);
|
|
49
|
+
const basis = list(c.basis, 6, 1).map(validateEvidence);
|
|
50
|
+
if (!basis.every(e => ['user', 'goal'].includes(e.role))) throw Error('Unrequested criterion');
|
|
51
|
+
return { id, description: string(c.description), basis };
|
|
52
|
+
});
|
|
53
|
+
const prior = new Map(), covered = new Set();
|
|
54
|
+
let current = 0;
|
|
55
|
+
const steps = list(value.steps, 12, 1).map(s => {
|
|
56
|
+
const id = identifier(s.id), title = string(s.title), exitCondition = string(s.exitCondition);
|
|
57
|
+
if (prior.has(id) || !['planned', 'current', 'reported'].includes(s.state)) throw Error('Invalid step');
|
|
58
|
+
const criterionIds = list(s.criterionIds, 8, 1).map(identifier);
|
|
59
|
+
if (criterionIds.some(id => !ids.has(id))) throw Error('Unknown criterion');
|
|
60
|
+
criterionIds.forEach(id => covered.add(id));
|
|
61
|
+
const dependsOn = list(s.dependsOn, 11).map(identifier);
|
|
62
|
+
// Prior-only references reject cycles and forward/missing dependencies.
|
|
63
|
+
if (dependsOn.some(id => !prior.has(id))) throw Error('Invalid dependency');
|
|
64
|
+
const evidence = list(s.evidence, 6).map(validateEvidence);
|
|
65
|
+
if (s.state === 'reported' && (!evidence.length || evidence.some(e => e.role === 'goal'))) throw Error('Missing result report');
|
|
66
|
+
if (s.state === 'current' && (++current > 1 || state !== 'continue' || title !== nextAction || dependsOn.some(id => prior.get(id) !== 'reported'))) throw Error('Wrong current step');
|
|
67
|
+
prior.set(id, s.state);
|
|
68
|
+
return { id, title, criterionIds, dependsOn, exitCondition, state: s.state, evidence };
|
|
69
|
+
});
|
|
70
|
+
if (covered.size !== ids.size || state === 'continue' && current !== 1) throw Error('Incomplete route');
|
|
71
|
+
return { destination: objective, criteria, steps };
|
|
72
|
+
} catch {
|
|
73
|
+
// An optional presentation plan must never change triage/dispatch authority.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -9,6 +9,33 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { purposeContract, llmDecisionInstruction } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
|
|
11
11
|
const root = new URL('../orchestrator/delivery/', import.meta.url);
|
|
12
|
+
|
|
13
|
+
// Transport context is a view. Scheduling, effects, stops and acceptance keep
|
|
14
|
+
// using the complete durable ledger; old alternatives must not exhaust RPC input.
|
|
15
|
+
export function purposeInstructionContext(managed) {
|
|
16
|
+
const view = structuredClone(managed), ledger = view.purpose?.evidence;
|
|
17
|
+
if (!Array.isArray(ledger?.units) || ledger.units.length <= 64) return view;
|
|
18
|
+
const ids = new Set([view.decision?.id, view.recoveryDiagnosisDecisionId,
|
|
19
|
+
view.executionUncertain?.decisionId].filter(Boolean));
|
|
20
|
+
const byId = new Map(ledger.units.map(unit => [unit.id, unit]));
|
|
21
|
+
const pending = [...ids];
|
|
22
|
+
for (let index = 0; index < pending.length; index++) {
|
|
23
|
+
for (const id of byId.get(pending[index])?.dependsOn || []) if (!ids.has(id)) {
|
|
24
|
+
ids.add(id); pending.push(id);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const important = ledger.units.filter(unit => ids.has(unit.id) ||
|
|
28
|
+
['unknown','running','dispatched','in_flight'].includes(unit.status));
|
|
29
|
+
const chosen = [...important, ...ledger.units.slice(-48)];
|
|
30
|
+
const unique = new Map(chosen.map(unit => [unit.id, unit]));
|
|
31
|
+
const statuses = {};
|
|
32
|
+
for (const unit of ledger.units) statuses[unit.status] = (statuses[unit.status] || 0) + 1;
|
|
33
|
+
ledger.contextProjection = { totalUnits: ledger.units.length, includedUnits: unique.size,
|
|
34
|
+
omittedUnits: ledger.units.length - unique.size, statuses,
|
|
35
|
+
note: 'Transmission view only. Omitted operations remain in durable state, not completed or authorized. Read additional units through the existing delivery status CLI when needed; select fields rather than dumping the full ledger.' };
|
|
36
|
+
ledger.units = [...unique.values()];
|
|
37
|
+
return view;
|
|
38
|
+
}
|
|
12
39
|
// Optional reference material must not become an execution prerequisite.
|
|
13
40
|
export async function loadPurposeRuleSet(options = {}) {
|
|
14
41
|
try { return await readPurposeRuleSet(options); }
|
|
@@ -45,6 +72,7 @@ async function readPurposeRuleSet({ readFileImpl = readFile, readdirImpl = readd
|
|
|
45
72
|
}
|
|
46
73
|
export async function buildPurposeInstruction(managed, { reviewer = false, cwd, webOrigin, allowNetwork = false, signal, knowledgeLoader = loadHypothesisKnowledge, authorizeKnowledgeNetwork, root: memoryRoot, record, memoryLoader = recallPerspectives } = {}) {
|
|
47
74
|
if (!managed?.purpose?.enabled) return withSceneReasoning();
|
|
75
|
+
managed = purposeInstructionContext(managed);
|
|
48
76
|
const ruleSet = await loadPurposeRuleSet();
|
|
49
77
|
if (reviewer) return `${llmDecisionInstruction}\nInspect the requested behavior and report concrete defects or missing outcomes. Consult relevant references only when useful. No rule inventory coverage, proof attachments, score or audit receipt is required.`;
|
|
50
78
|
const knowledge = await knowledgeLoader({ cwd, webOrigin, allowNetwork, signal, ...(authorizeKnowledgeNetwork ? { authorizeNetwork: authorizeKnowledgeNetwork } : {}) });
|
|
@@ -3,6 +3,18 @@ import { reconcileWorkFrontier, revalidateWorkProposal } from '../codex-plugins/
|
|
|
3
3
|
import { searchEnabled } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
4
4
|
import { taskDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
5
|
|
|
6
|
+
function frontierOptions(record) {
|
|
7
|
+
const m = record.run.managed, ledger = m.purpose?.evidence;
|
|
8
|
+
const prior = ledger?.units?.find(unit => unit.id === m.executionUncertain?.decisionId);
|
|
9
|
+
// This controller-created read exists to reconcile one retained unknown
|
|
10
|
+
// effect. Keep that effect unknown and fence all writers and other holders.
|
|
11
|
+
const reconciling = m.decision?.action?.kind === 'read' &&
|
|
12
|
+
m.recoveryDiagnosisDecisionId === m.decision.id && prior?.status === 'unknown' &&
|
|
13
|
+
prior.owner === m.taskId && prior.purposeRevision === ledger.purposeRevision;
|
|
14
|
+
return { stopped: ['paused', 'stopped', 'cancelled'].includes(record.run.state),
|
|
15
|
+
...(reconciling ? { reconcileUnknownUnitId: prior.id } : {}) };
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
// Enroll the controller's canonical workspace, never a worker-provided lock ID.
|
|
7
19
|
// Read-only diagnosis/verification is the path that resolves observed effects;
|
|
8
20
|
// it must remain possible while the frontier prevents another writer.
|
|
@@ -30,7 +42,7 @@ export function recoveryWorkProposal(record) {
|
|
|
30
42
|
if (unit.action.kind !== 'read' && !unit.writeResources.includes(workspace)) unit.writeResources.push(workspace);
|
|
31
43
|
}
|
|
32
44
|
// Missing PR readback stays blocked. This boundary never invents CI evidence.
|
|
33
|
-
const proposal = reconcileWorkFrontier(managed,
|
|
45
|
+
const proposal = reconcileWorkFrontier(managed, frontierOptions(record));
|
|
34
46
|
delete managed.frontierIssue;
|
|
35
47
|
return proposal;
|
|
36
48
|
} catch {
|
|
@@ -45,9 +57,7 @@ export function admitRecoveryWork(record, proposal, leaseId, now) {
|
|
|
45
57
|
const unit = managed.purpose.evidence.units.find(row => row.id === managed.decision.id);
|
|
46
58
|
if (!unit && managed.decision.action.kind === 'read' && ['understanding', 'needs_clarification'].includes(managed.phase)) return;
|
|
47
59
|
if (!unit) throw new Error('Selected work unit is missing.');
|
|
48
|
-
const current = revalidateWorkProposal(managed, proposal, unit.id,
|
|
49
|
-
stopped: ['paused', 'stopped', 'cancelled'].includes(record.run.state),
|
|
50
|
-
});
|
|
60
|
+
const current = revalidateWorkProposal(managed, proposal, unit.id, frontierOptions(record));
|
|
51
61
|
if (!current.eligible) {
|
|
52
62
|
throw new Error(`Work proposal needs reconciliation: ${current.reason || 'not eligible'}`);
|
|
53
63
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// A remote usage scan must not hold up explicit preference or stop requests.
|
|
2
|
+
// Mutations retain arrival order; authorization remains in the native handlers.
|
|
3
|
+
export function createRemoteCommandQueue({ handle }) {
|
|
4
|
+
let controls = Promise.resolve(), observations = Promise.resolve();
|
|
5
|
+
return {
|
|
6
|
+
enqueue(command) {
|
|
7
|
+
const observation = ['session_statistics', 'refresh'].includes(command.action);
|
|
8
|
+
const pending = (observation ? observations : controls).then(() => handle(command));
|
|
9
|
+
// Keep a failed request from poisoning subsequent requests in that lane.
|
|
10
|
+
if (observation) observations = pending.catch(() => {});
|
|
11
|
+
else controls = pending.catch(() => {});
|
|
12
|
+
return pending;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export { createRemoteManagedDelivery } from './remote-managed-delivery.js';
|
|
2
|
+
export { createRemoteCommandQueue } from './remote-command-queue.js';
|
|
3
|
+
|
|
1
4
|
// Only host-discovered admission may reach the native mutation API.
|
|
2
5
|
export function createControl({ authorize, select, set, followup, save, revoke, root }) {
|
|
3
6
|
return async command => {
|
|
@@ -24,7 +24,8 @@ function normalizedHost(value) {
|
|
|
24
24
|
if (!value || typeof value.alias !== 'string' || !aliasPattern.test(value.alias)) throw error('Enter an SSH host alias, for example nvidia-workstation.', 'remote_host_invalid');
|
|
25
25
|
const label = typeof value.label === 'string' ? value.label.trim() : '';
|
|
26
26
|
if (label.length > 80 || /[\x00-\x1f\x7f]/.test(label)) throw error('Choose a shorter host name.', 'remote_host_invalid');
|
|
27
|
-
|
|
27
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') throw error('Invalid connection preference.', 'remote_host_invalid');
|
|
28
|
+
return { alias: value.alias, label: label || value.alias, id: remoteHostId(value.alias), ...(value.enabled === false ? { enabled: false } : {}) };
|
|
28
29
|
}
|
|
29
30
|
export async function readHosts(root) {
|
|
30
31
|
let value;
|
|
@@ -67,7 +68,7 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
function connect(connection) {
|
|
70
|
-
if (closed || connection.child || now() < connection.retryAt) return;
|
|
71
|
+
if (closed || connection.enabled === false || connection.child || now() < connection.retryAt) return;
|
|
71
72
|
connection.state = 'connecting'; connection.reason = null;
|
|
72
73
|
let child;
|
|
73
74
|
try { child = spawnImpl('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5',
|
|
@@ -144,52 +145,102 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
144
145
|
for (const host of hosts) {
|
|
145
146
|
let c = connections.get(host.id);
|
|
146
147
|
if (!c) { c = { ...host, pending: new Map(), retryAt: 0, ready: false, status: null }; connections.set(host.id, c); }
|
|
147
|
-
c.label = host.label;
|
|
148
|
+
c.label = host.label; c.enabled = host.enabled !== false;
|
|
149
|
+
if (!c.enabled) {
|
|
150
|
+
if (c.child) disconnect(c);
|
|
151
|
+
c.ready = false; c.status = null; c.state = 'disconnected'; c.reason = null;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (c.state === 'disconnected') c.retryAt = 0;
|
|
148
155
|
if (c.ready && now() - c.receivedAt > 45_000) disconnect(c, 'Remote status is stale. Reconnecting.');
|
|
149
156
|
connect(c);
|
|
150
157
|
}
|
|
151
158
|
}
|
|
152
|
-
async function
|
|
153
|
-
const host = normalizedHost(value);
|
|
159
|
+
async function persistHosts(change, apply) {
|
|
154
160
|
const work = writes.catch(() => {}).then(async () => {
|
|
155
|
-
const hosts = (await readHosts(root))
|
|
156
|
-
if (!remove) hosts.push(host);
|
|
161
|
+
const hosts = await change(await readHosts(root));
|
|
157
162
|
if (hosts.length > MAX_HOSTS) throw error('At most eight remote hosts can be connected.', 'remote_host_invalid');
|
|
158
163
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
159
164
|
const path = join(root, 'remote-hosts.json'), temporary = `${path}.${randomUUID()}.tmp`;
|
|
160
165
|
try { await writeFile(temporary, JSON.stringify({ schemaVersion: 1, hosts }) + '\n', { mode: 0o600 }); await rename(temporary, path); }
|
|
161
166
|
finally { await rm(temporary, { force: true }); }
|
|
162
|
-
generation++;
|
|
163
|
-
const c = connections.get(host.id);
|
|
164
|
-
if (c) { disconnect(c); if (remove) connections.delete(host.id); else c.retryAt = 0; }
|
|
165
|
-
changed();
|
|
167
|
+
generation++; apply(); changed();
|
|
166
168
|
});
|
|
167
169
|
writes = work; await work;
|
|
168
170
|
}
|
|
171
|
+
async function save(value, remove = false) {
|
|
172
|
+
const host = normalizedHost(value);
|
|
173
|
+
await persistHosts(hosts => [...hosts.filter(h => h.id !== host.id), ...(remove ? [] : [host])], () => {
|
|
174
|
+
const c = connections.get(host.id);
|
|
175
|
+
if (c) { disconnect(c); if (remove) connections.delete(host.id); else { c.enabled = host.enabled !== false; c.retryAt = 0; } }
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function edit(value) {
|
|
179
|
+
const previous = normalizedHost({ alias: value.previousAlias });
|
|
180
|
+
const next = normalizedHost(value);
|
|
181
|
+
await persistHosts(hosts => {
|
|
182
|
+
const existing = hosts.find(h => h.id === previous.id);
|
|
183
|
+
if (!existing) throw error('This device is no longer registered. Refresh the device list.', 'remote_host_invalid');
|
|
184
|
+
if (next.id !== previous.id && hosts.some(h => h.id === next.id)) throw error('That SSH host is already registered.', 'remote_host_invalid');
|
|
185
|
+
return hosts.map(h => h.id === previous.id ? { ...next, ...(existing.enabled === false ? { enabled: false } : {}) } : h);
|
|
186
|
+
}, () => {
|
|
187
|
+
const c = connections.get(previous.id);
|
|
188
|
+
if (!c) return;
|
|
189
|
+
if (next.id === previous.id) c.label = next.label;
|
|
190
|
+
else { disconnect(c); connections.delete(previous.id); }
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
async function setEnabled(alias, enabled) {
|
|
194
|
+
const host = normalizedHost({ alias });
|
|
195
|
+
if (typeof enabled !== 'boolean') throw error('Invalid connection preference.', 'remote_host_invalid');
|
|
196
|
+
await persistHosts(hosts => {
|
|
197
|
+
if (!hosts.some(h => h.id === host.id)) throw error('This device is no longer registered. Refresh the device list.', 'remote_host_invalid');
|
|
198
|
+
return hosts.map(h => h.id === host.id ? normalizedHost({ ...h, enabled }) : h);
|
|
199
|
+
}, () => {
|
|
200
|
+
const c = connections.get(host.id);
|
|
201
|
+
if (!c) return;
|
|
202
|
+
c.enabled = enabled;
|
|
203
|
+
if (!enabled) { disconnect(c); c.status = null; c.state = 'disconnected'; c.reason = null; }
|
|
204
|
+
else { c.retryAt = 0; if (c.state === 'disconnected') c.state = 'connecting'; }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
169
207
|
async function forward(command, status) {
|
|
170
208
|
if (closed || !usable(status) || scopeOf(status) !== scope) throw error('SIMY account changed. Refresh before using the remote host.', 'remote_host_access');
|
|
171
209
|
if (status.providerLinks?.[command.provider]?.connected === false) throw error('Provider is disconnected.', 'remote_host_access');
|
|
172
210
|
const c = connections.get(command.hostId);
|
|
173
211
|
if (!c?.ready || !c.child || now() - c.receivedAt > 45_000) throw error('The remote host is disconnected. Reconnect before using this conversation.');
|
|
174
|
-
|
|
175
|
-
|
|
212
|
+
const manualAdd = command.action === 'session_add' && command.provider === 'codex' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.session || '');
|
|
213
|
+
if ((!manualAdd && !allowedActions.has(command.action)) || !['codex', 'claude'].includes(command.provider) || typeof command.session !== 'string' ||
|
|
214
|
+
(!manualAdd && !c.status?.sessionSupervision?.sessions?.some(s => s?.provider === command.provider && s.session === command.session))) throw error('Select an available remote conversation.', 'remote_host_invalid');
|
|
215
|
+
const restoreHidden = manualAdd && c.status?.sessionSupervision?.sessions?.some(row =>
|
|
216
|
+
row.provider === command.provider && row.session === command.session && row.localHidden === true);
|
|
176
217
|
const id = randomUUID();
|
|
177
218
|
const { hostId, ...payload } = command;
|
|
178
|
-
|
|
219
|
+
const result = await new Promise((resolve, reject) => {
|
|
179
220
|
const timer = setTimeout(() => { c.pending.delete(id); reject(error('Remote operation timed out. Check its result in the original conversation before retrying.')); }, requestTimeoutMs);
|
|
180
221
|
c.pending.set(id, { id: command.id, resolve, reject, timer });
|
|
181
|
-
c.child.stdin.write(JSON.stringify({ ...payload, id }) + '\n', e => {
|
|
222
|
+
c.child.stdin.write(JSON.stringify({ ...payload, ...(manualAdd ? { action: 'session_monitor', monitor: true } : {}), id }) + '\n', e => {
|
|
182
223
|
if (e && c.pending.has(id)) { c.pending.delete(id); clearTimeout(timer); reject(error('Remote command could not be delivered.')); }
|
|
183
224
|
});
|
|
184
225
|
});
|
|
226
|
+
// Existing remote runtimes understand these two idempotent navigation
|
|
227
|
+
// operations. Monitoring alone does not clear a remote local archive.
|
|
228
|
+
// Re-enter the normal forwarding path to recheck scope and connectivity;
|
|
229
|
+
// never restore a provider archive or resume the conversation's task.
|
|
230
|
+
if (restoreHidden && result.ok === true) return forward({ ...command, action: 'session_restore', scope: 'local' }, status);
|
|
231
|
+
return result;
|
|
185
232
|
}
|
|
186
233
|
function project(status, { providerLinks = status.providerLinks } = {}) {
|
|
187
234
|
if (!usable(status) || scopeOf(status) !== scope) return status;
|
|
188
235
|
const hosts = [...connections.values()];
|
|
189
|
-
const sessions = [], runs = [];
|
|
236
|
+
const sessions = [], runs = [], selectionCandidates = [];
|
|
190
237
|
for (const c of hosts) {
|
|
191
238
|
const ready = c.ready && now() - c.receivedAt <= 45_000;
|
|
192
239
|
const unavailableReason = c.reason || 'Remote status is stale.';
|
|
240
|
+
if (ready) for (const row of c.status?.sessionSupervision?.selectionCandidates || c.status?.sessionSupervision?.sessions || []) {
|
|
241
|
+
if (row?.provider === 'codex' && typeof row.session === 'string') selectionCandidates.push({ provider: 'codex', session: row.session,
|
|
242
|
+
title: typeof row.title === 'string' ? row.title : '', hostId: c.id, hostLabel: c.label === c.alias ? c.label : `${c.label} (${c.alias})` });
|
|
243
|
+
}
|
|
193
244
|
for (const row of c.status?.sessionSupervision?.sessions || []) {
|
|
194
245
|
if (!row || !['codex', 'claude'].includes(row.provider) || typeof row.session !== 'string' ||
|
|
195
246
|
providerLinks?.[row.provider]?.connected === false) continue;
|
|
@@ -212,11 +263,11 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
212
263
|
}
|
|
213
264
|
if (ready) for (const run of c.status?.deliveryHooks?.runs || []) if (['codex', 'claude'].includes(run?.provider) && providerLinks?.[run.provider]?.connected !== false) runs.push({ ...stamp(run, c), originalSessionTarget: { kind: 'manual', url: null, verifiedOnPlatform: false, reason: `Select ${c.label} in ${run.provider === 'claude' ? 'Claude Code' : 'Codex'}, then find conversation ${run.session}.` } });
|
|
214
265
|
}
|
|
215
|
-
return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, state: c.state, reason: c.reason })) },
|
|
216
|
-
sessionSupervision: { ...status.sessionSupervision, sessions: [...(status.sessionSupervision?.sessions || []).filter(row => !row.hostId || row.hostId === 'local'), ...sessions].sort(compareSessionWork) },
|
|
266
|
+
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, reason: c.reason })) },
|
|
267
|
+
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) },
|
|
217
268
|
deliveryHooks: { ...status.deliveryHooks, runs: [...(status.deliveryHooks?.runs || []).filter(row => !row.hostId || row.hostId === 'local'), ...runs] } };
|
|
218
269
|
}
|
|
219
|
-
return { update, save, forward, project,
|
|
270
|
+
return { update, save, edit, setEnabled, forward, project,
|
|
220
271
|
hasSession: session => [...connections.values()].some(c => c.status?.sessionSupervision?.sessions?.some(s => ['codex', 'claude'].includes(s?.provider) && s.session === session)),
|
|
221
272
|
async close() { closed = true; for (const c of connections.values()) disconnect(c); connections.clear(); await writes.catch(() => {}); } };
|
|
222
273
|
}
|