@aexol/spectral 0.9.234 → 0.9.241

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.
Files changed (68) hide show
  1. package/README.md +47 -0
  2. package/dist/agent/index.d.ts +32 -5
  3. package/dist/agent/index.d.ts.map +1 -1
  4. package/dist/agent/index.js +120 -22
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +18 -4
  8. package/dist/commands/desktop.d.ts +167 -0
  9. package/dist/commands/desktop.d.ts.map +1 -0
  10. package/dist/commands/desktop.js +540 -0
  11. package/dist/commands/serve.d.ts +9 -1
  12. package/dist/commands/serve.d.ts.map +1 -1
  13. package/dist/commands/serve.js +78 -1
  14. package/dist/commands/update.d.ts +85 -5
  15. package/dist/commands/update.d.ts.map +1 -1
  16. package/dist/commands/update.js +288 -58
  17. package/dist/extensions/desktop-control/macos.d.ts.map +1 -1
  18. package/dist/extensions/desktop-control/macos.js +2 -2
  19. package/dist/mcp/server-manager.d.ts.map +1 -1
  20. package/dist/mcp/server-manager.js +6 -0
  21. package/dist/memory/tools/receive-agent-observations.d.ts.map +1 -1
  22. package/dist/memory/tools/receive-agent-observations.js +12 -2
  23. package/dist/relay/dispatcher.d.ts.map +1 -1
  24. package/dist/relay/dispatcher.js +30 -2
  25. package/dist/sdk/coding-agent/core/extensions/types.d.ts +8 -0
  26. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  27. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  28. package/dist/sdk/coding-agent/core/system-prompt.js +5 -0
  29. package/dist/server/agent-bridge/tps-metrics.d.ts +34 -0
  30. package/dist/server/agent-bridge/tps-metrics.d.ts.map +1 -1
  31. package/dist/server/agent-bridge/tps-metrics.js +22 -0
  32. package/dist/server/agent-bridge.d.ts +9 -0
  33. package/dist/server/agent-bridge.d.ts.map +1 -1
  34. package/dist/server/agent-bridge.js +83 -3
  35. package/dist/server/handlers/paths-list.d.ts +1 -1
  36. package/dist/server/handlers/paths-list.js +1 -1
  37. package/dist/server/inter-agent-broker.d.ts +12 -0
  38. package/dist/server/inter-agent-broker.d.ts.map +1 -1
  39. package/dist/server/inter-agent-broker.js +1 -0
  40. package/dist/server/preset-chain.d.ts +151 -0
  41. package/dist/server/preset-chain.d.ts.map +1 -0
  42. package/dist/server/preset-chain.js +250 -0
  43. package/dist/server/session-stream.d.ts +55 -1
  44. package/dist/server/session-stream.d.ts.map +1 -1
  45. package/dist/server/session-stream.js +203 -24
  46. package/dist/server/storage/rows.d.ts +2 -0
  47. package/dist/server/storage/rows.d.ts.map +1 -1
  48. package/dist/server/storage/schema.d.ts +2 -2
  49. package/dist/server/storage/schema.d.ts.map +1 -1
  50. package/dist/server/storage/schema.js +2 -1
  51. package/dist/server/storage.d.ts +7 -0
  52. package/dist/server/storage.d.ts.map +1 -1
  53. package/dist/server/storage.js +142 -9
  54. package/dist/server/wire.d.ts +71 -0
  55. package/dist/server/wire.d.ts.map +1 -1
  56. package/dist/utils/augment-path.d.ts +51 -0
  57. package/dist/utils/augment-path.d.ts.map +1 -0
  58. package/dist/utils/augment-path.js +234 -0
  59. package/dist/utils/open-browser.d.ts +55 -0
  60. package/dist/utils/open-browser.d.ts.map +1 -0
  61. package/dist/utils/open-browser.js +163 -0
  62. package/dist/utils/serve-state.d.ts +264 -0
  63. package/dist/utils/serve-state.d.ts.map +1 -0
  64. package/dist/utils/serve-state.js +563 -0
  65. package/dist/utils/standalone.d.ts +14 -0
  66. package/dist/utils/standalone.d.ts.map +1 -0
  67. package/dist/utils/standalone.js +23 -0
  68. package/package.json +1 -3
@@ -0,0 +1 @@
1
+ {"version":3,"file":"desktop.d.ts","sourceRoot":"","sources":["../../src/commands/desktop.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,540 @@
1
+ /**
2
+ * `spectral desktop` — the thin, headless replacement for the Electron shell.
3
+ *
4
+ * What the Electron app actually contributed was four things, all reproduced
5
+ * here without a bundled Chromium:
6
+ *
7
+ * 1. single-instance guarantee → `~/.spectral/serve.pid` + liveness check
8
+ * 1. single-instance guarantee → `~/.spectral/serve.pid`, claimed
9
+ * atomically (`O_EXCL`) before the spawn
10
+ * 2. start the local relay daemon → detached `spectral serve`, unref'd,
11
+ * stdout+stderr appended to serve.log
12
+ * 3. "wait until it is really up" → the `SPECTRAL_SERVE_READY` stdout marker
13
+ * and `serve-ready.json` (see
14
+ * src/utils/serve-state.ts), replacing the
15
+ * old "assume ready after 3 s" heuristic
16
+ * 4. show the UI → open the system browser at
17
+ * https://aexol.ai/agent
18
+ * (override: SPECTRAL_LANDING_URL)
19
+ *
20
+ * The PATH augmentation the app performed for its child process now happens in
21
+ * `augmentPath()` (src/utils/augment-path.ts) and is applied to the spawned
22
+ * daemon, so MCP servers launched via `npx` keep working.
23
+ *
24
+ * What is deliberately NOT reproduced (see the removal notes): the splash
25
+ * window, in-app auto-update (use `spectral update`), the native app menu, and
26
+ * the JWT-via-IPC login bridge — authentication goes through the existing
27
+ * `spectral login` browser flow, which already writes `userJwt` to
28
+ * `~/.spectral/config.json`.
29
+ *
30
+ * Exit codes: 0 success / already running, 1 failure, 3 stopped-state for
31
+ * `status` when the daemon is not running.
32
+ */
33
+ import { closeSync, fchmodSync, existsSync, mkdirSync, openSync } from "node:fs";
34
+ import { spawn } from "node:child_process";
35
+ import { homedir } from "node:os";
36
+ import { basename, dirname, join } from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import pc from "picocolors";
39
+ import { readConfig } from "../config.js";
40
+ import { getVersion } from "../version.js";
41
+ import { augmentPath, getPathEnvKey } from "../utils/augment-path.js";
42
+ import { openBrowser } from "../utils/open-browser.js";
43
+ import { isStandaloneBinary } from "../utils/standalone.js";
44
+ import { claimServePidFile, clearServeState, confirmServePidChild, fileSizeOf, getLiveServePid, getServeLogFile, getServePidFile, isProcessAlive, readServePidFile, readServeReadyFile, releaseServePidClaim, rotateServeLogFile, DEFAULT_SERVE_LOG_MAX_BYTES, waitForServeReady, writeServePid, } from "../utils/serve-state.js";
45
+ /** Where the Studio agent UI lives. */
46
+ export const DEFAULT_LANDING_URL = "https://aexol.ai/agent";
47
+ /** How long to wait for the daemon's readiness signal. */
48
+ export const DEFAULT_READY_TIMEOUT_MS = 15_000;
49
+ /** Grace period between SIGTERM and SIGKILL in `desktop stop`. */
50
+ export const DEFAULT_STOP_TIMEOUT_MS = 10_000;
51
+ /**
52
+ * Set in the daemon's env to the wrapper's own pid. The daemon sees the
53
+ * placeholder we left in `serve.pid` and must not read it as "another serve is
54
+ * running" — it is the claim of the process that just launched it.
55
+ */
56
+ export const SERVE_PID_CLAIM_ENV = "SPECTRAL_SERVE_PID_CLAIM";
57
+ export function getLandingUrl(env = process.env) {
58
+ const override = env.SPECTRAL_LANDING_URL?.trim();
59
+ return override && override.length > 0 ? override : DEFAULT_LANDING_URL;
60
+ }
61
+ function readyTimeoutMs(env = process.env) {
62
+ const raw = env.SPECTRAL_DESKTOP_READY_TIMEOUT_MS;
63
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
64
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_READY_TIMEOUT_MS;
65
+ }
66
+ /** Grace period the daemon gets to drain in-flight turns before SIGKILL. */
67
+ function stopGraceMs(env = process.env) {
68
+ const raw = env.SPECTRAL_DESKTOP_STOP_TIMEOUT_MS;
69
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
70
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_STOP_TIMEOUT_MS;
71
+ }
72
+ /**
73
+ * Serve-log size that triggers a rotation at start. Overridable so the rotation
74
+ * path is testable without writing 10 MB.
75
+ */
76
+ function logMaxBytes(env = process.env) {
77
+ const raw = env.SPECTRAL_DESKTOP_LOG_MAX_BYTES;
78
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
79
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SERVE_LOG_MAX_BYTES;
80
+ }
81
+ /** Render a grace window the way it is actually configured (10s, 250ms). */
82
+ export function formatGraceMs(ms) {
83
+ return ms >= 1_000 && ms % 1_000 === 0 ? `${ms / 1_000}s` : `${ms}ms`;
84
+ }
85
+ /**
86
+ * Decide how to launch a detached `spectral serve`.
87
+ *
88
+ * Three launch shapes must all work:
89
+ * - standalone Bun binary → exec the binary itself with `serve`
90
+ * - npm install (`spectral` bin → dist/cli.js) → `node <cli.js> serve`
91
+ * - `npm run dev` (tsx src/cli.ts) → re-run the very entry the user invoked,
92
+ * with `process.execArgv` so the tsx loader is carried over
93
+ *
94
+ * @returns the spawn plan's command/args portion. Falls back to whatever
95
+ * `spectral` is on PATH when no entry point can be identified.
96
+ */
97
+ export function resolveServeCommand(input = {}) {
98
+ const execPath = input.execPath ?? process.execPath;
99
+ const execArgv = input.execArgv ?? process.execArgv;
100
+ const argv1 = input.argv1 ?? process.argv[1];
101
+ const standalone = input.standalone ?? isStandaloneBinary();
102
+ const exists = input.exists ?? ((p) => existsSync(p));
103
+ if (standalone)
104
+ return { command: execPath, args: ["serve"] };
105
+ const candidates = (input.candidates ?? [argv1, ...defaultEntryCandidates()]).filter((candidate) => typeof candidate === "string" && candidate.length > 0);
106
+ const entry = candidates.find((candidate) => exists(candidate));
107
+ if (entry)
108
+ return { command: execPath, args: [...execArgv, entry, "serve"] };
109
+ return { command: "spectral", args: ["serve"] };
110
+ }
111
+ /**
112
+ * Entry points relative to this module, covering both the compiled (`dist/`)
113
+ * and the source (`src/`, via tsx) layouts.
114
+ */
115
+ function defaultEntryCandidates() {
116
+ const here = dirname(fileURLToPath(import.meta.url));
117
+ return [
118
+ join(here, "..", "cli.js"), // dist/commands/desktop.js -> dist/cli.js
119
+ join(here, "..", "..", "src", "cli.ts"), // src/commands -> src/cli.ts (dev)
120
+ join(here, "..", "..", "dist", "cli.js"), // src/commands -> dist/cli.js
121
+ ];
122
+ }
123
+ /** Compose the full plan for spawning the daemon. */
124
+ export function planServeSpawn(env = process.env, input = {}) {
125
+ const { command, args } = resolveServeCommand(input);
126
+ const pathKey = getPathEnvKey(env);
127
+ return {
128
+ command,
129
+ args,
130
+ cwd: homedir(),
131
+ // The env we were given is the source of truth — `augmentPath(undefined)`
132
+ // would silently fall back to *this* process's PATH and leak it into a
133
+ // caller-supplied env.
134
+ env: {
135
+ ...env,
136
+ [pathKey]: augmentPath(env[pathKey] ?? ""),
137
+ [SERVE_PID_CLAIM_ENV]: String(process.pid),
138
+ },
139
+ logFile: getServeLogFile(env),
140
+ };
141
+ }
142
+ /**
143
+ * Will this spawn re-exec into the daemon instead of becoming it directly?
144
+ *
145
+ * Matters for readiness: a standalone binary and `node dist/cli.js serve` are
146
+ * the daemon, so their exit is a hard failure we can report instantly. Under
147
+ * `npm run dev` (`tsx src/cli.ts`) the child is a loader wrapper that execs
148
+ * something else — its death tells us nothing, and only the ready signal or the
149
+ * timeout may decide.
150
+ */
151
+ export function spawnExpectsReExec(plan) {
152
+ if (isStandaloneBinary())
153
+ return false;
154
+ const command = basename(plan.command).replace(/\.exe$/i, "");
155
+ if (command === "tsx" || command === "ts-node" || command === "oxnode")
156
+ return true;
157
+ return plan.args.some((arg) => /(?:^|[/\\.-])(?:tsx|ts-node)(?:$|[/\\._])/.test(arg) || arg.startsWith("--loader"));
158
+ }
159
+ /**
160
+ * Launch the daemon detached from this terminal so it survives the wrapper
161
+ * exiting (that is the whole point: close the shell, the agent stays up).
162
+ * Returns the child pid, or null when the spawn itself failed.
163
+ */
164
+ export function spawnDetachedServe(plan) {
165
+ try {
166
+ mkdirSync(dirname(plan.logFile), { recursive: true, mode: 0o700 });
167
+ }
168
+ catch {
169
+ // The log dir may already exist or be read-only; spawning with an
170
+ // unwritable log is still worth trying.
171
+ }
172
+ let fd;
173
+ try {
174
+ // 0600: the daemon log carries machine ids and relay diagnostics, and this
175
+ // file is created by whoever happened to start the agent first. The mode
176
+ // argument is umask-masked, so chmod the fresh descriptor as well.
177
+ fd = openSync(plan.logFile, "a", 0o600);
178
+ try {
179
+ fchmodSync(fd, 0o600);
180
+ }
181
+ catch {
182
+ // filesystem without POSIX modes
183
+ }
184
+ }
185
+ catch {
186
+ fd = undefined;
187
+ }
188
+ try {
189
+ const child = spawn(plan.command, plan.args, {
190
+ cwd: plan.cwd,
191
+ // detached: new process group, so `desktop stop` can signal the daemon
192
+ // without touching this CLI's own group, and Ctrl-C in the wrapper's
193
+ // terminal does not take the agent down.
194
+ detached: true,
195
+ stdio: [
196
+ "ignore",
197
+ fd ?? "ignore",
198
+ // Same fd for stdout and stderr, as the daemon interleaves them. When
199
+ // the log could not be opened, drop both rather than leaking the
200
+ // daemon's chatter into the wrapper's terminal (which we are about to
201
+ // exit) — the readiness fallback file still works.
202
+ fd ?? "ignore",
203
+ ],
204
+ env: plan.env,
205
+ });
206
+ child.unref();
207
+ return child.pid ?? null;
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ finally {
213
+ // The child dup'd the descriptor at spawn time; our copy is dead weight.
214
+ if (fd !== undefined) {
215
+ try {
216
+ closeSync(fd);
217
+ }
218
+ catch {
219
+ // ignore
220
+ }
221
+ }
222
+ }
223
+ }
224
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
225
+ function tailLines(tail, max = 12) {
226
+ if (!tail)
227
+ return "";
228
+ return tail
229
+ .split(/\r?\n/)
230
+ .map((line) => line.trimEnd())
231
+ .filter((line) => line.length > 0)
232
+ .slice(-max)
233
+ .join("\n");
234
+ }
235
+ /**
236
+ * SIGTERM → wait out the drain → SIGKILL. Shared by `desktop stop` and by the
237
+ * start path's "it never came up, do not leave it half-started" branch — the
238
+ * old start path fired a bare SIGTERM and walked away, which left a detached
239
+ * daemon that ignored TERM (or won the race) running behind a failed command.
240
+ *
241
+ * The wait is bounded by a poll COUNT rather than a wall-clock deadline, so a
242
+ * caller with a synchronous `sleep` (tests) is both fast and deterministic.
243
+ */
244
+ export async function terminateAgentPid(opts) {
245
+ const out = opts.out ?? (() => { });
246
+ const pollMs = opts.pollMs ?? 100;
247
+ const killWaitMs = opts.killWaitMs ?? 2_000;
248
+ try {
249
+ opts.signal(opts.pid, "SIGTERM");
250
+ }
251
+ catch (err) {
252
+ // ESRCH: it exited between the liveness check and the signal.
253
+ const code = err.code;
254
+ if (code !== "ESRCH")
255
+ throw err;
256
+ }
257
+ // `serve` drains in-flight turns on SIGTERM, so wait the grace window out
258
+ // before escalating. Then SIGKILL — bounded, never a hang.
259
+ for (let i = 0; i < Math.max(1, Math.ceil(opts.graceMs / pollMs)) && opts.alive(opts.pid); i++) {
260
+ await opts.sleep(pollMs);
261
+ }
262
+ if (!opts.alive(opts.pid))
263
+ return "stopped";
264
+ out(pc.yellow(` Agent did not exit within ${formatGraceMs(opts.graceMs)}; forcing SIGKILL.\n`));
265
+ try {
266
+ opts.signal(opts.pid, "SIGKILL");
267
+ }
268
+ catch {
269
+ // racing exit — fine
270
+ }
271
+ for (let i = 0; i < Math.max(1, Math.ceil(killWaitMs / pollMs)) && opts.alive(opts.pid); i++) {
272
+ await opts.sleep(pollMs);
273
+ }
274
+ return opts.alive(opts.pid) ? "failed" : "killed";
275
+ }
276
+ /**
277
+ * Which pid may this wrapper signal? Only the one recorded in the pid file:
278
+ * the spawn handle names the loader wrapper under `tsx`, and once the daemon
279
+ * has claimed the file the handle may refer to a process that is already gone.
280
+ *
281
+ * `placeholderPid` is what we wrote ourselves before spawning, so it counts as
282
+ * ours and falls back to the child handle. A *missing* entry means somebody
283
+ * else cleared the state — then nothing is signalled, rather than a guessed pid.
284
+ */
285
+ function stopTargetPid(childPid, placeholderPid) {
286
+ const recorded = readServePidFile();
287
+ if (recorded === null)
288
+ return null;
289
+ return recorded === placeholderPid ? childPid : recorded;
290
+ }
291
+ // ---------------------------------------------------------------------------
292
+ // Subcommands
293
+ // ---------------------------------------------------------------------------
294
+ async function startDesktop(deps) {
295
+ const out = deps.out ?? ((t) => process.stdout.write(t));
296
+ const err = deps.err ?? ((t) => process.stderr.write(t));
297
+ const env = deps.env ?? process.env;
298
+ const landingUrl = getLandingUrl(env);
299
+ const logFile = getServeLogFile(env);
300
+ const alive = deps.alive ?? isProcessAlive;
301
+ const readCfg = deps.readCfg ?? readConfig;
302
+ /** What both single-instance paths print: the agent is up, open the UI. */
303
+ const reportAlreadyRunning = async (pid) => {
304
+ out(pc.yellow(`Spectral agent is already running (pid ${pid}).\n`));
305
+ const opened = await (deps.openUrl ?? openBrowser)(landingUrl);
306
+ out(opened.opened ? `Opened ${landingUrl}\n` : `Agent: ${landingUrl}\n`);
307
+ out(`Stop it with ${pc.bold("spectral desktop stop")}.\n`);
308
+ return 0;
309
+ };
310
+ // Single instance. The pid file is only trusted after a liveness check, so a
311
+ // crashed daemon does not block a restart.
312
+ const running = getLiveServePid();
313
+ if (running !== null) {
314
+ return reportAlreadyRunning(running);
315
+ }
316
+ // Auth gate: the web UI needs a user JWT, and `serve` needs credentials too.
317
+ const cfg = await readCfg();
318
+ if (!cfg?.userJwt) {
319
+ const interactive = deps.isInteractive ?? (() => Boolean(process.stdin.isTTY));
320
+ if (!interactive()) {
321
+ err(pc.red("✗ Not signed in for Aexol Studio.\n"));
322
+ err(` Run ${pc.bold("spectral login")} in a terminal, then retry.\n`);
323
+ return 1;
324
+ }
325
+ out(pc.dim("Not signed in for Aexol Studio — opening the sign-in flow…\n"));
326
+ const runLogin = deps.runLogin ??
327
+ (async () => {
328
+ const { runLogin: login } = await import("./login.js");
329
+ await login();
330
+ });
331
+ await runLogin();
332
+ const afterLogin = await readCfg();
333
+ if (!afterLogin?.userJwt) {
334
+ err(pc.red("✗ Sign-in finished without a Studio token, so the desktop UI cannot start.\n"));
335
+ err(` Run ${pc.bold("spectral login")} and choose the OAuth option.\n`);
336
+ return 1;
337
+ }
338
+ }
339
+ // Claim the pid file NOW, before spawning. The liveness check above is only
340
+ // an optimisation: two invocations can both see "not running" and then both
341
+ // spawn, which is the race `open(O_EXCL)` closes. The claim records this
342
+ // wrapper's pid as a placeholder, so a late racer sees a live holder.
343
+ const claim = claimServePidFile();
344
+ if (claim.status === "failed") {
345
+ err(pc.red(`✗ Could not claim ${getServePidFile()}: ${claim.error}\n`));
346
+ return 1;
347
+ }
348
+ if (claim.status === "busy") {
349
+ return reportAlreadyRunning(claim.pid);
350
+ }
351
+ // Keep the daemon log bounded before anything is appended to it, and only
352
+ // THEN snapshot the offset — after a rotation the fresh log starts at byte 0,
353
+ // and the readiness marker of a previous run must never satisfy this one.
354
+ const maxLogBytes = logMaxBytes(env);
355
+ if (rotateServeLogFile(logFile, maxLogBytes)) {
356
+ out(pc.dim(`Rotated ${logFile} to ${logFile}.old (larger than ${maxLogBytes} bytes)\n`));
357
+ }
358
+ const logOffset = fileSizeOf(logFile);
359
+ const plan = planServeSpawn(env, deps.planInput);
360
+ const spawnServe = deps.spawnServe ?? spawnDetachedServe;
361
+ // Captured before the spawn: a ready file older than this is from an earlier
362
+ // run and must not be accepted as this one's signal.
363
+ const spawnStartedAt = Date.now();
364
+ const pid = spawnServe(plan);
365
+ if (pid === null || pid === undefined) {
366
+ err(pc.red(`✗ Could not start the Spectral agent (${plan.command}).\n`));
367
+ err(` Log: ${logFile}\n`);
368
+ releaseServePidClaim(claim.placeholderPid);
369
+ return 1;
370
+ }
371
+ // Hand the claim over: the pid file must end up naming the daemon, not us.
372
+ const ownership = confirmServePidChild(pid, claim.placeholderPid, {
373
+ genealogy: deps.genealogy,
374
+ });
375
+ // A foreign or unverifiable holder both mean "not provably ours": warn the
376
+ // user and leave the recorded state untouched instead of hijacking it.
377
+ if (ownership.status === "foreign" || ownership.status === "unknown") {
378
+ const held = ownership.pid === null
379
+ ? `${getServePidFile()} is held by a process this command could not verify`
380
+ : ownership.status === "foreign"
381
+ ? `${getServePidFile()} is held by pid ${ownership.pid}, which this command did not start`
382
+ : `${getServePidFile()} is held by pid ${ownership.pid}, which could not be verified as ours`;
383
+ err(pc.yellow(`⚠ ${held}; leaving its state alone.\n`));
384
+ }
385
+ const waitForReady = deps.waitForReady ?? ((o) => waitForServeReady(o));
386
+ const result = await waitForReady({
387
+ pid,
388
+ logFile,
389
+ logOffset,
390
+ sinceMs: spawnStartedAt,
391
+ expectReExec: spawnExpectsReExec(plan),
392
+ timeoutMs: readyTimeoutMs(env),
393
+ });
394
+ if (!result.ok) {
395
+ const why = result.reason === "process-exited"
396
+ ? "the agent process exited during startup"
397
+ : `no readiness signal within ${Math.round((result.waitedMs ?? 0) / 1000)}s`;
398
+ err(pc.red(`✗ Spectral agent did not become ready: ${why}.\n`));
399
+ err(` Log: ${logFile}\n`);
400
+ const tail = tailLines(result.logTail);
401
+ if (tail)
402
+ err(pc.dim(`${tail}\n`));
403
+ // A half-started detached daemon that may still connect later is worse than
404
+ // a restart, so stop it the same way `desktop stop` would — SIGTERM, grace,
405
+ // then SIGKILL — but only when the pid file provably names a process WE
406
+ // launched (`child` or `daemon`). A foreign or unverifiable holder must
407
+ // never be signalled, so its state is released untouched instead.
408
+ const target = ownership.status === "child" || ownership.status === "daemon"
409
+ ? stopTargetPid(pid, claim.placeholderPid)
410
+ : null;
411
+ if (target !== null && alive(target)) {
412
+ await terminateAgentPid({
413
+ pid: target,
414
+ graceMs: deps.stopGraceMs ?? stopGraceMs(env),
415
+ alive,
416
+ signal: deps.signal ?? ((p, s) => process.kill(p, s)),
417
+ sleep: deps.sleep ?? defaultSleep,
418
+ out,
419
+ });
420
+ }
421
+ releaseServePidClaim(claim.placeholderPid, pid);
422
+ return 1;
423
+ }
424
+ // The daemon writes its own pid file; backfill if it could not (read-only
425
+ // config dir races) so `status`/`stop` always have something to work with.
426
+ if (getLiveServePid() === null)
427
+ writeServePid(result.info?.pid ?? pid);
428
+ const opened = await (deps.openUrl ?? openBrowser)(landingUrl);
429
+ out(pc.green(`✓ Spectral agent running (pid ${result.info?.pid ?? pid}).\n`));
430
+ out(opened.opened
431
+ ? ` Opened ${pc.cyan(landingUrl)} in your browser.\n`
432
+ : ` Open ${pc.cyan(landingUrl)} in your browser (auto-open failed: ${opened.error ?? "no browser"}).\n`);
433
+ out(` Logs: ${logFile}\n`);
434
+ out(` Stop with ${pc.bold("spectral desktop stop")}, check with ${pc.bold("spectral desktop status")}.\n`);
435
+ return 0;
436
+ }
437
+ async function statusDesktop(deps) {
438
+ const out = deps.out ?? ((t) => process.stdout.write(t));
439
+ const env = deps.env ?? process.env;
440
+ const landingUrl = getLandingUrl(env);
441
+ const pid = getLiveServePid();
442
+ if (pid === null) {
443
+ out("Spectral agent: not running\n");
444
+ out(` Start with ${pc.bold("spectral desktop")}.\n`);
445
+ return 3;
446
+ }
447
+ const ready = readServeReadyFile();
448
+ out(pc.green(`Spectral agent: running (pid ${pid})`) + "\n");
449
+ if (ready) {
450
+ out(` Version: ${ready.version}\n`);
451
+ out(` Ready since: ${new Date(ready.ts).toISOString()}\n`);
452
+ out(` CLI version now: ${getVersion()}\n`);
453
+ }
454
+ else {
455
+ out(` Process is alive, but no readiness signal was recorded (relay may not be connected).\n`);
456
+ }
457
+ out(` Agent UI: ${landingUrl}\n`);
458
+ out(` Logs: ${getServeLogFile(env)}\n`);
459
+ return 0;
460
+ }
461
+ async function stopDesktop(deps) {
462
+ const out = deps.out ?? ((t) => process.stdout.write(t));
463
+ const err = deps.err ?? ((t) => process.stderr.write(t));
464
+ const alive = deps.alive ?? isProcessAlive;
465
+ const signal = deps.signal ?? ((p, s) => process.kill(p, s));
466
+ const sleep = deps.sleep ?? defaultSleep;
467
+ const graceMs = deps.stopGraceMs ?? stopGraceMs(deps.env ?? process.env);
468
+ const pid = readServePidFile();
469
+ if (pid === null) {
470
+ clearServeState();
471
+ out("Spectral agent: not running\n");
472
+ return 0;
473
+ }
474
+ if (!alive(pid)) {
475
+ clearServeState();
476
+ out(`Spectral agent was not running (stale pid ${pid}); cleaned up.\n`);
477
+ return 0;
478
+ }
479
+ out(`Stopping Spectral agent (pid ${pid})…\n`);
480
+ // Same escalation the start path uses when the daemon never came up. The
481
+ // target is always the pid read back from the pid file — never a pid cached
482
+ // from an earlier lookup, which could already have been recycled.
483
+ const outcome = await terminateAgentPid({
484
+ pid: readServePidFile() ?? pid,
485
+ graceMs,
486
+ alive,
487
+ signal,
488
+ sleep,
489
+ out,
490
+ });
491
+ if (outcome === "failed") {
492
+ err(pc.red(`✗ Could not stop pid ${pid}; it is still alive. Kill it manually.\n`));
493
+ return 1;
494
+ }
495
+ clearServeState();
496
+ out(pc.green("✓ Spectral agent stopped.\n"));
497
+ return 0;
498
+ }
499
+ function printUsage(out) {
500
+ out([
501
+ "spectral desktop — run the Spectral agent and open the Studio UI",
502
+ "",
503
+ " spectral desktop Start the agent (or reuse a running one) and open the UI",
504
+ " spectral desktop status Report whether the agent is running",
505
+ ` spectral desktop stop Stop the agent (SIGTERM, then SIGKILL after ${formatGraceMs(DEFAULT_STOP_TIMEOUT_MS)})`,
506
+ "",
507
+ "Environment:",
508
+ " SPECTRAL_LANDING_URL UI to open (default https://aexol.ai/agent)",
509
+ " SPECTRAL_SERVE_LOG_PATH Daemon log (default ~/.spectral/serve.log)",
510
+ " SPECTRAL_DESKTOP_READY_TIMEOUT_MS Readiness timeout (default 15000)",
511
+ ` SPECTRAL_DESKTOP_STOP_TIMEOUT_MS SIGTERM grace before SIGKILL (default ${DEFAULT_STOP_TIMEOUT_MS})`,
512
+ "",
513
+ ].join("\n"));
514
+ }
515
+ /**
516
+ * `spectral desktop [start|status|stop]`
517
+ *
518
+ * @returns process exit code; `cli.ts` assigns it to `process.exitCode`.
519
+ */
520
+ export async function runDesktop(args = [], deps = {}) {
521
+ const out = deps.out ?? ((t) => process.stdout.write(t));
522
+ const sub = args[0];
523
+ switch (sub) {
524
+ case undefined:
525
+ case "start":
526
+ return startDesktop(deps);
527
+ case "status":
528
+ return statusDesktop(deps);
529
+ case "stop":
530
+ return stopDesktop(deps);
531
+ case "--help":
532
+ case "-h":
533
+ printUsage(out);
534
+ return 0;
535
+ default:
536
+ out(`spectral desktop: unknown subcommand "${sub}".\n`);
537
+ printUsage(out);
538
+ return 1;
539
+ }
540
+ }
@@ -88,6 +88,13 @@ export interface RunServeOptions {
88
88
  fetchImpl?: typeof fetch;
89
89
  /** Override the WebSocket constructor (tests, RelayClient). */
90
90
  webSocketImpl?: ConstructorParameters<typeof RelayClient>[0]["webSocketImpl"];
91
+ /**
92
+ * Write the `serve.pid` / `serve-ready.json` bookkeeping files and print the
93
+ * `SPECTRAL_SERVE_READY` stdout marker once the relay socket opens. Defaults
94
+ * to true — it is the readiness contract `spectral desktop` waits on.
95
+ * Tests pass false to keep a temp config dir clean.
96
+ */
97
+ serveState?: boolean;
91
98
  }
92
99
  export interface RunServeHandle {
93
100
  /** Open store. Exposed for tests. */
@@ -104,7 +111,8 @@ export interface RunServeHandle {
104
111
  export declare function runServe(opts?: RunServeOptions): Promise<RunServeHandle>;
105
112
  /**
106
113
  * CLI entry point used by `cli.ts`. Surfaces the deprecation warning for
107
- * `--port`/`SPECTRAL_PORT` once, then defers to `runServe`.
114
+ * `--port`/`SPECTRAL_PORT` once, augments PATH for child processes, then defers
115
+ * to `runServe`.
108
116
  */
109
117
  export declare function runServeCli(rawArgs: string[]): Promise<RunServeHandle>;
110
118
  //# sourceMappingURL=serve.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/commands/serve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAwBH,OAAO,EAAE,WAAW,EAAmB,MAAM,oBAAoB,CAAC;AAclE,OAAO,KAAK,EAAE,aAAa,EAAc,MAAM,6BAA6B,CAAC;AAC7E,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAGnE,OAAO,EAAmB,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAarE;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAazD;AAED,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,eAAe,CA6B9D;AAED,MAAM,WAAW,eAAe;IAC9B,iEAAiE;IACjE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,uCAAuC;IACvC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,wCAAwC;IACxC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4CAA4C;IAC5C,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,qBAAqB,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC;CAC/E;AAED,MAAM,WAAW,cAAc;IAC7B,qCAAqC;IACrC,KAAK,EAAE,YAAY,CAAC;IACpB,wDAAwD;IACxD,OAAO,EAAE,oBAAoB,CAAC;IAC9B,4CAA4C;IAC5C,KAAK,EAAE,WAAW,CAAC;IACnB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,wBAAsB,QAAQ,CAAC,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,cAAc,CAAC,CA8lBlF;AAmFD;;;GAGG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,cAAc,CAAC,CAc5E"}
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/commands/serve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAwBH,OAAO,EAAE,WAAW,EAAmB,MAAM,oBAAoB,CAAC;AAclE,OAAO,KAAK,EAAE,aAAa,EAAc,MAAM,6BAA6B,CAAC;AAC7E,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAGnE,OAAO,EAAmB,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAsBrE;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAazD;AAED,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,eAAe,CA6B9D;AAED,MAAM,WAAW,eAAe;IAC9B,iEAAiE;IACjE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,uCAAuC;IACvC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,wCAAwC;IACxC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4CAA4C;IAC5C,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,qBAAqB,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC;IAC9E;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,qCAAqC;IACrC,KAAK,EAAE,YAAY,CAAC;IACpB,wDAAwD;IACxD,OAAO,EAAE,oBAAoB,CAAC;IAC9B,4CAA4C;IAC5C,KAAK,EAAE,WAAW,CAAC;IACnB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,wBAAsB,QAAQ,CAAC,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,cAAc,CAAC,CAoqBlF;AAmFD;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,cAAc,CAAC,CAsB5E"}