@awak-app/simy-cli 0.1.2 → 0.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # SIMY CLI
2
2
 
3
- Local execution agent for SIMY coding loops.
3
+ Local execution agent for SIMY Agentic Loop runs.
4
4
 
5
5
  ```bash
6
6
  npx @awak-app/simy-cli
@@ -18,16 +18,50 @@ available for automation, but the command-line flag takes precedence.
18
18
 
19
19
  The CLI starts a loopback HTTP agent on a random available port, keeps a
20
20
  48-hour, origin-scoped local session, and opens a native coding chat. The first
21
- chat message creates the remote audit ledger and starts the local coding-loop
21
+ chat message creates the remote audit ledger and starts the local Agentic Loop
22
22
  state machine; SIMY Web mirrors the lifecycle but is not required as the task
23
23
  entry point. The CLI also accepts backend-issued launch challenges for Web-led
24
24
  runs. It builds the requirement charter, runs Codex or Claude Code, audits
25
25
  structured completion evidence, and re-instructs the executor within the
26
26
  configured attempt budget.
27
27
 
28
+ ## Executor compatibility
29
+
30
+ SIMY checks the selected executor with `--version` before an Agentic Loop can
31
+ start. The currently validated compatibility floors are Codex `0.144.0` and
32
+ Claude Code `2.1.200`. A missing, older, or unversioned executable fails the
33
+ preflight and returns the installed version, required version, and a recovery
34
+ command to SIMY Web. Update Codex with
35
+ `npm install -g @openai/codex@latest`; update Claude Code with `claude update`.
36
+
37
+ ## Your Desktop executor
38
+
39
+ SIMY CLI is the `desktop` execution target, shown to users as **Your Desktop**.
40
+ It can run either Codex or Claude Code as a child process inside the verified
41
+ local Git checkout. Device heartbeats advertise both desktop provider
42
+ capabilities and their installed/required versions. Preflight and every standard
43
+ provider dispatch fail closed unless the selected binary is compatible; there
44
+ is no fallback to an unverified executable name.
45
+
46
+ Legacy local requests without `execution_target` continue to resolve to
47
+ `desktop`. A request marked for the hosted `simy` target is rejected by the
48
+ local CLI boundary so hosted and desktop execution cannot be confused. The
49
+ target and bound device id are preserved in the run Charter, redacted Web
50
+ ledger snapshot, and restored run.
51
+
52
+ ## Blocked recovery contract
53
+
54
+ Every `waiting_human`, `blocked`, or `failed` lifecycle event includes a
55
+ versioned `detail.recovery` object. It provides a stable reason code, a
56
+ user-safe explanation, a primary action, executable action metadata, and
57
+ bounded technical details. Preflight failures use the same contract for
58
+ executor updates, repository selection, and CLI reconnection. Web clients must
59
+ validate the action type before rendering or executing it; raw local error text
60
+ is not copied into the recovery contract.
61
+
28
62
  ## Interactive console
29
63
 
30
- Running `simy` in a terminal opens the Coding Loop chat. It discovers the
64
+ Running `simy` in a terminal opens the Agentic Loop chat. It discovers the
31
65
  current GitHub repository and branch, selects an available executor, and keeps
32
66
  the composer active so a natural-language request can start immediately. Each
33
67
  run appears in a selectable list with its lifecycle state, process ID, and a
@@ -74,7 +108,7 @@ The local PR lifecycle is intentionally separate from deployment:
74
108
  and human approval all pass.
75
109
 
76
110
  The Web UI can refresh review and CI evidence with
77
- `POST /v1/coding-loop/:run_id/recheck`. Passing the local implementation gate
111
+ `POST /v1/agentic-loop/:run_id/recheck`. Passing the local implementation gate
78
112
  does not by itself make a PR merge-ready.
79
113
 
80
114
  Start `simy` from the selected repository or a workspace containing it. Set
@@ -86,7 +120,7 @@ from an empty CLI chat, types repository, branch, executor, attachment, and task
86
120
  input through a pseudoterminal, creates the Web ledger, spawns real child
87
121
  processes, and exercises HIL plus lifecycle controls. It regenerates the
88
122
  screenshots, terminal frames, raw transcript, and SHA-256 manifest under
89
- `docs/pr-evidence/cli-coding-loop-console/`.
123
+ `docs/pr-evidence/cli-agentic-loop-console/`.
90
124
 
91
125
  ## Publishing
92
126
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.1.2",
4
- "description": "Local SIMY coding-loop agent for Codex and Claude Code.",
3
+ "version": "0.1.4",
4
+ "description": "Local SIMY Agentic Loop executor for Codex and Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "simy": "src/index.js"
@@ -14,11 +14,21 @@
14
14
  "start": "node ./src/index.js",
15
15
  "test": "node --test",
16
16
  "test:e2e:console": "node ./scripts/console-e2e.js",
17
+ "test:e2e:audit-gate": "node ./scripts/audit-gate-e2e.js",
18
+ "test:e2e:retry-circuit": "node ./scripts/retry-circuit-e2e.js",
19
+ "test:e2e:follow-up-charter": "node ./scripts/follow-up-charter-e2e.js",
20
+ "test:e2e:task-routing": "node ./scripts/task-routing-e2e.js",
21
+ "test:e2e:executor-version-preflight": "node ./scripts/executor-version-preflight-e2e.js",
22
+ "test:e2e:blocked-recovery": "node ./scripts/blocked-recovery-e2e.js",
23
+ "test:e2e:process-localization": "node ./scripts/process-localization-e2e.js",
24
+ "test:e2e:hypothesis-retries": "node ./scripts/hypothesis-retries-e2e.js",
25
+ "test:e2e:budgets": "node ./scripts/budget-enforcement-e2e.js",
26
+ "test:e2e:desktop-executor": "node ./scripts/desktop-executor-e2e.js",
17
27
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
18
28
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
19
- "check": "npm run check:syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
29
+ "check": "node --check ./src/desktop-executor.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
20
30
  "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",
21
- "check:syntax": "node --check ./src/index.js && node --check ./src/agent.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/run-registry.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/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.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 ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-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",
31
+ "check:syntax": "node --check ./src/index.js && node --check ./src/agent.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/run-registry.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/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 ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-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/console-e2e-fixture.js && node --check ./scripts/console-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",
22
32
  "check:package": "node ./scripts/check-package-contents.js"
23
33
  },
24
34
  "engines": {
package/src/agent.js CHANGED
@@ -36,7 +36,19 @@ import {
36
36
  referenceLocalAttachmentPaths,
37
37
  } from "./local-attachments.js";
38
38
  import { discoverWorkspace } from "./workspace-context.js";
39
- import { resolveBackendExecutable } from "./backend-executable.js";
39
+ import { BACKEND_VERSION_POLICIES } from "./backend-executable.js";
40
+ import {
41
+ DESKTOP_EXECUTION_LABEL,
42
+ DESKTOP_EXECUTION_TARGET,
43
+ desktopExecutorCapability,
44
+ inspectDesktopExecutor,
45
+ normalizeDesktopExecutionTarget,
46
+ } from "./desktop-executor.js";
47
+ import { recoveryContractForPreflight } from "./orchestrator/recovery.js";
48
+ import {
49
+ DEFAULT_RETRY_BUDGET,
50
+ DEFAULT_TOKEN_BUDGET,
51
+ } from "./orchestrator/budget.js";
40
52
  import {
41
53
  resolveWebApiBaseUrl,
42
54
  sessionRequiresWebAuthorization,
@@ -239,6 +251,7 @@ export async function startAgent({
239
251
  }
240
252
 
241
253
  const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
254
+ const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
242
255
  if (req.method === "GET" && url.pathname === "/v1/health") {
243
256
  json(res, 200, {
244
257
  ok: true,
@@ -253,7 +266,7 @@ export async function startAgent({
253
266
  json(res, 200, await readCapabilities(dependencies));
254
267
  return;
255
268
  }
256
- if (req.method === "POST" && url.pathname === "/v1/coding-loop/preflight") {
269
+ if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/preflight") {
257
270
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
258
271
  json(res, 401, { error: "simy session expired; run simy again" });
259
272
  return;
@@ -268,11 +281,22 @@ export async function startAgent({
268
281
  json(res, 400, { error: "backend must be codex or claude" });
269
282
  return;
270
283
  }
284
+ let executionTarget;
285
+ try {
286
+ executionTarget = normalizeDesktopExecutionTarget(body.execution_target);
287
+ } catch (error) {
288
+ json(res, 400, {
289
+ error: error instanceof Error ? error.message : "execution_target must be desktop",
290
+ code: "unsupported_execution_target",
291
+ });
292
+ return;
293
+ }
271
294
  const backend = body.backend;
272
295
  const indexedRepository = await ensureRepositoryIndexed(repository);
273
296
  const environment = await inspectCodingLoopEnvironment({
274
297
  repository,
275
298
  backend,
299
+ executionTarget,
276
300
  localPath:
277
301
  typeof body.local_path === "string"
278
302
  ? body.local_path
@@ -316,7 +340,7 @@ export async function startAgent({
316
340
  void synchronizeAuthorizedSession();
317
341
  return;
318
342
  }
319
- if (req.method === "POST" && url.pathname === "/v1/coding-loop/start") {
343
+ if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/start") {
320
344
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
321
345
  json(res, 401, { error: "simy session expired; run simy again" });
322
346
  return;
@@ -339,9 +363,40 @@ export async function startAgent({
339
363
  return;
340
364
  }
341
365
  if (registry.has(runId)) {
342
- json(res, 409, { error: "coding loop run already exists" });
366
+ json(res, 409, { error: "agentic loop run already exists" });
367
+ return;
368
+ }
369
+ let executionTarget;
370
+ try {
371
+ executionTarget = normalizeDesktopExecutionTarget(body.execution_target);
372
+ } catch (error) {
373
+ json(res, 400, {
374
+ error: error instanceof Error ? error.message : "execution_target must be desktop",
375
+ code: "unsupported_execution_target",
376
+ });
377
+ return;
378
+ }
379
+ if (body.backend !== "codex" && body.backend !== "claude") {
380
+ json(res, 400, { error: "backend must be codex or claude" });
343
381
  return;
344
382
  }
383
+ if (body.execution_target != null) {
384
+ const inspection = normalizeBackendInspection(body.backend, availableCapabilities);
385
+ if (!inspection.compatible) {
386
+ const summary = backendPreflightSummary(body.backend, inspection);
387
+ json(res, 409, {
388
+ error: summary,
389
+ code: `desktop_executor_${inspection.status}`,
390
+ execution_target: executionTarget,
391
+ recovery: recoveryContractForPreflight({
392
+ key: "backend",
393
+ summary,
394
+ details: inspection,
395
+ }),
396
+ });
397
+ return;
398
+ }
399
+ }
345
400
  let stagedAttachments = [];
346
401
  try {
347
402
  stagedAttachments = await stageRunAttachments({
@@ -358,6 +413,8 @@ export async function startAgent({
358
413
  runId,
359
414
  request: {
360
415
  backend: body.backend === "claude" ? "claude" : "codex",
416
+ execution_target: executionTarget,
417
+ execution_device_id: session?.device_id || null,
361
418
  audit_backend:
362
419
  body.audit_backend === "claude" || body.audit_backend === "codex"
363
420
  ? body.audit_backend
@@ -370,6 +427,8 @@ export async function startAgent({
370
427
  : indexedRepository?.local_path || null,
371
428
  base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
372
429
  max_attempts: body.max_attempts,
430
+ retry_budget: body.retry_budget,
431
+ token_budget: body.token_budget,
373
432
  ui_evidence_root:
374
433
  typeof body.ui_evidence_root === "string" ? body.ui_evidence_root : "",
375
434
  acceptance_criteria: Array.isArray(body.acceptance_criteria)
@@ -391,6 +450,12 @@ export async function startAgent({
391
450
  typeof body.design_review_url === "string" ? body.design_review_url : "",
392
451
  must_not: Array.isArray(body.must_not) ? body.must_not : [],
393
452
  proposal_id: typeof body.proposal_id === "string" ? body.proposal_id : null,
453
+ charter_context:
454
+ body.charter_context &&
455
+ typeof body.charter_context === "object" &&
456
+ !Array.isArray(body.charter_context)
457
+ ? body.charter_context
458
+ : null,
394
459
  attachments: stagedAttachments,
395
460
  },
396
461
  session,
@@ -407,7 +472,7 @@ export async function startAgent({
407
472
  return;
408
473
  }
409
474
 
410
- const controlMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/control$/);
475
+ const controlMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/control$/);
411
476
  if (req.method === "POST" && controlMatch) {
412
477
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
413
478
  json(res, 401, { error: "simy session expired; run simy again" });
@@ -421,6 +486,10 @@ export async function startAgent({
421
486
  const body = await readJson(req);
422
487
  if (body.action === "stop") {
423
488
  await stopLocalCodingRun(run);
489
+ if (run.status !== "stopped" || run.controlState !== "stopped") {
490
+ json(res, 500, { error: "local executor stop was not confirmed" });
491
+ return;
492
+ }
424
493
  } else if (body.action === "pause") {
425
494
  pauseLocalCodingRun(run);
426
495
  } else if (body.action === "resume") {
@@ -440,7 +509,7 @@ export async function startAgent({
440
509
  return;
441
510
  }
442
511
 
443
- const guidanceMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/guidance$/);
512
+ const guidanceMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/guidance$/);
444
513
  if (req.method === "POST" && guidanceMatch) {
445
514
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
446
515
  json(res, 401, { error: "simy session expired; run simy again" });
@@ -470,7 +539,7 @@ export async function startAgent({
470
539
  return;
471
540
  }
472
541
 
473
- const hilMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/hil$/);
542
+ const hilMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/hil$/);
474
543
  if (hilMatch) {
475
544
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
476
545
  json(res, 401, { error: "simy session expired; run simy again" });
@@ -545,14 +614,14 @@ export async function startAgent({
545
614
  repository: selected.repository,
546
615
  local_path: selected.local_path,
547
616
  root: scan.root,
548
- state: "starting",
617
+ state: "resuming",
549
618
  });
550
619
  void continuation.catch((error) => reportRepositoryResumeError(error, quiet));
551
620
  return;
552
621
  }
553
622
  }
554
623
 
555
- const recheckMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/recheck$/);
624
+ const recheckMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/recheck$/);
556
625
  if (req.method === "POST" && recheckMatch) {
557
626
  const run = registry.get(decodeURIComponent(recheckMatch[1]));
558
627
  if (!run) {
@@ -568,7 +637,7 @@ export async function startAgent({
568
637
  return;
569
638
  }
570
639
 
571
- const streamMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/stream$/);
640
+ const streamMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/stream$/);
572
641
  if (req.method === "GET" && streamMatch) {
573
642
  streamRun(res, registry.get(decodeURIComponent(streamMatch[1])));
574
643
  return;
@@ -636,6 +705,7 @@ export async function startAgent({
636
705
  const requirement = String(input.requirement || "").trim();
637
706
  const repository = String(input.repository || "").trim();
638
707
  const backend = input.backend === "claude" ? "claude" : "codex";
708
+ const executionTarget = normalizeDesktopExecutionTarget(input.executionTarget);
639
709
  if (!requirement) throw new Error("Describe the coding task first.");
640
710
  if (!repository) throw new Error("Set a repository with /repo owner/name.");
641
711
  if (availableCapabilities?.backends?.[backend] !== true) {
@@ -648,7 +718,11 @@ export async function startAgent({
648
718
  local_path: indexedRepository?.local_path || input.localPath || workspace.localPath,
649
719
  base_branch: String(input.baseBranch || "dev"),
650
720
  backend,
651
- max_attempts: 3,
721
+ execution_target: executionTarget,
722
+ execution_device_id: session?.device_id || null,
723
+ max_attempts: DEFAULT_RETRY_BUDGET + 1,
724
+ retry_budget: DEFAULT_RETRY_BUDGET,
725
+ token_budget: DEFAULT_TOKEN_BUDGET,
652
726
  acceptance_criteria: [],
653
727
  expected_tests: [],
654
728
  expected_evidence: [],
@@ -667,7 +741,7 @@ export async function startAgent({
667
741
  request,
668
742
  });
669
743
  const runId = String(remoteRun?.id || "");
670
- if (!runId) throw new Error("SIMY Web did not return a coding run id.");
744
+ if (!runId) throw new Error("SIMY Web did not return an Agentic Loop run id.");
671
745
  const run = createRun({ runId, request, session, apiOrigin });
672
746
  registry.create(run);
673
747
  void startLocalCodingRun(run, runOptions);
@@ -698,6 +772,8 @@ async function createRemoteRun({ apiOrigin, apiBaseUrl, token, request }) {
698
772
  backend: request.backend,
699
773
  base_branch: request.base_branch,
700
774
  max_attempts: request.max_attempts,
775
+ retry_budget: request.retry_budget,
776
+ token_budget: request.token_budget,
701
777
  acceptance_criteria: request.acceptance_criteria,
702
778
  expected_tests: request.expected_tests,
703
779
  expected_evidence: request.expected_evidence,
@@ -808,12 +884,31 @@ function localRepositoryHilRequest(run, root, authorizedRoots) {
808
884
 
809
885
  async function capabilities() {
810
886
  const [codex, claude] = await Promise.all([
811
- resolveBackendExecutable("codex"),
812
- resolveBackendExecutable("claude"),
887
+ inspectDesktopExecutor("codex"),
888
+ inspectDesktopExecutor("claude"),
813
889
  ]);
814
890
  return {
815
- backends: { codex: Boolean(codex), claude: Boolean(claude) },
816
- features: { repository_scan_approval: true },
891
+ backends: { codex: codex.compatible, claude: claude.compatible },
892
+ backend_versions: {
893
+ codex: publicBackendInspection(codex),
894
+ claude: publicBackendInspection(claude),
895
+ },
896
+ execution_targets: {
897
+ desktop: {
898
+ execution_target: DESKTOP_EXECUTION_TARGET,
899
+ label: DESKTOP_EXECUTION_LABEL,
900
+ available: codex.compatible || claude.compatible,
901
+ backends: {
902
+ codex: desktopExecutorCapability(codex),
903
+ claude: desktopExecutorCapability(claude),
904
+ },
905
+ },
906
+ },
907
+ features: {
908
+ repository_scan_approval: true,
909
+ executor_version_preflight: true,
910
+ desktop_executor: true,
911
+ },
817
912
  session_ttl_hours: 48,
818
913
  };
819
914
  }
@@ -822,13 +917,24 @@ async function readCapabilities(dependencies) {
822
917
  return dependencies.capabilities ? dependencies.capabilities() : capabilities();
823
918
  }
824
919
 
825
- async function inspectCodingLoopEnvironment({ repository, backend, localPath, dependencies }) {
920
+ async function inspectCodingLoopEnvironment({
921
+ repository,
922
+ backend,
923
+ executionTarget,
924
+ localPath,
925
+ dependencies,
926
+ }) {
826
927
  const checks = [
827
928
  {
828
929
  key: "session",
829
930
  status: "passed",
830
931
  summary: "Local CLI session is connected to this SIMY Web origin.",
831
932
  },
933
+ {
934
+ key: "execution_target",
935
+ status: "passed",
936
+ summary: `This run will execute on ${DESKTOP_EXECUTION_LABEL} through SIMY CLI.`,
937
+ },
832
938
  ];
833
939
  let repositoryPath = null;
834
940
  try {
@@ -839,32 +945,107 @@ async function inspectCodingLoopEnvironment({ repository, backend, localPath, de
839
945
  summary: `Verified ${repository} against the local Git origin.`,
840
946
  });
841
947
  } catch (error) {
948
+ const summary =
949
+ error instanceof Error ? error.message : "Local repository could not be resolved.";
842
950
  checks.push({
843
951
  key: "repository",
844
952
  status: "failed",
845
- summary: error instanceof Error ? error.message : "Local repository could not be resolved.",
953
+ summary,
954
+ recovery: recoveryContractForPreflight({ key: "repository", summary }),
846
955
  });
847
956
  }
848
957
 
849
958
  const available = await readCapabilities(dependencies);
850
- const backendAvailable = available?.backends?.[backend] === true;
959
+ const inspection = normalizeBackendInspection(backend, available);
960
+ const backendSummary = backendPreflightSummary(backend, inspection);
851
961
  checks.push({
852
962
  key: "backend",
853
- status: backendAvailable ? "passed" : "failed",
854
- summary: backendAvailable
855
- ? `${backend === "claude" ? "Claude Code" : "Codex"} is available locally.`
856
- : `${backend === "claude" ? "Claude Code" : "Codex"} is not available on PATH.`,
963
+ status: inspection.compatible ? "passed" : "failed",
964
+ summary: backendSummary,
965
+ details: {
966
+ status: inspection.status,
967
+ installed_version: inspection.installed_version,
968
+ minimum_version: inspection.minimum_version,
969
+ update_command: inspection.update_command,
970
+ },
971
+ ...(inspection.compatible
972
+ ? {}
973
+ : {
974
+ recovery: recoveryContractForPreflight({
975
+ key: "backend",
976
+ summary: backendSummary,
977
+ details: inspection,
978
+ }),
979
+ }),
857
980
  });
858
981
 
859
982
  return {
860
- ready: Boolean(repositoryPath && backendAvailable),
983
+ ready: Boolean(repositoryPath && inspection.compatible),
861
984
  repository,
862
985
  backend,
986
+ execution_target: executionTarget,
863
987
  local_path: repositoryPath,
864
988
  checks,
865
989
  };
866
990
  }
867
991
 
992
+ function publicBackendInspection(inspection) {
993
+ return {
994
+ status: inspection.status,
995
+ available: inspection.available,
996
+ compatible: inspection.compatible,
997
+ installed_version: inspection.installed_version,
998
+ minimum_version: inspection.minimum_version,
999
+ update_command: inspection.update_command,
1000
+ };
1001
+ }
1002
+
1003
+ function normalizeBackendInspection(backend, capabilitiesValue) {
1004
+ const policy = BACKEND_VERSION_POLICIES[backend];
1005
+ const detail = capabilitiesValue?.backend_versions?.[backend];
1006
+ if (detail && typeof detail === "object") {
1007
+ return {
1008
+ status: String(detail.status || (detail.compatible ? "compatible" : "missing")),
1009
+ available: detail.available === true,
1010
+ compatible: detail.compatible === true,
1011
+ installed_version:
1012
+ typeof detail.installed_version === "string" ? detail.installed_version : null,
1013
+ minimum_version:
1014
+ typeof detail.minimum_version === "string"
1015
+ ? detail.minimum_version
1016
+ : policy.minimumVersion,
1017
+ update_command:
1018
+ typeof detail.update_command === "string" ? detail.update_command : policy.updateCommand,
1019
+ };
1020
+ }
1021
+ const compatible = capabilitiesValue?.backends?.[backend] === true;
1022
+ return {
1023
+ status: compatible ? "compatible" : "missing",
1024
+ available: compatible,
1025
+ compatible,
1026
+ installed_version: null,
1027
+ minimum_version: policy.minimumVersion,
1028
+ update_command: policy.updateCommand,
1029
+ };
1030
+ }
1031
+
1032
+ function backendPreflightSummary(backend, inspection) {
1033
+ const label = BACKEND_VERSION_POLICIES[backend].label;
1034
+ if (inspection.status === "compatible" && inspection.installed_version) {
1035
+ return `${label} ${inspection.installed_version} is compatible (requires ${inspection.minimum_version} or newer).`;
1036
+ }
1037
+ if (inspection.status === "outdated") {
1038
+ return `${label} ${inspection.installed_version} is too old. Update to ${inspection.minimum_version} or newer: ${inspection.update_command}`;
1039
+ }
1040
+ if (inspection.status === "unknown_version") {
1041
+ return `${label} is installed, but its version could not be verified. Reinstall or update it: ${inspection.update_command}`;
1042
+ }
1043
+ if (inspection.status === "compatible") {
1044
+ return `${label} is available locally.`;
1045
+ }
1046
+ return `${label} is not available. Install or update it: ${inspection.update_command}`;
1047
+ }
1048
+
868
1049
  async function verifyLaunchChallenge({ apiOrigin, apiBaseUrl, token, runId, challenge }) {
869
1050
  try {
870
1051
  const response = await fetch(
@@ -886,6 +1067,10 @@ async function verifyLaunchChallenge({ apiOrigin, apiBaseUrl, token, runId, chal
886
1067
  }
887
1068
  }
888
1069
 
1070
+ function canonicalAgenticLoopPath(pathname) {
1071
+ return pathname.replace(/^\/v1\/coding-loop(?=\/|$)/, "/v1/agentic-loop");
1072
+ }
1073
+
889
1074
  function streamRun(res, run) {
890
1075
  if (!run) {
891
1076
  json(res, 404, { error: "run not found" });
@@ -2,14 +2,67 @@ import { spawn } from "node:child_process";
2
2
 
3
3
  const MACOS_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex";
4
4
 
5
- export async function resolveBackendExecutable(
5
+ export const BACKEND_VERSION_POLICIES = Object.freeze({
6
+ codex: Object.freeze({
7
+ label: "Codex",
8
+ minimumVersion: "0.144.0",
9
+ updateCommand: "npm install -g @openai/codex@latest",
10
+ }),
11
+ claude: Object.freeze({
12
+ label: "Claude Code",
13
+ minimumVersion: "2.1.200",
14
+ updateCommand: "claude update",
15
+ }),
16
+ });
17
+
18
+ export async function resolveBackendExecutable(backend, options = {}) {
19
+ const inspection = await inspectBackendExecutable(backend, options);
20
+ return inspection.available ? inspection.executable : null;
21
+ }
22
+
23
+ export async function inspectBackendExecutable(
6
24
  backend,
7
25
  { spawnImpl = spawn, candidates = backendCandidates(backend), timeoutMs = 1_500 } = {},
8
26
  ) {
27
+ const policy = versionPolicy(backend);
9
28
  for (const candidate of [...new Set(candidates.filter(Boolean))]) {
10
- if (await executableAvailable(candidate, { spawnImpl, timeoutMs })) return candidate;
29
+ const probe = await probeExecutable(candidate, { spawnImpl, timeoutMs });
30
+ if (!probe.available) continue;
31
+ const installedVersion = parseBackendVersion(probe.output);
32
+ if (!installedVersion) {
33
+ return {
34
+ backend,
35
+ executable: candidate,
36
+ available: true,
37
+ compatible: false,
38
+ status: "unknown_version",
39
+ installed_version: null,
40
+ minimum_version: policy.minimumVersion,
41
+ update_command: policy.updateCommand,
42
+ };
43
+ }
44
+ const compatible = compareVersions(installedVersion, policy.minimumVersion) >= 0;
45
+ return {
46
+ backend,
47
+ executable: candidate,
48
+ available: true,
49
+ compatible,
50
+ status: compatible ? "compatible" : "outdated",
51
+ installed_version: installedVersion,
52
+ minimum_version: policy.minimumVersion,
53
+ update_command: policy.updateCommand,
54
+ };
11
55
  }
12
- return null;
56
+ return {
57
+ backend,
58
+ executable: null,
59
+ available: false,
60
+ compatible: false,
61
+ status: "missing",
62
+ installed_version: null,
63
+ minimum_version: policy.minimumVersion,
64
+ update_command: policy.updateCommand,
65
+ };
13
66
  }
14
67
 
15
68
  export function backendCandidates(backend) {
@@ -23,16 +76,49 @@ export function backendCandidates(backend) {
23
76
  ];
24
77
  }
25
78
 
26
- function executableAvailable(command, { spawnImpl, timeoutMs }) {
79
+ export function parseBackendVersion(output) {
80
+ const match = String(output || "").match(/(?:^|\s|v)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$|\))/);
81
+ return match?.[1] ?? null;
82
+ }
83
+
84
+ export function compareVersions(left, right) {
85
+ const leftParts = numericVersionParts(left);
86
+ const rightParts = numericVersionParts(right);
87
+ for (let index = 0; index < 3; index += 1) {
88
+ if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
89
+ }
90
+ return 0;
91
+ }
92
+
93
+ function versionPolicy(backend) {
94
+ return backend === "claude" ? BACKEND_VERSION_POLICIES.claude : BACKEND_VERSION_POLICIES.codex;
95
+ }
96
+
97
+ function numericVersionParts(value) {
98
+ const match = String(value || "").match(/^(\d+)\.(\d+)\.(\d+)/);
99
+ if (!match) return [0, 0, 0];
100
+ return match.slice(1).map(Number);
101
+ }
102
+
103
+ function probeExecutable(command, { spawnImpl, timeoutMs }) {
27
104
  return new Promise((resolve) => {
28
105
  let settled = false;
29
106
  let timeout;
30
- const child = spawnImpl(command, ["--version"], { stdio: "ignore" });
107
+ let output = "";
108
+ const child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
109
+ child.stdout?.setEncoding?.("utf8");
110
+ child.stderr?.setEncoding?.("utf8");
111
+ child.stdout?.on?.("data", (chunk) => {
112
+ output += chunk;
113
+ });
114
+ child.stderr?.on?.("data", (chunk) => {
115
+ output += chunk;
116
+ });
31
117
  const finish = (available) => {
32
118
  if (settled) return;
33
119
  settled = true;
34
120
  if (timeout) clearTimeout(timeout);
35
- resolve(available);
121
+ resolve({ available, output: output.trim() });
36
122
  };
37
123
  timeout = setTimeout(() => {
38
124
  child.kill?.("SIGTERM");