@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 +36 -2
- package/package.json +4 -2
- package/src/agent.js +102 -10
- package/src/auto-update.js +631 -0
- package/src/console/app.js +37 -1
- package/src/index.js +37 -1
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.
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 (
|
|
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) =>
|
|
751
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/console/app.js
CHANGED
|
@@ -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
|
|
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
|
}
|