@awak-app/simy-cli 0.5.41 → 0.5.43
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/package.json +2 -2
- package/src/agent.js +93 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +12 -1
- package/src/delivery-hooks/supervisor.js +5 -1
- package/src/desktop-host.js +40 -21
- package/src/session-store.js +1 -1
- package/src/session-supervision/automatic-continuation.js +81 -0
- package/src/session-supervision/index.js +54 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.43",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
|
|
43
43
|
"check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
|
|
44
44
|
"check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js",
|
|
45
|
-
"check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js",
|
|
45
|
+
"check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
|
|
46
46
|
"check:package": "node ./scripts/check-package-contents.js",
|
|
47
47
|
"build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
|
|
48
48
|
"build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
|
package/src/agent.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "./runner.js";
|
|
28
28
|
import { codingRunsRoot, loadCodingRunSnapshots, saveCodingRunSnapshot } from "./coding-run-store.js";
|
|
29
29
|
import {
|
|
30
|
+
defaultSessionRoot,
|
|
30
31
|
expiresAtFromNow,
|
|
31
32
|
isSessionValid,
|
|
32
33
|
readSession,
|
|
@@ -177,6 +178,9 @@ export async function startAgent({
|
|
|
177
178
|
install: (options) => installSqmAgentSkill({ home: dependencies.home, ...options }),
|
|
178
179
|
};
|
|
179
180
|
let session = await readSession(apiOrigin, sessionRoot);
|
|
181
|
+
// The last session the Web stopped accepting. A fresh sign-in may continue
|
|
182
|
+
// as its device (see /v1/auth/complete), so keep it after it is cleared.
|
|
183
|
+
let reclaimableSession = null;
|
|
180
184
|
if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
|
|
181
185
|
await cleanupExpiredRuns();
|
|
182
186
|
for (const task of await restoreLocalTasks({ root: taskStorageRoot })) {
|
|
@@ -215,6 +219,7 @@ export async function startAgent({
|
|
|
215
219
|
};
|
|
216
220
|
refreshAvailableCapabilities();
|
|
217
221
|
const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
|
|
222
|
+
const reclaimDevice = dependencies.reclaimDevice || reclaimLocalDevice;
|
|
218
223
|
const runOptions = dependencies.runOptions || {};
|
|
219
224
|
const repositoryScanRoot = resolve(
|
|
220
225
|
String(
|
|
@@ -258,6 +263,10 @@ export async function startAgent({
|
|
|
258
263
|
if (dependencies.codexWorkPatternSync) {
|
|
259
264
|
codexWorkPatternSync = dependencies.codexWorkPatternSync;
|
|
260
265
|
} else {
|
|
266
|
+
// Callers usually leave sessionRoot unset and rely on the session-store
|
|
267
|
+
// default (SIMY_HOME or ~/.simy); the sync needs that concrete root for
|
|
268
|
+
// its cursor, otherwise it reports not_connected forever.
|
|
269
|
+
const syncRoot = sessionRoot ?? defaultSessionRoot();
|
|
261
270
|
const homes = workPatternHistoryHomes({
|
|
262
271
|
codexHome: dependencies.codexHome,
|
|
263
272
|
claudeHome: dependencies.claudeHome,
|
|
@@ -265,10 +274,10 @@ export async function startAgent({
|
|
|
265
274
|
reportCodexWorkPatternSyncStatus(
|
|
266
275
|
`reading history roots CODEX_HOME=${homes.codexHome} `
|
|
267
276
|
+ `(sessions/, archived_sessions/) and CLAUDE_CONFIG_DIR=${homes.claudeHome} (projects/); `
|
|
268
|
-
+ `state under ${
|
|
277
|
+
+ `state under ${syncRoot}.`,
|
|
269
278
|
quiet,
|
|
270
279
|
);
|
|
271
|
-
codexWorkPatternSync = createCodexWorkPatternSync({ sessionRoot, ...homes });
|
|
280
|
+
codexWorkPatternSync = createCodexWorkPatternSync({ sessionRoot: syncRoot, ...homes });
|
|
272
281
|
}
|
|
273
282
|
}
|
|
274
283
|
let syncPromise;
|
|
@@ -559,6 +568,7 @@ export async function startAgent({
|
|
|
559
568
|
const state = connectionStatus.failure(error);
|
|
560
569
|
notifyDesktopStatusChange();
|
|
561
570
|
if (state === "unauthorized") {
|
|
571
|
+
if (session?.device_id && session.token) reclaimableSession = session;
|
|
562
572
|
session = null;
|
|
563
573
|
loginUrl = null;
|
|
564
574
|
refreshAvailableCapabilities();
|
|
@@ -933,6 +943,8 @@ export async function startAgent({
|
|
|
933
943
|
});
|
|
934
944
|
return;
|
|
935
945
|
}
|
|
946
|
+
const previousSession = session ?? reclaimableSession;
|
|
947
|
+
reclaimableSession = null;
|
|
936
948
|
session = {
|
|
937
949
|
token: String(body.token || ""),
|
|
938
950
|
device_id: typeof body.device_id === "string" ? body.device_id : null,
|
|
@@ -950,6 +962,37 @@ export async function startAgent({
|
|
|
950
962
|
: null,
|
|
951
963
|
expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
|
|
952
964
|
};
|
|
965
|
+
// A fresh sign-in issues a new device. Keep running as the device used
|
|
966
|
+
// before (its desktop WorkFlow evidence stays resumable) when this agent
|
|
967
|
+
// still holds that device's token. Otherwise continue as the new device.
|
|
968
|
+
if (
|
|
969
|
+
previousSession?.device_id &&
|
|
970
|
+
previousSession.token &&
|
|
971
|
+
session.device_id &&
|
|
972
|
+
previousSession.device_id !== session.device_id &&
|
|
973
|
+
previousSession.api_origin &&
|
|
974
|
+
previousApiBaseUrl(previousSession) === session.api_base_url
|
|
975
|
+
) {
|
|
976
|
+
try {
|
|
977
|
+
const reclaimed = await reclaimDevice({
|
|
978
|
+
apiOrigin,
|
|
979
|
+
apiBaseUrl: session.api_base_url,
|
|
980
|
+
token: session.token,
|
|
981
|
+
previousDeviceId: previousSession.device_id,
|
|
982
|
+
previousToken: previousSession.token,
|
|
983
|
+
});
|
|
984
|
+
if (reclaimed?.device_id === previousSession.device_id) {
|
|
985
|
+
session.device_id = reclaimed.device_id;
|
|
986
|
+
if (typeof reclaimed.expires_at === "string") session.expires_at = reclaimed.expires_at;
|
|
987
|
+
}
|
|
988
|
+
} catch (error) {
|
|
989
|
+
if (!quiet) {
|
|
990
|
+
console.error(
|
|
991
|
+
`SIMY could not continue as the previous device: ${error instanceof Error ? error.message : String(error)}`,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
953
996
|
await writeSession(apiOrigin, session, sessionRoot);
|
|
954
997
|
startHeartbeatTimer();
|
|
955
998
|
loginUrl = null;
|
|
@@ -2212,6 +2255,54 @@ export async function heartbeatLocalDevice({
|
|
|
2212
2255
|
}
|
|
2213
2256
|
}
|
|
2214
2257
|
|
|
2258
|
+
// The previous token is only ever presented to the Web API it was issued by.
|
|
2259
|
+
function previousApiBaseUrl(previousSession) {
|
|
2260
|
+
try {
|
|
2261
|
+
return resolveWebApiBaseUrl(previousSession.api_origin, previousSession.api_base_url);
|
|
2262
|
+
} catch {
|
|
2263
|
+
return null;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* Moves a fresh sign-in onto the device this agent used before. Returns the
|
|
2269
|
+
* receipt, or null when the Web refuses the reclaim (the fresh device stays).
|
|
2270
|
+
*/
|
|
2271
|
+
export async function reclaimLocalDevice({
|
|
2272
|
+
apiOrigin,
|
|
2273
|
+
apiBaseUrl,
|
|
2274
|
+
token,
|
|
2275
|
+
previousDeviceId,
|
|
2276
|
+
previousToken,
|
|
2277
|
+
}) {
|
|
2278
|
+
const response = await fetch(
|
|
2279
|
+
webApiUrl("devices/reclaim", { webOrigin: apiOrigin, apiBaseUrl }),
|
|
2280
|
+
{
|
|
2281
|
+
method: "POST",
|
|
2282
|
+
headers: webApiHeaders(
|
|
2283
|
+
{ token },
|
|
2284
|
+
{ "Content-Type": "application/json" },
|
|
2285
|
+
),
|
|
2286
|
+
body: JSON.stringify({
|
|
2287
|
+
previous_device_id: previousDeviceId,
|
|
2288
|
+
previous_token: previousToken,
|
|
2289
|
+
}),
|
|
2290
|
+
signal: AbortSignal.timeout(5_000),
|
|
2291
|
+
},
|
|
2292
|
+
);
|
|
2293
|
+
if (response.status === 409) return null;
|
|
2294
|
+
if (!response.ok) {
|
|
2295
|
+
const payload = await response.json().catch(() => null);
|
|
2296
|
+
const error = new Error(webApiErrorMessage(payload, response.status));
|
|
2297
|
+
error.status = response.status;
|
|
2298
|
+
throw error;
|
|
2299
|
+
}
|
|
2300
|
+
const payload = await response.json();
|
|
2301
|
+
return payload && typeof payload.device_id === "string"
|
|
2302
|
+
? { device_id: payload.device_id, expires_at: payload.expires_at }
|
|
2303
|
+
: null;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2215
2306
|
function reportHeartbeatError(error, quiet) {
|
|
2216
2307
|
if (quiet) return;
|
|
2217
2308
|
console.error(
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.43";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -340,10 +340,11 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
340
340
|
return managed;
|
|
341
341
|
}
|
|
342
342
|
export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, managed, admission,
|
|
343
|
-
liveHookAdmission, inspectProcess = processIdentity, now = Date.now() }) {
|
|
343
|
+
liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
|
|
344
344
|
cwd = await realpath(cwd);
|
|
345
345
|
if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
|
|
346
346
|
return updateRecord(root, provider, session, async previous => {
|
|
347
|
+
if (autoContinueEpoch && (previous?.autoContinue !== true || previous.autoContinueEpoch !== autoContinueEpoch)) throw new Error('Automatic continuation was disabled before enrollment.');
|
|
347
348
|
const admitted = admission?.source === 'codex_app_server' && admission.provider === provider && admission.session === session && admission.cwd === cwd && previous?.admission?.source === admission.source && previous.admission.session === session && previous.cwd === cwd;
|
|
348
349
|
if (liveHookAdmission) {
|
|
349
350
|
const process = previous?.process;
|
|
@@ -355,6 +356,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
355
356
|
if ((!previous?.observedAt && !admitted) || previous.cwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or enable SIMY hooks.');
|
|
356
357
|
if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
|
|
357
358
|
const managedState = managedSpec(managed, session, provider, previous.process, admitted ? admission : null, now);
|
|
359
|
+
if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
|
|
358
360
|
if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
|
|
359
361
|
identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
|
|
360
362
|
const waiting = managedState?.decision?.outcome === 'wait';
|
|
@@ -653,6 +655,12 @@ export function managedBudgetReason(run, now = Date.now()) {
|
|
|
653
655
|
if (managed && managed.noProgress >= managed.maxNoProgress) return 'No verified acceptance progress within the configured continuation limit.';
|
|
654
656
|
return null;
|
|
655
657
|
}
|
|
658
|
+
// Manual/legacy runs have no marker and retain their existing controls.
|
|
659
|
+
export function automaticContinuationAllowed(record) {
|
|
660
|
+
const epoch = record?.run?.managed?.autoContinueEpoch;
|
|
661
|
+
return !epoch || record.autoContinue === true && record.autoContinueEpoch === epoch;
|
|
662
|
+
}
|
|
663
|
+
|
|
656
664
|
export function decideStop(run, now = Date.now()) {
|
|
657
665
|
if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
|
|
658
666
|
const next = structuredClone(run);
|
|
@@ -826,6 +834,9 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
826
834
|
run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
|
|
827
835
|
run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
|
|
828
836
|
} else if (event === 'Stop') {
|
|
837
|
+
// OFF suppresses future Stop blocking; it never interrupts the provider
|
|
838
|
+
// turn. Lease settlement and runtime observation above still run.
|
|
839
|
+
if (!automaticContinuationAllowed(record)) return record;
|
|
829
840
|
// Codex turn IDs make re-delivered Stop events idempotent. Claude has no
|
|
830
841
|
// stable event ID; never dedupe on text (identical text can be a new turn).
|
|
831
842
|
const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
import { assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
|
+
import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
5
|
import { resolveBackendExecutable } from '../backend-executable.js';
|
|
6
6
|
import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
|
|
7
7
|
import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
|
|
@@ -25,6 +25,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
|
|
|
25
25
|
throw new Error('Managed recovery lease changed before provider dispatch.');
|
|
26
26
|
}
|
|
27
27
|
if (currentTime() >= current.run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
|
|
28
|
+
if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
|
|
28
29
|
if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed progress budget expired before provider dispatch.');
|
|
29
30
|
const managed = current.run.managed;
|
|
30
31
|
const approvedWait = managed.decision?.outcome === 'wait' && managed.approvedAction?.id === managed.decision.id && !managed.approvedAction.dispatchedAt;
|
|
@@ -143,6 +144,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
143
144
|
});
|
|
144
145
|
continue;
|
|
145
146
|
}
|
|
147
|
+
if (!automaticContinuationAllowed(record)) continue;
|
|
146
148
|
const appServer = run.managed.resumeChannel === 'codex_app_server';
|
|
147
149
|
const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
|
|
148
150
|
const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(run.managed.decision.action?.kind) &&
|
|
@@ -162,6 +164,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
162
164
|
let claimed;
|
|
163
165
|
try { claimed = await updateRecord(root, record.provider, record.session, current => {
|
|
164
166
|
const live = current?.run;
|
|
167
|
+
if (!automaticContinuationAllowed(current)) return current;
|
|
165
168
|
const appChannel = live?.managed?.resumeChannel === 'codex_app_server';
|
|
166
169
|
const allowedCause = live?.recoveryCause === 'process_exit' || appChannel && ['session_supervision', 'approved_action'].includes(live?.recoveryCause);
|
|
167
170
|
const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
|
|
@@ -248,6 +251,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
248
251
|
await updateRecord(root, record.provider, record.session, current => {
|
|
249
252
|
const live = current?.run;
|
|
250
253
|
if (live?.state !== 'auto_resuming' || live.managed?.resumeLease?.id !== leaseId) throw new Error('Managed recovery lease changed before provider dispatch.');
|
|
254
|
+
if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
|
|
251
255
|
if (appServer && !live.managed.providerSandbox) {
|
|
252
256
|
if (!providerSandbox || !['readOnly', 'workspaceWrite', 'dangerFullAccess', 'externalSandbox'].includes(providerSandbox.type)) throw new Error('Original Codex sandbox policy is unavailable.');
|
|
253
257
|
live.managed.providerSandbox = { type: providerSandbox.type,
|
package/src/desktop-host.js
CHANGED
|
@@ -2,9 +2,10 @@ import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
|
2
2
|
import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
3
3
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
4
4
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
5
|
-
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession } from "./session-supervision/index.js";
|
|
5
|
+
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
|
|
6
6
|
import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
|
|
7
7
|
import { readContinuationContext } from "./session-supervision/continuation-analysis.js";
|
|
8
|
+
import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
|
|
8
9
|
import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
|
|
9
10
|
import { spawn, execFile } from "node:child_process";
|
|
10
11
|
import { promisify } from "node:util";
|
|
@@ -46,7 +47,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
46
47
|
}
|
|
47
48
|
const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
|
|
48
49
|
const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
|
|
49
|
-
let agent, updater, analysisManager, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
|
|
50
|
+
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
|
|
50
51
|
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin });
|
|
51
52
|
let actionAssociations = makeActionAssociations();
|
|
52
53
|
const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
|
|
@@ -75,6 +76,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
75
76
|
if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
|
|
76
77
|
return candidate;
|
|
77
78
|
};
|
|
79
|
+
const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
|
|
80
|
+
getManager: () => analysisManager, selectCandidate: selectedSession,
|
|
81
|
+
readSetting: dependencies.readSessionAutoContinue,
|
|
82
|
+
readContext: dependencies.readContinuationContext || readContinuationContext,
|
|
83
|
+
enroll: dependencies.continueSession || continueSession,
|
|
84
|
+
onChange: () => { if (!closed && !relay) refreshSessions(true); },
|
|
85
|
+
});
|
|
78
86
|
let pluginSetup = { state: "unknown" };
|
|
79
87
|
const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
|
|
80
88
|
const refreshPlugins = async () => {
|
|
@@ -96,7 +104,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
96
104
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
97
105
|
await actionAssociations.decorate(status.deliveryHooks);
|
|
98
106
|
const discovery = refreshSessions();
|
|
99
|
-
|
|
107
|
+
automaticContinuation?.tick(discovery.sessions);
|
|
108
|
+
status.sessionSupervision = { ...discovery, sessions: discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate))) };
|
|
100
109
|
if (!closed && !relay) emit({ type: "status", status });
|
|
101
110
|
}).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
102
111
|
return publishing;
|
|
@@ -104,6 +113,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
104
113
|
const stopAgent = async () => {
|
|
105
114
|
clearInterval(timer);
|
|
106
115
|
await actionAssociations.close();
|
|
116
|
+
await automaticContinuation?.close();
|
|
107
117
|
await analysisManager?.close();
|
|
108
118
|
await managedSupervisor.close();
|
|
109
119
|
await stopWorkflows();
|
|
@@ -126,6 +136,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
126
136
|
|
|
127
137
|
try {
|
|
128
138
|
analysisManager = await makeAnalysisManager();
|
|
139
|
+
automaticContinuation = makeAutomaticContinuation();
|
|
129
140
|
if (!candidate) {
|
|
130
141
|
await (dependencies.assertNoLegacyDaemon || assertNoLegacyCliDaemon)(webOrigin);
|
|
131
142
|
}
|
|
@@ -148,6 +159,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
148
159
|
if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
|
|
149
160
|
// The candidate shares this cache. Stop inference before it starts so a
|
|
150
161
|
// signed handoff cannot create two analysis writers or duplicate calls.
|
|
162
|
+
await automaticContinuation.close();
|
|
151
163
|
await analysisManager.close();
|
|
152
164
|
// The candidate also shares association records and their sync outbox.
|
|
153
165
|
// Drain the old writer before starting its replacement.
|
|
@@ -159,7 +171,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
159
171
|
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
|
|
160
172
|
}); } catch (error) {
|
|
161
173
|
actionAssociations = makeActionAssociations();
|
|
162
|
-
analysisManager = await makeAnalysisManager(); refreshSessions(true); throw error;
|
|
174
|
+
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true); throw error;
|
|
163
175
|
}
|
|
164
176
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
165
177
|
let accepted = false;
|
|
@@ -204,7 +216,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
204
216
|
lines.close(); await stopUpdateCandidate(child);
|
|
205
217
|
if (!closed && !relay) {
|
|
206
218
|
actionAssociations = makeActionAssociations();
|
|
207
|
-
analysisManager = await makeAnalysisManager(); refreshSessions(true);
|
|
219
|
+
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true);
|
|
208
220
|
}
|
|
209
221
|
throw error;
|
|
210
222
|
}
|
|
@@ -290,6 +302,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
290
302
|
taskId: command.taskId, criterionId: command.criterionId });
|
|
291
303
|
} else if (["session_action_select", "session_action_unlink", "session_action_refresh"].includes(action)) {
|
|
292
304
|
await actionAssociations.correct(command);
|
|
305
|
+
} else if (action === "session_set_auto_continue") {
|
|
306
|
+
if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
|
|
307
|
+
if (command.enabled) {
|
|
308
|
+
const candidate = await selectedSession(command);
|
|
309
|
+
if (candidate.capabilities?.canAutoContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
310
|
+
}
|
|
311
|
+
await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
|
|
312
|
+
provider: command.provider, session: command.session, enabled: command.enabled });
|
|
313
|
+
if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
|
|
314
|
+
refreshSessions(true);
|
|
315
|
+
if (command.enabled) {
|
|
316
|
+
const fresh = await selectedSession(command);
|
|
317
|
+
if (fresh.capabilities?.canAnalyze === true) await analysisManager.request(fresh);
|
|
318
|
+
}
|
|
293
319
|
} else if (action === "session_track" || action === "session_untrack") {
|
|
294
320
|
const candidate = await selectedSession(command);
|
|
295
321
|
if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
|
|
@@ -307,25 +333,18 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
307
333
|
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
|
|
308
334
|
await refreshSessions(true);
|
|
309
335
|
} else if (action === "session_analyze") {
|
|
310
|
-
|
|
336
|
+
const candidate = await selectedSession(command);
|
|
337
|
+
await analysisManager.request(candidate);
|
|
338
|
+
if (candidate.autoContinue === true && candidate.capabilities?.canContinue === true) automaticContinuation.retry(candidate.provider, candidate.session);
|
|
311
339
|
} else if (action === "session_continue") {
|
|
312
340
|
const candidate = await selectedSession(command);
|
|
313
341
|
const manager = analysisManager;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
await manager.invalidate(candidate);
|
|
321
|
-
throw new Error('This conversation changed; wait for its updated analysis.');
|
|
322
|
-
}
|
|
323
|
-
// Reading provider context can overlap an update handoff. The old
|
|
324
|
-
// manager must still be active before task enrollment is dispatched.
|
|
325
|
-
manager.requireRecommendation(candidate);
|
|
326
|
-
await (dependencies.continueSession || continueSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
327
|
-
session: command.session, autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
|
|
328
|
-
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
|
|
342
|
+
await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
|
|
343
|
+
readContext: dependencies.readContinuationContext || readContinuationContext,
|
|
344
|
+
enroll: dependencies.continueSession || continueSession,
|
|
345
|
+
settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
|
|
346
|
+
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
|
|
347
|
+
maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens } });
|
|
329
348
|
await refreshSessions(true);
|
|
330
349
|
} else if (action === "session_answer") {
|
|
331
350
|
await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
package/src/session-store.js
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { continueSession, readSessionAutoContinue } from './index.js';
|
|
2
|
+
import { readContinuationContext } from './continuation-analysis.js';
|
|
3
|
+
|
|
4
|
+
// Shared by explicit Continue and automatic enrollment. Analysis alone never
|
|
5
|
+
// dispatches work: the exact current provider context is checked again here.
|
|
6
|
+
export async function continueAnalyzedSession({ candidate, manager, root, settings = {}, signal,
|
|
7
|
+
readContext = readContinuationContext, enroll = continueSession, beforeEnroll = () => {} }) {
|
|
8
|
+
const recommendation = manager.requireRecommendation(candidate);
|
|
9
|
+
if (candidate.isRunning || candidate.capabilities?.canContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
10
|
+
const context = await readContext(candidate, { signal });
|
|
11
|
+
if (context.awaitingTurn || context.incomplete || !recommendation.contextDigest || context.contextDigest !== recommendation.contextDigest) {
|
|
12
|
+
await manager.invalidate(candidate);
|
|
13
|
+
throw new Error('This conversation changed; wait for its updated analysis.');
|
|
14
|
+
}
|
|
15
|
+
manager.requireRecommendation(candidate);
|
|
16
|
+
await beforeEnroll();
|
|
17
|
+
if (signal?.aborted) throw new Error('Automatic continuation was cancelled before enrollment.');
|
|
18
|
+
return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// One pending enrollment per identity. A failed attempt is retried only after
|
|
22
|
+
// context/opt-in changes; an existing (including terminal) run is never reset.
|
|
23
|
+
export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
24
|
+
readSetting = readSessionAutoContinue, readContext = readContinuationContext, enroll = continueSession,
|
|
25
|
+
onChange = () => {} }) {
|
|
26
|
+
const pending = new Map(), attempted = new Map(), errors = new Map();
|
|
27
|
+
let closed = false;
|
|
28
|
+
const tick = candidates => {
|
|
29
|
+
if (closed) return;
|
|
30
|
+
for (const candidate of candidates) {
|
|
31
|
+
if (pending.size >= 3) break;
|
|
32
|
+
if (candidate.autoContinue !== true || !candidate.autoContinueEpoch || candidate.isRunning ||
|
|
33
|
+
candidate.capabilities?.canAutoContinue !== true || candidate.capabilities?.canContinue !== true) continue;
|
|
34
|
+
const key = `${candidate.provider}:${candidate.session}`;
|
|
35
|
+
if (pending.has(key)) continue;
|
|
36
|
+
let recommendation;
|
|
37
|
+
const manager = getManager();
|
|
38
|
+
try { recommendation = manager.requireRecommendation(candidate); } catch { continue; }
|
|
39
|
+
const attempt = `${candidate.autoContinueEpoch}:${recommendation.contextDigest}`;
|
|
40
|
+
if (attempted.get(key) === attempt) continue;
|
|
41
|
+
attempted.set(key, attempt);
|
|
42
|
+
errors.delete(key);
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const guard = async () => {
|
|
45
|
+
const setting = await readSetting({ root, provider: candidate.provider, session: candidate.session });
|
|
46
|
+
if (closed || controller.signal.aborted || !setting.enabled || setting.epoch !== candidate.autoContinueEpoch || setting.hasRun) throw new Error('Automatic continuation was disabled or already enrolled.');
|
|
47
|
+
return setting;
|
|
48
|
+
};
|
|
49
|
+
const job = Promise.resolve().then(async () => {
|
|
50
|
+
const setting = await guard();
|
|
51
|
+
const fresh = await selectCandidate(candidate);
|
|
52
|
+
if (!fresh.autoContinue || fresh.autoContinueEpoch !== candidate.autoContinueEpoch) throw new Error('Automatic continuation setting changed.');
|
|
53
|
+
await continueAnalyzedSession({ root, candidate: fresh, manager, signal: controller.signal,
|
|
54
|
+
readContext, enroll, beforeEnroll: guard,
|
|
55
|
+
settings: { ...setting.settings, autoContinueEpoch: candidate.autoContinueEpoch } });
|
|
56
|
+
}).catch(error => {
|
|
57
|
+
if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
|
|
58
|
+
errors.set(key, { epoch: candidate.autoContinueEpoch, reason: 'Could not start auto-continue. Check the original conversation and retry.' });
|
|
59
|
+
}
|
|
60
|
+
}).finally(() => { pending.delete(key); if (!closed) onChange(); });
|
|
61
|
+
pending.set(key, { job, controller });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return { tick,
|
|
65
|
+
retry(provider, session) {
|
|
66
|
+
const key = `${provider}:${session}`;
|
|
67
|
+
if (!closed && !pending.has(key)) { attempted.delete(key); errors.delete(key); }
|
|
68
|
+
},
|
|
69
|
+
project(candidate) {
|
|
70
|
+
const error = errors.get(`${candidate.provider}:${candidate.session}`);
|
|
71
|
+
if (candidate.autoContinue === true && candidate.autoContinueEpoch === error?.epoch && candidate.capabilities?.canContinue === true &&
|
|
72
|
+
!['no_action', 'needs_user', 'waiting', 'uncertain'].includes(candidate.analysis?.state)) return {
|
|
73
|
+
...candidate, autoContinueStatus: 'error', autoContinueReason: error.reason,
|
|
74
|
+
};
|
|
75
|
+
return candidate;
|
|
76
|
+
},
|
|
77
|
+
revoke(provider, session) { pending.get(`${provider}:${session}`)?.controller.abort(); },
|
|
78
|
+
async whenIdle() { await Promise.allSettled([...pending.values()].map(entry => entry.job)); },
|
|
79
|
+
async close() { closed = true; for (const entry of pending.values()) entry.controller.abort(); await Promise.allSettled([...pending.values()].map(entry => entry.job)); },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
import { realpath } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { TERMINAL as TERMINAL_RUNS, controlDelivery, listRecords, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
4
|
import { listCodexSessions, openCodexAppServer, readCodexSession } from './codex-app-server.js';
|
|
4
5
|
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
5
6
|
import { originalSessionTarget } from './original-session-target.js';
|
|
6
7
|
|
|
8
|
+
function autoContinueState(record, isRunning, supported) {
|
|
9
|
+
if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
|
|
10
|
+
const state = record.run?.state;
|
|
11
|
+
if (state === 'budget_exhausted') return { autoContinueStatus: 'limit', autoContinueReason: 'This conversation reached its continuation limit. Its budgets cannot be reset.' };
|
|
12
|
+
if (['completed', 'reported_done'].includes(state)) return { autoContinueStatus: 'finished', autoContinueReason: 'Bounded continuation finished for this conversation.' };
|
|
13
|
+
const managed = record.run?.managed;
|
|
14
|
+
const recoverableCause = record.run?.recoveryCause === 'process_exit' || managed?.resumeChannel === 'codex_app_server' && ['session_supervision', 'approved_action'].includes(record.run?.recoveryCause);
|
|
15
|
+
if (state === 'recovery_required' && (!managed?.autoRecovery || !recoverableCause || managed.pendingApproval)) return {
|
|
16
|
+
autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention before more work can continue.',
|
|
17
|
+
};
|
|
18
|
+
if (['paused', 'blocked', 'stopped', 'failed', 'verification_pending'].includes(state)) return { autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention before more work can continue.' };
|
|
19
|
+
if (!supported) return { autoContinueStatus: 'unavailable', autoContinueReason: 'The original provider continuation channel is unavailable.' };
|
|
20
|
+
return { autoContinueStatus: record.run ? 'supervising' : isRunning ? 'waiting' : 'watching',
|
|
21
|
+
autoContinueReason: record.run ? 'Continuing within the existing task budget.' : isRunning ? 'Waiting for the original turn to finish.' : 'Checking settled requests for worthwhile remaining work.' };
|
|
22
|
+
}
|
|
23
|
+
|
|
7
24
|
function initialDiagnosisPlan(objective, contextBootstrap = false) {
|
|
8
25
|
const action = { kind: 'read', scope: 'task', summary: contextBootstrap ? 'Understand the original user request and current progress without changing files' : 'Inspect the selected conversation and acceptance evidence without changing files' };
|
|
9
26
|
return {
|
|
@@ -36,7 +53,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
36
53
|
const records = await listRecords(root);
|
|
37
54
|
let discovered = [], codexError = null, claude = [], claudeError = null;
|
|
38
55
|
try { discovered = await withClient(openClient, client => listCodexSessions({ client, inspectState: true,
|
|
39
|
-
prioritySessions: records.filter(record => record.provider === 'codex' && (record.monitor || record.tracked || record.run)).map(record => record.session) })); }
|
|
56
|
+
prioritySessions: records.filter(record => record.provider === 'codex' && (record.autoContinue || record.monitor || record.tracked || record.run)).map(record => record.session) })); }
|
|
40
57
|
catch (error) { codexError = error?.code === 'unavailable' ? 'Codex executable unavailable.' : error?.code === 'unsupported' ? 'This Codex version lacks a verified app-server writer lock; update before enrollment.' : 'Codex conversation discovery unavailable.'; }
|
|
41
58
|
try { claude = await listClaude(); } catch { claudeError = 'Claude conversation discovery unavailable.'; }
|
|
42
59
|
const byKey = new Map();
|
|
@@ -70,13 +87,17 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
70
87
|
...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
|
|
71
88
|
...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
|
|
72
89
|
tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
|
|
90
|
+
autoContinue: record?.autoContinue === true,
|
|
91
|
+
autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
|
|
92
|
+
...autoContinueState(record, isRunning, supported || liveHook),
|
|
73
93
|
lastActivityAt: item.lastActivityAt, observation: item.observation, availability: item.availability,
|
|
74
94
|
binding: { mode, taskId: run?.managed?.taskId || null },
|
|
75
95
|
originalSessionTarget: originalSessionTarget(item.provider, item.session),
|
|
76
96
|
continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
|
|
77
97
|
capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
|
|
78
98
|
canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
|
|
79
|
-
canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
|
|
99
|
+
canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
|
|
100
|
+
canAutoContinue: (supported || liveHook) && (!run || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
|
|
80
101
|
reason: item.provider === 'claude' ? liveHook ? 'Live SIMY hook can continue this Claude session at its next Stop boundary; cold CLI takeover is unavailable.' :
|
|
81
102
|
process.platform === 'win32' ? 'Claude live hook enrollment is unavailable on Windows because provider process identity cannot be verified; monitoring remains available.' :
|
|
82
103
|
'Claude enrollment needs a live matching SIMY SessionStart hook and provider process; cold CLI takeover is unavailable.' :
|
|
@@ -85,11 +106,37 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
85
106
|
}))).sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
86
107
|
// Keep existing bindings visible during a truncated discovery or provider
|
|
87
108
|
// outage, even when newer unbound conversations fill the candidate window.
|
|
88
|
-
const bound = projected.filter(item => item.binding.mode !== 'unmanaged');
|
|
89
|
-
const sessions = [...bound, ...projected.filter(item => item.binding.mode === 'unmanaged').slice(0, Math.max(0, 200 - bound.length))];
|
|
109
|
+
const bound = projected.filter(item => item.autoContinue || item.binding.mode !== 'unmanaged');
|
|
110
|
+
const sessions = [...bound, ...projected.filter(item => !item.autoContinue && item.binding.mode === 'unmanaged').slice(0, Math.max(0, 200 - bound.length))];
|
|
90
111
|
return { sessions, providers: { codex: { discovery: codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
91
112
|
}
|
|
92
113
|
|
|
114
|
+
// Explicit execution authority: legacy monitor/tracked records stay read-only.
|
|
115
|
+
export async function setSessionAutoContinue({ root, provider, session, enabled, ...options }) {
|
|
116
|
+
if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
|
|
117
|
+
if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
|
|
118
|
+
return updateRecord(root, provider, session, async previous => {
|
|
119
|
+
if (!previous) throw new Error('Refresh this conversation before changing automatic continuation.');
|
|
120
|
+
if (enabled && previous.run && !previous.run.managed) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
121
|
+
if (enabled && TERMINAL_RUNS.has(previous.run?.state)) throw new Error('Bounded continuation finished or reached its limit; budgets cannot be reset.');
|
|
122
|
+
if (enabled && provider === 'codex' && !previous.admission?.model) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
123
|
+
if (enabled && provider === 'claude' && !await liveClaudeHook(previous,
|
|
124
|
+
{ session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
|
|
125
|
+
const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
|
|
126
|
+
const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
|
|
127
|
+
...(enabled ? { tracked: true, monitor: true, autoContinueSettings: { autonomyPolicy: 'balanced', maxMinutes: 30, maxContinuations: 6, maxFailures: 3 } } : { tracked: false, monitor: false }) };
|
|
128
|
+
// Adopt existing supervision without restarting it or renewing its budgets.
|
|
129
|
+
if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
|
|
130
|
+
return next;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function readSessionAutoContinue({ root, provider, session }) {
|
|
135
|
+
const record = (await listRecords(root)).find(item => item.provider === provider && item.session === session);
|
|
136
|
+
return { enabled: record?.autoContinue === true, epoch: record?.autoContinueEpoch || null,
|
|
137
|
+
settings: record?.autoContinueSettings, hasRun: Boolean(record?.run) };
|
|
138
|
+
}
|
|
139
|
+
|
|
93
140
|
// Tracking is local observation metadata. It neither starts nor stops the
|
|
94
141
|
// original provider task and never grants automatic continuation authority.
|
|
95
142
|
export async function trackSession({ root, provider, session, tracked = true, openClient = openCodexAppServer, readClaude = readClaudeSession, now = Date.now() }) {
|
|
@@ -133,7 +180,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
|
|
|
133
180
|
}
|
|
134
181
|
|
|
135
182
|
export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
|
|
136
|
-
maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, openClient = openCodexAppServer,
|
|
183
|
+
maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, autoContinueEpoch, openClient = openCodexAppServer,
|
|
137
184
|
readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
|
|
138
185
|
if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
|
|
139
186
|
if (maxTokens !== undefined && provider !== 'codex') throw new Error('Token-budgeted supervision requires host-observed Codex usage; Claude live hooks cannot enforce this budget.');
|
|
@@ -159,7 +206,7 @@ export async function enrollSession({ root, provider, session, objective, accept
|
|
|
159
206
|
const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
|
|
160
207
|
const liveRecord = provider === 'claude' ? (await listRecords(root)).find(item => item.provider === provider && item.session === session) : null;
|
|
161
208
|
const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
162
|
-
maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess,
|
|
209
|
+
maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess, autoContinueEpoch,
|
|
163
210
|
liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
|
|
164
211
|
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, maxTokens, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
165
212
|
initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),
|