@aexol/spectral 0.9.231 → 0.9.240

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 (157) 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 +209 -36
  5. package/dist/backend/generated-images.d.ts +1 -1
  6. package/dist/backend/generated-images.d.ts.map +1 -1
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +18 -4
  10. package/dist/commands/desktop.d.ts +167 -0
  11. package/dist/commands/desktop.d.ts.map +1 -0
  12. package/dist/commands/desktop.js +540 -0
  13. package/dist/commands/serve.d.ts +9 -1
  14. package/dist/commands/serve.d.ts.map +1 -1
  15. package/dist/commands/serve.js +90 -2
  16. package/dist/commands/update.d.ts +85 -5
  17. package/dist/commands/update.d.ts.map +1 -1
  18. package/dist/commands/update.js +288 -58
  19. package/dist/extensions/desktop-control/macos.d.ts.map +1 -1
  20. package/dist/extensions/desktop-control/macos.js +2 -2
  21. package/dist/extensions/logic-trace.d.ts +1 -1
  22. package/dist/extensions/logic-trace.d.ts.map +1 -1
  23. package/dist/extensions/logic-trace.js +199 -70
  24. package/dist/extensions/video-generation/index.d.ts +3 -0
  25. package/dist/extensions/video-generation/index.d.ts.map +1 -0
  26. package/dist/extensions/video-generation/index.js +299 -0
  27. package/dist/generated/zeus/const.d.ts.map +1 -1
  28. package/dist/generated/zeus/const.js +30 -0
  29. package/dist/generated/zeus/index.d.ts +140 -0
  30. package/dist/generated/zeus/index.d.ts.map +1 -1
  31. package/dist/generated/zeus/index.js +5 -0
  32. package/dist/mcp/server-manager.d.ts.map +1 -1
  33. package/dist/mcp/server-manager.js +6 -0
  34. package/dist/memory/tools/receive-agent-observations.d.ts.map +1 -1
  35. package/dist/memory/tools/receive-agent-observations.js +12 -2
  36. package/dist/relay/dispatcher.d.ts +13 -1
  37. package/dist/relay/dispatcher.d.ts.map +1 -1
  38. package/dist/relay/dispatcher.js +41 -2
  39. package/dist/sdk/agent-core/agent-loop.js +13 -3
  40. package/dist/sdk/agent-core/agent.d.ts +11 -4
  41. package/dist/sdk/agent-core/agent.d.ts.map +1 -1
  42. package/dist/sdk/agent-core/agent.js +16 -4
  43. package/dist/sdk/agent-core/tool-call-registry.d.ts +75 -0
  44. package/dist/sdk/agent-core/tool-call-registry.d.ts.map +1 -0
  45. package/dist/sdk/agent-core/tool-call-registry.js +131 -0
  46. package/dist/sdk/agent-core/tool-execution.d.ts.map +1 -1
  47. package/dist/sdk/agent-core/tool-execution.js +103 -4
  48. package/dist/sdk/agent-core/types.d.ts +15 -4
  49. package/dist/sdk/agent-core/types.d.ts.map +1 -1
  50. package/dist/sdk/ai/index.d.ts +3 -0
  51. package/dist/sdk/ai/index.d.ts.map +1 -1
  52. package/dist/sdk/ai/index.js +3 -0
  53. package/dist/sdk/ai/providers/faux.d.ts.map +1 -1
  54. package/dist/sdk/ai/providers/faux.js +3 -0
  55. package/dist/sdk/ai/providers/leaked-tool-call-parser.d.ts +39 -6
  56. package/dist/sdk/ai/providers/leaked-tool-call-parser.d.ts.map +1 -1
  57. package/dist/sdk/ai/providers/leaked-tool-call-parser.js +159 -18
  58. package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
  59. package/dist/sdk/ai/providers/openai-completions.js +22 -8
  60. package/dist/sdk/ai/providers/openrouter-videos.d.ts +3 -0
  61. package/dist/sdk/ai/providers/openrouter-videos.d.ts.map +1 -0
  62. package/dist/sdk/ai/providers/openrouter-videos.js +412 -0
  63. package/dist/sdk/ai/providers/register-builtins.d.ts.map +1 -1
  64. package/dist/sdk/ai/providers/register-builtins.js +6 -0
  65. package/dist/sdk/ai/providers/simple-options.d.ts +1 -6
  66. package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
  67. package/dist/sdk/ai/providers/simple-options.js +0 -22
  68. package/dist/sdk/ai/providers/transform-messages.d.ts.map +1 -1
  69. package/dist/sdk/ai/providers/transform-messages.js +52 -1
  70. package/dist/sdk/ai/types.d.ts +91 -10
  71. package/dist/sdk/ai/types.d.ts.map +1 -1
  72. package/dist/sdk/ai/video-models.d.ts +10 -0
  73. package/dist/sdk/ai/video-models.d.ts.map +1 -0
  74. package/dist/sdk/ai/video-models.generated.d.ts +440 -0
  75. package/dist/sdk/ai/video-models.generated.d.ts.map +1 -0
  76. package/dist/sdk/ai/video-models.generated.js +441 -0
  77. package/dist/sdk/ai/video-models.js +22 -0
  78. package/dist/sdk/ai/videos-api-registry.d.ts +14 -0
  79. package/dist/sdk/ai/videos-api-registry.d.ts.map +1 -0
  80. package/dist/sdk/ai/videos-api-registry.js +21 -0
  81. package/dist/sdk/ai/videos.d.ts +4 -0
  82. package/dist/sdk/ai/videos.d.ts.map +1 -0
  83. package/dist/sdk/ai/videos.js +13 -0
  84. package/dist/sdk/coding-agent/core/agent-session.d.ts +8 -0
  85. package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
  86. package/dist/sdk/coding-agent/core/agent-session.js +11 -0
  87. package/dist/sdk/coding-agent/core/extensions/loader.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/extensions/loader.js +14 -0
  89. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts +26 -0
  90. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  91. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +23 -0
  92. package/dist/sdk/coding-agent/core/extensions/session-events.d.ts +2 -2
  93. package/dist/sdk/coding-agent/core/extensions/session-events.d.ts.map +1 -1
  94. package/dist/sdk/coding-agent/core/extensions/types.d.ts +12 -2
  95. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  96. package/dist/sdk/coding-agent/core/messages.d.ts.map +1 -1
  97. package/dist/sdk/coding-agent/core/messages.js +42 -2
  98. package/dist/sdk/coding-agent/core/resource-loader.d.ts +11 -0
  99. package/dist/sdk/coding-agent/core/resource-loader.d.ts.map +1 -1
  100. package/dist/sdk/coding-agent/core/resource-loader.js +23 -6
  101. package/dist/sdk/coding-agent/core/sdk.d.ts +24 -2
  102. package/dist/sdk/coding-agent/core/sdk.d.ts.map +1 -1
  103. package/dist/sdk/coding-agent/core/sdk.js +15 -3
  104. package/dist/sdk/coding-agent/core/settings-manager.d.ts +0 -8
  105. package/dist/sdk/coding-agent/core/settings-manager.d.ts.map +1 -1
  106. package/dist/sdk/coding-agent/core/settings-manager.js +3 -3
  107. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  108. package/dist/sdk/coding-agent/core/system-prompt.js +5 -0
  109. package/dist/sdk/coding-agent/index.d.ts +2 -2
  110. package/dist/sdk/coding-agent/index.d.ts.map +1 -1
  111. package/dist/sdk/coding-agent/index.js +1 -1
  112. package/dist/sdk-public.d.ts +96 -0
  113. package/dist/sdk-public.d.ts.map +1 -0
  114. package/dist/sdk-public.js +109 -0
  115. package/dist/server/agent-bridge/rehydrate.d.ts +2 -2
  116. package/dist/server/agent-bridge/rehydrate.d.ts.map +1 -1
  117. package/dist/server/agent-bridge/rehydrate.js +14 -0
  118. package/dist/server/agent-bridge/tps-metrics.d.ts +34 -0
  119. package/dist/server/agent-bridge/tps-metrics.d.ts.map +1 -1
  120. package/dist/server/agent-bridge/tps-metrics.js +22 -0
  121. package/dist/server/agent-bridge.d.ts +17 -2
  122. package/dist/server/agent-bridge.d.ts.map +1 -1
  123. package/dist/server/agent-bridge.js +108 -5
  124. package/dist/server/handlers/paths-list.d.ts +1 -1
  125. package/dist/server/handlers/paths-list.js +1 -1
  126. package/dist/server/inter-agent-broker.d.ts +12 -0
  127. package/dist/server/inter-agent-broker.d.ts.map +1 -1
  128. package/dist/server/inter-agent-broker.js +1 -0
  129. package/dist/server/preset-chain.d.ts +151 -0
  130. package/dist/server/preset-chain.d.ts.map +1 -0
  131. package/dist/server/preset-chain.js +250 -0
  132. package/dist/server/session-stream.d.ts +74 -1
  133. package/dist/server/session-stream.d.ts.map +1 -1
  134. package/dist/server/session-stream.js +238 -24
  135. package/dist/server/storage/rows.d.ts +2 -0
  136. package/dist/server/storage/rows.d.ts.map +1 -1
  137. package/dist/server/storage/schema.d.ts +2 -2
  138. package/dist/server/storage/schema.d.ts.map +1 -1
  139. package/dist/server/storage/schema.js +2 -1
  140. package/dist/server/storage.d.ts +7 -0
  141. package/dist/server/storage.d.ts.map +1 -1
  142. package/dist/server/storage.js +142 -9
  143. package/dist/server/wire.d.ts +84 -0
  144. package/dist/server/wire.d.ts.map +1 -1
  145. package/dist/utils/augment-path.d.ts +51 -0
  146. package/dist/utils/augment-path.d.ts.map +1 -0
  147. package/dist/utils/augment-path.js +234 -0
  148. package/dist/utils/open-browser.d.ts +55 -0
  149. package/dist/utils/open-browser.d.ts.map +1 -0
  150. package/dist/utils/open-browser.js +163 -0
  151. package/dist/utils/serve-state.d.ts +264 -0
  152. package/dist/utils/serve-state.d.ts.map +1 -0
  153. package/dist/utils/serve-state.js +563 -0
  154. package/dist/utils/standalone.d.ts +14 -0
  155. package/dist/utils/standalone.d.ts.map +1 -0
  156. package/dist/utils/standalone.js +23 -0
  157. package/package.json +7 -3
@@ -0,0 +1,563 @@
1
+ /**
2
+ * On-disk rendezvous between `spectral serve` (the relay daemon) and whoever
3
+ * launched it — today `spectral desktop`, tomorrow any supervisor.
4
+ *
5
+ * Two files, both under `getConfigDir()` (`~/.spectral`, or `SPECTRAL_CONFIG_DIR`
6
+ * in tests/CI):
7
+ *
8
+ * serve.pid the daemon's own pid (single-instance lock)
9
+ * serve-ready.json { pid, ts, version } written once the relay socket is open
10
+ *
11
+ * Why both: the pid file alone says "a process exists", not "the relay is
12
+ * connected" — and the web app at aexol.ai/agent subscribes to the relay the
13
+ * moment it loads, so opening the browser before the socket is up produces a
14
+ * `machine_disconnected` on every start (the exact bug the removed Electron app
15
+ * papered over with a 3-second `setTimeout`). `serve-ready.json` plus the
16
+ * `SPECTRAL_SERVE_READY` stdout marker give the launcher a real signal.
17
+ *
18
+ * A stale pid file (SIGKILL, power loss, crash) is expected: every reader
19
+ * sanity-checks liveness with `kill(pid, 0)` and removes dead entries, so a
20
+ * crashed daemon never blocks the next start.
21
+ *
22
+ * Ownership is explicit: the pid file is a *claim*, taken atomically with
23
+ * `O_EXCL` ({@link claimServePidFile}) and only ever rewritten or removed by
24
+ * whoever holds it ({@link ownsServeState}). A launcher that merely *read* the
25
+ * file must never treat it as its own, and a process that lost the claim must
26
+ * not overwrite the state of the daemon that actually owns it.
27
+ *
28
+ * All helpers are synchronous and never throw — a daemon must not fail because
29
+ * a bookkeeping file could not be written.
30
+ */
31
+ import { fchmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
32
+ import { spawnSync } from "node:child_process";
33
+ import { dirname, join } from "node:path";
34
+ import { getConfigDir } from "../config.js";
35
+ /** Line `spectral serve` prints to stdout once the relay socket is open. */
36
+ export const SERVE_READY_MARKER = "SPECTRAL_SERVE_READY";
37
+ /** Serve log size threshold past which `spectral desktop` rotates the log. */
38
+ export const DEFAULT_SERVE_LOG_MAX_BYTES = 10 * 1024 * 1024;
39
+ export function getServePidFile() {
40
+ return join(getConfigDir(), "serve.pid");
41
+ }
42
+ export function getServeReadyFile() {
43
+ return join(getConfigDir(), "serve-ready.json");
44
+ }
45
+ /**
46
+ * @param env env to read `SPECTRAL_SERVE_LOG_PATH` from. Defaults to
47
+ * `process.env`, but a caller that holds a custom env (the desktop spawn
48
+ * plan) must pass it — otherwise the wrapper watches one file while the
49
+ * daemon logs into another.
50
+ */
51
+ export function getServeLogFile(env = process.env) {
52
+ const override = env.SPECTRAL_SERVE_LOG_PATH?.trim();
53
+ if (override)
54
+ return override;
55
+ // An empty/whitespace override means "not set", not "log to an empty path".
56
+ return join(getConfigDir(), "serve.log");
57
+ }
58
+ /**
59
+ * Is `pid` a live process? Uses signal 0, which performs error checking
60
+ * without delivering a signal. `EPERM` counts as alive — the process exists,
61
+ * it just belongs to somebody else.
62
+ */
63
+ export function isProcessAlive(pid) {
64
+ if (!Number.isInteger(pid) || pid <= 0)
65
+ return false;
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ }
70
+ catch (err) {
71
+ return err.code === "EPERM";
72
+ }
73
+ }
74
+ /** Synchronous sleep that works on the main thread and in workers. */
75
+ function sleepSync(ms) {
76
+ try {
77
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
78
+ }
79
+ catch {
80
+ // no SharedArrayBuffer support — fall through, retry immediately
81
+ }
82
+ }
83
+ /** Parent pid on POSIX (`ps -o ppid=`); null when it cannot be determined. */
84
+ function getParentPid(pid) {
85
+ if (process.platform === "win32")
86
+ return null;
87
+ // A single `ps` probe is not trustworthy under load: test runners and the
88
+ // spawner itself fork heavily, so ps can transiently time out or fail to
89
+ // spawn (EAGAIN). `isDescendantOf` reads null as "benefit of the doubt",
90
+ // which would misclassify a live foreign process as our daemon — so retry
91
+ // before concluding the genealogy is unreadable.
92
+ for (let attempt = 0; attempt < 3; attempt++) {
93
+ if (attempt > 0)
94
+ sleepSync(50);
95
+ try {
96
+ const res = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
97
+ encoding: "utf8",
98
+ timeout: 1_000,
99
+ });
100
+ if (res.status !== 0)
101
+ continue;
102
+ const ppid = Number.parseInt(res.stdout.trim(), 10);
103
+ if (Number.isInteger(ppid) && ppid > 0)
104
+ return ppid;
105
+ }
106
+ catch {
107
+ // transient spawn failure (EAGAIN) — retry
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ /**
113
+ * Walk the parent chain of `pid` and classify it against `ancestors`.
114
+ * Strict: an unreadable chain answers `unknown` instead of guessing.
115
+ */
116
+ function descendantOf(pid, ancestors, maxDepth = 8) {
117
+ if (ancestors.includes(pid))
118
+ return "ours";
119
+ let current = pid;
120
+ for (let depth = 0; depth < maxDepth; depth++) {
121
+ const parent = getParentPid(current);
122
+ if (parent === null)
123
+ return "unknown";
124
+ if (ancestors.includes(parent))
125
+ return "ours";
126
+ if (parent <= 1 || parent === current)
127
+ return "not-ours";
128
+ current = parent;
129
+ }
130
+ return "not-ours";
131
+ }
132
+ /**
133
+ * Is `pid` a descendant of `ancestor`? Used to confirm that the pid file handed
134
+ * over after a spawn really belongs to the process we launched: under `tsx` the
135
+ * CLI re-execs, so the daemon is the *child* of the spawned pid.
136
+ *
137
+ * Best-effort: when parentage cannot be read (Windows, no `ps`, process gone)
138
+ * it answers true, i.e. "unknown" is treated as ours — a launcher must never
139
+ * block a legitimate start over an unverifiable genealogy. Callers that must
140
+ * keep "ours" apart from "unreadable" (see {@link confirmServePidChild}) use the
141
+ * strict tri-state verdict instead.
142
+ */
143
+ export function isDescendantOf(pid, ancestor, maxDepth = 8) {
144
+ return descendantOf(pid, [ancestor], maxDepth) !== "not-ours";
145
+ }
146
+ function removeFile(file) {
147
+ try {
148
+ unlinkSync(file);
149
+ }
150
+ catch {
151
+ // missing or unremovable — nothing to do
152
+ }
153
+ }
154
+ function writeTextFile(file, text) {
155
+ // tmp + rename so a concurrent reader never sees a half-written file.
156
+ const tmp = `${file}.tmp-${process.pid}`;
157
+ try {
158
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
159
+ writeFileSync(tmp, text, { encoding: "utf8", mode: 0o600 });
160
+ renameSync(tmp, file);
161
+ }
162
+ catch {
163
+ // Bookkeeping only — never take the daemon down over it. But do not leak
164
+ // the scratch file: a stray `serve.pid.tmp-*` is pure clutter in a config
165
+ // dir that supervisors glob.
166
+ removeFile(tmp);
167
+ }
168
+ }
169
+ /** Write the pid file for the current (or given) daemon process. */
170
+ export function writeServePid(pid = process.pid) {
171
+ writeTextFile(getServePidFile(), `${pid}\n`);
172
+ }
173
+ /**
174
+ * Read a pid file WITHOUT a liveness check. Returns null when the file is
175
+ * missing or is not a plain pid. The whole payload must be digits: `parseInt`
176
+ * turns `"12abc"` into 12, and a launcher would then signal a process nobody
177
+ * claimed.
178
+ *
179
+ * @param file defaults to {@link getServePidFile}; tests and alternative
180
+ * layouts may point elsewhere.
181
+ */
182
+ export function readServePidFile(file = getServePidFile()) {
183
+ let raw;
184
+ try {
185
+ raw = readFileSync(file, "utf8");
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ if (!/^\d+\s*$/.test(raw))
191
+ return null;
192
+ const pid = Number.parseInt(raw.trim(), 10);
193
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
194
+ }
195
+ /**
196
+ * Read the pid file WITH a liveness check. A stale entry (process gone) is
197
+ * deleted and reported as "not running".
198
+ */
199
+ export function getLiveServePid() {
200
+ const pid = readServePidFile();
201
+ if (pid === null)
202
+ return null;
203
+ if (isProcessAlive(pid))
204
+ return pid;
205
+ removeFile(getServePidFile());
206
+ removeFile(getServeReadyFile());
207
+ return null;
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // Ownership — who may write or clear the rendezvous files
211
+ // ---------------------------------------------------------------------------
212
+ /**
213
+ * Does `pid` hold the pid-file claim? Every cleanup and every ready-signal
214
+ * write is gated on this, so a second daemon (or a wrapper that merely read
215
+ * the file) can never delete or overwrite the state of the process that owns
216
+ * it.
217
+ */
218
+ export function ownsServeState(pid = process.pid) {
219
+ return readServePidFile() === pid;
220
+ }
221
+ /**
222
+ * Atomically claim `serve.pid` BEFORE spawning anything.
223
+ *
224
+ * The naive version — read the file, ask "is it alive?", then spawn — races two
225
+ * concurrent `spectral desktop` invocations into starting two daemons, because
226
+ * both see "not running" before either writes. `open(O_EXCL)` folds the test
227
+ * and the claim into one syscall: exactly one winner, everyone else gets
228
+ * `busy`.
229
+ *
230
+ * The winner writes its *own* pid as a placeholder — a live pid, so a racer
231
+ * that arrives microseconds later also sees `busy` — and hands it over with
232
+ * {@link confirmServePidChild} once the spawn succeeded.
233
+ */
234
+ export function claimServePidFile(placeholderPid = process.pid) {
235
+ const file = getServePidFile();
236
+ try {
237
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
238
+ }
239
+ catch {
240
+ // already there / read-only: openSync below reports the real failure
241
+ }
242
+ for (let attempt = 0; attempt < 4; attempt++) {
243
+ let fd;
244
+ try {
245
+ fd = openSync(file, "wx", 0o600);
246
+ }
247
+ catch (err) {
248
+ const code = err.code;
249
+ if (code !== "EEXIST") {
250
+ return { status: "failed", error: `${code ?? "error"}: ${err.message}` };
251
+ }
252
+ const holder = readServePidFile();
253
+ if (holder !== null && isProcessAlive(holder))
254
+ return { status: "busy", pid: holder };
255
+ // Stale (crash / SIGKILL) or garbage: prune, then race the next O_EXCL.
256
+ removeFile(file);
257
+ removeFile(getServeReadyFile());
258
+ continue;
259
+ }
260
+ try {
261
+ try {
262
+ // The mode argument of open(2) is masked by the umask; chmod on the
263
+ // fresh fd is what actually guarantees 0600 for a file holding a pid
264
+ // other processes will be signalled from.
265
+ fchmodSync(fd, 0o600);
266
+ }
267
+ catch {
268
+ // best-effort on filesystems without POSIX modes
269
+ }
270
+ writeSync(fd, `${placeholderPid}\n`);
271
+ return { status: "claimed", placeholderPid };
272
+ }
273
+ catch (err) {
274
+ // Leave an empty file behind rather than unlinking it: it reads as
275
+ // "no holder" (see readServePidFile) and the next reader prunes it, while
276
+ // unlinking could delete a claim another process took in the meantime.
277
+ return { status: "failed", error: err.message };
278
+ }
279
+ finally {
280
+ try {
281
+ closeSync(fd);
282
+ }
283
+ catch {
284
+ // ignore
285
+ }
286
+ }
287
+ }
288
+ return { status: "failed", error: "could not claim serve.pid (contended)" };
289
+ }
290
+ /**
291
+ * After a successful spawn, confirm `serve.pid` records the daemon we launched.
292
+ *
293
+ * - `child` — it holds the spawned pid (the normal standalone/node case).
294
+ * - `daemon` — the daemon already rewrote it with its own pid, which happens
295
+ * when the CLI re-execs (`tsx` in dev). Accepted only when that pid is
296
+ * provably our descendant.
297
+ * - `foreign` — a live, unrelated process owns it: the caller must warn and
298
+ * stop touching the state instead of hijacking it.
299
+ * - `unknown` — the hand-off write did not land (read-only dir), or a live pid
300
+ * this command did not spawn could not be verified as ours (unreadable
301
+ * parentage); the caller must warn (unverified holder) but must never signal
302
+ * it — readiness still works via the log marker and the ready file.
303
+ */
304
+ export function confirmServePidChild(childPid, placeholderPid = process.pid, opts = {}) {
305
+ const current = readServePidFile();
306
+ if (current === childPid)
307
+ return { status: "child", pid: current };
308
+ if (current !== null && current !== placeholderPid && isProcessAlive(current)) {
309
+ const verdict = opts.genealogy?.(current, [childPid, placeholderPid]) ??
310
+ descendantOf(current, [childPid, placeholderPid]);
311
+ if (verdict === "ours") {
312
+ return { status: "daemon", pid: current };
313
+ }
314
+ if (verdict === "not-ours")
315
+ return { status: "foreign", pid: current };
316
+ // A live pid we cannot prove is ours must never read as "daemon": the
317
+ // caller would treat a foreign process's state as its own (and `desktop
318
+ // start` would signal it when the readiness wait fails).
319
+ return { status: "unknown", pid: current };
320
+ }
321
+ // Still our placeholder (or nothing, or a dead pid): hand ownership over.
322
+ writeServePid(childPid);
323
+ const after = readServePidFile();
324
+ return after === childPid
325
+ ? { status: "child", pid: after }
326
+ : { status: "unknown", pid: after };
327
+ }
328
+ /**
329
+ * Drop a claim taken by {@link claimServePidFile} when the start failed. Only
330
+ * removes the files while they still record the placeholder or the child we
331
+ * launched — never a foreign daemon's state.
332
+ */
333
+ export function releaseServePidClaim(placeholderPid = process.pid, childPid) {
334
+ const current = readServePidFile();
335
+ if (current === null || current === placeholderPid || current === childPid) {
336
+ clearServeState();
337
+ }
338
+ }
339
+ /** Write the readiness marker next to the pid file. */
340
+ export function writeServeReady(version, pid = process.pid) {
341
+ const info = { pid, ts: Date.now(), version };
342
+ writeTextFile(getServeReadyFile(), `${JSON.stringify(info, null, 2)}\n`);
343
+ return info;
344
+ }
345
+ /** Read `serve-ready.json`, or null when missing/corrupt. */
346
+ export function readServeReadyFile(file = getServeReadyFile()) {
347
+ let raw;
348
+ try {
349
+ raw = readFileSync(file, "utf8");
350
+ }
351
+ catch {
352
+ return null;
353
+ }
354
+ try {
355
+ const parsed = JSON.parse(raw);
356
+ if (typeof parsed.pid !== "number" ||
357
+ !Number.isInteger(parsed.pid) ||
358
+ parsed.pid <= 0 ||
359
+ typeof parsed.ts !== "number" ||
360
+ typeof parsed.version !== "string") {
361
+ return null;
362
+ }
363
+ return { pid: parsed.pid, ts: parsed.ts, version: parsed.version };
364
+ }
365
+ catch {
366
+ return null;
367
+ }
368
+ }
369
+ /** Remove both the pid file and the ready file. Idempotent. */
370
+ export function clearServeState() {
371
+ removeFile(getServePidFile());
372
+ removeFile(getServeReadyFile());
373
+ }
374
+ /** Current size of a file, 0 when it does not exist yet. */
375
+ export function fileSizeOf(file) {
376
+ try {
377
+ return statSync(file).size;
378
+ }
379
+ catch {
380
+ return 0;
381
+ }
382
+ }
383
+ /**
384
+ * Truncate a daemon log that outgrew {@link DEFAULT_SERVE_LOG_MAX_BYTES} by
385
+ * moving it aside as `<file>.old` (one generation kept — enough to debug the
386
+ * previous start, without an unbounded log growing forever).
387
+ *
388
+ * Must run BEFORE the new daemon is spawned and before the caller snapshots the
389
+ * log offset: after a rotate the fresh log starts at byte 0.
390
+ *
391
+ * @returns true when a rotation happened.
392
+ */
393
+ export function rotateServeLogFile(file = getServeLogFile(), maxBytes = DEFAULT_SERVE_LOG_MAX_BYTES) {
394
+ try {
395
+ if (statSync(file).size <= maxBytes)
396
+ return false;
397
+ }
398
+ catch {
399
+ return false; // no log yet
400
+ }
401
+ try {
402
+ removeFile(`${file}.old`);
403
+ renameSync(file, `${file}.old`);
404
+ return true;
405
+ }
406
+ catch {
407
+ return false;
408
+ }
409
+ }
410
+ /**
411
+ * Append-mode read: return the bytes written to `file` since `offset` and the
412
+ * new offset. The desktop launcher snapshots the log size before spawning so
413
+ * the `SPECTRAL_SERVE_READY` marker of a *previous* run can never be mistaken
414
+ * for the current one.
415
+ *
416
+ * The returned offset is always the offset to read from next time — including
417
+ * when there is nothing new, and when the file shrank under us (a rotation).
418
+ * A caller that only advanced on new text would stay pinned past end-of-file
419
+ * and never see the next marker.
420
+ */
421
+ export function readFromOffset(file, offset) {
422
+ let fd;
423
+ try {
424
+ fd = openSync(file, "r");
425
+ const size = fstatSync(fd).size;
426
+ if (size <= offset)
427
+ return { text: "", offset: Math.min(offset, size) };
428
+ const buffer = Buffer.alloc(size - offset);
429
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, offset);
430
+ return { text: buffer.subarray(0, bytesRead).toString("utf8"), offset: offset + bytesRead };
431
+ }
432
+ catch {
433
+ return { text: "", offset };
434
+ }
435
+ finally {
436
+ if (fd !== undefined) {
437
+ try {
438
+ closeSync(fd);
439
+ }
440
+ catch {
441
+ // ignore
442
+ }
443
+ }
444
+ }
445
+ }
446
+ const DEFAULT_READY_TIMEOUT_MS = 15_000;
447
+ const DEFAULT_READY_INTERVAL_MS = 100;
448
+ function sleep(ms) {
449
+ return new Promise((resolve) => setTimeout(resolve, ms));
450
+ }
451
+ /**
452
+ * Wait for the daemon to announce readiness, by either signal:
453
+ * - `SPECTRAL_SERVE_READY` appearing in the serve log after `logOffset`, or
454
+ * - a live `serve-ready.json`.
455
+ *
456
+ * A signal only counts when it belongs to THIS launch:
457
+ * - the pid it reports is the spawned pid, or the pid `serve.pid` currently
458
+ * records (the daemon's own pid once it re-exec'd), and
459
+ * - its timestamp is not older than `sinceMs`, and
460
+ * - that pid is alive.
461
+ *
462
+ * The log marker carries no pid of its own, so whenever a ready file exists it
463
+ * must pass the same check — a marker can never be trusted on its own while the
464
+ * ready file proves the signal came from a foreign or stale daemon.
465
+ *
466
+ * Gives up early when the child dies (registration/auth/sqlite failures) so the
467
+ * user sees the real error instead of waiting out the timeout, and always
468
+ * returns (never throws) with the tail of the log for diagnostics.
469
+ */
470
+ export async function waitForServeReady(opts) {
471
+ const logFile = opts.logFile ?? getServeLogFile();
472
+ const readyFile = opts.readyFile ?? getServeReadyFile();
473
+ const pidFile = opts.pidFile ?? getServePidFile();
474
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
475
+ const intervalMs = opts.intervalMs ?? DEFAULT_READY_INTERVAL_MS;
476
+ const alive = opts.alive ?? isProcessAlive;
477
+ const sinceMs = opts.sinceMs ?? 0;
478
+ const start = Date.now();
479
+ const deadline = start + timeoutMs;
480
+ let logOffset = opts.logOffset ?? 0;
481
+ let logTail = "";
482
+ const grabLog = () => {
483
+ const chunk = readFromOffset(logFile, logOffset);
484
+ // Advance unconditionally, even when nothing new was appended.
485
+ logOffset = chunk.offset;
486
+ if (chunk.text) {
487
+ logTail = (logTail + chunk.text).slice(-2000);
488
+ }
489
+ };
490
+ /**
491
+ * Pids a ready signal may legitimately report. The spawned pid always
492
+ * qualifies; so does whatever the pid file currently records, because the
493
+ * daemon writes its own pid there before announcing readiness — that is what
494
+ * keeps a re-exec launch (tsx in dev) valid.
495
+ */
496
+ const acceptedPids = () => {
497
+ const pids = new Set(opts.expectedPids ?? []);
498
+ pids.add(opts.pid);
499
+ const holder = readServePidFile(pidFile);
500
+ if (holder !== null && (holder === opts.pid || alive(holder)))
501
+ pids.add(holder);
502
+ return pids;
503
+ };
504
+ /**
505
+ * Ownership of a ready signal: the reported pid must be one of ours and the
506
+ * signal must not predate this launch. `requireAlive` is what distinguishes
507
+ * the two uses:
508
+ * - the ready *file* is the answer to "is my agent up right now" → liveness
509
+ * required;
510
+ * - corroborating a *marker* only has to prove the marker is ours — the
511
+ * writer's liveness is already covered by the child check below, and a
512
+ * daemon that started, announced and died between two polls must still
513
+ * be recognised as the one we launched.
514
+ */
515
+ const isOurSignal = (info) => info !== null && info.ts >= sinceMs && acceptedPids().has(info.pid);
516
+ const isOurLiveSignal = (info) => isOurSignal(info) && alive(info.pid);
517
+ for (;;) {
518
+ // The ready file is the authoritative signal (it carries pid + version),
519
+ // and it survives a log-rotation, so check it first.
520
+ if (existsSync(readyFile)) {
521
+ const info = readServeReadyFile(readyFile);
522
+ if (isOurLiveSignal(info)) {
523
+ return {
524
+ ok: true,
525
+ reason: "ready-file",
526
+ waitedMs: Date.now() - start,
527
+ info,
528
+ logTail,
529
+ };
530
+ }
531
+ }
532
+ grabLog();
533
+ if (logTail.includes(SERVE_READY_MARKER)) {
534
+ const info = readServeReadyFile(readyFile);
535
+ // No ready file → the marker stands alone (the daemon could not write
536
+ // bookkeeping). With one present, it has to corroborate the marker.
537
+ if (!info || isOurSignal(info)) {
538
+ return {
539
+ ok: true,
540
+ reason: "marker",
541
+ waitedMs: Date.now() - start,
542
+ info: info ?? undefined,
543
+ logTail,
544
+ };
545
+ }
546
+ }
547
+ // Fast-fail on a dead child: registration/auth/sqlite errors exit
548
+ // immediately, and the user should see the log tail instead of waiting out
549
+ // the timeout. Skipped for re-exec launches, where this pid is the wrapper.
550
+ if (!opts.expectReExec && !alive(opts.pid)) {
551
+ return {
552
+ ok: false,
553
+ reason: "process-exited",
554
+ waitedMs: Date.now() - start,
555
+ logTail,
556
+ };
557
+ }
558
+ if (Date.now() >= deadline) {
559
+ return { ok: false, reason: "timeout", waitedMs: Date.now() - start, logTail };
560
+ }
561
+ await sleep(intervalMs);
562
+ }
563
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Is spectral running as a self-contained compiled binary (Bun `--compile`)
3
+ * rather than as a script under `node`?
4
+ *
5
+ * Two signals, either sufficient:
6
+ * - the Bun global with embedded files (the standalone build embeds data),
7
+ * - `process.execPath` naming something that is not a JS runtime — i.e. the
8
+ * `spectral` binary itself is the executable.
9
+ *
10
+ * Used by `spectral update` (only a standalone binary can self-update) and by
11
+ * `spectral desktop` (to decide what to spawn for a detached `serve`).
12
+ */
13
+ export declare function isStandaloneBinary(): boolean;
14
+ //# sourceMappingURL=standalone.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"standalone.d.ts","sourceRoot":"","sources":["../../src/utils/standalone.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,IAAI,OAAO,CAS5C"}
@@ -0,0 +1,23 @@
1
+ import { basename } from "node:path";
2
+ /**
3
+ * Is spectral running as a self-contained compiled binary (Bun `--compile`)
4
+ * rather than as a script under `node`?
5
+ *
6
+ * Two signals, either sufficient:
7
+ * - the Bun global with embedded files (the standalone build embeds data),
8
+ * - `process.execPath` naming something that is not a JS runtime — i.e. the
9
+ * `spectral` binary itself is the executable.
10
+ *
11
+ * Used by `spectral update` (only a standalone binary can self-update) and by
12
+ * `spectral desktop` (to decide what to spawn for a detached `serve`).
13
+ */
14
+ export function isStandaloneBinary() {
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ const Bun = globalThis.Bun;
17
+ if (Bun && typeof Bun === "object" && "embeddedFiles" in Bun) {
18
+ return true;
19
+ }
20
+ const execName = basename(process.execPath).replace(/\.exe$/i, "");
21
+ const isRuntime = execName === "node" || execName === "bun";
22
+ return !isRuntime;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.231",
3
+ "version": "0.9.240",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -16,6 +16,11 @@
16
16
  "import": "./dist/index.js",
17
17
  "default": "./dist/index.js"
18
18
  },
19
+ "./sdk": {
20
+ "types": "./dist/sdk-public.d.ts",
21
+ "import": "./dist/sdk-public.js",
22
+ "default": "./dist/sdk-public.js"
23
+ },
19
24
  "./package.json": "./package.json"
20
25
  },
21
26
  "files": [
@@ -31,12 +36,11 @@
31
36
  "dev": "tsx src/cli.ts",
32
37
  "test": "vitest run",
33
38
  "test:watch": "vitest",
39
+ "generate-video-models": "tsx scripts/generate-video-models.ts",
34
40
  "benchmark:cache": "tsx scripts/benchmark-cache.ts",
35
41
  "prepublishOnly": "npm run build && npm test",
36
42
  "build:standalone": "bash scripts/build-standalone.sh",
37
43
  "build:standalone:all": "bash scripts/build-standalone.sh --all",
38
- "build:electron": "bash scripts/build-electron.sh",
39
- "build:electron:all": "bash scripts/build-electron.sh --all",
40
44
  "postinstall": "node scripts/postinstall.mjs"
41
45
  },
42
46
  "overrides": {