@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.
- package/README.md +47 -0
- package/dist/agent/index.d.ts +32 -5
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +209 -36
- package/dist/backend/generated-images.d.ts +1 -1
- package/dist/backend/generated-images.d.ts.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +18 -4
- package/dist/commands/desktop.d.ts +167 -0
- package/dist/commands/desktop.d.ts.map +1 -0
- package/dist/commands/desktop.js +540 -0
- package/dist/commands/serve.d.ts +9 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +90 -2
- package/dist/commands/update.d.ts +85 -5
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +288 -58
- package/dist/extensions/desktop-control/macos.d.ts.map +1 -1
- package/dist/extensions/desktop-control/macos.js +2 -2
- package/dist/extensions/logic-trace.d.ts +1 -1
- package/dist/extensions/logic-trace.d.ts.map +1 -1
- package/dist/extensions/logic-trace.js +199 -70
- package/dist/extensions/video-generation/index.d.ts +3 -0
- package/dist/extensions/video-generation/index.d.ts.map +1 -0
- package/dist/extensions/video-generation/index.js +299 -0
- package/dist/generated/zeus/const.d.ts.map +1 -1
- package/dist/generated/zeus/const.js +30 -0
- package/dist/generated/zeus/index.d.ts +140 -0
- package/dist/generated/zeus/index.d.ts.map +1 -1
- package/dist/generated/zeus/index.js +5 -0
- package/dist/mcp/server-manager.d.ts.map +1 -1
- package/dist/mcp/server-manager.js +6 -0
- package/dist/memory/tools/receive-agent-observations.d.ts.map +1 -1
- package/dist/memory/tools/receive-agent-observations.js +12 -2
- package/dist/relay/dispatcher.d.ts +13 -1
- package/dist/relay/dispatcher.d.ts.map +1 -1
- package/dist/relay/dispatcher.js +41 -2
- package/dist/sdk/agent-core/agent-loop.js +13 -3
- package/dist/sdk/agent-core/agent.d.ts +11 -4
- package/dist/sdk/agent-core/agent.d.ts.map +1 -1
- package/dist/sdk/agent-core/agent.js +16 -4
- package/dist/sdk/agent-core/tool-call-registry.d.ts +75 -0
- package/dist/sdk/agent-core/tool-call-registry.d.ts.map +1 -0
- package/dist/sdk/agent-core/tool-call-registry.js +131 -0
- package/dist/sdk/agent-core/tool-execution.d.ts.map +1 -1
- package/dist/sdk/agent-core/tool-execution.js +103 -4
- package/dist/sdk/agent-core/types.d.ts +15 -4
- package/dist/sdk/agent-core/types.d.ts.map +1 -1
- package/dist/sdk/ai/index.d.ts +3 -0
- package/dist/sdk/ai/index.d.ts.map +1 -1
- package/dist/sdk/ai/index.js +3 -0
- package/dist/sdk/ai/providers/faux.d.ts.map +1 -1
- package/dist/sdk/ai/providers/faux.js +3 -0
- package/dist/sdk/ai/providers/leaked-tool-call-parser.d.ts +39 -6
- package/dist/sdk/ai/providers/leaked-tool-call-parser.d.ts.map +1 -1
- package/dist/sdk/ai/providers/leaked-tool-call-parser.js +159 -18
- package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openai-completions.js +22 -8
- package/dist/sdk/ai/providers/openrouter-videos.d.ts +3 -0
- package/dist/sdk/ai/providers/openrouter-videos.d.ts.map +1 -0
- package/dist/sdk/ai/providers/openrouter-videos.js +412 -0
- package/dist/sdk/ai/providers/register-builtins.d.ts.map +1 -1
- package/dist/sdk/ai/providers/register-builtins.js +6 -0
- package/dist/sdk/ai/providers/simple-options.d.ts +1 -6
- package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
- package/dist/sdk/ai/providers/simple-options.js +0 -22
- package/dist/sdk/ai/providers/transform-messages.d.ts.map +1 -1
- package/dist/sdk/ai/providers/transform-messages.js +52 -1
- package/dist/sdk/ai/types.d.ts +91 -10
- package/dist/sdk/ai/types.d.ts.map +1 -1
- package/dist/sdk/ai/video-models.d.ts +10 -0
- package/dist/sdk/ai/video-models.d.ts.map +1 -0
- package/dist/sdk/ai/video-models.generated.d.ts +440 -0
- package/dist/sdk/ai/video-models.generated.d.ts.map +1 -0
- package/dist/sdk/ai/video-models.generated.js +441 -0
- package/dist/sdk/ai/video-models.js +22 -0
- package/dist/sdk/ai/videos-api-registry.d.ts +14 -0
- package/dist/sdk/ai/videos-api-registry.d.ts.map +1 -0
- package/dist/sdk/ai/videos-api-registry.js +21 -0
- package/dist/sdk/ai/videos.d.ts +4 -0
- package/dist/sdk/ai/videos.d.ts.map +1 -0
- package/dist/sdk/ai/videos.js +13 -0
- package/dist/sdk/coding-agent/core/agent-session.d.ts +8 -0
- package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/agent-session.js +11 -0
- package/dist/sdk/coding-agent/core/extensions/loader.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/loader.js +14 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts +26 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +23 -0
- package/dist/sdk/coding-agent/core/extensions/session-events.d.ts +2 -2
- package/dist/sdk/coding-agent/core/extensions/session-events.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/types.d.ts +12 -2
- package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/messages.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/messages.js +42 -2
- package/dist/sdk/coding-agent/core/resource-loader.d.ts +11 -0
- package/dist/sdk/coding-agent/core/resource-loader.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/resource-loader.js +23 -6
- package/dist/sdk/coding-agent/core/sdk.d.ts +24 -2
- package/dist/sdk/coding-agent/core/sdk.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/sdk.js +15 -3
- package/dist/sdk/coding-agent/core/settings-manager.d.ts +0 -8
- package/dist/sdk/coding-agent/core/settings-manager.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/settings-manager.js +3 -3
- package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.js +5 -0
- package/dist/sdk/coding-agent/index.d.ts +2 -2
- package/dist/sdk/coding-agent/index.d.ts.map +1 -1
- package/dist/sdk/coding-agent/index.js +1 -1
- package/dist/sdk-public.d.ts +96 -0
- package/dist/sdk-public.d.ts.map +1 -0
- package/dist/sdk-public.js +109 -0
- package/dist/server/agent-bridge/rehydrate.d.ts +2 -2
- package/dist/server/agent-bridge/rehydrate.d.ts.map +1 -1
- package/dist/server/agent-bridge/rehydrate.js +14 -0
- package/dist/server/agent-bridge/tps-metrics.d.ts +34 -0
- package/dist/server/agent-bridge/tps-metrics.d.ts.map +1 -1
- package/dist/server/agent-bridge/tps-metrics.js +22 -0
- package/dist/server/agent-bridge.d.ts +17 -2
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +108 -5
- package/dist/server/handlers/paths-list.d.ts +1 -1
- package/dist/server/handlers/paths-list.js +1 -1
- package/dist/server/inter-agent-broker.d.ts +12 -0
- package/dist/server/inter-agent-broker.d.ts.map +1 -1
- package/dist/server/inter-agent-broker.js +1 -0
- package/dist/server/preset-chain.d.ts +151 -0
- package/dist/server/preset-chain.d.ts.map +1 -0
- package/dist/server/preset-chain.js +250 -0
- package/dist/server/session-stream.d.ts +74 -1
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +238 -24
- package/dist/server/storage/rows.d.ts +2 -0
- package/dist/server/storage/rows.d.ts.map +1 -1
- package/dist/server/storage/schema.d.ts +2 -2
- package/dist/server/storage/schema.d.ts.map +1 -1
- package/dist/server/storage/schema.js +2 -1
- package/dist/server/storage.d.ts +7 -0
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +142 -9
- package/dist/server/wire.d.ts +84 -0
- package/dist/server/wire.d.ts.map +1 -1
- package/dist/utils/augment-path.d.ts +51 -0
- package/dist/utils/augment-path.d.ts.map +1 -0
- package/dist/utils/augment-path.js +234 -0
- package/dist/utils/open-browser.d.ts +55 -0
- package/dist/utils/open-browser.d.ts.map +1 -0
- package/dist/utils/open-browser.js +163 -0
- package/dist/utils/serve-state.d.ts +264 -0
- package/dist/utils/serve-state.d.ts.map +1 -0
- package/dist/utils/serve-state.js +563 -0
- package/dist/utils/standalone.d.ts +14 -0
- package/dist/utils/standalone.d.ts.map +1 -0
- package/dist/utils/standalone.js +23 -0
- 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.
|
|
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": {
|