@awak-app/simy-cli 0.1.4 → 0.2.0
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 +126 -13
- package/src/auto-update.js +631 -0
- package/src/cli-contract.js +11 -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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
@@ -64,6 +64,11 @@ import {
|
|
|
64
64
|
scanGitRepositories,
|
|
65
65
|
writeRepositoryInventory,
|
|
66
66
|
} from "./repository-inventory.js";
|
|
67
|
+
import {
|
|
68
|
+
CLI_API_CONTRACT_VERSION,
|
|
69
|
+
CLI_VERSION,
|
|
70
|
+
withCliContract,
|
|
71
|
+
} from "./cli-contract.js";
|
|
67
72
|
|
|
68
73
|
const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
|
|
69
74
|
|
|
@@ -72,6 +77,7 @@ export async function startAgent({
|
|
|
72
77
|
daemon = false,
|
|
73
78
|
webOrigin = null,
|
|
74
79
|
sessionRoot,
|
|
80
|
+
updateManager = null,
|
|
75
81
|
dependencies = {},
|
|
76
82
|
quiet = false,
|
|
77
83
|
} = {}) {
|
|
@@ -98,7 +104,11 @@ export async function startAgent({
|
|
|
98
104
|
]
|
|
99
105
|
: [],
|
|
100
106
|
);
|
|
101
|
-
const
|
|
107
|
+
const installMode = updateManager?.snapshot?.()?.install_mode ?? null;
|
|
108
|
+
const availableCapabilities = withCliContract(
|
|
109
|
+
await readCapabilities(dependencies),
|
|
110
|
+
installMode,
|
|
111
|
+
);
|
|
102
112
|
const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
|
|
103
113
|
const runOptions = dependencies.runOptions || {};
|
|
104
114
|
const repositoryScanRoot = resolve(
|
|
@@ -219,6 +229,7 @@ export async function startAgent({
|
|
|
219
229
|
};
|
|
220
230
|
|
|
221
231
|
const selectRepository = async (run, repository) => {
|
|
232
|
+
if (run) ensureAcceptingNewWork(updateManager);
|
|
222
233
|
const selected = repository?.local_path
|
|
223
234
|
? repositoryInventory.find(
|
|
224
235
|
(item) =>
|
|
@@ -231,10 +242,14 @@ export async function startAgent({
|
|
|
231
242
|
throw new Error(`This run requires ${run.request.repository}; select that local repository.`);
|
|
232
243
|
}
|
|
233
244
|
if (!run) return selected;
|
|
234
|
-
return
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
245
|
+
return withUpdateWork(
|
|
246
|
+
updateManager,
|
|
247
|
+
() =>
|
|
248
|
+
continueLocalCodingRunAfterRepositoryApproval(
|
|
249
|
+
run,
|
|
250
|
+
{ repository: selected.repository, localPath: selected.local_path },
|
|
251
|
+
runOptions,
|
|
252
|
+
),
|
|
238
253
|
);
|
|
239
254
|
};
|
|
240
255
|
|
|
@@ -252,18 +267,34 @@ export async function startAgent({
|
|
|
252
267
|
|
|
253
268
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
254
269
|
const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
|
|
270
|
+
if (
|
|
271
|
+
req.method === "POST" &&
|
|
272
|
+
agenticLoopPath.startsWith("/v1/agentic-loop") &&
|
|
273
|
+
agenticLoopPath !== "/v1/agentic-loop/preflight"
|
|
274
|
+
) {
|
|
275
|
+
const releaseUpdateWork = acquireUpdateWork(updateManager);
|
|
276
|
+
if (!releaseUpdateWork) {
|
|
277
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
res.once("finish", releaseUpdateWork);
|
|
281
|
+
res.once("close", releaseUpdateWork);
|
|
282
|
+
}
|
|
255
283
|
if (req.method === "GET" && url.pathname === "/v1/health") {
|
|
256
284
|
json(res, 200, {
|
|
257
285
|
ok: true,
|
|
286
|
+
cli_version: CLI_VERSION,
|
|
287
|
+
api_contract_version: CLI_API_CONTRACT_VERSION,
|
|
258
288
|
daemon,
|
|
259
289
|
web_origin: apiOrigin,
|
|
260
290
|
session_valid: isSessionValid(session, Date.now(), apiOrigin),
|
|
261
291
|
session_expires_at: session?.expires_at ?? null,
|
|
292
|
+
cli_update: updateManager?.snapshot?.() ?? null,
|
|
262
293
|
});
|
|
263
294
|
return;
|
|
264
295
|
}
|
|
265
296
|
if (req.method === "GET" && url.pathname === "/v1/capabilities") {
|
|
266
|
-
json(res, 200,
|
|
297
|
+
json(res, 200, availableCapabilities);
|
|
267
298
|
return;
|
|
268
299
|
}
|
|
269
300
|
if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/preflight") {
|
|
@@ -336,11 +367,20 @@ export async function startAgent({
|
|
|
336
367
|
startHeartbeatTimer();
|
|
337
368
|
loginUrl = null;
|
|
338
369
|
registry.emit("change", registry.list());
|
|
339
|
-
json(res, 200, {
|
|
370
|
+
json(res, 200, {
|
|
371
|
+
ok: true,
|
|
372
|
+
expires_at: session.expires_at,
|
|
373
|
+
cli_version: CLI_VERSION,
|
|
374
|
+
api_contract_version: CLI_API_CONTRACT_VERSION,
|
|
375
|
+
});
|
|
340
376
|
void synchronizeAuthorizedSession();
|
|
341
377
|
return;
|
|
342
378
|
}
|
|
343
379
|
if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/start") {
|
|
380
|
+
if (!acceptsNewWork(updateManager)) {
|
|
381
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
344
384
|
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
345
385
|
json(res, 401, { error: "simy session expired; run simy again" });
|
|
346
386
|
return;
|
|
@@ -493,6 +533,10 @@ export async function startAgent({
|
|
|
493
533
|
} else if (body.action === "pause") {
|
|
494
534
|
pauseLocalCodingRun(run);
|
|
495
535
|
} else if (body.action === "resume") {
|
|
536
|
+
if (!acceptsNewWork(updateManager)) {
|
|
537
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
496
540
|
resumeLocalCodingRun(run);
|
|
497
541
|
} else {
|
|
498
542
|
json(res, 400, { error: "action must be pause, resume, or stop" });
|
|
@@ -567,6 +611,10 @@ export async function startAgent({
|
|
|
567
611
|
}
|
|
568
612
|
|
|
569
613
|
if (req.method === "POST") {
|
|
614
|
+
if (!acceptsNewWork(updateManager)) {
|
|
615
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
570
618
|
const body = await readJson(req);
|
|
571
619
|
const requestId = localRepositoryHilRequestId(run);
|
|
572
620
|
if (body.request_id !== requestId) {
|
|
@@ -623,6 +671,10 @@ export async function startAgent({
|
|
|
623
671
|
|
|
624
672
|
const recheckMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/recheck$/);
|
|
625
673
|
if (req.method === "POST" && recheckMatch) {
|
|
674
|
+
if (!acceptsNewWork(updateManager)) {
|
|
675
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
626
678
|
const run = registry.get(decodeURIComponent(recheckMatch[1]));
|
|
627
679
|
if (!run) {
|
|
628
680
|
json(res, 404, { error: "run not found" });
|
|
@@ -662,6 +714,9 @@ export async function startAgent({
|
|
|
662
714
|
loginUrl = new URL("/local-cli/connect", apiOrigin);
|
|
663
715
|
loginUrl.searchParams.set("port", String(port));
|
|
664
716
|
loginUrl.searchParams.set("nonce", authNonce);
|
|
717
|
+
loginUrl.searchParams.set("cli_version", CLI_VERSION);
|
|
718
|
+
loginUrl.searchParams.set("api_contract_version", String(CLI_API_CONTRACT_VERSION));
|
|
719
|
+
if (installMode) loginUrl.searchParams.set("install_mode", installMode);
|
|
665
720
|
if (!quiet) {
|
|
666
721
|
console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
|
|
667
722
|
console.log(`Session file: ${sessionPath(apiOrigin, sessionRoot)}`);
|
|
@@ -681,6 +736,7 @@ export async function startAgent({
|
|
|
681
736
|
|
|
682
737
|
server.on("close", () => {
|
|
683
738
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
739
|
+
updateManager?.stop?.();
|
|
684
740
|
registry.close();
|
|
685
741
|
});
|
|
686
742
|
return {
|
|
@@ -694,11 +750,12 @@ export async function startAgent({
|
|
|
694
750
|
workspace,
|
|
695
751
|
repositoryScanRoot,
|
|
696
752
|
capabilities: availableCapabilities,
|
|
753
|
+
updates: updateManager,
|
|
697
754
|
controls: {
|
|
698
755
|
repositoryInventory: () => [...repositoryInventory],
|
|
699
756
|
scanRepositories,
|
|
700
757
|
selectRepository,
|
|
701
|
-
create: async (
|
|
758
|
+
create: (input) => withUpdateWork(updateManager, async () => {
|
|
702
759
|
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
703
760
|
throw new Error("Sign in to SIMY before starting a coding task.");
|
|
704
761
|
}
|
|
@@ -746,15 +803,69 @@ export async function startAgent({
|
|
|
746
803
|
registry.create(run);
|
|
747
804
|
void startLocalCodingRun(run, runOptions);
|
|
748
805
|
return run;
|
|
749
|
-
},
|
|
750
|
-
continue: (run, guidance) =>
|
|
751
|
-
|
|
806
|
+
}),
|
|
807
|
+
continue: (run, guidance) =>
|
|
808
|
+
withUpdateWork(updateManager, () => continueLocalCodingRun(run, guidance, runOptions)),
|
|
809
|
+
applyDecision: (run, decision) =>
|
|
810
|
+
withUpdateWork(updateManager, () => applyLocalHumanDecision(run, decision, runOptions)),
|
|
752
811
|
queueGuidance: queueLocalCodingGuidance,
|
|
753
812
|
pause: pauseLocalCodingRun,
|
|
754
|
-
resume: resumeLocalCodingRun,
|
|
813
|
+
resume: (run) => withUpdateWorkSync(updateManager, () => resumeLocalCodingRun(run)),
|
|
755
814
|
stop: stopLocalCodingRun,
|
|
756
815
|
recheck: (run) =>
|
|
757
|
-
|
|
816
|
+
withUpdateWork(updateManager, () =>
|
|
817
|
+
recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence }),
|
|
818
|
+
),
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function acceptsNewWork(updateManager) {
|
|
824
|
+
return updateManager?.acceptsNewWork?.() !== false;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function acquireUpdateWork(updateManager) {
|
|
828
|
+
if (!acceptsNewWork(updateManager)) return null;
|
|
829
|
+
return updateManager?.beginWork?.() ?? (() => {});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function ensureAcceptingNewWork(updateManager) {
|
|
833
|
+
if (acceptsNewWork(updateManager)) return;
|
|
834
|
+
throw new Error(
|
|
835
|
+
updateManager?.snapshot?.().message ||
|
|
836
|
+
"SIMY is installing an update. Wait for the daemon to restart, then try again.",
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function withUpdateWork(updateManager, action) {
|
|
841
|
+
const release = acquireUpdateWork(updateManager);
|
|
842
|
+
if (!release) ensureAcceptingNewWork(updateManager);
|
|
843
|
+
try {
|
|
844
|
+
return await action();
|
|
845
|
+
} finally {
|
|
846
|
+
release?.();
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function withUpdateWorkSync(updateManager, action) {
|
|
851
|
+
const release = acquireUpdateWork(updateManager);
|
|
852
|
+
if (!release) ensureAcceptingNewWork(updateManager);
|
|
853
|
+
try {
|
|
854
|
+
return action();
|
|
855
|
+
} finally {
|
|
856
|
+
release?.();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function updateInProgressResponse(updateManager) {
|
|
861
|
+
return {
|
|
862
|
+
error:
|
|
863
|
+
updateManager?.snapshot?.().message ||
|
|
864
|
+
"SIMY is installing an update. Wait for the daemon to restart, then try again.",
|
|
865
|
+
code: "cli_update_in_progress",
|
|
866
|
+
recovery: {
|
|
867
|
+
title: "Wait for SIMY to restart",
|
|
868
|
+
description: "The CLI will reconnect automatically after the update finishes.",
|
|
758
869
|
},
|
|
759
870
|
};
|
|
760
871
|
}
|
|
@@ -818,6 +929,8 @@ export async function heartbeatLocalDevice({
|
|
|
818
929
|
),
|
|
819
930
|
body: JSON.stringify({
|
|
820
931
|
port,
|
|
932
|
+
cli_version: CLI_VERSION,
|
|
933
|
+
api_contract_version: CLI_API_CONTRACT_VERSION,
|
|
821
934
|
capabilities,
|
|
822
935
|
repo_inventory: repoInventory,
|
|
823
936
|
}),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const CLI_VERSION = "0.2.0";
|
|
2
|
+
export const CLI_API_CONTRACT_VERSION = 2;
|
|
3
|
+
|
|
4
|
+
export function withCliContract(capabilities = {}, installMode = null) {
|
|
5
|
+
return {
|
|
6
|
+
...capabilities,
|
|
7
|
+
cli_version: CLI_VERSION,
|
|
8
|
+
api_contract_version: CLI_API_CONTRACT_VERSION,
|
|
9
|
+
...(installMode ? { install_mode: installMode } : {}),
|
|
10
|
+
};
|
|
11
|
+
}
|
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
|
}
|