@awak-app/simy-cli 0.5.7 → 0.5.10

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.
@@ -0,0 +1,231 @@
1
+ import { mkdir, readFile, realpath, rm, writeFile, stat } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { normalizeWebOrigin } from "./web-origin.js";
7
+
8
+ export const PROCESS_CONFLICT_CODE = "process_conflict";
9
+ export const PROCESS_CONFLICT_MESSAGE =
10
+ "Another SIMY background service is already running for this Web address. Stop the existing CLI daemon, then reopen SIMY CLI.";
11
+
12
+ export class SimyProcessConflictError extends Error {
13
+ constructor(message = PROCESS_CONFLICT_MESSAGE) {
14
+ super(message);
15
+ this.name = "SimyProcessConflictError";
16
+ this.code = PROCESS_CONFLICT_CODE;
17
+ }
18
+ }
19
+
20
+ export async function acquireDesktopProcessLock(root, { pid = process.pid, alive = processAlive, identity = processStart, graceMs = 30_000, webOrigin = null } = {}) {
21
+ await mkdir(root, { recursive: true, mode: 0o700 });
22
+ const directory = join(root, "desktop-app.lock");
23
+ const ownerFile = join(directory, "owner.json");
24
+ try {
25
+ await mkdir(directory, { mode: 0o700 });
26
+ } catch (error) {
27
+ if (error.code !== "EEXIST") throw error;
28
+ let owner;
29
+ try { owner = JSON.parse(await readFile(ownerFile, "utf8")); } catch { owner = {}; }
30
+ if (!Number.isSafeInteger(owner.pid) && Date.now() - (await stat(directory)).mtimeMs < graceMs) {
31
+ throw new SimyProcessConflictError("Another SIMY window is starting. Try opening it again shortly.");
32
+ }
33
+ if (Number.isSafeInteger(owner.pid) && alive(owner.pid) && (!owner.startedAt || owner.startedAt === await identity(owner.pid))) {
34
+ throw new SimyProcessConflictError("SIMY is already running. Open its menu bar icon.");
35
+ }
36
+ // Only one process may reclaim a dead owner; other contenders must retry.
37
+ const recovery = join(directory, "recovery");
38
+ try { await mkdir(recovery, { mode: 0o700 }); } catch {
39
+ const age = Date.now() - (await stat(recovery)).mtimeMs;
40
+ if (age < graceMs) throw new SimyProcessConflictError("SIMY is recovering a previous session. Try again shortly.");
41
+ await rm(recovery, { recursive: true });
42
+ await mkdir(recovery, { mode: 0o700 });
43
+ }
44
+ const latest = await readFile(ownerFile, "utf8").catch(() => "{}");
45
+ let current; try { current = JSON.parse(latest); } catch { current = {}; }
46
+ if (current.nonce !== owner.nonce) {
47
+ throw new SimyProcessConflictError("SIMY is starting in another window.");
48
+ }
49
+ await rm(directory, { recursive: true });
50
+ return acquireDesktopProcessLock(root, { pid, alive, identity, graceMs, webOrigin });
51
+ }
52
+ const nonce = randomBytes(32).toString("hex");
53
+ await writeFile(ownerFile, JSON.stringify({
54
+ pid,
55
+ nonce,
56
+ startedAt: await identity(pid),
57
+ webOrigin: normalizedOriginOrNull(webOrigin),
58
+ }), { mode: 0o600 });
59
+ return {
60
+ nonce,
61
+ async release() {
62
+ const owner = JSON.parse(await readFile(ownerFile, "utf8").catch(() => "{}"));
63
+ if (owner.pid === pid && owner.nonce === nonce) await rm(directory, { recursive: true, force: true });
64
+ },
65
+ };
66
+ }
67
+
68
+ export async function assertNoDesktopHostForOrigin(
69
+ root,
70
+ webOrigin,
71
+ { alive = processAlive, identity = processStart, graceMs = 30_000, now = Date.now } = {},
72
+ ) {
73
+ const directory = join(root, "desktop-app.lock");
74
+ const ownerFile = join(directory, "owner.json");
75
+ let owner;
76
+ try {
77
+ owner = JSON.parse(await readFile(ownerFile, "utf8"));
78
+ } catch (error) {
79
+ const metadata = await stat(directory).catch(() => null);
80
+ if (metadata && now() - metadata.mtimeMs < graceMs) throw new SimyProcessConflictError();
81
+ return;
82
+ }
83
+ if (!Number.isSafeInteger(owner?.pid) || !alive(owner.pid)) return;
84
+ if (owner.startedAt) {
85
+ let startedAt;
86
+ try {
87
+ startedAt = await identity(owner.pid);
88
+ } catch {
89
+ throw new SimyProcessConflictError();
90
+ }
91
+ if (startedAt !== owner.startedAt) return;
92
+ }
93
+ const ownerOrigin = normalizedOriginOrNull(owner.webOrigin);
94
+ const requestedOrigin = normalizedOriginOrNull(webOrigin);
95
+ if (ownerOrigin && requestedOrigin && ownerOrigin !== requestedOrigin) return;
96
+ throw new SimyProcessConflictError();
97
+ }
98
+
99
+ export async function assertNoLegacyCliDaemon(
100
+ webOrigin,
101
+ {
102
+ processes = listSameUserProcesses,
103
+ verifyEntry = verifySimyCliEntry,
104
+ currentPid = process.pid,
105
+ } = {},
106
+ ) {
107
+ const requestedOrigin = normalizeWebOrigin(webOrigin);
108
+ let candidates;
109
+ try {
110
+ candidates = await processes();
111
+ } catch {
112
+ throw new SimyProcessConflictError(
113
+ "SIMY could not verify whether another CLI service is running. Stop the existing CLI daemon, then reopen SIMY CLI.",
114
+ );
115
+ }
116
+ for (const processInfo of candidates) {
117
+ if (processInfo.pid === currentPid) continue;
118
+ const parsed = parseLegacyAgentArguments(processInfo.command ?? processInfo.args);
119
+ if (!parsed || !(await verifyEntry(parsed.entryPath))) continue;
120
+ if (parsed.webOrigin && parsed.webOrigin !== requestedOrigin) continue;
121
+ throw new SimyProcessConflictError();
122
+ }
123
+ }
124
+
125
+ export function parseLegacyAgentArguments(args) {
126
+ const argv = Array.isArray(args) ? args : recoverProcessArguments(args);
127
+ if (argv.length < 2 || !["node", "nodejs"].includes(basename(argv[0]))) return null;
128
+ const entryIndex = argv.findIndex((value, index) => index > 0 && /(?:^|\/)src\/index\.js$/.test(value));
129
+ if (entryIndex < 1) return null;
130
+ const agentArgs = argv.slice(entryIndex + 1);
131
+ if (agentArgs.includes("--help") || agentArgs.includes("-h")) return null;
132
+ const subcommand = agentArgs.find((value, index) => {
133
+ if (value.startsWith("-")) return false;
134
+ const previous = agentArgs[index - 1];
135
+ return previous !== "--host" && previous !== "--port";
136
+ });
137
+ if (subcommand === "sqm" || subcommand === "update") return null;
138
+ let webOrigin = null;
139
+ const hostIndex = argv.indexOf("--host", entryIndex + 1);
140
+ const inlineHost = argv.slice(entryIndex + 1).find((value) => value.startsWith("--host="));
141
+ const explicitHost = hostIndex >= 0 ? argv[hostIndex + 1] : inlineHost?.slice("--host=".length);
142
+ if (explicitHost) webOrigin = normalizedOriginOrNull(explicitHost);
143
+ return { entryPath: argv[entryIndex], webOrigin };
144
+ }
145
+
146
+ function recoverProcessArguments(command) {
147
+ const raw = String(command || "");
148
+ // macOS ps does not retain shell quoting. Packaged app paths contain spaces, so
149
+ // recover the two known absolute path suffixes before tokenizing CLI flags.
150
+ const packaged = raw.match(/^\s*(\/.*?\/(?:node|nodejs))\s+(\/.*?\/src\/index\.js)(?=\s|$)(.*)$/);
151
+ if (packaged) return [packaged[1], packaged[2], ...splitProcessArguments(packaged[3])];
152
+ return splitProcessArguments(raw);
153
+ }
154
+
155
+ export function splitProcessArguments(command) {
156
+ const values = [];
157
+ let value = "";
158
+ let quote = null;
159
+ let escaped = false;
160
+ for (const character of String(command || "")) {
161
+ if (escaped) {
162
+ value += character;
163
+ escaped = false;
164
+ } else if (character === "\\" && quote !== "'") {
165
+ escaped = true;
166
+ } else if (quote) {
167
+ if (character === quote) quote = null;
168
+ else value += character;
169
+ } else if (character === "'" || character === '"') {
170
+ quote = character;
171
+ } else if (/\s/.test(character)) {
172
+ if (value) values.push(value);
173
+ value = "";
174
+ } else {
175
+ value += character;
176
+ }
177
+ }
178
+ if (escaped) value += "\\";
179
+ if (value) values.push(value);
180
+ return values;
181
+ }
182
+
183
+ export async function validateDesktopSupervisor(root, nonce) {
184
+ if (!nonce) return false;
185
+ const owner = JSON.parse(await readFile(join(root, "desktop-app.lock", "owner.json"), "utf8").catch(() => "{}"));
186
+ return owner.nonce === nonce && Number.isSafeInteger(owner.pid) && processAlive(owner.pid) && owner.startedAt === await processStart(owner.pid);
187
+ }
188
+
189
+ async function processStart(pid) {
190
+ const { stdout } = await promisify(execFile)("/bin/ps", ["-p", String(pid), "-o", "lstart="], { timeout: 2_000 });
191
+ return stdout.trim();
192
+ }
193
+
194
+ function processAlive(pid) {
195
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code !== "ESRCH"; }
196
+ }
197
+
198
+ async function listSameUserProcesses() {
199
+ const uid = process.getuid?.();
200
+ if (!Number.isSafeInteger(uid)) return [];
201
+ const { stdout } = await promisify(execFile)("/bin/ps", ["-ww", "-axo", "pid=,uid=,command="], {
202
+ timeout: 2_000,
203
+ maxBuffer: 2 * 1024 * 1024,
204
+ });
205
+ const processes = [];
206
+ for (const line of stdout.split("\n")) {
207
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
208
+ if (!match || Number(match[2]) !== uid) continue;
209
+ processes.push({ pid: Number(match[1]), command: match[3] });
210
+ }
211
+ return processes;
212
+ }
213
+
214
+ async function verifySimyCliEntry(entryPath) {
215
+ try {
216
+ const resolved = await realpath(entryPath);
217
+ const packageJson = JSON.parse(await readFile(join(dirname(dirname(resolved)), "package.json"), "utf8"));
218
+ return packageJson?.name === "@awak-app/simy-cli";
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ function normalizedOriginOrNull(value) {
225
+ if (typeof value !== "string" || !value.trim()) return null;
226
+ try {
227
+ return normalizeWebOrigin(value);
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
@@ -0,0 +1,263 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import { inspectBackendExecutable } from "./backend-executable.js";
4
+ import { isSessionValid } from "./session-store.js";
5
+
6
+ const CODEX_STATUS_CACHE_MS = 30_000;
7
+ const MAX_DETAIL_LENGTH = 240;
8
+ const MAX_LABEL_LENGTH = 120;
9
+ const MAX_STATUS_ITEMS = 50;
10
+ const MAX_PROBE_OUTPUT = 4_096;
11
+ const INACTIVE_STATES = new Set([
12
+ "succeeded",
13
+ "failed",
14
+ "stopped",
15
+ "cancelled",
16
+ "canceled",
17
+ "complete",
18
+ "completed",
19
+ "merge_ready",
20
+ "pr_ready_for_review",
21
+ ]);
22
+
23
+ export function createConnectionStatusTracker({ now = () => new Date() } = {}) {
24
+ let lastSuccessAt = null;
25
+ let lastAttemptAt = null;
26
+ let state = "unknown";
27
+ let error = null;
28
+
29
+ return {
30
+ attempt() {
31
+ lastAttemptAt = isoNow(now);
32
+ state = state === "online" ? "online" : "connecting";
33
+ error = null;
34
+ },
35
+ success() {
36
+ lastSuccessAt = isoNow(now);
37
+ state = "online";
38
+ error = null;
39
+ },
40
+ failure(cause) {
41
+ state = isUnauthorizedError(cause) ? "unauthorized" : "offline";
42
+ error = safeStatusDetail(cause);
43
+ return state;
44
+ },
45
+ snapshot({ authenticated = false } = {}) {
46
+ const visibleState = authenticated
47
+ ? state === "unknown"
48
+ ? "connecting"
49
+ : state
50
+ : state === "unauthorized"
51
+ ? "unauthorized"
52
+ : "unknown";
53
+ return {
54
+ state: visibleState,
55
+ lastSuccessAt,
56
+ lastAttemptAt,
57
+ error: visibleState === "unknown" ? null : error,
58
+ };
59
+ },
60
+ };
61
+ }
62
+
63
+ export function desktopAuthStatus(session, { now = Date.now(), webOrigin = null } = {}) {
64
+ const exists = Boolean(session);
65
+ const valid = isSessionValid(session, now, webOrigin);
66
+ return {
67
+ state: valid ? "signed_in" : exists ? "expired" : "signed_out",
68
+ expiresAt: typeof session?.expires_at === "string" ? session.expires_at : null,
69
+ userLabel: sessionUserLabel(session),
70
+ };
71
+ }
72
+
73
+ export function desktopTaskStatus(registries, { maxItems = MAX_STATUS_ITEMS } = {}) {
74
+ const tasks = registries.flatMap((registry, registryIndex) =>
75
+ typeof registry?.list === "function"
76
+ ? registry.list().map((task) => taskStatusItem(task, registryIndex))
77
+ : [],
78
+ );
79
+ tasks.sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id));
80
+ return {
81
+ activeCount: tasks.filter((task) => !INACTIVE_STATES.has(task.state)).length,
82
+ items: tasks.slice(0, maxItems).map(({ updatedAt: _updatedAt, ...item }) => item),
83
+ };
84
+ }
85
+
86
+ export function createCodexStatusInspector({
87
+ cacheMs = CODEX_STATUS_CACHE_MS,
88
+ inspect = inspectCodexStatus,
89
+ now = () => Date.now(),
90
+ inspectOptions = {},
91
+ } = {}) {
92
+ let cached = null;
93
+ let cachedAt = 0;
94
+ let inFlight = null;
95
+ return async ({ force = false } = {}) => {
96
+ const current = now();
97
+ if (!force && cached && current - cachedAt < cacheMs) return structuredClone(cached);
98
+ if (inFlight) return structuredClone(await inFlight);
99
+ inFlight = Promise.resolve(inspect({ ...inspectOptions, now: () => new Date(now()) }))
100
+ .then((result) => {
101
+ cached = result;
102
+ cachedAt = now();
103
+ return result;
104
+ })
105
+ .finally(() => {
106
+ inFlight = null;
107
+ });
108
+ return structuredClone(await inFlight);
109
+ };
110
+ }
111
+
112
+ export async function inspectCodexStatus({
113
+ inspectExecutable = inspectBackendExecutable,
114
+ spawnImpl = spawn,
115
+ timeoutMs = 2_000,
116
+ now = () => new Date(),
117
+ } = {}) {
118
+ const checkedAt = isoNow(now);
119
+ let executable;
120
+ try {
121
+ executable = await inspectExecutable("codex", { spawnImpl, timeoutMs });
122
+ } catch {
123
+ return codexStatus("unknown", null, checkedAt, "Codex availability could not be checked.");
124
+ }
125
+ const version = executable?.installed_version ?? null;
126
+ if (!executable?.available) {
127
+ return codexStatus("missing", version, checkedAt, "Codex is not installed or cannot be started.");
128
+ }
129
+ if (!executable.compatible) {
130
+ return codexStatus(
131
+ executable.status === "outdated" ? "outdated" : "unknown",
132
+ version,
133
+ checkedAt,
134
+ executable.status === "outdated"
135
+ ? `Codex ${version || "installed version"} is older than the supported minimum.`
136
+ : "The installed Codex version could not be verified.",
137
+ );
138
+ }
139
+
140
+ const probe = await runLoginStatus(executable.executable, { spawnImpl, timeoutMs });
141
+ if (probe.kind === "signed_in") {
142
+ return codexStatus("ready", version, checkedAt, "Codex is installed and signed in.");
143
+ }
144
+ if (probe.kind === "signed_out") {
145
+ return codexStatus("signed_out", version, checkedAt, "Codex is installed but signed out.");
146
+ }
147
+ return codexStatus("unknown", version, checkedAt, "Codex login status could not be verified.");
148
+ }
149
+
150
+ export function safeStatusDetail(cause) {
151
+ const source = cause instanceof Error ? cause.message : String(cause || "Connection failed.");
152
+ if (isUnauthorizedError(cause)) return "SIMY authorization was rejected.";
153
+ if (/\b(?:abort|timed? ?out|timeout)\b/i.test(source)) return "SIMY connection timed out.";
154
+ if (/\b(?:fetch failed|network|ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN)\b/i.test(source)) {
155
+ return "SIMY Web could not be reached.";
156
+ }
157
+ return bounded("SIMY connection failed.", MAX_DETAIL_LENGTH);
158
+ }
159
+
160
+ export function isUnauthorizedError(error) {
161
+ const status = Number(error?.status ?? error?.statusCode);
162
+ if (status === 401 || status === 403) return true;
163
+ return /\b(?:401|403|unauthori[sz]ed|forbidden|session expired|invalid token|token expired)\b/i.test(
164
+ error instanceof Error ? error.message : String(error || ""),
165
+ );
166
+ }
167
+
168
+ function taskStatusItem(task, registryIndex) {
169
+ const state = bounded(
170
+ String(
171
+ task?.status ||
172
+ task?.snapshot?.state ||
173
+ task?.state ||
174
+ task?.control_state ||
175
+ task?.controlState ||
176
+ "unknown",
177
+ ),
178
+ 48,
179
+ );
180
+ return {
181
+ id: bounded(String(task?.id || `unknown-${registryIndex}`), MAX_LABEL_LENGTH),
182
+ label: bounded(
183
+ String(
184
+ task?.request?.requirement ||
185
+ task?.instruction ||
186
+ task?.snapshot?.charter?.requirement ||
187
+ task?.id ||
188
+ "Task",
189
+ ),
190
+ MAX_LABEL_LENGTH,
191
+ ),
192
+ state,
193
+ updatedAt:
194
+ Date.parse(task?.updated_at || task?.snapshot?.updated_at || task?.startedAt || 0) || 0,
195
+ };
196
+ }
197
+
198
+ function sessionUserLabel(session) {
199
+ if (!session) return null;
200
+ for (const key of ["user_label", "display_name", "email", "account_id", "auth_user_id"]) {
201
+ if (typeof session[key] === "string" && session[key].trim()) {
202
+ return bounded(session[key].trim(), MAX_LABEL_LENGTH);
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+
208
+ function runLoginStatus(command, { spawnImpl, timeoutMs }) {
209
+ return new Promise((resolve) => {
210
+ let settled = false;
211
+ let output = "";
212
+ let timeout;
213
+ let child;
214
+ const finish = (kind) => {
215
+ if (settled) return;
216
+ settled = true;
217
+ if (timeout) clearTimeout(timeout);
218
+ resolve({ kind });
219
+ };
220
+ try {
221
+ child = spawnImpl(command, ["login", "status"], {
222
+ stdio: ["ignore", "pipe", "pipe"],
223
+ });
224
+ } catch {
225
+ finish("unknown");
226
+ return;
227
+ }
228
+ const append = (chunk) => {
229
+ if (output.length >= MAX_PROBE_OUTPUT) return;
230
+ output += String(chunk).slice(0, MAX_PROBE_OUTPUT - output.length);
231
+ };
232
+ child.stdout?.on?.("data", append);
233
+ child.stderr?.on?.("data", append);
234
+ timeout = setTimeout(() => {
235
+ child.kill?.("SIGTERM");
236
+ finish("unknown");
237
+ }, timeoutMs);
238
+ child.on?.("error", () => finish("unknown"));
239
+ child.on?.("close", (code) => {
240
+ if (/not logged in|signed out|login required|please log in/i.test(output)) {
241
+ finish("signed_out");
242
+ } else if (code === 0 && /logged in|signed in/i.test(output)) {
243
+ finish("signed_in");
244
+ } else {
245
+ finish("unknown");
246
+ }
247
+ });
248
+ });
249
+ }
250
+
251
+ function codexStatus(state, version, checkedAt, detail) {
252
+ return { state, version, checkedAt, detail };
253
+ }
254
+
255
+ function isoNow(now) {
256
+ const value = now();
257
+ return (value instanceof Date ? value : new Date(value)).toISOString();
258
+ }
259
+
260
+ function bounded(value, limit) {
261
+ const clean = String(value || "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
262
+ return clean.length <= limit ? clean : `${clean.slice(0, limit - 1)}…`;
263
+ }
@@ -17,6 +17,12 @@ export class DirectTaskRegistry {
17
17
  get(taskId) {
18
18
  return this.#tasks.get(taskId) ?? null;
19
19
  }
20
+
21
+ list() {
22
+ return [...this.#tasks.values()].sort(
23
+ (left, right) => Date.parse(right.updated_at || 0) - Date.parse(left.updated_at || 0),
24
+ );
25
+ }
20
26
  }
21
27
 
22
28
  export function createDirectTask({
package/src/index.js CHANGED
@@ -3,6 +3,8 @@
3
3
  import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createInterface } from "node:readline/promises";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
6
8
 
7
9
  import { startAgent } from "./agent.js";
8
10
  import {
@@ -22,6 +24,7 @@ import {
22
24
  import { prepareDaemonWorkflows } from "./sqm/workflow-onboarding.js";
23
25
  import { startWorkflowUpdates } from "./sqm/workflow-update.js";
24
26
  import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
27
+ import { assertNoDesktopHostForOrigin } from "./desktop-process-lock.js";
25
28
 
26
29
  const argv = process.argv.slice(2);
27
30
  const args = new Set(argv);
@@ -29,12 +32,22 @@ const dispatchArgv = argv.filter((value) => value !== "--no-auto-update");
29
32
  const entryPath = fileURLToPath(import.meta.url);
30
33
  const packageInfo = await readCliPackageInfo();
31
34
  const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
35
+ let configuredWebOrigin;
36
+ try {
37
+ configuredWebOrigin = resolveWebOrigin(readOption(argv, "--host"));
38
+ } catch (error) {
39
+ console.error(error instanceof Error ? error.message : String(error));
40
+ process.exitCode = 2;
41
+ }
32
42
 
33
- if (dispatchArgv[0] === "update") {
43
+ if (!configuredWebOrigin) {
44
+ // Invalid host input is reported above before update discovery or startup.
45
+ } else if (dispatchArgv[0] === "update") {
34
46
  const checkOnly = args.has("--check");
35
47
  const result = await runCliUpdateGate({
36
48
  argv,
37
49
  packageInfo,
50
+ webOrigin: configuredWebOrigin,
38
51
  mode: checkOnly ? "check" : "update",
39
52
  disabled: updatesDisabled,
40
53
  allowMajor: args.has("--yes"),
@@ -46,16 +59,17 @@ if (dispatchArgv[0] === "update") {
46
59
  argv,
47
60
  packageInfo,
48
61
  entryPath,
62
+ webOrigin: configuredWebOrigin,
49
63
  disabled: updatesDisabled,
50
64
  });
51
65
  if (startupUpdate.reexecuted) {
52
66
  process.exitCode = startupUpdate.exitCode;
53
67
  } else {
54
- await runCommand();
68
+ await runCommand(startupUpdate.installMode);
55
69
  }
56
70
  }
57
71
 
58
- async function runCommand() {
72
+ async function runCommand(installMode) {
59
73
  if (dispatchArgv[0] === "sqm") {
60
74
  try {
61
75
  const { runSqmCommand } = await import("./sqm/command.js");
@@ -66,6 +80,7 @@ async function runCommand() {
66
80
  argv,
67
81
  packageInfo,
68
82
  entryPath,
83
+ webOrigin: configuredWebOrigin,
69
84
  mode: "incompatible",
70
85
  disabled: updatesDisabled,
71
86
  allowMajor: args.has("--yes"),
@@ -95,7 +110,7 @@ async function runCommand() {
95
110
  simy sqm lint Validate module reliability before publication
96
111
  simy sqm replay Replay modules against labelled historical snapshots
97
112
  simy update --check Check the installed and latest stable CLI versions
98
- simy update Install an eligible stable CLI update
113
+ simy update ${installMode === "macos_installer" ? "Show Apple Silicon update status and installer action" : "Install an eligible stable CLI update"}
99
114
 
100
115
  Options:
101
116
  --daemon, --deamon Run detached in the background
@@ -103,11 +118,11 @@ Options:
103
118
  --no-open Do not open the browser when authorization is required
104
119
  --port <port> Bind a specific localhost port
105
120
  --host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
106
- --install-workflows Install or update SIMY Codex/Claude skills and PR hooks
121
+ --install-workflows Install or update SIMY Codex/Claude opt-in SQM skills
107
122
  --no-workflow-prompt Skip the daemon startup workflow-installation prompt
108
123
  --no-auto-update Disable update checks and automatic installation
109
124
 
110
- ${autoUpdateHelpText()}
125
+ ${autoUpdateHelpText({ installMode, webOrigin: configuredWebOrigin })}
111
126
  `);
112
127
  return;
113
128
  }
@@ -119,9 +134,11 @@ async function runLocalAgent() {
119
134
  const daemon = args.has("--daemon") || args.has("--deamon");
120
135
  const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
121
136
  const port = readPort(argv);
122
- let webOrigin;
137
+ const webOrigin = configuredWebOrigin;
138
+ const sessionRoot = process.env.SIMY_HOME?.trim() || join(homedir(), ".simy");
139
+
123
140
  try {
124
- webOrigin = resolveWebOrigin(readOption(argv, "--host"));
141
+ await prepareDaemonWorkflows({ argv, daemon });
125
142
  } catch (error) {
126
143
  console.error(error instanceof Error ? error.message : String(error));
127
144
  process.exitCode = 2;
@@ -129,7 +146,7 @@ async function runLocalAgent() {
129
146
  }
130
147
 
131
148
  try {
132
- await prepareDaemonWorkflows({ argv, daemon });
149
+ await assertNoDesktopHostForOrigin(sessionRoot, webOrigin);
133
150
  } catch (error) {
134
151
  console.error(error instanceof Error ? error.message : String(error));
135
152
  process.exitCode = 2;
@@ -151,6 +168,7 @@ async function runLocalAgent() {
151
168
  daemon,
152
169
  interactive,
153
170
  requestedPort: port,
171
+ webOrigin,
154
172
  disabled: args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0",
155
173
  dependencies: { packageInfo },
156
174
  });
@@ -168,15 +186,23 @@ async function runLocalAgent() {
168
186
  await closeAgent();
169
187
  };
170
188
  installAgentShutdownSignalHandlers({ shutdown: shutdownAgent });
171
- await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
189
+ await writeUpdateHandoffFromEnvironment({
190
+ version: packageInfo.version,
191
+ port: agent.port,
192
+ webOrigin,
193
+ installMode: updateManager.snapshot().install_mode,
194
+ });
172
195
  stopWorkflowUpdates = startWorkflowUpdates({ enabled: daemon && !updatesDisabled });
173
196
  updateManager.start({
174
197
  registry: agent.registry,
175
- restartDaemon: async (targetVersion) => {
198
+ restartDaemon: async (targetVersion, installation) => {
176
199
  await restartDaemonProcess({
177
- entryPath: fileURLToPath(import.meta.url),
200
+ entryPath: installation?.entryPath || fileURLToPath(import.meta.url),
201
+ executablePath: installation?.executablePath,
178
202
  argv: process.argv.slice(2),
179
203
  targetVersion,
204
+ expectedWebOrigin: webOrigin,
205
+ expectedInstallMode: updateManager.snapshot().install_mode,
180
206
  });
181
207
  await shutdownAgent();
182
208
  },