@awak-app/simy-cli 0.6.11 → 0.6.13
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 +52 -0
- package/package.json +2 -2
- package/src/agent.js +76 -43
- package/src/cli-contract.js +1 -1
- package/src/coding-run-recovery.js +5 -0
- package/src/connection-diagnostics.js +77 -0
- package/src/connection-discovery.js +161 -0
- package/src/desktop-executor.js +24 -10
- package/src/index.js +21 -0
- package/src/launch-authorization.js +80 -0
- package/src/orchestrator/audit.js +2 -2
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/independent-audit.js +11 -3
- package/src/orchestrator/instruction.js +3 -3
- package/src/orchestrator/loop.js +2 -1
- package/src/orchestrator/problem-solving.js +7 -6
- package/src/orchestrator/result.js +104 -8
- package/src/provider-model-selection.js +24 -0
- package/src/runner.js +16 -30
- package/src/sqm/session.js +9 -2
package/README.md
CHANGED
|
@@ -816,3 +816,55 @@ Only summary metadata is retained, without analysis prompts, source inputs,
|
|
|
816
816
|
model responses or credentials. Historical summaries are not a replacement for
|
|
817
817
|
signed proof files or delivery acceptance. Records from versions that kept
|
|
818
818
|
analysis only in memory cannot be recovered after that process exits.
|
|
819
|
+
|
|
820
|
+
The CLI Agentic Loop uses [proportionate retry and review decisions](docs/stop-prevention/proportionate-continuation.md): reasoning notes are advisory, and an independent reviewer may explicitly defer or dismiss minor/warning/info findings with a nonempty explanation while retaining them in the receipt. Required acceptance evidence and major safety findings remain protected.
|
|
821
|
+
|
|
822
|
+
### Local coding attempt result collection
|
|
823
|
+
|
|
824
|
+
For `executeProcessAttempt`, native provider JSONL is read only from a successfully finished turn's final
|
|
825
|
+
assistant/result channel. Tool output and intermediate messages are never a
|
|
826
|
+
fallback result. Legacy line-anchored markers still work and may contain formatted
|
|
827
|
+
JSON; missing, ambiguous, truncated, invalid, oversized and failed-turn results
|
|
828
|
+
have distinct `result_parse.code` values. Invalid collection cannot become a
|
|
829
|
+
successful empty result. Plain-text markers are supported only for explicit
|
|
830
|
+
shell command overrides. This does not independently verify executor claims.
|
|
831
|
+
The parser can re-read a saved raw result without rerunning the provider.
|
|
832
|
+
Local Task and direct-task result parsers are separate boundaries and are not
|
|
833
|
+
covered by this coding-attempt guarantee.
|
|
834
|
+
|
|
835
|
+
### Explicit desktop provider selection
|
|
836
|
+
|
|
837
|
+
Local coding requests may specify `model` / `reasoning_effort` and independently
|
|
838
|
+
`audit_model` / `audit_reasoning_effort`. Both selections survive recovery.
|
|
839
|
+
Codex receives the exact model and reasoning config; the CLI does not use a
|
|
840
|
+
catalog to reject unknown model names or silently replace a rejected model.
|
|
841
|
+
Claude receives the exact model; explicit reasoning is unsupported by this
|
|
842
|
+
adapter and fails before dispatch. A custom shell override cannot enforce an
|
|
843
|
+
explicit selection and is rejected instead of dropping it. Provider acceptance,
|
|
844
|
+
authentication and actual availability still require the provider's response.
|
|
845
|
+
This changes command construction only; it does not start any workflow.
|
|
846
|
+
|
|
847
|
+
### Reusing a running service and launch recovery
|
|
848
|
+
|
|
849
|
+
`simy` and `simy --daemon` first inspect the private discovery record and verify
|
|
850
|
+
its live PID, instance, Web origin, CLI/API version and saved account,
|
|
851
|
+
organization and device. A compatible background service is reused: the entry
|
|
852
|
+
prints its existing address and opens SIMY Web (or its sign-in page). The local
|
|
853
|
+
interactive console is only created when this entry owns a new service.
|
|
854
|
+
SQM authorization also reuses that service and leaves it running when SQM exits.
|
|
855
|
+
Unreachable or incompatible live services produce diagnostics instead of a
|
|
856
|
+
second daemon. A stale record with a dead PID permits normal startup.
|
|
857
|
+
|
|
858
|
+
Web-led launches can omit their temporary challenge. The daemon requests a
|
|
859
|
+
credential for the existing queued run using its authenticated device. An
|
|
860
|
+
explicitly expired challenge can be renewed once; consumed, invalid or
|
|
861
|
+
cross-scope credentials cannot. Refresh the original run/device before retrying
|
|
862
|
+
a consumed credential because execution may have started before its ledger
|
|
863
|
+
status was uploaded. This recovery requires the companion Web
|
|
864
|
+
`/api/local-cli/challenges/issue` endpoint; existing valid challenges continue
|
|
865
|
+
to work against older Web versions.
|
|
866
|
+
|
|
867
|
+
Scope mismatch diagnostics identify the changed account, organization, device
|
|
868
|
+
or Web host and tell the caller to restore the original identity. Launch and
|
|
869
|
+
recovery operations do not switch identity automatically. The private
|
|
870
|
+
`/v1/connection` endpoint rejects browser requests and never returns tokens.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.13",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
|
|
44
44
|
"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/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
|
|
45
45
|
"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",
|
|
46
|
-
"check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && 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/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && 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/login-shell-path.js && node --check ./src/hook-runtime.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 && node --check ./src/session-supervision/stop-notifications.js",
|
|
46
|
+
"check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && 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/connection-diagnostics.js && node --check ./src/launch-authorization.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && 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/login-shell-path.js && node --check ./src/hook-runtime.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 && node --check ./src/session-supervision/stop-notifications.js",
|
|
47
47
|
"check:package": "node ./scripts/check-package-contents.js",
|
|
48
48
|
"build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
|
|
49
49
|
"build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
|
package/src/agent.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { normalizeProviderModelSelection } from "./provider-model-selection.js";
|
|
2
|
+
import { assertConnectionScope, connectionScope, connectionDiagnostic } from "./connection-diagnostics.js";
|
|
3
|
+
import { authorizeLaunch } from "./launch-authorization.js";
|
|
1
4
|
import { normalizePurposeVerification, assertPurposeActor, preparePurposeVerification, verifyPurposeLocalTask, purposeSigningKeyPath, purposeVerificationCapability, purposeWorkspaceBusy } from "./purpose-local-proof.js";
|
|
2
5
|
import { startupRequirementChecks, summarizeStartupChecks } from "./startup-preflight.js";
|
|
3
6
|
import { BROWSER_HANDOFF_TRANSPORT, browserHandoffPage, decryptBrowserHandoff } from "./browser-handoff.js";
|
|
@@ -540,7 +543,7 @@ export async function startAgent({
|
|
|
540
543
|
})();
|
|
541
544
|
return organizationRefresh;
|
|
542
545
|
};
|
|
543
|
-
const requestDesktopLogin = (organizationId = null, { passive = false } = {}) => {
|
|
546
|
+
const requestDesktopLogin = (organizationId = null, { passive = false, reauthorize = false } = {}) => {
|
|
544
547
|
const authorization = authorizationWindow.snapshot();
|
|
545
548
|
if (authorization.state === "timed_out") {
|
|
546
549
|
if (passive) return null;
|
|
@@ -568,7 +571,7 @@ export async function startAgent({
|
|
|
568
571
|
refreshAvailableCapabilities();
|
|
569
572
|
notifyDesktopStatusChange();
|
|
570
573
|
} else if (isSessionValid(session, Date.now(), apiOrigin)) {
|
|
571
|
-
if (!enforceDesktopAccess || desktopAccess.snapshot().state !== 'signed_out') return null;
|
|
574
|
+
if (!reauthorize && (!enforceDesktopAccess || desktopAccess.snapshot().state !== 'signed_out')) return null;
|
|
572
575
|
if (!passive || !loginUrl) {
|
|
573
576
|
authNonce = randomBytes(16).toString("base64url");
|
|
574
577
|
loginUrl = null;
|
|
@@ -1027,14 +1030,17 @@ export async function startAgent({
|
|
|
1027
1030
|
);
|
|
1028
1031
|
};
|
|
1029
1032
|
|
|
1033
|
+
let connectionDiscovery = null;
|
|
1034
|
+
const launchingRuns = new Set();
|
|
1030
1035
|
const server = createServer(async (req, res) => {
|
|
1036
|
+
let reservedLaunch = null;
|
|
1031
1037
|
try {
|
|
1032
1038
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
1033
1039
|
const localHandoff = url.pathname === "/v1/auth/browser-complete";
|
|
1034
1040
|
const localOrigin = `http://127.0.0.1:${port}`;
|
|
1035
1041
|
if (localHandoff ? (req.headers.origin !== localOrigin || req.headers.host !== `127.0.0.1:${port}`)
|
|
1036
1042
|
: !isRequestOriginAllowed(req.headers.origin, apiOrigin)) {
|
|
1037
|
-
json(res, 403,
|
|
1043
|
+
json(res, 403, connectionDiagnostic("connection_scope_changed", "web_origin"));
|
|
1038
1044
|
return;
|
|
1039
1045
|
}
|
|
1040
1046
|
applyCors(req, res, apiOrigin);
|
|
@@ -1050,7 +1056,7 @@ export async function startAgent({
|
|
|
1050
1056
|
return;
|
|
1051
1057
|
}
|
|
1052
1058
|
const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
|
|
1053
|
-
const desktopRecoveryRoute = ['/v1/health', '/v1/capabilities', '/v1/auth/complete', '/v1/auth/browser-complete'].includes(url.pathname) ||
|
|
1059
|
+
const desktopRecoveryRoute = ['/v1/health', '/v1/connection', '/v1/capabilities', '/v1/auth/complete', '/v1/auth/browser-complete'].includes(url.pathname) ||
|
|
1054
1060
|
req.method === 'POST' && (/^\/v1\/local-tasks\/[^/]+\/control$/.test(url.pathname) || /^\/v1\/agentic-loop\/[^/]+\/control$/.test(agenticLoopPath));
|
|
1055
1061
|
let requestAccessScope;
|
|
1056
1062
|
if (enforceDesktopAccess && !desktopRecoveryRoute) {
|
|
@@ -1075,6 +1081,28 @@ export async function startAgent({
|
|
|
1075
1081
|
res.once("finish", releaseUpdateWork);
|
|
1076
1082
|
res.once("close", releaseUpdateWork);
|
|
1077
1083
|
}
|
|
1084
|
+
if (["GET", "POST"].includes(req.method) && url.pathname === "/v1/connection") {
|
|
1085
|
+
// Private local entry points only. Browser origins must not read account
|
|
1086
|
+
// bindings, instance identifiers or the pending authorization nonce.
|
|
1087
|
+
if (req.headers.origin !== undefined || req.headers.host !== `127.0.0.1:${port}`) {
|
|
1088
|
+
json(res, 403, { error: "Local connection discovery only.", code: "local_connection_only" }); return;
|
|
1089
|
+
}
|
|
1090
|
+
let authorizationUrl;
|
|
1091
|
+
if (req.method === "POST") {
|
|
1092
|
+
const body = await readJson(req);
|
|
1093
|
+
if (!connectionDiscovery?.instanceId || body.instance_id !== connectionDiscovery.instanceId) {
|
|
1094
|
+
json(res, 409, connectionDiagnostic("connection_service_mismatch")); return;
|
|
1095
|
+
}
|
|
1096
|
+
try { assertConnectionScope(body.scope, connectionScope(session || reclaimableSession, apiOrigin)); }
|
|
1097
|
+
catch (error) { json(res, 409, { error: error.message, code: error.code, field: error.field, recovery: error.recovery }); return; }
|
|
1098
|
+
authorizationUrl = requestDesktopLogin(null, { reauthorize: body.reauthorize === true });
|
|
1099
|
+
} else authorizationUrl = requestDesktopLogin(null, { passive: true });
|
|
1100
|
+
json(res, 200, { ok: true, cli_version: CLI_VERSION, api_contract_version: CLI_API_CONTRACT_VERSION,
|
|
1101
|
+
daemon, web_origin: apiOrigin, instance_id: connectionDiscovery?.instanceId || null, pid: process.pid,
|
|
1102
|
+
scope: connectionScope(session || reclaimableSession, apiOrigin), session_valid: isSessionValid(session, Date.now(), apiOrigin),
|
|
1103
|
+
login_url: authorizationUrl });
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1078
1106
|
if (req.method === "GET" && url.pathname === "/v1/health") {
|
|
1079
1107
|
const sessionValid = isSessionValid(session, Date.now(), apiOrigin);
|
|
1080
1108
|
const connection = connectionStatus.snapshot({ authenticated: sessionValid });
|
|
@@ -1971,6 +1999,13 @@ export async function startAgent({
|
|
|
1971
1999
|
return;
|
|
1972
2000
|
}
|
|
1973
2001
|
const { body, attachments } = await readCodingLoopStart(req);
|
|
2002
|
+
let providerSelection;
|
|
2003
|
+
try {
|
|
2004
|
+
providerSelection = normalizeProviderModelSelection(body);
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
json(res, 400, { error: error.message, code: "invalid_provider_selection" });
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
1974
2009
|
const guardrailSource = String(body.source_message || "").trim();
|
|
1975
2010
|
if (guardrailSource) {
|
|
1976
2011
|
const guardrail = classifyExecutionRequirement(guardrailSource);
|
|
@@ -1987,25 +2022,23 @@ export async function startAgent({
|
|
|
1987
2022
|
return;
|
|
1988
2023
|
}
|
|
1989
2024
|
}
|
|
1990
|
-
const verified = await verifyLaunchChallenge({
|
|
1991
|
-
apiOrigin,
|
|
1992
|
-
apiBaseUrl: session.api_base_url,
|
|
1993
|
-
token: session.token,
|
|
1994
|
-
runId: String(body.run_id || ""),
|
|
1995
|
-
challenge: String(body.challenge || ""),
|
|
1996
|
-
});
|
|
1997
|
-
if (!verified.ok) {
|
|
1998
|
-
json(res, 403, { error: verified.error || "launch challenge rejected" });
|
|
1999
|
-
return;
|
|
2000
|
-
}
|
|
2001
2025
|
const runId = String(body.run_id || "");
|
|
2002
|
-
if (!runId) {
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
}
|
|
2026
|
+
if (!runId) { json(res, 400, { error: "run_id is required" }); return; }
|
|
2027
|
+
const launchSession = session;
|
|
2028
|
+
try { assertConnectionScope(body.connection_scope, connectionScope(launchSession, apiOrigin)); }
|
|
2029
|
+
catch (error) { json(res, 409, { error: error.message, code: error.code, field: error.field, recovery: error.recovery }); return; }
|
|
2030
|
+
if (launchingRuns.has(runId)) { json(res, 409, { error: "This launch is still being checked. Refresh its status before retrying.", code: "launch_in_progress" }); return; }
|
|
2031
|
+
launchingRuns.add(runId);
|
|
2032
|
+
reservedLaunch = runId;
|
|
2033
|
+
const verified = await authorizeLaunch({ apiOrigin, apiBaseUrl: launchSession.api_base_url,
|
|
2034
|
+
token: launchSession.token, runId, challenge: String(body.challenge || ""),
|
|
2035
|
+
fetchImpl: dependencies.fetch || globalThis.fetch });
|
|
2036
|
+
if (session !== launchSession || !isSessionValid(session, Date.now(), apiOrigin)) {
|
|
2037
|
+
json(res, 409, changedLaunchSession(launchSession, session, apiOrigin)); return;
|
|
2038
|
+
}
|
|
2039
|
+
if (!verified.ok) { json(res, verified.status, connectionDiagnostic(verified.code)); return; }
|
|
2006
2040
|
if (registry.has(runId)) {
|
|
2007
|
-
json(res, 409, { error: "agentic loop run already exists" });
|
|
2008
|
-
return;
|
|
2041
|
+
json(res, 409, { error: "agentic loop run already exists" }); return;
|
|
2009
2042
|
}
|
|
2010
2043
|
let executionTarget;
|
|
2011
2044
|
try {
|
|
@@ -2069,9 +2102,14 @@ export async function startAgent({
|
|
|
2069
2102
|
});
|
|
2070
2103
|
return;
|
|
2071
2104
|
}
|
|
2105
|
+
if (session !== launchSession || !isSessionValid(session, Date.now(), apiOrigin)) {
|
|
2106
|
+
await cleanupRunAttachments(stagedAttachments);
|
|
2107
|
+
json(res, 409, changedLaunchSession(launchSession, session, apiOrigin)); return;
|
|
2108
|
+
}
|
|
2072
2109
|
const run = createRun({
|
|
2073
2110
|
runId,
|
|
2074
2111
|
request: {
|
|
2112
|
+
...providerSelection,
|
|
2075
2113
|
backend: body.backend === "claude" ? "claude" : "codex",
|
|
2076
2114
|
execution_target: executionTarget,
|
|
2077
2115
|
execution_device_id: session?.device_id || null,
|
|
@@ -2136,6 +2174,9 @@ export async function startAgent({
|
|
|
2136
2174
|
throw error;
|
|
2137
2175
|
}
|
|
2138
2176
|
await authorizeDesktopWork(requestAccessScope);
|
|
2177
|
+
if (session !== launchSession || !isSessionValid(session, Date.now(), apiOrigin)) {
|
|
2178
|
+
json(res, 409, changedLaunchSession(launchSession, session, apiOrigin)); return;
|
|
2179
|
+
}
|
|
2139
2180
|
void startLocalCodingRun(run, dependencies.runOptions);
|
|
2140
2181
|
json(res, 202, { ok: true, run_id: run.id });
|
|
2141
2182
|
return;
|
|
@@ -2216,7 +2257,7 @@ export async function startAgent({
|
|
|
2216
2257
|
json(res, 200, { recovery, run_id: run.id, resumed: false });
|
|
2217
2258
|
}
|
|
2218
2259
|
} catch (error) {
|
|
2219
|
-
json(res, 409, { error: error instanceof Error ? error.message : "Recovery could not be verified.", code: error.code || "recovery_rejected" });
|
|
2260
|
+
json(res, 409, { error: error instanceof Error ? error.message : "Recovery could not be verified.", code: error.code || "recovery_rejected", ...(error.field ? { field: error.field, recovery: error.recovery } : {}) });
|
|
2220
2261
|
}
|
|
2221
2262
|
return;
|
|
2222
2263
|
}
|
|
@@ -2449,13 +2490,16 @@ export async function startAgent({
|
|
|
2449
2490
|
}
|
|
2450
2491
|
if (String(err?.code || "").startsWith("purpose_verification_")) { json(res, err.status || 409, { error: err.message, code: err.code }); return; }
|
|
2451
2492
|
json(res, 500, { error: err instanceof Error ? err.message : "internal error" });
|
|
2493
|
+
} finally {
|
|
2494
|
+
// An aborted HTTP response must not leave a permanent launch reservation.
|
|
2495
|
+
// Keep the reservation until all asynchronous preparation has settled.
|
|
2496
|
+
if (reservedLaunch) launchingRuns.delete(reservedLaunch);
|
|
2452
2497
|
}
|
|
2453
2498
|
});
|
|
2454
2499
|
|
|
2455
2500
|
await new Promise((resolve) => server.listen(requestedPort, "127.0.0.1", resolve));
|
|
2456
2501
|
const address = server.address();
|
|
2457
2502
|
port = typeof address === "object" && address ? address.port : requestedPort;
|
|
2458
|
-
let connectionDiscovery = null;
|
|
2459
2503
|
try {
|
|
2460
2504
|
connectionDiscovery = await (
|
|
2461
2505
|
dependencies.publishConnectionDiscovery || publishConnectionDiscovery
|
|
@@ -2717,6 +2761,7 @@ export async function startAgent({
|
|
|
2717
2761
|
scanRepositories,
|
|
2718
2762
|
selectRepository,
|
|
2719
2763
|
create: (input) => withUpdateWork(updateManager, async () => {
|
|
2764
|
+
const providerSelection = normalizeProviderModelSelection(input);
|
|
2720
2765
|
const scope = await authorizeDesktopWork();
|
|
2721
2766
|
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
2722
2767
|
throw new Error("Sign in to SIMY before starting a coding task.");
|
|
@@ -2732,6 +2777,8 @@ export async function startAgent({
|
|
|
2732
2777
|
}
|
|
2733
2778
|
const indexedRepository = await ensureRepositoryIndexed(repository);
|
|
2734
2779
|
const request = {
|
|
2780
|
+
...providerSelection,
|
|
2781
|
+
audit_backend: input.audit_backend === "claude" || input.audit_backend === "codex" ? input.audit_backend : null,
|
|
2735
2782
|
requirement,
|
|
2736
2783
|
repository,
|
|
2737
2784
|
local_path: indexedRepository?.local_path || input.localPath || workspace.localPath,
|
|
@@ -3292,26 +3339,6 @@ function backendPreflightSummary(backend, inspection) {
|
|
|
3292
3339
|
return `${label} is not available. Install or update it: ${inspection.update_command}`;
|
|
3293
3340
|
}
|
|
3294
3341
|
|
|
3295
|
-
async function verifyLaunchChallenge({ apiOrigin, apiBaseUrl, token, runId, challenge }) {
|
|
3296
|
-
try {
|
|
3297
|
-
const response = await fetch(
|
|
3298
|
-
webApiUrl("challenges/verify", { webOrigin: apiOrigin, apiBaseUrl }),
|
|
3299
|
-
{
|
|
3300
|
-
method: "POST",
|
|
3301
|
-
headers: webApiHeaders(
|
|
3302
|
-
{ token },
|
|
3303
|
-
{ "Content-Type": "application/json" },
|
|
3304
|
-
),
|
|
3305
|
-
body: JSON.stringify({ run_id: runId, challenge }),
|
|
3306
|
-
},
|
|
3307
|
-
);
|
|
3308
|
-
if (response.ok) return { ok: true };
|
|
3309
|
-
const payload = await response.json().catch(() => null);
|
|
3310
|
-
return { ok: false, error: webApiErrorMessage(payload, response.status) };
|
|
3311
|
-
} catch (err) {
|
|
3312
|
-
return { ok: false, error: err instanceof Error ? err.message : "challenge check failed" };
|
|
3313
|
-
}
|
|
3314
|
-
}
|
|
3315
3342
|
|
|
3316
3343
|
function canonicalAgenticLoopPath(pathname) {
|
|
3317
3344
|
return pathname.replace(/^\/v1\/coding-loop(?=\/|$)/, "/v1/agentic-loop");
|
|
@@ -3573,3 +3600,9 @@ function providerAuthenticationFailure(provider, status) {
|
|
|
3573
3600
|
}),
|
|
3574
3601
|
};
|
|
3575
3602
|
}
|
|
3603
|
+
|
|
3604
|
+
function changedLaunchSession(previous, current, origin) {
|
|
3605
|
+
try { assertConnectionScope(connectionScope(previous, origin), connectionScope(current, origin)); }
|
|
3606
|
+
catch (error) { return { error: error.message, code: error.code, field: error.field, recovery: error.recovery }; }
|
|
3607
|
+
return connectionDiagnostic("connection_session_changed");
|
|
3608
|
+
}
|
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.6.
|
|
16
|
+
export const CLI_VERSION = "0.6.13";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -91,6 +91,11 @@ export function assertRecoveryActor(run) {
|
|
|
91
91
|
if (saved[field] && saved[field] !== current[field]) {
|
|
92
92
|
const error = new Error(`The saved run belongs to another ${field}; restore its original authorized session.`);
|
|
93
93
|
error.code = "recovery_actor_mismatch";
|
|
94
|
+
error.field = field;
|
|
95
|
+
error.recovery = { action: "reconnect_cli", steps: [
|
|
96
|
+
"Select the original SIMY Web host, account and organization.",
|
|
97
|
+
"Reconnect the original device, refresh the saved run and retry recovery.",
|
|
98
|
+
] };
|
|
94
99
|
throw error;
|
|
95
100
|
}
|
|
96
101
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export function connectionScope(session, webOrigin) {
|
|
2
|
+
return {
|
|
3
|
+
web_origin: webOrigin,
|
|
4
|
+
account_id: session?.account_id || session?.auth_user_id || null,
|
|
5
|
+
organization_id: session?.organization_id || session?.org_id || null,
|
|
6
|
+
device_id: session?.device_id || null,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function connectionDiagnostic(code, field = null) {
|
|
11
|
+
const scopeChanged = code === "connection_scope_changed";
|
|
12
|
+
const error = scopeChanged
|
|
13
|
+
? `SIMY ${field.replaceAll("_", " ")} changed. Restore the account, organization, device and Web host that authorized this run before reconnecting.`
|
|
14
|
+
: {
|
|
15
|
+
connection_service_mismatch:
|
|
16
|
+
"The discovered port belongs to a different service. Run simy doctor, then reconnect to the original SIMY Web host.",
|
|
17
|
+
connection_service_unavailable:
|
|
18
|
+
"A SIMY service is recorded but cannot be reached. Run simy doctor; reopen SIMY or restart the confirmed daemon before reconnecting.",
|
|
19
|
+
connection_service_not_background:
|
|
20
|
+
"The existing SIMY service runs in a foreground terminal. Keep that terminal open, or stop it when idle and start simy --daemon before reconnecting.",
|
|
21
|
+
connection_port_mismatch:
|
|
22
|
+
"The existing SIMY service uses a different port. Reconnect without --port to reuse it, or select its recorded port.",
|
|
23
|
+
connection_session_changed:
|
|
24
|
+
"SIMY sign-in changed while the launch was being authorized. Refresh this run on the original device before retrying.",
|
|
25
|
+
connection_service_incompatible:
|
|
26
|
+
"The existing SIMY service has an incompatible version or API. Run simy update --check; update or restart that service when its tasks are idle.",
|
|
27
|
+
connection_session_expired:
|
|
28
|
+
"SIMY sign-in expired. Open the verification link for the existing service and sign in to the original account and organization.",
|
|
29
|
+
launch_challenge_expired:
|
|
30
|
+
"The launch credential expired. Retry this same run to obtain a fresh credential.",
|
|
31
|
+
launch_challenge_consumed:
|
|
32
|
+
"This launch credential was already used. Refresh the run status on its original device before retrying; execution may already have started.",
|
|
33
|
+
launch_scope_mismatch:
|
|
34
|
+
"This run or credential is unavailable for the current account, organization or device. Reconnect to the service and identity that authorized the run.",
|
|
35
|
+
launch_challenge_invalid:
|
|
36
|
+
"The launch credential is invalid. Refresh this run in SIMY Web and reconnect to its original device.",
|
|
37
|
+
launch_service_incompatible:
|
|
38
|
+
"SIMY Web does not support launch-credential recovery yet. Update the Web service, refresh this run and retry on its original device.",
|
|
39
|
+
launch_service_unavailable:
|
|
40
|
+
"The SIMY launch service could not be reached. Check the connection and retry this same run.",
|
|
41
|
+
launch_run_not_queued:
|
|
42
|
+
"This run has already progressed or stopped. Refresh its status on the original device before starting anything again.",
|
|
43
|
+
}[code] ||
|
|
44
|
+
"SIMY could not authorize this launch. Refresh the run in SIMY Web and reconnect to its original device.";
|
|
45
|
+
return {
|
|
46
|
+
error,
|
|
47
|
+
code,
|
|
48
|
+
...(field ? { field } : {}),
|
|
49
|
+
recovery: {
|
|
50
|
+
action: "reconnect_cli",
|
|
51
|
+
steps:
|
|
52
|
+
scopeChanged || code === "launch_scope_mismatch"
|
|
53
|
+
? [
|
|
54
|
+
"Select the original SIMY Web host, account and organization.",
|
|
55
|
+
"Reconnect the original device, refresh the run and retry.",
|
|
56
|
+
]
|
|
57
|
+
: [error],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function assertConnectionScope(expected, current) {
|
|
63
|
+
for (const field of [
|
|
64
|
+
"web_origin",
|
|
65
|
+
"account_id",
|
|
66
|
+
"organization_id",
|
|
67
|
+
"device_id",
|
|
68
|
+
]) {
|
|
69
|
+
if (expected?.[field] && expected[field] !== current?.[field]) {
|
|
70
|
+
const diagnostic = connectionDiagnostic(
|
|
71
|
+
"connection_scope_changed",
|
|
72
|
+
field,
|
|
73
|
+
);
|
|
74
|
+
throw Object.assign(new Error(diagnostic.error), diagnostic);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -3,6 +3,8 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
|
|
6
|
+
import { assertConnectionScope, connectionDiagnostic } from "./connection-diagnostics.js";
|
|
7
|
+
|
|
6
8
|
import { normalizeWebOrigin } from "./web-origin.js";
|
|
7
9
|
|
|
8
10
|
export function connectionDiscoveryPath(webOrigin, sessionRoot) {
|
|
@@ -47,3 +49,162 @@ export async function removeConnectionDiscovery(discovery) {
|
|
|
47
49
|
if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
|
|
48
50
|
}
|
|
49
51
|
}
|
|
52
|
+
|
|
53
|
+
// A private discovery file identifies a candidate; the live instance must prove
|
|
54
|
+
// it owns that record before an entry point can reuse it. Never send a token.
|
|
55
|
+
export async function findReusableConnection({
|
|
56
|
+
webOrigin,
|
|
57
|
+
sessionRoot,
|
|
58
|
+
cliVersion,
|
|
59
|
+
apiContractVersion,
|
|
60
|
+
expectedScope,
|
|
61
|
+
requestedPort = 0,
|
|
62
|
+
authorize = false,
|
|
63
|
+
reauthorize = false,
|
|
64
|
+
fetchImpl = fetch,
|
|
65
|
+
alive = (pid) => {
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, 0);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}) {
|
|
74
|
+
const origin = normalizeWebOrigin(webOrigin);
|
|
75
|
+
let record;
|
|
76
|
+
try {
|
|
77
|
+
record = JSON.parse(
|
|
78
|
+
await readFile(connectionDiscoveryPath(origin, sessionRoot), "utf8"),
|
|
79
|
+
);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code === "ENOENT") return null;
|
|
82
|
+
throw connectionError("connection_service_mismatch");
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
record?.schema_version !== 1 ||
|
|
86
|
+
record.web_origin !== origin ||
|
|
87
|
+
!Number.isInteger(record.port) ||
|
|
88
|
+
record.port < 1 ||
|
|
89
|
+
record.port > 65535 ||
|
|
90
|
+
!Number.isInteger(record.pid) ||
|
|
91
|
+
record.pid <= 0 ||
|
|
92
|
+
typeof record.instance_id !== "string" ||
|
|
93
|
+
!record.instance_id
|
|
94
|
+
) {
|
|
95
|
+
throw connectionError("connection_service_mismatch");
|
|
96
|
+
}
|
|
97
|
+
if (!alive(record.pid)) return null;
|
|
98
|
+
let response, health;
|
|
99
|
+
try {
|
|
100
|
+
response = await fetchImpl(
|
|
101
|
+
`http://127.0.0.1:${record.port}/v1/connection`,
|
|
102
|
+
{
|
|
103
|
+
signal: AbortSignal.timeout(1500),
|
|
104
|
+
redirect: "error",
|
|
105
|
+
cache: "no-store",
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
health = response.ok ? await response.json() : null;
|
|
109
|
+
} catch {
|
|
110
|
+
throw connectionError("connection_service_unavailable");
|
|
111
|
+
}
|
|
112
|
+
if (response.status === 404)
|
|
113
|
+
throw connectionError("connection_service_incompatible");
|
|
114
|
+
if (
|
|
115
|
+
!health?.ok ||
|
|
116
|
+
health.instance_id !== record.instance_id ||
|
|
117
|
+
health.pid !== record.pid ||
|
|
118
|
+
health.web_origin !== origin
|
|
119
|
+
) {
|
|
120
|
+
throw connectionError("connection_service_mismatch");
|
|
121
|
+
}
|
|
122
|
+
if (health.daemon !== true)
|
|
123
|
+
throw connectionError("connection_service_not_background");
|
|
124
|
+
if (requestedPort && requestedPort !== record.port)
|
|
125
|
+
throw connectionError("connection_port_mismatch");
|
|
126
|
+
if (
|
|
127
|
+
!compatibleServiceVersion(health.cli_version, cliVersion) ||
|
|
128
|
+
health.api_contract_version !== apiContractVersion
|
|
129
|
+
)
|
|
130
|
+
throw connectionError("connection_service_incompatible");
|
|
131
|
+
assertConnectionScope(expectedScope, health.scope);
|
|
132
|
+
if (authorize && (reauthorize || !health.session_valid)) {
|
|
133
|
+
try {
|
|
134
|
+
response = await fetchImpl(
|
|
135
|
+
`http://127.0.0.1:${record.port}/v1/connection`,
|
|
136
|
+
{
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/json" },
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
instance_id: record.instance_id,
|
|
141
|
+
scope: health.scope,
|
|
142
|
+
reauthorize,
|
|
143
|
+
}),
|
|
144
|
+
signal: AbortSignal.timeout(1500),
|
|
145
|
+
redirect: "error",
|
|
146
|
+
cache: "no-store",
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
health = await response.json().catch(() => null);
|
|
150
|
+
} catch {
|
|
151
|
+
throw connectionError("connection_service_unavailable");
|
|
152
|
+
}
|
|
153
|
+
if (!response.ok && health?.code === "connection_scope_changed" &&
|
|
154
|
+
["web_origin", "account_id", "organization_id", "device_id"].includes(health.field)) {
|
|
155
|
+
const diagnostic = connectionDiagnostic(health.code, health.field);
|
|
156
|
+
throw Object.assign(new Error(diagnostic.error), diagnostic);
|
|
157
|
+
}
|
|
158
|
+
if (!response.ok) throw connectionError("connection_service_mismatch");
|
|
159
|
+
if (
|
|
160
|
+
!health?.ok ||
|
|
161
|
+
health.instance_id !== record.instance_id ||
|
|
162
|
+
health.pid !== record.pid ||
|
|
163
|
+
health.web_origin !== origin
|
|
164
|
+
) {
|
|
165
|
+
throw connectionError("connection_service_mismatch");
|
|
166
|
+
}
|
|
167
|
+
assertConnectionScope(expectedScope, health.scope);
|
|
168
|
+
}
|
|
169
|
+
if (health.login_url !== null) {
|
|
170
|
+
let url;
|
|
171
|
+
try {
|
|
172
|
+
url = new URL(health.login_url);
|
|
173
|
+
} catch {
|
|
174
|
+
throw connectionError("connection_service_mismatch");
|
|
175
|
+
}
|
|
176
|
+
if (
|
|
177
|
+
url.origin !== origin ||
|
|
178
|
+
url.pathname !== "/local-cli/connect" ||
|
|
179
|
+
url.searchParams.get("port") !== String(record.port)
|
|
180
|
+
) {
|
|
181
|
+
throw connectionError("connection_service_mismatch");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
port: record.port,
|
|
186
|
+
webOrigin: origin,
|
|
187
|
+
loginUrl: health.login_url,
|
|
188
|
+
sessionValid: health.session_valid === true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function connectionError(code) {
|
|
193
|
+
const diagnostic = connectionDiagnostic(code);
|
|
194
|
+
return Object.assign(new Error(diagnostic.error), diagnostic);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function compatibleServiceVersion(running, requested) {
|
|
198
|
+
const version = (value) =>
|
|
199
|
+
typeof value === "string"
|
|
200
|
+
? /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(value)
|
|
201
|
+
: null;
|
|
202
|
+
const existing = version(running),
|
|
203
|
+
target = version(requested);
|
|
204
|
+
return Boolean(
|
|
205
|
+
existing &&
|
|
206
|
+
target &&
|
|
207
|
+
existing[1] === target[1] &&
|
|
208
|
+
(existing[1] !== "0" || existing[2] === target[2]),
|
|
209
|
+
);
|
|
210
|
+
}
|
package/src/desktop-executor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
|
+
import { normalizeProviderModelSelection } from "./provider-model-selection.js";
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
BACKEND_VERSION_POLICIES,
|
|
@@ -48,6 +49,7 @@ export function desktopExecutorCapability(inspection) {
|
|
|
48
49
|
export async function resolveDesktopExecutorCommand({
|
|
49
50
|
backend,
|
|
50
51
|
model = null,
|
|
52
|
+
reasoningEffort = null,
|
|
51
53
|
instruction,
|
|
52
54
|
repositoryPath,
|
|
53
55
|
executionKind = "agentic_loop",
|
|
@@ -62,17 +64,26 @@ export async function resolveDesktopExecutorCommand({
|
|
|
62
64
|
throw new Error("Desktop execution kind must be agentic_loop or direct.");
|
|
63
65
|
}
|
|
64
66
|
const normalizedPermissionMode = normalizePermissionMode(permissionMode);
|
|
65
|
-
const normalizedModel =
|
|
66
|
-
model
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
throw
|
|
67
|
+
const { model: normalizedModel, reasoning_effort: effort } =
|
|
68
|
+
normalizeProviderModelSelection({ model, reasoning_effort: reasoningEffort });
|
|
69
|
+
if (provider !== "codex" && effort !== null) {
|
|
70
|
+
const error = new Error("Explicit reasoning effort is not supported by this Claude command adapter.");
|
|
71
|
+
error.code = "desktop_executor_selection_unsupported";
|
|
72
|
+
throw error;
|
|
71
73
|
}
|
|
74
|
+
const selectionArgs = [
|
|
75
|
+
...(normalizedModel ? ["--model", normalizedModel] : []),
|
|
76
|
+
...(effort ? ["--config", `model_reasoning_effort=${JSON.stringify(effort)}`] : []),
|
|
77
|
+
];
|
|
72
78
|
const target = normalizeDesktopExecutionTarget(DESKTOP_EXECUTION_TARGET);
|
|
73
79
|
const override =
|
|
74
80
|
provider === "claude" ? environment.SIMY_CLAUDE_COMMAND : environment.SIMY_CODEX_COMMAND;
|
|
75
81
|
if (override) {
|
|
82
|
+
if (normalizedModel || effort) {
|
|
83
|
+
const error = new Error("Explicit provider selection cannot be enforced by a shell command override.");
|
|
84
|
+
error.code = "desktop_executor_selection_unsupported";
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
76
87
|
return {
|
|
77
88
|
...shellCommand(override, repositoryPath, instruction, spawnImpl),
|
|
78
89
|
backend: provider,
|
|
@@ -100,7 +111,7 @@ export async function resolveDesktopExecutorCommand({
|
|
|
100
111
|
normalizedPermissionMode,
|
|
101
112
|
normalizedModel,
|
|
102
113
|
)
|
|
103
|
-
: claudeBackendArgs(instruction)
|
|
114
|
+
: claudeBackendArgs(instruction, normalizedModel)
|
|
104
115
|
: directExecution
|
|
105
116
|
? [
|
|
106
117
|
"exec",
|
|
@@ -108,22 +119,24 @@ export async function resolveDesktopExecutorCommand({
|
|
|
108
119
|
"--skip-git-repo-check",
|
|
109
120
|
"--sandbox",
|
|
110
121
|
normalizedPermissionMode === "read_only" ? "read-only" : "workspace-write",
|
|
111
|
-
...
|
|
122
|
+
...selectionArgs,
|
|
112
123
|
instruction,
|
|
113
124
|
]
|
|
114
|
-
: ["exec", "--json", instruction],
|
|
125
|
+
: ["exec", "--json", ...selectionArgs, instruction],
|
|
115
126
|
env: {},
|
|
116
127
|
spawn: spawnImpl,
|
|
117
128
|
backend: provider,
|
|
118
129
|
execution_target: target,
|
|
119
130
|
execution_kind: executionKind,
|
|
120
131
|
permission_mode: normalizedPermissionMode,
|
|
132
|
+
requested_model: normalizedModel,
|
|
133
|
+
requested_reasoning_effort: effort,
|
|
121
134
|
verification: "compatible_version",
|
|
122
135
|
installed_version: inspection.installed_version,
|
|
123
136
|
};
|
|
124
137
|
}
|
|
125
138
|
|
|
126
|
-
export function claudeBackendArgs(instruction) {
|
|
139
|
+
export function claudeBackendArgs(instruction, model = null) {
|
|
127
140
|
return [
|
|
128
141
|
"-p",
|
|
129
142
|
instruction,
|
|
@@ -134,6 +147,7 @@ export function claudeBackendArgs(instruction) {
|
|
|
134
147
|
// by the user. Print mode cannot display permission prompts, so edits would
|
|
135
148
|
// otherwise be silently unavailable to the desktop executor.
|
|
136
149
|
"--dangerously-skip-permissions",
|
|
150
|
+
...(model ? ["--model", model] : []),
|
|
137
151
|
];
|
|
138
152
|
}
|
|
139
153
|
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,10 @@ import { createInterface } from "node:readline/promises";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
|
+
import { findReusableConnection } from "./connection-discovery.js";
|
|
10
|
+
import { connectionScope } from "./connection-diagnostics.js";
|
|
11
|
+
import { readSession } from "./session-store.js";
|
|
12
|
+
import { CLI_API_CONTRACT_VERSION } from "./cli-contract.js";
|
|
9
13
|
import { startAgent } from "./agent.js";
|
|
10
14
|
import { createRuntimeDiagnostics, captureRuntimeOutput } from "./runtime-diagnostics.js";
|
|
11
15
|
import {
|
|
@@ -165,6 +169,23 @@ async function runLocalAgent() {
|
|
|
165
169
|
const webOrigin = configuredWebOrigin;
|
|
166
170
|
const sessionRoot = process.env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
167
171
|
|
|
172
|
+
try {
|
|
173
|
+
const existing = await findReusableConnection({ webOrigin, sessionRoot, cliVersion: packageInfo.version,
|
|
174
|
+
apiContractVersion: CLI_API_CONTRACT_VERSION, requestedPort: port, authorize: true,
|
|
175
|
+
expectedScope: connectionScope(await readSession(webOrigin, sessionRoot), webOrigin) });
|
|
176
|
+
if (existing) {
|
|
177
|
+
console.log(`Connected to existing SIMY service at http://127.0.0.1:${existing.port}`);
|
|
178
|
+
const url = existing.loginUrl || webOrigin;
|
|
179
|
+
console.log(`Continue in SIMY Web: ${url}`);
|
|
180
|
+
if (!args.has('--no-open') && process.env.SIMY_DAEMON_CHILD !== '1') await openAuthorizationUrl(url);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error(error.message);
|
|
185
|
+
process.exitCode = 2;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
168
189
|
try {
|
|
169
190
|
await prepareDaemonWorkflows({ argv, daemon });
|
|
170
191
|
} catch (error) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { webApiHeaders, webApiUrl } from "./web-api.js";
|
|
2
|
+
|
|
3
|
+
// Only an explicitly expired credential is renewable. A consumed or rejected
|
|
4
|
+
// credential may represent an execution that already started; never replay it.
|
|
5
|
+
export async function authorizeLaunch({
|
|
6
|
+
apiOrigin,
|
|
7
|
+
apiBaseUrl,
|
|
8
|
+
token,
|
|
9
|
+
runId,
|
|
10
|
+
challenge,
|
|
11
|
+
fetchImpl = fetch,
|
|
12
|
+
}) {
|
|
13
|
+
const post = async (path, body) => {
|
|
14
|
+
const response = await fetchImpl(
|
|
15
|
+
webApiUrl(path, { webOrigin: apiOrigin, apiBaseUrl }),
|
|
16
|
+
{
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: webApiHeaders(
|
|
19
|
+
{ token },
|
|
20
|
+
{ "Content-Type": "application/json" },
|
|
21
|
+
),
|
|
22
|
+
body: JSON.stringify(body),
|
|
23
|
+
redirect: "error",
|
|
24
|
+
signal: AbortSignal.timeout(10_000),
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
return { response, payload: await response.json().catch(() => null) };
|
|
28
|
+
};
|
|
29
|
+
const rejected = ({ response, payload }) => ({
|
|
30
|
+
ok: false,
|
|
31
|
+
status: response.status >= 500 ? 502 : response.status === 401 ? 401 : 403,
|
|
32
|
+
code:
|
|
33
|
+
typeof payload?.code === "string"
|
|
34
|
+
? payload.code
|
|
35
|
+
: response.status >= 500
|
|
36
|
+
? "launch_service_unavailable"
|
|
37
|
+
: response.status === 401
|
|
38
|
+
? "connection_session_expired"
|
|
39
|
+
: response.status === 404
|
|
40
|
+
? "launch_service_incompatible"
|
|
41
|
+
: "launch_challenge_invalid",
|
|
42
|
+
});
|
|
43
|
+
const verificationOutcome = (result) => {
|
|
44
|
+
if (!result.response.ok) return rejected(result);
|
|
45
|
+
return result.payload?.ok === true
|
|
46
|
+
? { ok: true }
|
|
47
|
+
: { ok: false, status: 502, code: "launch_service_unavailable" };
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
if (challenge) {
|
|
51
|
+
const verified = await post("challenges/verify", {
|
|
52
|
+
run_id: runId,
|
|
53
|
+
challenge,
|
|
54
|
+
});
|
|
55
|
+
if (verified.response.ok) return verificationOutcome(verified);
|
|
56
|
+
if (verified.payload?.code !== "launch_challenge_expired")
|
|
57
|
+
return rejected(verified);
|
|
58
|
+
}
|
|
59
|
+
const issued = await post("challenges/issue", {
|
|
60
|
+
run_id: runId,
|
|
61
|
+
...(challenge ? { previous_challenge: challenge } : {}),
|
|
62
|
+
});
|
|
63
|
+
if (!issued.response.ok) return rejected(issued);
|
|
64
|
+
if (
|
|
65
|
+
typeof issued.payload?.challenge !== "string" ||
|
|
66
|
+
!issued.payload.challenge ||
|
|
67
|
+
!Number.isFinite(Date.parse(issued.payload.expires_at)) ||
|
|
68
|
+
Date.parse(issued.payload.expires_at) <= Date.now()
|
|
69
|
+
) {
|
|
70
|
+
return { ok: false, status: 502, code: "launch_service_unavailable" };
|
|
71
|
+
}
|
|
72
|
+
const verified = await post("challenges/verify", {
|
|
73
|
+
run_id: runId,
|
|
74
|
+
challenge: issued.payload.challenge,
|
|
75
|
+
});
|
|
76
|
+
return verificationOutcome(verified);
|
|
77
|
+
} catch {
|
|
78
|
+
return { ok: false, status: 502, code: "launch_service_unavailable" };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -134,7 +134,7 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null, s
|
|
|
134
134
|
|
|
135
135
|
const retryProblemSolving = evaluateRetryProblemSolving(attempt, previousAttempt);
|
|
136
136
|
for (const check of retryProblemSolving.checks) {
|
|
137
|
-
addCheck(check.id, check.label, check.passed, check.detail);
|
|
137
|
+
addCheck(check.id, check.label, check.passed, check.detail, check.severity);
|
|
138
138
|
}
|
|
139
139
|
attempt.retry_problem_solving = retryProblemSolving;
|
|
140
140
|
|
|
@@ -220,7 +220,7 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null, s
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
const findings = checks
|
|
223
|
-
.filter((check) => !check.passed)
|
|
223
|
+
.filter((check) => !check.passed && check.severity !== "advisory")
|
|
224
224
|
.map((check) => ({
|
|
225
225
|
code: check.id.toUpperCase(),
|
|
226
226
|
title: check.label,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeProviderModelSelection } from "../provider-model-selection.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -103,6 +104,7 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
|
|
|
103
104
|
charter: {
|
|
104
105
|
id: `${runId}_charter`,
|
|
105
106
|
requirement: cleanString(request.requirement),
|
|
107
|
+
...normalizeProviderModelSelection(request),
|
|
106
108
|
repository: cleanString(request.repository),
|
|
107
109
|
backend: request.backend === "claude" ? "claude" : "codex",
|
|
108
110
|
execution_target: executionTarget,
|
|
@@ -112,7 +112,7 @@ export function buildIndependentAuditInstruction(charter, attempt) {
|
|
|
112
112
|
"criterion_results must contain one entry per stable criterion ID with criterion_id, passed, verification_method, evidence_refs, and observed_result.",
|
|
113
113
|
"visual_review must contain inspected, artifacts, and acceptance_observations.",
|
|
114
114
|
"Only passed=false entries are findings. Omit passing checks, and never report passed=true evidence as a finding.",
|
|
115
|
-
"Use passed=false when a blocker or major finding remains, or when evidence is unavailable.",
|
|
115
|
+
"Use passed=false when a blocker or major finding remains, or when required evidence is unavailable. For minor/warning/info findings, choose disposition=fix, defer, or dismiss based on impact and reversibility. Include the reason in explanation. Deferring or dismissing a non-major finding does not require human input and must not hide it. Major/blocker findings cannot be deferred by this field.",
|
|
116
116
|
].join("\n"),
|
|
117
117
|
),
|
|
118
118
|
].join("\n\n");
|
|
@@ -165,6 +165,8 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
|
|
|
165
165
|
target: cleanString(item.target) || "pull_request",
|
|
166
166
|
explanation: cleanString(item.explanation) || "Independent audit finding.",
|
|
167
167
|
repairability: item.repairability === "manual" ? "manual" : "auto",
|
|
168
|
+
disposition: ["defer", "dismiss"].includes(item.disposition) && cleanString(item.explanation)
|
|
169
|
+
? item.disposition : "fix",
|
|
168
170
|
blocker_kind: ["machine", "human", "external"].includes(item.blocker_kind)
|
|
169
171
|
? item.blocker_kind : item.repairability === "manual" ? "human" : "machine",
|
|
170
172
|
auto_fix_hint: cleanString(item.auto_fix_hint) || null,
|
|
@@ -249,10 +251,16 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
|
|
|
249
251
|
findings.push({ passed: false, code: "USER_OUTCOME_UNPROVEN", severity: "blocker",
|
|
250
252
|
target: "user_outcome", explanation: "The original user goal lacks independently observed operation and postcondition evidence.", repairability: "auto" });
|
|
251
253
|
}
|
|
252
|
-
|
|
254
|
+
// Only the independent reviewer can defer/dismiss a non-major finding.
|
|
255
|
+
// Unknown severities, generated evidence failures and major findings remain blocking.
|
|
256
|
+
const followUps = findings.filter(item => ["minor", "warning", "info"].includes(item.severity) &&
|
|
257
|
+
["defer", "dismiss"].includes(item.disposition));
|
|
258
|
+
const blockingFindings = findings.filter(item => !followUps.includes(item));
|
|
259
|
+
const requiresHuman = blockingFindings.some((item) => item.repairability === "manual");
|
|
253
260
|
return {
|
|
254
261
|
...objectiveBinding,
|
|
255
|
-
passed: result.passed === true
|
|
262
|
+
passed: blockingFindings.length === 0 && (result.passed === true || followUps.length > 0),
|
|
263
|
+
follow_ups: followUps,
|
|
256
264
|
summary: cleanString(result.summary) || "Independent audit completed.",
|
|
257
265
|
findings,
|
|
258
266
|
criterion_results: criterionResults,
|
|
@@ -171,8 +171,8 @@ export function buildCodingInstruction(
|
|
|
171
171
|
[
|
|
172
172
|
"Treat implementation as hypothesis testing: state assumptions, list plausible causes or approaches, inspect discriminating evidence, then choose the smallest strategy supported by that evidence.",
|
|
173
173
|
attemptNumber > 1
|
|
174
|
-
? "This is a retry.
|
|
175
|
-
: "For the first attempt,
|
|
174
|
+
? "This is a retry. Repair clear failures directly; compare plausible causes when needed. Reasoning notes are advisory, not a prerequisite to editing."
|
|
175
|
+
: "For the first attempt, investigate material uncertainty before a risky operation; use a reasonable assumption for low-impact reversible choices and verify as you proceed.",
|
|
176
176
|
attemptNumber > 1
|
|
177
177
|
? "Explain exactly what changed from the previous attempt and why the new evidence supports this strategy."
|
|
178
178
|
: "Record the uncertainty, first verification, concrete evidence checked, observed result, selected hypothesis, and strategy in the structured result.",
|
|
@@ -244,7 +244,7 @@ export function buildPromptInterventions(
|
|
|
244
244
|
trigger: "repair_focus",
|
|
245
245
|
title: "Repair focus",
|
|
246
246
|
prompt:
|
|
247
|
-
"
|
|
247
|
+
"Repair clear failures directly and verify the change. Investigate competing causes only when uncertainty warrants it; do not invent eight hypotheses or change strategy merely to satisfy a form.",
|
|
248
248
|
evidence_required: [
|
|
249
249
|
"hypotheses",
|
|
250
250
|
"selected_hypothesis_id",
|
package/src/orchestrator/loop.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./independent-audit.js";
|
|
11
11
|
import { buildCodingInstruction, buildPromptInterventions } from "./instruction.js";
|
|
12
12
|
import { canStartProvider, codingAttemptCeiling, refreshBudgetState } from "./budget.js";
|
|
13
|
-
import { buildAttempt, mergeTokenUsage } from "./result.js";
|
|
13
|
+
import { buildAttempt, mergeTokenUsage, normalizeResultParseDiagnostic } from "./result.js";
|
|
14
14
|
import { evaluateRetryCircuit } from "./retry.js";
|
|
15
15
|
import { assessOutcomeProgress, verifyObjective } from "./outcome-monitor.js";
|
|
16
16
|
import { appendEvent, publish } from "./shared.js";
|
|
@@ -291,6 +291,7 @@ async function runCodingLoopOwned({
|
|
|
291
291
|
return snapshot;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
attempt.independent_audit.result_parse = normalizeResultParseDiagnostic(auditExecution.result_parse);
|
|
294
295
|
attempt.independent_audit.minimum_matrix_sha256 = reviewedMatrix;
|
|
295
296
|
attempt.independent_audit.evidence_identity_sha256 = reviewedEvidenceIdentity;
|
|
296
297
|
attempt.independent_audit.objective_digest = reviewedObjectiveDigest;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cleanString, stringArray } from "./shared.js";
|
|
2
2
|
|
|
3
|
-
const MIN_RETRY_HYPOTHESES =
|
|
3
|
+
const MIN_RETRY_HYPOTHESES = 1;
|
|
4
4
|
|
|
5
5
|
export function normalizeProblemSolving(value) {
|
|
6
6
|
const record = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
@@ -49,7 +49,7 @@ export function evaluateRetryProblemSolving(attempt, previousAttempt) {
|
|
|
49
49
|
const checks = [
|
|
50
50
|
{
|
|
51
51
|
id: "retry_hypotheses_expanded",
|
|
52
|
-
label: "Retry considers
|
|
52
|
+
label: "Retry considers a plausible explanation",
|
|
53
53
|
passed: distinctHypotheses >= MIN_RETRY_HYPOTHESES,
|
|
54
54
|
detail: `Expected at least ${MIN_RETRY_HYPOTHESES} distinct hypotheses; recorded ${distinctHypotheses}.`,
|
|
55
55
|
},
|
|
@@ -78,9 +78,10 @@ export function evaluateRetryProblemSolving(attempt, previousAttempt) {
|
|
|
78
78
|
},
|
|
79
79
|
];
|
|
80
80
|
return {
|
|
81
|
-
required:
|
|
82
|
-
passed:
|
|
83
|
-
checks,
|
|
81
|
+
required: false,
|
|
82
|
+
passed: true,
|
|
83
|
+
assessment_complete: checks.every((check) => check.passed),
|
|
84
|
+
checks: checks.map(check => ({ ...check, severity: "advisory" })),
|
|
84
85
|
current,
|
|
85
86
|
previous,
|
|
86
87
|
};
|
|
@@ -91,7 +92,7 @@ export function problemSolvingResultContract() {
|
|
|
91
92
|
"problem_solving must be an object with hypotheses, selected_hypothesis_id, evidence_checked, strategy, and changed_from_previous.",
|
|
92
93
|
"Each hypothesis must use {id, statement}; evidence_checked must list concrete observations or commands.",
|
|
93
94
|
"On attempt 1, record the assumptions, hypotheses considered, selected hypothesis, and implementation strategy; changed_from_previous may be null.",
|
|
94
|
-
|
|
95
|
+
"On retry, use reasoning proportional to the uncertainty. If the repair is clear, make it and verify it; do not invent a fixed number of hypotheses or change a working strategy merely to satisfy a form. For persistent unexplained failures, compare plausible causes and inspect discriminating evidence. These reasoning notes are advisory; unchanged failures are still subject to the retry circuit.",
|
|
95
96
|
].join("\n");
|
|
96
97
|
}
|
|
97
98
|
|
|
@@ -56,6 +56,7 @@ export function buildAttempt({ attemptNumber, charter, instruction, promptInterv
|
|
|
56
56
|
visual_qa_result: null,
|
|
57
57
|
release_gate: null,
|
|
58
58
|
executor_logs: stringArray(execution.logs).slice(-100),
|
|
59
|
+
result_parse: normalizeResultParseDiagnostic(execution.result_parse),
|
|
59
60
|
token_usage:
|
|
60
61
|
execution.tokenUsage && typeof execution.tokenUsage === "object"
|
|
61
62
|
? execution.tokenUsage
|
|
@@ -79,16 +80,104 @@ export function parseStructuredResult(text) {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
export function parseStructuredMarker(text, marker) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
return diagnoseStructuredMarker(text, marker).value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Parse only a line-anchored result channel. Braces in JSON strings are not
|
|
87
|
+
// boundaries; JSON.parse remains responsible for syntax and object validation.
|
|
88
|
+
export function diagnoseStructuredMarker(text, marker, { maxBytes = 1024 * 1024 } = {}) {
|
|
89
|
+
const failure = code => ({ code, value: {} });
|
|
90
|
+
if (typeof text !== "string") return failure("missing_marker");
|
|
91
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) return failure("result_too_large");
|
|
92
|
+
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
93
|
+
const matches = [...text.matchAll(new RegExp(`(?:^|\\n)[ \t]*${escaped}`, "g"))];
|
|
94
|
+
if (!matches.length) return failure("missing_marker");
|
|
95
|
+
if (matches.length !== 1) return failure("ambiguous_marker");
|
|
96
|
+
const payload = text.slice(matches[0].index + matches[0][0].length).trim();
|
|
97
|
+
if (!payload) return failure("truncated_json");
|
|
98
|
+
if (!payload.startsWith("{")) return failure("result_root_not_object");
|
|
99
|
+
let depth = 0, quoted = false, escapedChar = false, end = -1;
|
|
100
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
101
|
+
const char = payload[index];
|
|
102
|
+
if (quoted) {
|
|
103
|
+
if (escapedChar) escapedChar = false;
|
|
104
|
+
else if (char === "\\") escapedChar = true;
|
|
105
|
+
else if (char === '"') quoted = false;
|
|
106
|
+
} else if (char === '"') quoted = true;
|
|
107
|
+
else if (char === "{" || char === "[") depth += 1;
|
|
108
|
+
else if (char === "}" || char === "]") {
|
|
109
|
+
depth -= 1;
|
|
110
|
+
if (depth === 0) { end = index + 1; break; }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (end < 0) return failure("truncated_json");
|
|
114
|
+
if (payload.slice(end).trim()) return failure("trailing_result_content");
|
|
86
115
|
try {
|
|
87
|
-
const value = JSON.parse(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return {};
|
|
116
|
+
const value = JSON.parse(payload.slice(0, end));
|
|
117
|
+
if (!value || Array.isArray(value) || typeof value !== "object") return failure("result_root_not_object");
|
|
118
|
+
if (!Object.keys(value).length) return failure("empty_result");
|
|
119
|
+
return { code: "parsed", value };
|
|
120
|
+
} catch { return failure("invalid_json"); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Native JSONL admits results only from a successfully finished provider turn.
|
|
124
|
+
// Command output, echoed prompts and intermediate assistant items cannot be a
|
|
125
|
+
// fallback channel. Explicit shell overrides retain the documented legacy text.
|
|
126
|
+
export function parseProviderStructuredResult(raw, { backend, marker = "SIMY_RESULT_JSON:", allowPlainText = false,
|
|
127
|
+
maxBytes = 1024 * 1024, maxStreamBytes = 64 * 1024 * 1024 } = {}) {
|
|
128
|
+
const failure = code => ({ code, value: {} });
|
|
129
|
+
if (typeof raw !== "string") return failure("missing_provider_final");
|
|
130
|
+
if (Buffer.byteLength(raw, "utf8") > maxStreamBytes) return failure("provider_stream_too_large");
|
|
131
|
+
const events = [];
|
|
132
|
+
let malformedStream = false;
|
|
133
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
134
|
+
if (!line.trim()) continue;
|
|
135
|
+
try {
|
|
136
|
+
const event = JSON.parse(line);
|
|
137
|
+
if (event && typeof event === "object" && !Array.isArray(event) && typeof event.type === "string") {
|
|
138
|
+
// Tool payload size is independent of the bounded final result. Retain
|
|
139
|
+
// event identity/order, not arbitrary command output, during selection.
|
|
140
|
+
events.push(event.type === "result" || event.item?.type === "agent_message"
|
|
141
|
+
? event : { type: event.type, item: { type: event.item?.type } });
|
|
142
|
+
} else malformedStream = true;
|
|
143
|
+
}
|
|
144
|
+
catch { malformedStream = true; }
|
|
91
145
|
}
|
|
146
|
+
const nativeTypes = new Set(backend === "claude"
|
|
147
|
+
? ["system", "assistant", "user", "result", "stream_event", "tool_progress", "tool_use_summary", "rate_limit_event", "auth_status"]
|
|
148
|
+
: ["thread.started", "turn.started", "turn.completed", "turn.failed", "item.started", "item.updated", "item.completed", "error"]);
|
|
149
|
+
const native = events.some(e => nativeTypes.has(e.type));
|
|
150
|
+
// A discarded line could be a later error or unfinished turn. Native streams
|
|
151
|
+
// must stay intact; only an explicit non-native override admits plain text.
|
|
152
|
+
if (malformedStream && (native || !allowPlainText)) return failure("malformed_provider_stream");
|
|
153
|
+
if (!native) return allowPlainText ? diagnoseStructuredMarker(raw, marker, {maxBytes}) : failure("missing_provider_final");
|
|
154
|
+
if (backend === "claude") {
|
|
155
|
+
const results = events.filter(e => e.type === "result");
|
|
156
|
+
if (results.length > 1) return failure("contradictory_provider_terminal");
|
|
157
|
+
const final = results[0];
|
|
158
|
+
if (!final) return failure("missing_provider_final");
|
|
159
|
+
if (events.slice(events.indexOf(final) + 1).some(e => typeof e.type === "string")) return failure("unsettled_provider_turn");
|
|
160
|
+
if (final.is_error === true || String(final.subtype || "").startsWith("error")) return failure("provider_failed");
|
|
161
|
+
return diagnoseStructuredMarker(final.result, marker, {maxBytes});
|
|
162
|
+
}
|
|
163
|
+
const end = events.findLastIndex(e => e.type === "turn.completed" || e.type === "turn.failed");
|
|
164
|
+
if (end < 0) return failure("missing_provider_final");
|
|
165
|
+
if (events[end].type === "turn.failed") return failure("provider_failed");
|
|
166
|
+
const start = events.slice(0, end).findLastIndex(e => e.type === "turn.started");
|
|
167
|
+
const turn = events.slice(start + 1, end);
|
|
168
|
+
if (turn.some(e => e.type === "turn.failed" || e.type === "turn.completed")) {
|
|
169
|
+
return failure("contradictory_provider_terminal");
|
|
170
|
+
}
|
|
171
|
+
const finalIndex = turn.findLastIndex(e => e.type === "item.completed" &&
|
|
172
|
+
e.item?.type === "agent_message" && (!e.item.phase || e.item.phase === "final_answer"));
|
|
173
|
+
if (finalIndex < 0) return failure("missing_provider_final");
|
|
174
|
+
if (turn.slice(finalIndex + 1).some(e => typeof e.type === "string")) {
|
|
175
|
+
return failure("nonterminal_agent_message");
|
|
176
|
+
}
|
|
177
|
+
const final = turn[finalIndex].item;
|
|
178
|
+
// A later unsettled turn must not replay an earlier completed result.
|
|
179
|
+
if (events.slice(end + 1).some(e => typeof e.type === "string")) return failure("unsettled_provider_turn");
|
|
180
|
+
return diagnoseStructuredMarker(final.text, marker, {maxBytes});
|
|
92
181
|
}
|
|
93
182
|
|
|
94
183
|
function buildArtifactPreviews(result) {
|
|
@@ -125,3 +214,10 @@ function isUsageRecord(value) {
|
|
|
125
214
|
}
|
|
126
215
|
|
|
127
216
|
function safeDigest(compute) { try { return compute(); } catch { return null; } }
|
|
217
|
+
|
|
218
|
+
export function normalizeResultParseDiagnostic(value) {
|
|
219
|
+
if (!value) return null;
|
|
220
|
+
const codes = ["parsed", "missing_marker", "result_too_large", "ambiguous_marker", "truncated_json", "result_root_not_object", "trailing_result_content", "empty_result", "invalid_json", "missing_provider_final", "provider_failed", "unsettled_provider_turn", "nonterminal_agent_message", "contradictory_provider_terminal", "provider_stream_too_large", "malformed_provider_stream"];
|
|
221
|
+
return { code: codes.includes(value.code) ? value.code : "unknown_parse_diagnostic",
|
|
222
|
+
channel: ["override_or_native", "provider_final"].includes(value.channel) ? value.channel : "unknown" };
|
|
223
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const SELECTION_FIELDS = ["model", "reasoning_effort", "audit_model", "audit_reasoning_effort"];
|
|
2
|
+
const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
|
|
3
|
+
|
|
4
|
+
// Preserve explicit selections at request boundaries; do not infer a model catalog.
|
|
5
|
+
export function normalizeProviderModelSelection(input = {}) {
|
|
6
|
+
const result = {};
|
|
7
|
+
for (const field of SELECTION_FIELDS) {
|
|
8
|
+
const value = input[field];
|
|
9
|
+
if (value == null) {
|
|
10
|
+
result[field] = null;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
14
|
+
throw new Error(`${field} must be a non-empty string.`);
|
|
15
|
+
}
|
|
16
|
+
const normalized = value.trim();
|
|
17
|
+
const valid = field.endsWith("reasoning_effort")
|
|
18
|
+
? REASONING_EFFORTS.has(normalized)
|
|
19
|
+
: /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,159}(?:\[1m\])?$/.test(normalized);
|
|
20
|
+
if (!valid) throw new Error(`${field} is invalid.`);
|
|
21
|
+
result[field] = normalized;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
package/src/runner.js
CHANGED
|
@@ -8,11 +8,10 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import {
|
|
9
9
|
collectPrEvidence,
|
|
10
10
|
createCodingLoopSnapshot,
|
|
11
|
-
parseStructuredMarker,
|
|
12
|
-
parseStructuredResult,
|
|
13
11
|
recheckPrReadiness,
|
|
14
12
|
runCodingLoop,
|
|
15
13
|
} from "./orchestrator/index.js";
|
|
14
|
+
import { parseProviderStructuredResult } from "./orchestrator/result.js";
|
|
16
15
|
import {
|
|
17
16
|
EXECUTION_IO_POLICY_VERSION,
|
|
18
17
|
executionTextSha256,
|
|
@@ -180,6 +179,10 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
|
|
|
180
179
|
sqmHistoryRoot,
|
|
181
180
|
request: {
|
|
182
181
|
backend: charter.backend === "claude" ? "claude" : "codex",
|
|
182
|
+
model: charter.model || null,
|
|
183
|
+
reasoning_effort: charter.reasoning_effort || null,
|
|
184
|
+
audit_model: charter.audit_model || null,
|
|
185
|
+
audit_reasoning_effort: charter.audit_reasoning_effort || null,
|
|
183
186
|
execution_target: normalizeDesktopExecutionTarget(charter.execution_target),
|
|
184
187
|
execution_device_id:
|
|
185
188
|
String(snapshot.metadata?.execution_device_id || session?.device_id || "").trim() || null,
|
|
@@ -1012,18 +1015,21 @@ export async function executeProcessAttempt({
|
|
|
1012
1015
|
marker = "SIMY_RESULT_JSON:",
|
|
1013
1016
|
attemptNumber = null,
|
|
1014
1017
|
resolveCommandImpl = resolveDesktopExecutorCommand,
|
|
1018
|
+
charter = run?.snapshot?.charter,
|
|
1015
1019
|
onExecutionComplete = null,
|
|
1016
1020
|
}) {
|
|
1021
|
+
const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
|
|
1017
1022
|
const command = await resolveCommandImpl({
|
|
1018
1023
|
backend,
|
|
1019
1024
|
instruction,
|
|
1020
1025
|
repositoryPath,
|
|
1026
|
+
model: phase === "reviewer" ? charter?.audit_model : charter?.model,
|
|
1027
|
+
reasoningEffort: phase === "reviewer" ? charter?.audit_reasoning_effort : charter?.reasoning_effort,
|
|
1021
1028
|
});
|
|
1022
1029
|
const startedAt = new Date().toISOString();
|
|
1023
1030
|
const stdout = [];
|
|
1024
1031
|
const stderr = [];
|
|
1025
1032
|
const tokenUsageRecords = [];
|
|
1026
|
-
const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
|
|
1027
1033
|
const initialBudget = budgetState(run.snapshot);
|
|
1028
1034
|
const invocationId = randomUUID();
|
|
1029
1035
|
const observationSender =
|
|
@@ -1106,18 +1112,17 @@ export async function executeProcessAttempt({
|
|
|
1106
1112
|
run.child = null;
|
|
1107
1113
|
await Promise.all(checkpointWrites);
|
|
1108
1114
|
const rawStdout = stdout.join("");
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
: (value) => parseStructuredMarker(value, marker);
|
|
1114
|
-
const result = parse(assistantText);
|
|
1115
|
+
const parsed = parseProviderStructuredResult(rawStdout, {
|
|
1116
|
+
backend, marker, allowPlainText: command.verification === "explicit_command_override",
|
|
1117
|
+
});
|
|
1118
|
+
const parseError = parsed.code === "parsed" ? null : `Provider result unavailable: ${parsed.code}.`;
|
|
1115
1119
|
const execution = {
|
|
1116
1120
|
invocationId,
|
|
1117
1121
|
exitCode: terminalError && exitCode === 0 ? 1 : exitCode,
|
|
1118
|
-
error: error || terminalError,
|
|
1122
|
+
error: error || terminalError || parseError,
|
|
1119
1123
|
...(terminalErrorCode ? { errorCode: terminalErrorCode } : {}),
|
|
1120
|
-
result:
|
|
1124
|
+
result: parsed.value,
|
|
1125
|
+
result_parse: { code: parsed.code, channel: command.verification === "explicit_command_override" ? "override_or_native" : "provider_final" },
|
|
1121
1126
|
tokenUsage:
|
|
1122
1127
|
tokenUsageRecords.length > 0 ? { records: tokenUsageRecords } : {},
|
|
1123
1128
|
logs: [...stdout, ...stderr]
|
|
@@ -1212,25 +1217,6 @@ async function pathExists(value) {
|
|
|
1212
1217
|
}
|
|
1213
1218
|
}
|
|
1214
1219
|
|
|
1215
|
-
function extractAssistantText(raw) {
|
|
1216
|
-
const messages = [];
|
|
1217
|
-
for (const line of String(raw || "").split(/\r?\n/)) {
|
|
1218
|
-
if (!line.trim().startsWith("{")) continue;
|
|
1219
|
-
try {
|
|
1220
|
-
const item = JSON.parse(line);
|
|
1221
|
-
if (item.type === "result" && typeof item.result === "string") messages.push(item.result);
|
|
1222
|
-
if (item.type === "item.completed" && item.item?.type === "agent_message") {
|
|
1223
|
-
if (typeof item.item.text === "string") messages.push(item.item.text);
|
|
1224
|
-
}
|
|
1225
|
-
if (item.type === "assistant" && typeof item.message?.content === "string") {
|
|
1226
|
-
messages.push(item.message.content);
|
|
1227
|
-
}
|
|
1228
|
-
} catch {
|
|
1229
|
-
// Non-JSON executor output is still available to the fallback parser.
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
return messages.join("\n");
|
|
1233
|
-
}
|
|
1234
1220
|
|
|
1235
1221
|
function emitOutput(run, text, { backend = run.request.backend, promote = true } = {}) {
|
|
1236
1222
|
for (const line of String(text).split(/\r?\n/)) {
|
package/src/sqm/session.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { findReusableConnection } from "../connection-discovery.js";
|
|
2
|
+
import { connectionScope } from "../connection-diagnostics.js";
|
|
3
|
+
import { CLI_VERSION, CLI_API_CONTRACT_VERSION } from "../cli-contract.js";
|
|
1
4
|
import { startAgent } from "../agent.js";
|
|
2
5
|
import { openAuthorizationUrl } from "../browser.js";
|
|
3
6
|
import { isSessionValid, readSession } from "../session-store.js";
|
|
@@ -16,6 +19,7 @@ export async function ensureSqmSession(
|
|
|
16
19
|
readSessionImpl = readSession,
|
|
17
20
|
isSessionValidImpl = isSessionValid,
|
|
18
21
|
startAgentImpl = startAgent,
|
|
22
|
+
findConnectionImpl = findReusableConnection,
|
|
19
23
|
openAuthorizationUrlImpl = openAuthorizationUrl,
|
|
20
24
|
createAgentShutdownImpl = createAgentShutdown,
|
|
21
25
|
expectedScope,
|
|
@@ -31,8 +35,11 @@ export async function ensureSqmSession(
|
|
|
31
35
|
return session;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
const
|
|
35
|
-
|
|
38
|
+
const existing = await findConnectionImpl({ webOrigin, cliVersion: CLI_VERSION,
|
|
39
|
+
apiContractVersion: CLI_API_CONTRACT_VERSION, expectedScope: connectionScope(session, webOrigin),
|
|
40
|
+
authorize: true, reauthorize: session?.token === rejectedToken && Boolean(rejectedToken) });
|
|
41
|
+
const agent = existing || await startAgentImpl({ requestedPort: 0, webOrigin, quiet: true });
|
|
42
|
+
const shutdown = existing ? async () => {} : createAgentShutdownImpl(agent);
|
|
36
43
|
try {
|
|
37
44
|
const loginUrl = agent.loginUrl;
|
|
38
45
|
if (!loginUrl) throw new Error("SIMY could not create an SQM authorization session.");
|