@awak-app/simy-cli 0.4.0 → 0.5.1

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
@@ -9,8 +9,8 @@ npm install -g @awak-app/simy-cli
9
9
  simy --daemon
10
10
  ```
11
11
 
12
- This is the recommended installation because the daemon can safely install
13
- patch releases and restart itself when no Agentic Loop task is active. To run
12
+ This is the recommended installation because the CLI can safely install patch
13
+ and minor releases, and the daemon restarts only when no Agentic Loop task is active. To run
14
14
  the interactive chat after installation, use `simy`.
15
15
 
16
16
  For a one-time evaluation or source development, these modes remain supported:
@@ -135,23 +135,54 @@ available for older Web clients.
135
135
 
136
136
  ## CLI updates
137
137
 
138
- The CLI checks the npm registry at startup and once per hour while it is
139
- running. A daemon installed with `npm install -g` automatically installs stable
140
- patch releases only after a strict idle check confirms that there is no active,
141
- paused, blocked, or human-waiting task, executor child process, or pending
142
- ledger write. It stops accepting new work during installation, starts the new
143
- daemon, verifies its version and local health handoff, and only then closes the
144
- old process.
138
+ Every command checks the stable npm release before dispatch, including `simy`,
139
+ `simy --daemon`, `simy sqm min-check`, and `simy sqm check`. Globally installed
140
+ patch releases update silently; minor releases update automatically with one
141
+ short notice. The updated executable is verified and then re-executes the exact
142
+ original argv once. A process-wide nonce/version guard prevents update loops.
143
+ Major releases are never installed at startup: `simy update` asks for explicit
144
+ confirmation (`simy update --yes` is available for non-interactive automation).
145
145
 
146
- Foreground, `npx`, source-checkout, fixed-port, minor, and major updates are not
147
- restarted silently. The CLI instead displays the exact command a normal user
148
- can run:
146
+ Use these commands to inspect or apply an update explicitly:
147
+
148
+ ```bash
149
+ simy update --check
150
+ simy update
151
+ ```
152
+
153
+ A running daemon also checks hourly. Before installing, its strict idle gate
154
+ confirms there is no active, paused, blocked, or human-waiting task, executor
155
+ child process, or pending ledger write. It stops accepting new work during
156
+ installation, verifies the installed package, starts the new daemon, verifies
157
+ its version and local health handoff, and only then closes the old process.
158
+ Fixed-port daemons retain the manual restart path.
159
+
160
+ Only a global npm installation may be changed automatically. `npx`, a source
161
+ checkout, and a normal dependency installation are read-only and display an
162
+ exact recovery command instead:
149
163
 
150
164
  ```bash
151
165
  npm install -g @awak-app/simy-cli@<version> && simy --daemon
152
166
  ```
153
167
 
154
- Use `--no-auto-update` or `SIMY_AUTO_UPDATE=0` to disable background checks.
168
+ If installation or startup verification fails, the updater restores the
169
+ current exact version when possible and prints both the pinned recovery command
170
+ and the target command. Use `--no-auto-update` or `SIMY_AUTO_UPDATE=0` to disable
171
+ all update checks and writes for any command.
172
+
173
+ CLI `0.3.3` predates this startup updater and cannot use it to upgrade itself.
174
+ The last required manual bridge is:
175
+
176
+ ```bash
177
+ npm install -g @awak-app/simy-cli@0.4.0
178
+ ```
179
+
180
+ From `0.4.0` onward, subsequent compatible minor releases update automatically.
181
+ When SQM Cloud returns structured `incompatible_cli`, the CLI runs the same
182
+ verified updater and re-executes the original SQM command at most once. An SQM
183
+ min-check or full check pins its CLI version and signed knowledge bundle for the
184
+ entire execution; no update is applied mid-check.
185
+
155
186
  The current update state is also exposed by `GET /v1/health` as `cli_update`.
156
187
  Auto-update E2E screenshots are generated locally under
157
188
  `.artifacts/cli-auto-update/`; this ignored directory must not be committed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "test": "node --test",
16
16
  "test:e2e:console": "node ./scripts/console-e2e.js",
17
17
  "test:e2e:sqm-complete-loop": "node ./scripts/sqm-complete-loop-e2e.js",
18
+ "test:e2e:sqm-local-review": "node ./scripts/sqm-local-review-e2e.js",
18
19
  "test:e2e:console:narrow": "CLI_E2E_COLUMNS=78 CLI_E2E_ROWS=40 node ./scripts/console-e2e.js",
19
20
  "test:e2e:console:no-color": "CLI_E2E_NO_COLOR=1 node ./scripts/console-e2e.js",
20
21
  "test:e2e:console:recovery": "node ./scripts/console-recovery-e2e.js",
@@ -37,7 +38,7 @@
37
38
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
38
39
  "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-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",
39
40
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
40
- "check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.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 ./scripts/sqm-complete-loop-e2e.js",
41
+ "check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.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/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
41
42
  "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",
42
43
  "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/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/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/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",
43
44
  "check:package": "node ./scripts/check-package-contents.js"
package/src/agent.js CHANGED
@@ -115,6 +115,11 @@ import {
115
115
  normalizeLocalTaskScenarioPack,
116
116
  validateLocalTaskScenarioInputs,
117
117
  } from "./local-task-scenario-packs.js";
118
+ import { inspectSqmAgentSkill, installSqmAgentSkill } from "./sqm/agent-skill.js";
119
+ import {
120
+ createSqmLocalReviewApi,
121
+ createSqmLocalReviewWorker,
122
+ } from "./sqm/review-worker.js";
118
123
 
119
124
  const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
120
125
 
@@ -143,6 +148,10 @@ export async function startAgent({
143
148
  });
144
149
  await localTaskAttachmentStore.cleanupExpired?.();
145
150
  const authNonce = randomBytes(16).toString("base64url");
151
+ const sqmAgentIntegration = dependencies.sqmAgentIntegration || {
152
+ inspect: () => inspectSqmAgentSkill({ home: dependencies.home }),
153
+ install: (options) => installSqmAgentSkill({ home: dependencies.home, ...options }),
154
+ };
146
155
  let session = await readSession(apiOrigin, sessionRoot);
147
156
  if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
148
157
  await cleanupExpiredRuns();
@@ -168,23 +177,39 @@ export async function startAgent({
168
177
  );
169
178
  const installMode = updateManager?.snapshot?.()?.install_mode ?? null;
170
179
  const inspectedCapabilities = await readCapabilities(dependencies);
180
+ const durableControlPlane =
181
+ inspectedCapabilities?.features?.local_task_control_plane === true;
182
+ const hasSqmLocalReview = Object.hasOwn(
183
+ inspectedCapabilities?.features || {},
184
+ "sqm_local_review",
185
+ );
171
186
  const contractSourceCapabilities =
172
- inspectedCapabilities?.features?.local_task_control_plane === true
187
+ durableControlPlane || hasSqmLocalReview
173
188
  ? {
174
189
  ...inspectedCapabilities,
175
190
  features: {
176
- ...inspectedCapabilities.features,
177
- local_task_control_plane: daemon,
178
- pipeline_local_task_artifacts:
179
- daemon &&
180
- inspectedCapabilities.features.pipeline_local_task_artifacts === true,
181
- durable_local_task_attachments: daemon,
182
- durable_local_task_steps:
183
- daemon &&
184
- inspectedCapabilities.features.durable_local_task_steps === true,
185
- bounded_local_task_subtasks:
186
- daemon &&
187
- inspectedCapabilities.features.bounded_local_task_subtasks === true,
191
+ ...inspectedCapabilities?.features,
192
+ ...(durableControlPlane
193
+ ? {
194
+ local_task_control_plane: daemon,
195
+ pipeline_local_task_artifacts:
196
+ daemon &&
197
+ inspectedCapabilities.features.pipeline_local_task_artifacts === true,
198
+ durable_local_task_attachments: daemon,
199
+ durable_local_task_steps:
200
+ daemon &&
201
+ inspectedCapabilities.features.durable_local_task_steps === true,
202
+ bounded_local_task_subtasks:
203
+ daemon &&
204
+ inspectedCapabilities.features.bounded_local_task_subtasks === true,
205
+ }
206
+ : {}),
207
+ ...(hasSqmLocalReview
208
+ ? {
209
+ sqm_local_review:
210
+ daemon && inspectedCapabilities?.features?.sqm_local_review === true,
211
+ }
212
+ : {}),
188
213
  },
189
214
  }
190
215
  : inspectedCapabilities;
@@ -211,6 +236,7 @@ export async function startAgent({
211
236
  let heartbeatTimer = null;
212
237
  let heartbeatInFlight = null;
213
238
  let durableLocalTaskWorker = null;
239
+ let sqmLocalReviewWorker = null;
214
240
 
215
241
  const restoreRemoteRuns = async () => {
216
242
  if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
@@ -254,7 +280,10 @@ export async function startAgent({
254
280
  }),
255
281
  )
256
282
  .then(async () => {
257
- if (durableLocalTaskWorker) await durableLocalTaskWorker.poll();
283
+ const sqmResult = sqmLocalReviewWorker
284
+ ? await sqmLocalReviewWorker.poll()
285
+ : null;
286
+ if (!sqmResult && durableLocalTaskWorker) await durableLocalTaskWorker.poll();
258
287
  return true;
259
288
  })
260
289
  .finally(() => {
@@ -362,6 +391,27 @@ export async function startAgent({
362
391
  });
363
392
  }
364
393
 
394
+ if (daemon && availableCapabilities?.features?.sqm_local_review === true) {
395
+ const sqmApi =
396
+ dependencies.sqmLocalReviewApi ||
397
+ createSqmLocalReviewApi({
398
+ getConnection: () => ({
399
+ apiOrigin,
400
+ apiBaseUrl: session?.api_base_url,
401
+ token: session?.token,
402
+ }),
403
+ fetchImpl: dependencies.fetch || globalThis.fetch,
404
+ });
405
+ const createWorker =
406
+ dependencies.createSqmLocalReviewWorker || createSqmLocalReviewWorker;
407
+ sqmLocalReviewWorker = createWorker({
408
+ api: sqmApi,
409
+ beginWork: () => acquireUpdateWork(updateManager),
410
+ runReview: dependencies.runSqmLocalReview,
411
+ onError: (error) => reportSqmLocalReviewError(error, quiet),
412
+ });
413
+ }
414
+
365
415
  const selectRepository = async (run, repository) => {
366
416
  if (run) ensureAcceptingNewWork(updateManager);
367
417
  const selected = repository?.local_path
@@ -435,6 +485,34 @@ export async function startAgent({
435
485
  json(res, 200, refreshAvailableCapabilities());
436
486
  return;
437
487
  }
488
+ if (url.pathname === "/v1/integrations/sqm-agent") {
489
+ if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) {
490
+ json(res, 401, { error: "Reconnect SIMY CLI before managing the SQM agent skill." });
491
+ return;
492
+ }
493
+ if (req.method === "GET") {
494
+ json(res, 200, await sqmAgentIntegration.inspect());
495
+ return;
496
+ }
497
+ if (req.method === "POST") {
498
+ const body = await readJson(req);
499
+ if (body.confirmed !== true) {
500
+ json(res, 400, {
501
+ error: "Explicit confirmation is required before installing the SIMY plugin, agent skills, and PR hooks.",
502
+ });
503
+ return;
504
+ }
505
+ try {
506
+ const installed = await sqmAgentIntegration.install({
507
+ platforms: Array.isArray(body.platforms) ? body.platforms : undefined,
508
+ });
509
+ json(res, 200, installed);
510
+ } catch (error) {
511
+ json(res, 400, { error: error instanceof Error ? error.message : String(error) });
512
+ }
513
+ return;
514
+ }
515
+ }
438
516
  if (
439
517
  req.method === "POST" &&
440
518
  url.pathname === "/v1/local-tasks/attachments/stage"
@@ -1482,8 +1560,9 @@ export async function startAgent({
1482
1560
  if (shutdownPromise) return shutdownPromise;
1483
1561
  shutdownPromise = (async () => {
1484
1562
  if (heartbeatTimer) clearInterval(heartbeatTimer);
1485
- await durableLocalTaskWorker?.close?.();
1486
1563
  updateManager?.stop?.();
1564
+ await sqmLocalReviewWorker?.close?.();
1565
+ await durableLocalTaskWorker?.close?.();
1487
1566
  registry.close();
1488
1567
  localTaskRegistry.close();
1489
1568
  })();
@@ -1499,6 +1578,7 @@ export async function startAgent({
1499
1578
  directRegistry,
1500
1579
  localTaskRegistry,
1501
1580
  durableLocalTaskWorker,
1581
+ sqmLocalReviewWorker,
1502
1582
  shutdown,
1503
1583
  webOrigin: apiOrigin,
1504
1584
  get loginUrl() {
@@ -1716,6 +1796,13 @@ function reportDurableLocalTaskError(error, quiet) {
1716
1796
  );
1717
1797
  }
1718
1798
 
1799
+ function reportSqmLocalReviewError(error, quiet) {
1800
+ if (quiet) return;
1801
+ console.error(
1802
+ `SIMY SQM Local Review worker: ${error instanceof Error ? error.message : String(error)}`,
1803
+ );
1804
+ }
1805
+
1719
1806
  function reportStartupConnectionError(error, quiet) {
1720
1807
  if (quiet) return;
1721
1808
  console.error(
@@ -1808,6 +1895,8 @@ async function capabilities() {
1808
1895
  durable_local_task_scenario_packs: true,
1809
1896
  durable_local_task_steps: true,
1810
1897
  bounded_local_task_subtasks: true,
1898
+ sqm_agent_skill_management: true,
1899
+ sqm_local_review: codex.compatible,
1811
1900
  },
1812
1901
  session_ttl_hours: 48,
1813
1902
  };
@@ -19,6 +19,8 @@ const execFileAsync = promisify(execFile);
19
19
 
20
20
  export const CLI_PACKAGE_NAME = "@awak-app/simy-cli";
21
21
  export const AUTO_UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1_000;
22
+ export const UPDATE_REEXEC_ENV = "SIMY_UPDATE_REEXEC_VERSION";
23
+ export const INCOMPATIBLE_RETRY_ENV = "SIMY_UPDATE_INCOMPATIBLE_RETRY";
22
24
  const AUTO_UPDATE_INITIAL_DELAY_MS = 15_000;
23
25
  const AUTO_UPDATE_IDLE_RETRY_MS = 30_000;
24
26
  const UPDATE_LOCK_STALE_MS = 30 * 60 * 1_000;
@@ -166,10 +168,8 @@ export class CliAutoUpdater extends EventEmitter {
166
168
  return this.snapshot();
167
169
  }
168
170
 
169
- const automatic = this.#snapshot.automatic && isPatchUpgrade(
170
- this.#options.currentVersion,
171
- targetVersion,
172
- );
171
+ const change = classifyVersionChange(this.#options.currentVersion, targetVersion);
172
+ const automatic = this.#snapshot.automatic && ["patch", "minor"].includes(change);
173
173
  const action = updateAction(targetVersion);
174
174
  if (!automatic) {
175
175
  this.#clearIdleTimer();
@@ -256,7 +256,12 @@ export class CliAutoUpdater extends EventEmitter {
256
256
  return this.snapshot();
257
257
  }
258
258
 
259
- await this.#options.installVersion(targetVersion);
259
+ await installAndVerifyVersion({
260
+ currentVersion: this.#options.currentVersion,
261
+ targetVersion,
262
+ installVersion: this.#options.installVersion,
263
+ readInstalledVersion: this.#options.readInstalledVersion,
264
+ });
260
265
  if (typeof this.#restartDaemon !== "function") {
261
266
  throw new Error("The daemon restart handoff is unavailable.");
262
267
  }
@@ -343,8 +348,11 @@ export async function createCliAutoUpdater({
343
348
  installVersion:
344
349
  dependencies.installVersion ||
345
350
  ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand })),
351
+ readInstalledVersion:
352
+ dependencies.readInstalledVersion ||
353
+ (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand })),
346
354
  acquireUpdateLock:
347
- dependencies.acquireUpdateLock || (() => acquireUpdateLock(stateRoot, dependencies)),
355
+ dependencies.acquireUpdateLock || (() => acquireGlobalUpdateLock(stateRoot, dependencies)),
348
356
  logger: dependencies.logger || console.log,
349
357
  quiet: Boolean(interactive),
350
358
  now: dependencies.now || Date.now,
@@ -416,6 +424,214 @@ export async function installGlobalNpmVersion(
416
424
  );
417
425
  }
418
426
 
427
+ export async function readGlobalNpmVersion({ execFileCommand = execFileAsync } = {}) {
428
+ const { stdout } = await execFileCommand("npm", ["root", "--global"], {
429
+ timeout: 10_000,
430
+ encoding: "utf8",
431
+ });
432
+ const packagePath = path.join(String(stdout || "").trim(), ...CLI_PACKAGE_NAME.split("/"), "package.json");
433
+ return (await readCliPackageInfo(path.dirname(packagePath))).version;
434
+ }
435
+
436
+ export async function installAndVerifyVersion({
437
+ currentVersion,
438
+ targetVersion,
439
+ installVersion = installGlobalNpmVersion,
440
+ readInstalledVersion = readGlobalNpmVersion,
441
+ }) {
442
+ try {
443
+ await installVersion(targetVersion);
444
+ const installedVersion = await readInstalledVersion();
445
+ if (installedVersion !== targetVersion) {
446
+ throw new Error(`npm reported success, but installed SIMY ${installedVersion}; expected ${targetVersion}.`);
447
+ }
448
+ return installedVersion;
449
+ } catch (error) {
450
+ let recoveryError = null;
451
+ try {
452
+ await installVersion(currentVersion);
453
+ const restoredVersion = await readInstalledVersion();
454
+ if (restoredVersion !== currentVersion) {
455
+ throw new Error(`restored version is ${restoredVersion || "unknown"}`);
456
+ }
457
+ } catch (restoreError) {
458
+ recoveryError = restoreError;
459
+ }
460
+ const suffix = recoveryError
461
+ ? ` Automatic rollback could not be verified (${errorMessage(recoveryError)}).`
462
+ : ` SIMY ${currentVersion} was restored.`;
463
+ throw new Error(`${errorMessage(error)}${suffix}`);
464
+ }
465
+ }
466
+
467
+ export async function runCliUpdateGate({
468
+ argv = [],
469
+ packageInfo,
470
+ mode = "startup",
471
+ disabled = false,
472
+ allowMajor = false,
473
+ confirmMajor,
474
+ incompatibleRetry = false,
475
+ packageRoot = PACKAGE_ROOT,
476
+ entryPath,
477
+ env = process.env,
478
+ logger = console.log,
479
+ errorLogger = console.error,
480
+ dependencies = {},
481
+ } = {}) {
482
+ const info = packageInfo || (await readCliPackageInfo(packageRoot));
483
+ const installMode = dependencies.installMode || (await detectInstallMode({
484
+ packageRoot,
485
+ execFileCommand: dependencies.execFileCommand,
486
+ }));
487
+ const result = {
488
+ currentVersion: info.version,
489
+ latestVersion: null,
490
+ installMode,
491
+ outcome: disabled ? "disabled" : "not_checked",
492
+ exitCode: null,
493
+ reexecuted: false,
494
+ };
495
+ const report = (outcome, latest = null) => {
496
+ result.outcome = outcome;
497
+ result.latestVersion = latest;
498
+ if (mode !== "startup") {
499
+ logger(`Current version: ${info.version}`);
500
+ logger(`Latest version: ${latest || "unknown"}`);
501
+ logger(`Install mode: ${installMode}`);
502
+ logger(`Result: ${outcome}`);
503
+ }
504
+ return result;
505
+ };
506
+
507
+ if (disabled) return report("disabled");
508
+ if (mode === "startup" && (
509
+ env.SIMY_DAEMON_CHILD === "1" || env[UPDATE_REEXEC_ENV] === info.version
510
+ )) {
511
+ return report("reexec_guard");
512
+ }
513
+
514
+ let release;
515
+ try {
516
+ const releaseInfo = await (dependencies.checkLatestVersion || checkLatestVersion)();
517
+ release = String(releaseInfo?.version || "").trim();
518
+ if (!parseStableVersion(release)) throw new Error("The npm registry returned an invalid stable CLI version.");
519
+ } catch (error) {
520
+ if (mode !== "startup") errorLogger(`Update check failed: ${errorMessage(error)}`);
521
+ result.error = errorMessage(error);
522
+ return report("check_failed");
523
+ }
524
+
525
+ if (compareStableVersions(release, info.version) <= 0) return report("up_to_date", release);
526
+ const change = classifyVersionChange(info.version, release);
527
+ const action = updateAction(release);
528
+ result.recoveryCommand = action.command;
529
+
530
+ if (installMode !== "global_npm") {
531
+ logger(`SIMY CLI ${release} is available. This ${installMode} installation is read-only; run: ${action.command}`);
532
+ return report("manual_install_required", release);
533
+ }
534
+ if (change === "major" && !allowMajor) {
535
+ if (mode === "update" && typeof confirmMajor === "function") {
536
+ const confirmed = await confirmMajor({ currentVersion: info.version, latestVersion: release });
537
+ if (!confirmed) {
538
+ logger(`SIMY CLI ${release} was not installed.`);
539
+ return report("major_update_declined", release);
540
+ }
541
+ } else {
542
+ logger(`SIMY CLI ${release} is a major update and was not installed. Run \`simy update\` to review and confirm it.`);
543
+ return report("major_confirmation_required", release);
544
+ }
545
+ }
546
+ if (mode === "check") return report("update_available", release);
547
+
548
+ if (change === "minor") logger(`Updating SIMY CLI ${info.version} → ${release}...`);
549
+ if (change === "major") logger(`Installing confirmed major SIMY CLI update ${info.version} → ${release}...`);
550
+ const stateRoot = dependencies.stateRoot || defaultStateRoot();
551
+ const acquire = dependencies.acquireUpdateLock || (() => acquireGlobalUpdateLock(stateRoot, dependencies));
552
+ let releaseLock = await acquire();
553
+ if (!releaseLock) {
554
+ const installed = await waitForInstalledVersion(release, {
555
+ readInstalledVersion: dependencies.readInstalledVersion || (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand })),
556
+ timeoutMs: dependencies.lockWaitTimeoutMs,
557
+ pollMs: dependencies.lockPollMs,
558
+ });
559
+ if (!installed) {
560
+ logger(`Another SIMY process holds the update lock. Retry shortly or run: ${action.command}`);
561
+ return report("waiting_for_other_updater", release);
562
+ }
563
+ } else {
564
+ try {
565
+ await installAndVerifyVersion({
566
+ currentVersion: info.version,
567
+ targetVersion: release,
568
+ installVersion: dependencies.installVersion || ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand })),
569
+ readInstalledVersion: dependencies.readInstalledVersion || (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand })),
570
+ });
571
+ } catch (error) {
572
+ errorLogger(`SIMY update failed: ${errorMessage(error)}`);
573
+ errorLogger(`Recovery: npm install -g ${CLI_PACKAGE_NAME}@${info.version}`);
574
+ result.error = errorMessage(error);
575
+ return report("install_failed", release);
576
+ } finally {
577
+ await releaseLock();
578
+ }
579
+ }
580
+
581
+ if (mode === "update" && !entryPath) return report("updated", release);
582
+ if (!entryPath) throw new Error("The CLI entry path is required to restart after an update.");
583
+ const reexecute = dependencies.reexecute || reexecuteCliCommand;
584
+ const childEnv = {
585
+ ...env,
586
+ [UPDATE_REEXEC_ENV]: release,
587
+ };
588
+ if (incompatibleRetry) childEnv[INCOMPATIBLE_RETRY_ENV] = "1";
589
+ let exitCode;
590
+ try {
591
+ exitCode = await reexecute({ entryPath, argv, env: childEnv, spawnProcess: dependencies.spawnProcess });
592
+ } catch (error) {
593
+ let rollbackDetail = `Recovery: npm install -g ${CLI_PACKAGE_NAME}@${info.version}`;
594
+ try {
595
+ const installVersion = dependencies.installVersion || ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand }));
596
+ const readInstalledVersion = dependencies.readInstalledVersion || (() => readGlobalNpmVersion({ execFileCommand: dependencies.execFileCommand }));
597
+ await installVersion(info.version);
598
+ const restored = await readInstalledVersion();
599
+ if (restored !== info.version) throw new Error(`installed version is ${restored || "unknown"}`);
600
+ rollbackDetail = `SIMY ${info.version} was restored. ${rollbackDetail}`;
601
+ } catch (rollbackError) {
602
+ rollbackDetail = `Automatic rollback failed (${errorMessage(rollbackError)}). ${rollbackDetail}`;
603
+ }
604
+ errorLogger(`Updated SIMY could not start: ${errorMessage(error)}`);
605
+ errorLogger(rollbackDetail);
606
+ result.error = errorMessage(error);
607
+ return report("restart_failed", release);
608
+ }
609
+ result.exitCode = exitCode;
610
+ result.reexecuted = true;
611
+ result.outcome = "updated_and_reexecuted";
612
+ result.latestVersion = release;
613
+ return result;
614
+ }
615
+
616
+ export async function reexecuteCliCommand({
617
+ entryPath,
618
+ argv,
619
+ env = process.env,
620
+ spawnProcess = spawn,
621
+ } = {}) {
622
+ return new Promise((resolve, reject) => {
623
+ const child = spawnProcess(process.execPath, [entryPath, ...argv], {
624
+ env,
625
+ stdio: "inherit",
626
+ });
627
+ child.once("error", reject);
628
+ child.once("exit", (code, signal) => {
629
+ if (signal) reject(new Error(`Updated SIMY exited from signal ${signal}.`));
630
+ else resolve(code ?? 1);
631
+ });
632
+ });
633
+ }
634
+
419
635
  export function isRegistryIdle(registry) {
420
636
  const runs = registry?.list?.() || [];
421
637
  return runs.every((run) => {
@@ -450,6 +666,20 @@ export function isPatchUpgrade(currentVersion, targetVersion) {
450
666
  );
451
667
  }
452
668
 
669
+ export function classifyVersionChange(currentVersion, targetVersion) {
670
+ const current = parseStableVersion(currentVersion);
671
+ const target = parseStableVersion(targetVersion);
672
+ if (!current || !target) throw new Error("A stable semantic version is required.");
673
+ if (compareStableVersions(currentVersion, targetVersion) >= 0) return "none";
674
+ if (current[0] !== target[0]) return "major";
675
+ if (current[1] !== target[1]) return "minor";
676
+ return "patch";
677
+ }
678
+
679
+ export function shouldRetryIncompatibleCli(error, env = process.env) {
680
+ return error?.code === "incompatible_cli" && env[INCOMPATIBLE_RETRY_ENV] !== "1";
681
+ }
682
+
453
683
  export async function restartDaemonProcess({
454
684
  entryPath,
455
685
  argv,
@@ -518,7 +748,7 @@ export async function writeUpdateHandoffFromEnvironment({
518
748
 
519
749
  export function autoUpdateHelpText() {
520
750
  return [
521
- "Recommended installation (enables daemon auto-update):",
751
+ "Recommended installation (enables verified CLI auto-update):",
522
752
  ` npm install -g ${CLI_PACKAGE_NAME}`,
523
753
  " simy --daemon",
524
754
  ].join("\n");
@@ -543,7 +773,7 @@ function manualUpdateMessage({
543
773
  return `SIMY CLI ${targetVersion} is available. This version change requires manual confirmation.`;
544
774
  }
545
775
 
546
- function updateAction(version) {
776
+ export function updateAction(version) {
547
777
  return {
548
778
  id: "install_global_cli",
549
779
  label: "Install and restart SIMY",
@@ -556,7 +786,7 @@ function parseStableVersion(value) {
556
786
  return match ? match.slice(1).map(Number) : null;
557
787
  }
558
788
 
559
- async function acquireUpdateLock(stateRoot, dependencies = {}) {
789
+ export async function acquireGlobalUpdateLock(stateRoot = defaultStateRoot(), dependencies = {}) {
560
790
  const now = dependencies.now || Date.now;
561
791
  const lockDirectory = path.join(stateRoot, "updates");
562
792
  const lockPath = path.join(lockDirectory, "global-npm-update.lock");
@@ -585,6 +815,23 @@ async function acquireUpdateLock(stateRoot, dependencies = {}) {
585
815
  return null;
586
816
  }
587
817
 
818
+ async function waitForInstalledVersion(targetVersion, {
819
+ readInstalledVersion,
820
+ timeoutMs = 30_000,
821
+ pollMs = 100,
822
+ }) {
823
+ const startedAt = Date.now();
824
+ while (Date.now() - startedAt < timeoutMs) {
825
+ try {
826
+ if ((await readInstalledVersion()) === targetVersion) return true;
827
+ } catch {
828
+ // npm may replace the package directory while the lock owner is installing.
829
+ }
830
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
831
+ }
832
+ return false;
833
+ }
834
+
588
835
  async function isStaleLock(lockPath, now) {
589
836
  try {
590
837
  const details = await stat(lockPath);
@@ -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.4.0";
16
+ export const CLI_VERSION = "0.5.1";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(