@awak-app/simy-cli 0.1.4 → 0.1.5

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
@@ -2,11 +2,22 @@
2
2
 
3
3
  Local execution agent for SIMY Agentic Loop runs.
4
4
 
5
+ For normal use, install SIMY globally and keep its background agent running:
6
+
5
7
  ```bash
6
- npx @awak-app/simy-cli
7
8
  npm install -g @awak-app/simy-cli
8
- simy
9
9
  simy --daemon
10
+ ```
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
14
+ the interactive chat after installation, use `simy`.
15
+
16
+ For a one-time evaluation or source development, these modes remain supported:
17
+
18
+ ```bash
19
+ npx @awak-app/simy-cli
20
+ npm start
10
21
  simy --no-tui
11
22
  simy --host https://simy.example.com
12
23
  ```
@@ -25,6 +36,29 @@ runs. It builds the requirement charter, runs Codex or Claude Code, audits
25
36
  structured completion evidence, and re-instructs the executor within the
26
37
  configured attempt budget.
27
38
 
39
+ ## CLI updates
40
+
41
+ The CLI checks the npm registry at startup and once per hour while it is
42
+ running. A daemon installed with `npm install -g` automatically installs stable
43
+ patch releases only after a strict idle check confirms that there is no active,
44
+ paused, blocked, or human-waiting task, executor child process, or pending
45
+ ledger write. It stops accepting new work during installation, starts the new
46
+ daemon, verifies its version and local health handoff, and only then closes the
47
+ old process.
48
+
49
+ Foreground, `npx`, source-checkout, fixed-port, minor, and major updates are not
50
+ restarted silently. The CLI instead displays the exact command a normal user
51
+ can run:
52
+
53
+ ```bash
54
+ npm install -g @awak-app/simy-cli@<version> && simy --daemon
55
+ ```
56
+
57
+ Use `--no-auto-update` or `SIMY_AUTO_UPDATE=0` to disable background checks.
58
+ The current update state is also exposed by `GET /v1/health` as `cli_update`.
59
+ Auto-update E2E screenshots are generated locally under
60
+ `.artifacts/cli-auto-update/`; this ignored directory must not be committed.
61
+
28
62
  ## Executor compatibility
29
63
 
30
64
  SIMY checks the selected executor with `--version` before an Agentic Loop can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Local SIMY Agentic Loop executor for Codex and Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
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:auto-update": "node ./scripts/auto-update-e2e.js",
17
18
  "test:e2e:audit-gate": "node ./scripts/audit-gate-e2e.js",
18
19
  "test:e2e:retry-circuit": "node ./scripts/retry-circuit-e2e.js",
19
20
  "test:e2e:follow-up-charter": "node ./scripts/follow-up-charter-e2e.js",
@@ -26,7 +27,8 @@
26
27
  "test:e2e:desktop-executor": "node ./scripts/desktop-executor-e2e.js",
27
28
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
28
29
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
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",
30
+ "check": "npm run check:auto-update && 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",
31
+ "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js",
30
32
  "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",
31
33
  "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",
32
34
  "check:package": "node ./scripts/check-package-contents.js"
package/src/agent.js CHANGED
@@ -72,6 +72,7 @@ export async function startAgent({
72
72
  daemon = false,
73
73
  webOrigin = null,
74
74
  sessionRoot,
75
+ updateManager = null,
75
76
  dependencies = {},
76
77
  quiet = false,
77
78
  } = {}) {
@@ -219,6 +220,7 @@ export async function startAgent({
219
220
  };
220
221
 
221
222
  const selectRepository = async (run, repository) => {
223
+ if (run) ensureAcceptingNewWork(updateManager);
222
224
  const selected = repository?.local_path
223
225
  ? repositoryInventory.find(
224
226
  (item) =>
@@ -231,10 +233,14 @@ export async function startAgent({
231
233
  throw new Error(`This run requires ${run.request.repository}; select that local repository.`);
232
234
  }
233
235
  if (!run) return selected;
234
- return continueLocalCodingRunAfterRepositoryApproval(
235
- run,
236
- { repository: selected.repository, localPath: selected.local_path },
237
- runOptions,
236
+ return withUpdateWork(
237
+ updateManager,
238
+ () =>
239
+ continueLocalCodingRunAfterRepositoryApproval(
240
+ run,
241
+ { repository: selected.repository, localPath: selected.local_path },
242
+ runOptions,
243
+ ),
238
244
  );
239
245
  };
240
246
 
@@ -252,6 +258,19 @@ export async function startAgent({
252
258
 
253
259
  const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
254
260
  const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
261
+ if (
262
+ req.method === "POST" &&
263
+ agenticLoopPath.startsWith("/v1/agentic-loop") &&
264
+ agenticLoopPath !== "/v1/agentic-loop/preflight"
265
+ ) {
266
+ const releaseUpdateWork = acquireUpdateWork(updateManager);
267
+ if (!releaseUpdateWork) {
268
+ json(res, 503, updateInProgressResponse(updateManager));
269
+ return;
270
+ }
271
+ res.once("finish", releaseUpdateWork);
272
+ res.once("close", releaseUpdateWork);
273
+ }
255
274
  if (req.method === "GET" && url.pathname === "/v1/health") {
256
275
  json(res, 200, {
257
276
  ok: true,
@@ -259,6 +278,7 @@ export async function startAgent({
259
278
  web_origin: apiOrigin,
260
279
  session_valid: isSessionValid(session, Date.now(), apiOrigin),
261
280
  session_expires_at: session?.expires_at ?? null,
281
+ cli_update: updateManager?.snapshot?.() ?? null,
262
282
  });
263
283
  return;
264
284
  }
@@ -341,6 +361,10 @@ export async function startAgent({
341
361
  return;
342
362
  }
343
363
  if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/start") {
364
+ if (!acceptsNewWork(updateManager)) {
365
+ json(res, 503, updateInProgressResponse(updateManager));
366
+ return;
367
+ }
344
368
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
345
369
  json(res, 401, { error: "simy session expired; run simy again" });
346
370
  return;
@@ -493,6 +517,10 @@ export async function startAgent({
493
517
  } else if (body.action === "pause") {
494
518
  pauseLocalCodingRun(run);
495
519
  } else if (body.action === "resume") {
520
+ if (!acceptsNewWork(updateManager)) {
521
+ json(res, 503, updateInProgressResponse(updateManager));
522
+ return;
523
+ }
496
524
  resumeLocalCodingRun(run);
497
525
  } else {
498
526
  json(res, 400, { error: "action must be pause, resume, or stop" });
@@ -567,6 +595,10 @@ export async function startAgent({
567
595
  }
568
596
 
569
597
  if (req.method === "POST") {
598
+ if (!acceptsNewWork(updateManager)) {
599
+ json(res, 503, updateInProgressResponse(updateManager));
600
+ return;
601
+ }
570
602
  const body = await readJson(req);
571
603
  const requestId = localRepositoryHilRequestId(run);
572
604
  if (body.request_id !== requestId) {
@@ -623,6 +655,10 @@ export async function startAgent({
623
655
 
624
656
  const recheckMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/recheck$/);
625
657
  if (req.method === "POST" && recheckMatch) {
658
+ if (!acceptsNewWork(updateManager)) {
659
+ json(res, 503, updateInProgressResponse(updateManager));
660
+ return;
661
+ }
626
662
  const run = registry.get(decodeURIComponent(recheckMatch[1]));
627
663
  if (!run) {
628
664
  json(res, 404, { error: "run not found" });
@@ -681,6 +717,7 @@ export async function startAgent({
681
717
 
682
718
  server.on("close", () => {
683
719
  if (heartbeatTimer) clearInterval(heartbeatTimer);
720
+ updateManager?.stop?.();
684
721
  registry.close();
685
722
  });
686
723
  return {
@@ -694,11 +731,12 @@ export async function startAgent({
694
731
  workspace,
695
732
  repositoryScanRoot,
696
733
  capabilities: availableCapabilities,
734
+ updates: updateManager,
697
735
  controls: {
698
736
  repositoryInventory: () => [...repositoryInventory],
699
737
  scanRepositories,
700
738
  selectRepository,
701
- create: async (input) => {
739
+ create: (input) => withUpdateWork(updateManager, async () => {
702
740
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
703
741
  throw new Error("Sign in to SIMY before starting a coding task.");
704
742
  }
@@ -746,15 +784,69 @@ export async function startAgent({
746
784
  registry.create(run);
747
785
  void startLocalCodingRun(run, runOptions);
748
786
  return run;
749
- },
750
- continue: (run, guidance) => continueLocalCodingRun(run, guidance, runOptions),
751
- applyDecision: (run, decision) => applyLocalHumanDecision(run, decision, runOptions),
787
+ }),
788
+ continue: (run, guidance) =>
789
+ withUpdateWork(updateManager, () => continueLocalCodingRun(run, guidance, runOptions)),
790
+ applyDecision: (run, decision) =>
791
+ withUpdateWork(updateManager, () => applyLocalHumanDecision(run, decision, runOptions)),
752
792
  queueGuidance: queueLocalCodingGuidance,
753
793
  pause: pauseLocalCodingRun,
754
- resume: resumeLocalCodingRun,
794
+ resume: (run) => withUpdateWorkSync(updateManager, () => resumeLocalCodingRun(run)),
755
795
  stop: stopLocalCodingRun,
756
796
  recheck: (run) =>
757
- recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence }),
797
+ withUpdateWork(updateManager, () =>
798
+ recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence }),
799
+ ),
800
+ },
801
+ };
802
+ }
803
+
804
+ function acceptsNewWork(updateManager) {
805
+ return updateManager?.acceptsNewWork?.() !== false;
806
+ }
807
+
808
+ function acquireUpdateWork(updateManager) {
809
+ if (!acceptsNewWork(updateManager)) return null;
810
+ return updateManager?.beginWork?.() ?? (() => {});
811
+ }
812
+
813
+ function ensureAcceptingNewWork(updateManager) {
814
+ if (acceptsNewWork(updateManager)) return;
815
+ throw new Error(
816
+ updateManager?.snapshot?.().message ||
817
+ "SIMY is installing an update. Wait for the daemon to restart, then try again.",
818
+ );
819
+ }
820
+
821
+ async function withUpdateWork(updateManager, action) {
822
+ const release = acquireUpdateWork(updateManager);
823
+ if (!release) ensureAcceptingNewWork(updateManager);
824
+ try {
825
+ return await action();
826
+ } finally {
827
+ release?.();
828
+ }
829
+ }
830
+
831
+ function withUpdateWorkSync(updateManager, action) {
832
+ const release = acquireUpdateWork(updateManager);
833
+ if (!release) ensureAcceptingNewWork(updateManager);
834
+ try {
835
+ return action();
836
+ } finally {
837
+ release?.();
838
+ }
839
+ }
840
+
841
+ function updateInProgressResponse(updateManager) {
842
+ return {
843
+ error:
844
+ updateManager?.snapshot?.().message ||
845
+ "SIMY is installing an update. Wait for the daemon to restart, then try again.",
846
+ code: "cli_update_in_progress",
847
+ recovery: {
848
+ title: "Wait for SIMY to restart",
849
+ description: "The CLI will reconnect automatically after the update finishes.",
758
850
  },
759
851
  };
760
852
  }
@@ -0,0 +1,631 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { EventEmitter } from "node:events";
4
+ import {
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ realpath,
9
+ rm,
10
+ stat,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { promisify } from "node:util";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ export const CLI_PACKAGE_NAME = "@awak-app/simy-cli";
21
+ export const AUTO_UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1_000;
22
+ const AUTO_UPDATE_INITIAL_DELAY_MS = 15_000;
23
+ const AUTO_UPDATE_IDLE_RETRY_MS = 30_000;
24
+ const UPDATE_LOCK_STALE_MS = 30 * 60 * 1_000;
25
+ const RESTART_HANDOFF_TIMEOUT_MS = 30_000;
26
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
27
+ const NON_IDLE_STATES = new Set([
28
+ "queued",
29
+ "risk_classifying",
30
+ "chartering",
31
+ "dispatching",
32
+ "coding",
33
+ "collecting_evidence",
34
+ "auditing",
35
+ "independent_auditing",
36
+ "checking_pr",
37
+ "re_instructing",
38
+ "waiting_human",
39
+ "blocked",
40
+ ]);
41
+ const NON_IDLE_CONTROL_STATES = new Set([
42
+ "queued",
43
+ "running",
44
+ "paused",
45
+ "waiting_human",
46
+ "stopping",
47
+ ]);
48
+
49
+ export class CliAutoUpdater extends EventEmitter {
50
+ #snapshot;
51
+ #options;
52
+ #registry = null;
53
+ #restartDaemon = null;
54
+ #checkTimer = null;
55
+ #initialTimer = null;
56
+ #idleTimer = null;
57
+ #checking = null;
58
+ #applying = null;
59
+ #stopped = false;
60
+ #registryListener = null;
61
+ #activeWork = 0;
62
+
63
+ constructor(options) {
64
+ super();
65
+ this.#options = options;
66
+ this.#snapshot = {
67
+ state: options.disabled ? "disabled" : "idle",
68
+ current_version: options.currentVersion,
69
+ target_version: null,
70
+ install_mode: options.installMode,
71
+ automatic: Boolean(
72
+ !options.disabled &&
73
+ options.daemon &&
74
+ options.installMode === "global_npm" &&
75
+ options.requestedPort === 0
76
+ ),
77
+ checked_at: null,
78
+ message: options.disabled
79
+ ? "Automatic update checks are disabled."
80
+ : "SIMY will check for CLI updates in the background.",
81
+ action: null,
82
+ error: null,
83
+ };
84
+ }
85
+
86
+ snapshot() {
87
+ return structuredClone(this.#snapshot);
88
+ }
89
+
90
+ acceptsNewWork() {
91
+ return !["updating", "restarting"].includes(this.#snapshot.state);
92
+ }
93
+
94
+ beginWork() {
95
+ if (!this.acceptsNewWork()) return null;
96
+ this.#activeWork += 1;
97
+ let released = false;
98
+ return () => {
99
+ if (released) return;
100
+ released = true;
101
+ this.#activeWork = Math.max(0, this.#activeWork - 1);
102
+ if (this.#activeWork === 0 && this.#snapshot.state === "waiting_for_idle") {
103
+ void this.maybeApply();
104
+ }
105
+ };
106
+ }
107
+
108
+ start({ registry, restartDaemon } = {}) {
109
+ if (this.#snapshot.state === "disabled" || this.#checkTimer || this.#stopped) return this;
110
+ this.#registry = registry;
111
+ this.#restartDaemon = restartDaemon;
112
+ this.#registryListener = () => {
113
+ if (this.#snapshot.state === "waiting_for_idle") void this.maybeApply();
114
+ };
115
+ this.#registry?.on("change", this.#registryListener);
116
+ this.#initialTimer = setTimeout(() => void this.checkNow(), this.#options.initialDelayMs);
117
+ this.#initialTimer.unref?.();
118
+ this.#checkTimer = setInterval(() => void this.checkNow(), this.#options.checkIntervalMs);
119
+ this.#checkTimer.unref?.();
120
+ return this;
121
+ }
122
+
123
+ stop() {
124
+ this.#stopped = true;
125
+ if (this.#initialTimer) clearTimeout(this.#initialTimer);
126
+ if (this.#checkTimer) clearInterval(this.#checkTimer);
127
+ if (this.#idleTimer) clearInterval(this.#idleTimer);
128
+ if (this.#registryListener) this.#registry?.off("change", this.#registryListener);
129
+ this.#initialTimer = null;
130
+ this.#checkTimer = null;
131
+ this.#idleTimer = null;
132
+ }
133
+
134
+ async checkNow() {
135
+ if (this.#stopped || this.#snapshot.state === "disabled") return this.snapshot();
136
+ if (this.#checking) return this.#checking;
137
+ if (["updating", "restarting"].includes(this.#snapshot.state)) return this.snapshot();
138
+
139
+ this.#checking = this.#performCheck().finally(() => {
140
+ this.#checking = null;
141
+ });
142
+ return this.#checking;
143
+ }
144
+
145
+ async #performCheck() {
146
+ this.#setState("checking", {
147
+ message: "Checking for a newer SIMY CLI version...",
148
+ error: null,
149
+ });
150
+ try {
151
+ const release = await this.#options.checkLatestVersion();
152
+ const targetVersion = String(release?.version || "").trim();
153
+ if (!parseStableVersion(targetVersion)) {
154
+ throw new Error("The npm registry returned an invalid stable CLI version.");
155
+ }
156
+ const checkedAt = new Date(this.#options.now()).toISOString();
157
+ if (compareStableVersions(targetVersion, this.#options.currentVersion) <= 0) {
158
+ this.#clearIdleTimer();
159
+ this.#setState("up_to_date", {
160
+ target_version: null,
161
+ checked_at: checkedAt,
162
+ message: `SIMY CLI ${this.#options.currentVersion} is up to date.`,
163
+ action: null,
164
+ error: null,
165
+ });
166
+ return this.snapshot();
167
+ }
168
+
169
+ const automatic = this.#snapshot.automatic && isPatchUpgrade(
170
+ this.#options.currentVersion,
171
+ targetVersion,
172
+ );
173
+ const action = updateAction(targetVersion);
174
+ if (!automatic) {
175
+ this.#clearIdleTimer();
176
+ this.#setState("update_available", {
177
+ target_version: targetVersion,
178
+ checked_at: checkedAt,
179
+ message: manualUpdateMessage({
180
+ currentVersion: this.#options.currentVersion,
181
+ targetVersion,
182
+ installMode: this.#options.installMode,
183
+ daemon: this.#options.daemon,
184
+ requestedPort: this.#options.requestedPort,
185
+ }),
186
+ action,
187
+ error: null,
188
+ });
189
+ this.#announce();
190
+ return this.snapshot();
191
+ }
192
+
193
+ this.#setState("waiting_for_idle", {
194
+ target_version: targetVersion,
195
+ checked_at: checkedAt,
196
+ message: `SIMY CLI ${targetVersion} is ready and will install when no task is active.`,
197
+ action,
198
+ error: null,
199
+ });
200
+ this.#startIdleTimer();
201
+ this.#announce();
202
+ return this.maybeApply();
203
+ } catch (error) {
204
+ this.#clearIdleTimer();
205
+ this.#setState("check_failed", {
206
+ checked_at: new Date(this.#options.now()).toISOString(),
207
+ message: "SIMY could not check for updates. It will try again later.",
208
+ error: errorMessage(error),
209
+ });
210
+ this.#announce();
211
+ return this.snapshot();
212
+ }
213
+ }
214
+
215
+ async maybeApply() {
216
+ if (this.#stopped || this.#snapshot.state !== "waiting_for_idle") {
217
+ return this.snapshot();
218
+ }
219
+ if (this.#applying) return this.#applying;
220
+ if (!this.#isIdle()) return this.snapshot();
221
+
222
+ this.#applying = this.#performUpdate().finally(() => {
223
+ this.#applying = null;
224
+ });
225
+ return this.#applying;
226
+ }
227
+
228
+ async #performUpdate() {
229
+ const targetVersion = this.#snapshot.target_version;
230
+ this.#setState("updating", {
231
+ message: `Updating SIMY CLI ${this.#options.currentVersion} to ${targetVersion}...`,
232
+ error: null,
233
+ });
234
+ this.#announce();
235
+
236
+ if (!this.#isIdle()) {
237
+ this.#setState("waiting_for_idle", {
238
+ message: `SIMY CLI ${targetVersion} is ready and will install when no task is active.`,
239
+ });
240
+ return this.snapshot();
241
+ }
242
+
243
+ let releaseLock = null;
244
+ try {
245
+ releaseLock = await this.#options.acquireUpdateLock();
246
+ if (!releaseLock) {
247
+ this.#setState("waiting_for_idle", {
248
+ message: "Another SIMY process is updating the CLI. This process will check again shortly.",
249
+ });
250
+ return this.snapshot();
251
+ }
252
+ if (!this.#isIdle()) {
253
+ this.#setState("waiting_for_idle", {
254
+ message: `SIMY CLI ${targetVersion} is ready and will install when no task is active.`,
255
+ });
256
+ return this.snapshot();
257
+ }
258
+
259
+ await this.#options.installVersion(targetVersion);
260
+ if (typeof this.#restartDaemon !== "function") {
261
+ throw new Error("The daemon restart handoff is unavailable.");
262
+ }
263
+ this.#setState("restarting", {
264
+ message: `SIMY CLI ${targetVersion} is installed. Verifying the restarted daemon...`,
265
+ });
266
+ this.#announce();
267
+ await this.#restartDaemon?.(targetVersion);
268
+ this.#clearIdleTimer();
269
+ this.#setState("updated", {
270
+ message: `SIMY CLI ${targetVersion} is running.`,
271
+ action: null,
272
+ error: null,
273
+ });
274
+ return this.snapshot();
275
+ } catch (error) {
276
+ this.#clearIdleTimer();
277
+ this.#setState("failed", {
278
+ message: `Automatic update failed. SIMY ${this.#options.currentVersion} is still running.`,
279
+ action: updateAction(targetVersion),
280
+ error: errorMessage(error),
281
+ });
282
+ this.#announce();
283
+ return this.snapshot();
284
+ } finally {
285
+ await releaseLock?.();
286
+ }
287
+ }
288
+
289
+ #startIdleTimer() {
290
+ if (this.#idleTimer) return;
291
+ this.#idleTimer = setInterval(() => void this.maybeApply(), this.#options.idleRetryMs);
292
+ this.#idleTimer.unref?.();
293
+ }
294
+
295
+ #isIdle() {
296
+ return this.#activeWork === 0 && isRegistryIdle(this.#registry);
297
+ }
298
+
299
+ #clearIdleTimer() {
300
+ if (this.#idleTimer) clearInterval(this.#idleTimer);
301
+ this.#idleTimer = null;
302
+ }
303
+
304
+ #setState(state, values = {}) {
305
+ this.#snapshot = { ...this.#snapshot, ...values, state };
306
+ this.emit("change", this.snapshot());
307
+ }
308
+
309
+ #announce() {
310
+ if (this.#options.quiet) return;
311
+ const suffix =
312
+ ["update_available", "failed"].includes(this.#snapshot.state) &&
313
+ this.#snapshot.action?.command
314
+ ? ` Run manually: ${this.#snapshot.action.command}`
315
+ : "";
316
+ const detail = this.#snapshot.error ? ` (${this.#snapshot.error})` : "";
317
+ this.#options.logger(`${this.#snapshot.message}${suffix}${detail}`);
318
+ }
319
+ }
320
+
321
+ export async function createCliAutoUpdater({
322
+ daemon,
323
+ interactive,
324
+ requestedPort = 0,
325
+ disabled = false,
326
+ packageRoot = PACKAGE_ROOT,
327
+ dependencies = {},
328
+ } = {}) {
329
+ const packageInfo = dependencies.packageInfo || (await readCliPackageInfo(packageRoot));
330
+ const installMode = dependencies.installMode || (await detectInstallMode({
331
+ packageRoot,
332
+ execFileCommand: dependencies.execFileCommand,
333
+ }));
334
+ const stateRoot = dependencies.stateRoot || defaultStateRoot();
335
+ return new CliAutoUpdater({
336
+ daemon: Boolean(daemon),
337
+ interactive: Boolean(interactive),
338
+ requestedPort,
339
+ disabled,
340
+ currentVersion: packageInfo.version,
341
+ installMode,
342
+ checkLatestVersion: dependencies.checkLatestVersion || checkLatestVersion,
343
+ installVersion:
344
+ dependencies.installVersion ||
345
+ ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand })),
346
+ acquireUpdateLock:
347
+ dependencies.acquireUpdateLock || (() => acquireUpdateLock(stateRoot, dependencies)),
348
+ logger: dependencies.logger || console.log,
349
+ quiet: Boolean(interactive),
350
+ now: dependencies.now || Date.now,
351
+ initialDelayMs: dependencies.initialDelayMs ?? AUTO_UPDATE_INITIAL_DELAY_MS,
352
+ checkIntervalMs: dependencies.checkIntervalMs ?? AUTO_UPDATE_CHECK_INTERVAL_MS,
353
+ idleRetryMs: dependencies.idleRetryMs ?? AUTO_UPDATE_IDLE_RETRY_MS,
354
+ });
355
+ }
356
+
357
+ export async function readCliPackageInfo(packageRoot = PACKAGE_ROOT) {
358
+ const payload = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
359
+ const version = String(payload.version || "").trim();
360
+ if (!parseStableVersion(version)) throw new Error("SIMY CLI package.json has an invalid version.");
361
+ return { name: String(payload.name || ""), version };
362
+ }
363
+
364
+ export async function detectInstallMode({
365
+ packageRoot = PACKAGE_ROOT,
366
+ execFileCommand = execFileAsync,
367
+ } = {}) {
368
+ const resolvedRoot = await realpathOrResolve(packageRoot);
369
+ if (resolvedRoot.split(path.sep).includes("_npx")) return "npx";
370
+
371
+ try {
372
+ const { stdout } = await execFileCommand("npm", ["root", "--global"], {
373
+ timeout: 10_000,
374
+ encoding: "utf8",
375
+ });
376
+ const globalRoot = await realpathOrResolve(String(stdout || "").trim());
377
+ if (isPathInside(globalRoot, resolvedRoot)) return "global_npm";
378
+ } catch {
379
+ // The actionable fallback is the same for source checkouts and unknown package managers.
380
+ }
381
+
382
+ return resolvedRoot.split(path.sep).includes("node_modules") ? "package" : "source";
383
+ }
384
+
385
+ export async function checkLatestVersion({ fetchImpl = fetch } = {}) {
386
+ const response = await fetchImpl(
387
+ "https://registry.npmjs.org/@awak-app%2fsimy-cli/latest",
388
+ {
389
+ headers: { Accept: "application/vnd.npm.install-v1+json" },
390
+ signal: AbortSignal.timeout(10_000),
391
+ },
392
+ );
393
+ if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`);
394
+ const payload = await response.json();
395
+ return {
396
+ version: payload?.version,
397
+ integrity: payload?.dist?.integrity || null,
398
+ };
399
+ }
400
+
401
+ export async function installGlobalNpmVersion(
402
+ version,
403
+ { execFileCommand = execFileAsync } = {},
404
+ ) {
405
+ if (!parseStableVersion(version)) throw new Error("Refusing to install an invalid CLI version.");
406
+ await execFileCommand(
407
+ "npm",
408
+ [
409
+ "install",
410
+ "--global",
411
+ `${CLI_PACKAGE_NAME}@${version}`,
412
+ "--no-audit",
413
+ "--no-fund",
414
+ ],
415
+ { timeout: 10 * 60_000, encoding: "utf8" },
416
+ );
417
+ }
418
+
419
+ export function isRegistryIdle(registry) {
420
+ const runs = registry?.list?.() || [];
421
+ return runs.every((run) => {
422
+ if (run?.operation || run?.child || run?.pendingLedgerUpdate || run?.ledgerUpdateRunning) {
423
+ return false;
424
+ }
425
+ if (NON_IDLE_STATES.has(run?.status)) return false;
426
+ if (NON_IDLE_CONTROL_STATES.has(run?.controlState)) return false;
427
+ return true;
428
+ });
429
+ }
430
+
431
+ export function compareStableVersions(left, right) {
432
+ const leftParts = parseStableVersion(left);
433
+ const rightParts = parseStableVersion(right);
434
+ if (!leftParts || !rightParts) throw new Error("A stable semantic version is required.");
435
+ for (let index = 0; index < 3; index += 1) {
436
+ if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
437
+ }
438
+ return 0;
439
+ }
440
+
441
+ export function isPatchUpgrade(currentVersion, targetVersion) {
442
+ const current = parseStableVersion(currentVersion);
443
+ const target = parseStableVersion(targetVersion);
444
+ return Boolean(
445
+ current &&
446
+ target &&
447
+ current[0] === target[0] &&
448
+ current[1] === target[1] &&
449
+ target[2] > current[2]
450
+ );
451
+ }
452
+
453
+ export async function restartDaemonProcess({
454
+ entryPath,
455
+ argv,
456
+ targetVersion,
457
+ stateRoot = defaultStateRoot(),
458
+ spawnProcess = spawn,
459
+ timeoutMs = RESTART_HANDOFF_TIMEOUT_MS,
460
+ env = process.env,
461
+ } = {}) {
462
+ const handoffDirectory = path.join(stateRoot, "updates", "handoff");
463
+ await mkdir(handoffDirectory, { recursive: true, mode: 0o700 });
464
+ const nonce = randomBytes(24).toString("hex");
465
+ const handoffPath = path.join(handoffDirectory, `${process.pid}-${nonce}.json`);
466
+ const child = spawnProcess(process.execPath, [entryPath, ...argv], {
467
+ detached: true,
468
+ stdio: "ignore",
469
+ env: {
470
+ ...env,
471
+ SIMY_DAEMON_CHILD: "1",
472
+ SIMY_UPDATE_HANDOFF_FILE: handoffPath,
473
+ SIMY_UPDATE_HANDOFF_NONCE: nonce,
474
+ },
475
+ });
476
+ child.unref?.();
477
+ const spawnFailure = new Promise((_, reject) => {
478
+ child.once?.("error", reject);
479
+ });
480
+
481
+ try {
482
+ const handoff = await Promise.race([
483
+ waitForHandoff(handoffPath, nonce, timeoutMs),
484
+ spawnFailure,
485
+ ]);
486
+ if (handoff.version !== targetVersion) {
487
+ throw new Error(
488
+ `Restarted SIMY reported version ${handoff.version || "unknown"}, expected ${targetVersion}.`,
489
+ );
490
+ }
491
+ return handoff;
492
+ } finally {
493
+ await rm(handoffPath, { force: true });
494
+ }
495
+ }
496
+
497
+ export async function writeUpdateHandoffFromEnvironment({
498
+ version,
499
+ port,
500
+ env = process.env,
501
+ stateRoot = defaultStateRoot(),
502
+ } = {}) {
503
+ const handoffPath = String(env.SIMY_UPDATE_HANDOFF_FILE || "").trim();
504
+ const nonce = String(env.SIMY_UPDATE_HANDOFF_NONCE || "").trim();
505
+ if (!handoffPath || !nonce) return false;
506
+ const handoffDirectory = path.resolve(stateRoot, "updates", "handoff");
507
+ if (!isPathInside(handoffDirectory, path.resolve(handoffPath))) {
508
+ throw new Error("Refusing to write an update handoff outside the SIMY state directory.");
509
+ }
510
+ await mkdir(handoffDirectory, { recursive: true, mode: 0o700 });
511
+ await writeFile(
512
+ handoffPath,
513
+ `${JSON.stringify({ nonce, version, port, pid: process.pid, ready_at: new Date().toISOString() })}\n`,
514
+ { encoding: "utf8", mode: 0o600 },
515
+ );
516
+ return true;
517
+ }
518
+
519
+ export function autoUpdateHelpText() {
520
+ return [
521
+ "Recommended installation (enables daemon auto-update):",
522
+ ` npm install -g ${CLI_PACKAGE_NAME}`,
523
+ " simy --daemon",
524
+ ].join("\n");
525
+ }
526
+
527
+ function manualUpdateMessage({
528
+ currentVersion,
529
+ targetVersion,
530
+ installMode,
531
+ daemon,
532
+ requestedPort,
533
+ }) {
534
+ if (installMode !== "global_npm") {
535
+ return `SIMY CLI ${targetVersion} is available. Install the global version to enable automatic updates.`;
536
+ }
537
+ if (!daemon) {
538
+ return `SIMY CLI ${targetVersion} is available. Update it after leaving this foreground session.`;
539
+ }
540
+ if (requestedPort !== 0) {
541
+ return `SIMY CLI ${targetVersion} is available. A daemon using a fixed port requires a manual restart.`;
542
+ }
543
+ return `SIMY CLI ${targetVersion} is available. This version change requires manual confirmation.`;
544
+ }
545
+
546
+ function updateAction(version) {
547
+ return {
548
+ id: "install_global_cli",
549
+ label: "Install and restart SIMY",
550
+ command: `npm install -g ${CLI_PACKAGE_NAME}@${version} && simy --daemon`,
551
+ };
552
+ }
553
+
554
+ function parseStableVersion(value) {
555
+ const match = String(value || "").match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
556
+ return match ? match.slice(1).map(Number) : null;
557
+ }
558
+
559
+ async function acquireUpdateLock(stateRoot, dependencies = {}) {
560
+ const now = dependencies.now || Date.now;
561
+ const lockDirectory = path.join(stateRoot, "updates");
562
+ const lockPath = path.join(lockDirectory, "global-npm-update.lock");
563
+ await mkdir(lockDirectory, { recursive: true, mode: 0o700 });
564
+
565
+ for (let attempt = 0; attempt < 2; attempt += 1) {
566
+ try {
567
+ const handle = await open(lockPath, "wx", 0o600);
568
+ const token = randomBytes(16).toString("hex");
569
+ await handle.writeFile(`${JSON.stringify({ token, pid: process.pid, created_at: now() })}\n`);
570
+ await handle.close();
571
+ return async () => {
572
+ try {
573
+ const payload = JSON.parse(await readFile(lockPath, "utf8"));
574
+ if (payload.token === token) await rm(lockPath, { force: true });
575
+ } catch (error) {
576
+ if (error?.code !== "ENOENT") throw error;
577
+ }
578
+ };
579
+ } catch (error) {
580
+ if (error?.code !== "EEXIST") throw error;
581
+ if (!(await isStaleLock(lockPath, now))) return null;
582
+ await rm(lockPath, { force: true });
583
+ }
584
+ }
585
+ return null;
586
+ }
587
+
588
+ async function isStaleLock(lockPath, now) {
589
+ try {
590
+ const details = await stat(lockPath);
591
+ return now() - details.mtimeMs > UPDATE_LOCK_STALE_MS;
592
+ } catch (error) {
593
+ if (error?.code === "ENOENT") return true;
594
+ throw error;
595
+ }
596
+ }
597
+
598
+ async function waitForHandoff(handoffPath, nonce, timeoutMs) {
599
+ const startedAt = Date.now();
600
+ while (Date.now() - startedAt < timeoutMs) {
601
+ try {
602
+ const payload = JSON.parse(await readFile(handoffPath, "utf8"));
603
+ if (payload.nonce === nonce) return payload;
604
+ } catch (error) {
605
+ if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
606
+ }
607
+ await new Promise((resolve) => setTimeout(resolve, 100));
608
+ }
609
+ throw new Error("The updated SIMY daemon did not become healthy in time.");
610
+ }
611
+
612
+ async function realpathOrResolve(value) {
613
+ try {
614
+ return await realpath(value);
615
+ } catch {
616
+ return path.resolve(value);
617
+ }
618
+ }
619
+
620
+ function isPathInside(parent, child) {
621
+ const relative = path.relative(parent, child);
622
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
623
+ }
624
+
625
+ function defaultStateRoot() {
626
+ return process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy");
627
+ }
628
+
629
+ function errorMessage(error) {
630
+ return error instanceof Error ? error.message : String(error);
631
+ }
@@ -24,6 +24,7 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
24
24
  const { stdout } = useStdout();
25
25
  const [terminal, setTerminal] = useState(() => terminalSize(stdout));
26
26
  const [runs, setRuns] = useState(() => agent.registry.list());
27
+ const [cliUpdate, setCliUpdate] = useState(() => agent.updates?.snapshot?.() ?? null);
27
28
  const [selectedId, setSelectedId] = useState(() => runs[0]?.id ?? NEW_TASK);
28
29
  const [inputMode, setInputMode] = useState(() => runs.length === 0);
29
30
  const [input, setInput] = useState("");
@@ -51,6 +52,13 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
51
52
  return () => agent.registry.off("change", update);
52
53
  }, [agent.registry]);
53
54
 
55
+ useEffect(() => {
56
+ if (!agent.updates?.on) return undefined;
57
+ const update = (nextState) => setCliUpdate(nextState);
58
+ agent.updates.on("change", update);
59
+ return () => agent.updates.off("change", update);
60
+ }, [agent.updates]);
61
+
54
62
  useEffect(() => {
55
63
  const resize = () => setTerminal(terminalSize(stdout));
56
64
  stdout.on("resize", resize);
@@ -68,7 +76,8 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
68
76
  1 +
69
77
  (waitingForHuman ? 1 : 0) +
70
78
  (!selectedRun ? 1 : 0) +
71
- (notice || busy ? 1 : 0);
79
+ (notice || busy ? 1 : 0) +
80
+ (visibleUpdateNotice(cliUpdate) ? 1 : 0);
72
81
  const mainHeight = Math.max(8, terminal.rows - 5 - actionRows);
73
82
  const detailInnerWidth = Math.max(20, terminal.columns - 4);
74
83
  const detailRows = useMemo(
@@ -529,6 +538,7 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
529
538
  draft,
530
539
  repositoryMissing: isRepositoryMissing(selectedRun),
531
540
  modalOpen: Boolean(scanPrompt || repositoryPicker),
541
+ cliUpdate,
532
542
  }),
533
543
  ],
534
544
  });
@@ -544,6 +554,12 @@ function Header({ agent, runCount }) {
544
554
  children: [
545
555
  jsx(Text, { bold: true, color: "cyan", children: "SIMY" }),
546
556
  jsx(Text, { bold: true, children: " Coding Chat" }),
557
+ agent.updates?.snapshot?.().current_version
558
+ ? jsx(Text, {
559
+ dimColor: true,
560
+ children: ` v${agent.updates.snapshot().current_version}`,
561
+ })
562
+ : null,
547
563
  jsx(Text, { dimColor: true, children: ` localhost:${agent.port}` }),
548
564
  jsx(Spacer, {}),
549
565
  jsx(Text, { color: runCount > 0 ? "green" : "yellow", children: `${runCount} runs` }),
@@ -780,6 +796,7 @@ function ActionBar({
780
796
  draft,
781
797
  repositoryMissing,
782
798
  modalOpen,
799
+ cliUpdate,
783
800
  }) {
784
801
  const waiting = run && (run.controlState === "waiting_human" || run.status === "waiting_human");
785
802
  return jsxs(Box, {
@@ -825,10 +842,29 @@ function ActionBar({
825
842
  : busy
826
843
  ? jsx(Text, { color: "cyan", children: "Working..." })
827
844
  : null,
845
+ visibleUpdateNotice(cliUpdate)
846
+ ? jsx(Text, {
847
+ color: cliUpdate.state === "failed" ? "red" : "yellow",
848
+ children: visibleUpdateNotice(cliUpdate),
849
+ })
850
+ : null,
828
851
  ],
829
852
  });
830
853
  }
831
854
 
855
+ function visibleUpdateNotice(update) {
856
+ if (
857
+ !update ||
858
+ !["update_available", "waiting_for_idle", "updating", "restarting", "failed"].includes(
859
+ update.state,
860
+ )
861
+ ) {
862
+ return "";
863
+ }
864
+ const command = update.action?.command ? ` Run: ${update.action.command}` : "";
865
+ return `${update.message || "SIMY CLI update status changed."}${command}`;
866
+ }
867
+
832
868
  function initialDraft(agent) {
833
869
  const backends = agent.capabilities?.backends ?? {};
834
870
  return {
package/src/index.js CHANGED
@@ -4,6 +4,13 @@ import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { startAgent } from "./agent.js";
7
+ import {
8
+ autoUpdateHelpText,
9
+ createCliAutoUpdater,
10
+ readCliPackageInfo,
11
+ restartDaemonProcess,
12
+ writeUpdateHandoffFromEnvironment,
13
+ } from "./auto-update.js";
7
14
  import { openAuthorizationUrl } from "./browser.js";
8
15
  import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
9
16
 
@@ -22,6 +29,9 @@ Options:
22
29
  --no-open Do not open the browser when authorization is required
23
30
  --port <port> Bind a specific localhost port
24
31
  --host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
32
+ --no-auto-update Disable the hourly CLI update check
33
+
34
+ ${autoUpdateHelpText()}
25
35
  `);
26
36
  process.exit(0);
27
37
  }
@@ -29,6 +39,7 @@ Options:
29
39
  const daemon = args.has("--daemon") || args.has("--deamon");
30
40
  const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
31
41
  const port = readPort(argv);
42
+ const packageInfo = await readCliPackageInfo();
32
43
  let webOrigin;
33
44
  try {
34
45
  webOrigin = resolveWebOrigin(readOption(argv, "--host"));
@@ -48,7 +59,32 @@ if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
48
59
  process.exit(0);
49
60
  }
50
61
 
51
- const agent = await startAgent({ requestedPort: port, daemon, webOrigin, quiet: interactive });
62
+ const updateManager = await createCliAutoUpdater({
63
+ daemon,
64
+ interactive,
65
+ requestedPort: port,
66
+ disabled: args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0",
67
+ dependencies: { packageInfo },
68
+ });
69
+ const agent = await startAgent({
70
+ requestedPort: port,
71
+ daemon,
72
+ webOrigin,
73
+ quiet: interactive,
74
+ updateManager,
75
+ });
76
+ await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
77
+ updateManager.start({
78
+ registry: agent.registry,
79
+ restartDaemon: async (targetVersion) => {
80
+ await restartDaemonProcess({
81
+ entryPath: fileURLToPath(import.meta.url),
82
+ argv: process.argv.slice(2),
83
+ targetVersion,
84
+ });
85
+ await closeServer(agent.server);
86
+ },
87
+ });
52
88
  if (agent.loginUrl && !args.has("--no-open")) {
53
89
  await openAuthorizationUrl(agent.loginUrl);
54
90
  }