@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
@@ -2,24 +2,58 @@
2
2
  * `spectral update` — standalone binary self-update.
3
3
  *
4
4
  * Fetches the latest release manifest from api.aexol.ai, compares versions,
5
- * downloads the new binary, verifies its SHA256 checksum, and atomically
6
- * replaces the running binary on disk.
5
+ * downloads the new artifacts, verifies their SHA256 digests, and installs them
6
+ * over the current installation.
7
7
  *
8
- * Non-standalone install modes (Electron desktop app, npm global) are detected
9
- * and the user is pointed at the correct update path instead.
8
+ * Two manifest shapes are supported:
9
+ *
10
+ * - binary only (every manifest ever published): `url` + `sha256` pin the
11
+ * single-file binary. Only the binary is replaced — the companion assets an
12
+ * `install.sh` user has next to it (`data/systems/`, `mcp/`,
13
+ * `app-bridge.bundle.js`) keep the previous version, which is called out
14
+ * with a warning telling the user to re-run `curl | sh`.
15
+ * - release zip (ADDITIVE `zipUrl` + `zipSha256`, emitted by
16
+ * cli-node/scripts/generate-cli-manifest.sh once scripts/package-standalone.sh
17
+ * produced the archive): the zip carries the binary *and* the
18
+ * `<platform>-data/` companion directory. Both are installed with the same
19
+ * replace-don't-merge semantics as scripts/install.sh, so files deleted
20
+ * between releases do not linger.
21
+ *
22
+ * Nothing is installed unverified: the zip digest is checked before unpacking,
23
+ * the inner binary digest before swapping, and any mismatch aborts without
24
+ * touching the current installation.
25
+ *
26
+ * Non-standalone install modes (npm global install) are detected and the user
27
+ * is pointed at the correct update path instead.
10
28
  */
29
+ import { spawn } from "node:child_process";
11
30
  import { createHash } from "node:crypto";
12
- import { chmod, mkdtemp, rename, rm, stat, writeFile, } from "node:fs/promises";
31
+ import { chmod, cp, mkdir, mkdtemp, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
13
32
  import { tmpdir } from "node:os";
14
33
  import { basename, dirname, join } from "node:path";
15
34
  import pc from "picocolors";
16
35
  import { getVersion } from "../version.js";
36
+ import { isStandaloneBinary } from "../utils/standalone.js";
17
37
  /** Base URL for the Aexol release manifest / binary downloads. */
18
38
  const RELEASE_BASE = "https://api.aexol.ai/api/releases/download";
39
+ /** Stable entry point of the curl|sh installer (quoted in warnings). */
40
+ const INSTALLER_URL = "https://api.aexol.ai/api/releases/download/latest/install.sh";
41
+ /** Default `run`: a real spawn, stdio detached from the CLI's own output. */
42
+ const defaultRun = (cmd, args) => new Promise((resolveRun, rejectRun) => {
43
+ const child = spawn(cmd, args, { stdio: "ignore" });
44
+ child.on("error", rejectRun);
45
+ child.on("close", (code) => resolveRun(code ?? -1));
46
+ });
47
+ /** Companion directories install.sh places next to the binary. */
48
+ const COMPANION_DIRS = ["data", "mcp"];
49
+ /** Companion files install.sh places next to the binary. */
50
+ const COMPANION_FILES = ["app-bridge.bundle.js"];
51
+ /** Version stamp read by scripts/install.sh to skip a redundant reinstall. */
52
+ const VERSION_STAMP = ".spectral-version";
19
53
  /**
20
54
  * Resolve the current CLI version via the centralized getVersion() helper
21
- * (src/version.ts), which works for npm install, standalone Bun binaries, and
22
- * the Electron app — all of which previously fell back to "0.0.0".
55
+ * (src/version.ts), which works for npm install and standalone Bun binaries —
56
+ * both of which previously fell back to "0.0.0".
23
57
  */
24
58
  function currentVersion() {
25
59
  return getVersion();
@@ -59,28 +93,6 @@ function detectPlatform() {
59
93
  return "win-x64";
60
94
  return null;
61
95
  }
62
- /**
63
- * Whether the process is running as a Bun `--compile` standalone binary.
64
- *
65
- * In a compiled binary, `process.execPath` points at the binary itself (e.g.
66
- * `spectral`) rather than a `node`/`bun` runtime, and Bun's embedded-files
67
- * surface may be present.
68
- */
69
- function isStandaloneBinary() {
70
- // Bun-compiled binary with embedded files is a strong positive signal.
71
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
- const Bun = globalThis.Bun;
73
- if (Bun && typeof Bun === "object" && "embeddedFiles" in Bun) {
74
- return true;
75
- }
76
- const execName = basename(process.execPath).replace(/\.exe$/i, "");
77
- const isRuntime = execName === "node" || execName === "bun";
78
- return !isRuntime;
79
- }
80
- /** True when invoked inside the Electron desktop app. */
81
- function isElectronBundled() {
82
- return Boolean(process.env.SPECTRAL_DESKTOP_VERSION);
83
- }
84
96
  /**
85
97
  * Compute the SHA256 hex digest of a file's contents.
86
98
  */
@@ -133,6 +145,139 @@ async function fetchManifest(platform) {
133
145
  const data = (await res.json());
134
146
  return data;
135
147
  }
148
+ /**
149
+ * Unpack `zipPath` into a fresh sub-directory of `tempDir` using the first
150
+ * archive tool that is both present and successful:
151
+ *
152
+ * 1. `unzip` — the normal case (macOS, most Linux distros, Git Bash);
153
+ * 2. `tar -xf`— works where tar is bsdtar (macOS, Windows' built-in tar) and
154
+ * merely exits non-zero where tar is GNU tar (which has no zip support).
155
+ *
156
+ * `node:zlib` is deliberately not used: it decodes gzip, not the zip container.
157
+ */
158
+ async function unpackReleaseZip(params) {
159
+ const { zipPath, tempDir, run } = params;
160
+ const destDir = join(tempDir, "unpacked");
161
+ await mkdir(destDir, { recursive: true });
162
+ const attempts = [
163
+ { cmd: "unzip", args: ["-q", "-o", zipPath, "-d", destDir] },
164
+ { cmd: "tar", args: ["-xf", zipPath, "-C", destDir] },
165
+ ];
166
+ const notes = [];
167
+ for (const attempt of attempts) {
168
+ try {
169
+ const code = await run(attempt.cmd, attempt.args);
170
+ if (code === 0)
171
+ return { ok: true, destDir, notes: [...notes, `${attempt.cmd} ok`] };
172
+ notes.push(`${attempt.cmd} exited with code ${code}`);
173
+ }
174
+ catch (runErr) {
175
+ const msg = runErr instanceof Error ? runErr.message : String(runErr);
176
+ notes.push(`${attempt.cmd} unavailable (${msg})`);
177
+ }
178
+ }
179
+ return { ok: false, notes };
180
+ }
181
+ /**
182
+ * Locate the `<platform>-data/` companion directory at the root of an unpacked
183
+ * release zip (the layout defined by scripts/package-standalone.sh).
184
+ */
185
+ async function findDataDir(stageDir) {
186
+ const entries = await readdir(stageDir, { withFileTypes: true });
187
+ const hit = entries.find((e) => e.isDirectory() && e.name.endsWith("-data"));
188
+ return hit ? join(stageDir, hit.name) : null;
189
+ }
190
+ /**
191
+ * Pick the binary out of an unpacked release zip. The archive ships it as
192
+ * `spectral` / `spectral.exe` (scripts/package-standalone.sh renames it), while
193
+ * a user may have renamed their own install — so the live name is tried first,
194
+ * then the canonical ones.
195
+ */
196
+ async function findStagedBinary(stageDir, liveName) {
197
+ const candidates = [liveName, "spectral", "spectral.exe"];
198
+ for (const name of candidates) {
199
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
200
+ continue;
201
+ }
202
+ const candidate = join(stageDir, name);
203
+ try {
204
+ const st = await stat(candidate);
205
+ if (st.isFile())
206
+ return candidate;
207
+ }
208
+ catch {
209
+ // not there — try the next candidate name
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+ /**
215
+ * Replace the companion assets next to the binary with the ones from the zip.
216
+ *
217
+ * Semantics are those of scripts/install.sh: the `<platform>-data/` directory is
218
+ * placed next to the binary and *then* its contents are copied alongside it,
219
+ * with the previous `data/`, `mcp/` and `app-bridge.bundle.js` removed first —
220
+ * replace, not merge, so files deleted between releases do not linger.
221
+ *
222
+ * SAFETY: `installDir` is `dirname(process.execPath)` — the directory that
223
+ * *holds* the running binary. Only the explicitly named companion paths inside
224
+ * it are ever removed, and only for a real absolute path, so a bad `execPath`
225
+ * can never wipe a directory that contains the binary.
226
+ */
227
+ export async function swapCompanionAssets(params) {
228
+ const { stageDir, installDir } = params;
229
+ if (!installDir || !installDir.startsWith("/") || installDir === "/") {
230
+ return { dataDirName: null, replaced: [] };
231
+ }
232
+ const srcDataDir = await findDataDir(stageDir);
233
+ if (!srcDataDir)
234
+ return { dataDirName: null, replaced: [] };
235
+ const dataDirName = basename(srcDataDir);
236
+ if (!dataDirName || dataDirName === "." || dataDirName === "..") {
237
+ return { dataDirName: null, replaced: [] };
238
+ }
239
+ // Drop the previous version's data directory, then stage the new one next to
240
+ // the binary. `cp` rather than `rename`: the temp dir may live on a different
241
+ // filesystem than ~/.spectral/bin, which would make a rename fail with EXDEV.
242
+ const destDataDir = join(installDir, dataDirName);
243
+ await rm(destDataDir, { recursive: true, force: true });
244
+ await cp(srcDataDir, destDataDir, { recursive: true, force: true });
245
+ // Remove the stale companion assets *before* copying the new ones.
246
+ for (const name of COMPANION_DIRS) {
247
+ await rm(join(installDir, name), { recursive: true, force: true });
248
+ }
249
+ for (const name of COMPANION_FILES) {
250
+ await rm(join(installDir, name), { force: true });
251
+ }
252
+ // Copy the new companion contents next to the binary: the compiled binary
253
+ // resolves data/systems and app-bridge.bundle.js relative to execPath.
254
+ const replaced = [];
255
+ for (const entry of await readdir(destDataDir, { withFileTypes: true })) {
256
+ await cp(join(destDataDir, entry.name), join(installDir, entry.name), {
257
+ recursive: true,
258
+ force: true,
259
+ });
260
+ replaced.push(entry.name);
261
+ }
262
+ return { dataDirName, replaced };
263
+ }
264
+ /**
265
+ * Record the installed version so install.sh can skip a redundant reinstall.
266
+ * Only written when the companion assets moved together with the binary.
267
+ */
268
+ async function writeVersionStamp(installDir, version) {
269
+ await writeFile(join(installDir, VERSION_STAMP), `${version}\n`);
270
+ }
271
+ /**
272
+ * Drop the version stamp: only the binary was updated, so the installation as a
273
+ * whole no longer matches the stamped version — re-running `curl | sh` must
274
+ * then refresh the companion assets instead of reporting "already installed".
275
+ */
276
+ async function clearVersionStamp(installDir) {
277
+ if (!installDir || installDir === "/")
278
+ return;
279
+ await rm(join(installDir, VERSION_STAMP), { force: true });
280
+ }
136
281
  /**
137
282
  * Atomically replace the running binary at `currentPath` with `newBinaryPath`.
138
283
  *
@@ -140,7 +285,7 @@ async function fetchManifest(platform) {
140
285
  * place, and makes it executable. On Windows the running executable cannot be
141
286
  * renamed while live, so we print an actionable message instead.
142
287
  */
143
- async function atomicReplace(currentPath, newBinaryPath) {
288
+ async function atomicReplace(currentPath, newBinaryPath, out = (t) => process.stdout.write(t)) {
144
289
  const isWindows = process.platform === "win32";
145
290
  const dir = dirname(currentPath);
146
291
  if (isWindows) {
@@ -149,7 +294,7 @@ async function atomicReplace(currentPath, newBinaryPath) {
149
294
  // user to restart. A launcher / wrapper script would handle the swap.
150
295
  const staged = join(dir, basename(currentPath) + ".new");
151
296
  await rename(newBinaryPath, staged);
152
- process.stdout.write(pc.yellow(`New binary staged at ${staged}\n` +
297
+ out(pc.yellow(`New binary staged at ${staged}\n` +
153
298
  `Close all spectral processes, then replace the .exe manually,\n` +
154
299
  `or run the provided update helper.\n`));
155
300
  return;
@@ -199,76 +344,161 @@ async function atomicReplace(currentPath, newBinaryPath) {
199
344
  * Run the `spectral update` subcommand.
200
345
  *
201
346
  * @param args remaining CLI args after the `update` subcommand
347
+ * @param deps injectable I/O for tests (network, spawns, exec path, version)
202
348
  */
203
- export async function runUpdate(args) {
349
+ export async function runUpdate(args, deps = {}) {
204
350
  const checkOnly = args.includes("--check") || args.includes("-c");
351
+ const out = deps.out ?? ((t) => process.stdout.write(t));
352
+ const err = deps.err ?? ((t) => process.stderr.write(t));
353
+ const getManifest = deps.fetchManifest ?? fetchManifest;
354
+ const download = deps.download ?? downloadToFile;
355
+ const run = deps.run ?? defaultRun;
356
+ const standalone = deps.standalone ?? isStandaloneBinary();
357
+ const execPath = deps.execPath ?? process.execPath;
205
358
  // --- Installation mode detection --------------------------------------
206
- if (isElectronBundled()) {
207
- process.stdout.write(pc.cyan("You're running Spectral inside the desktop app. Use the app menu → " +
208
- "Check for Updates to update.\n"));
209
- return;
210
- }
211
- if (!isStandaloneBinary()) {
212
- process.stdout.write(pc.cyan("Run `npm i -g @aexol/spectral@latest` to update.\n"));
359
+ if (!standalone) {
360
+ out(pc.cyan("Run `npm i -g @aexol/spectral@latest` to update.\n"));
213
361
  return;
214
362
  }
215
363
  // --- Standalone self-update flow --------------------------------------
216
- const platform = detectPlatform();
364
+ const platform = deps.platform !== undefined ? deps.platform : detectPlatform();
217
365
  if (!platform) {
218
- process.stderr.write(pc.red(`Update is not supported on this platform (${process.platform}/${process.arch}).\n`));
366
+ err(pc.red(`Update is not supported on this platform (${process.platform}/${process.arch}).\n`));
219
367
  return;
220
368
  }
221
- const cur = currentVersion();
369
+ const cur = deps.version ?? currentVersion();
222
370
  let manifest;
223
371
  try {
224
- manifest = await fetchManifest(platform);
372
+ manifest = await getManifest(platform);
225
373
  }
226
374
  catch {
227
- process.stdout.write(pc.yellow("Could not check for updates. Make sure you're connected to the internet.\n"));
375
+ out(pc.yellow("Could not check for updates. Make sure you're connected to the internet.\n"));
228
376
  return;
229
377
  }
230
378
  const latest = manifest.version.replace(/^v/i, "");
231
379
  const comparison = compareSemver(cur, latest);
232
380
  if (comparison >= 0) {
233
- process.stdout.write(pc.green(`Spectral is up to date (v${cur}).\n`));
381
+ out(pc.green(`Spectral is up to date (v${cur}).\n`));
234
382
  return;
235
383
  }
236
384
  if (checkOnly) {
237
- process.stdout.write(pc.cyan(`Update available: v${cur} → v${latest}. Run \`spectral update\` to install.\n`));
385
+ out(pc.cyan(`Update available: v${cur} → v${latest}. Run \`spectral update\` to install.\n`));
238
386
  return;
239
387
  }
240
388
  // --- Download + verify + replace -------------------------------------
241
389
  if (!manifest.url) {
242
- process.stderr.write(pc.red("Update manifest is missing a download URL. Aborting.\n"));
390
+ err(pc.red("Update manifest is missing a download URL. Aborting.\n"));
243
391
  return;
244
392
  }
245
393
  let tempDir = null;
246
394
  try {
247
395
  tempDir = await mkdtemp(join(tmpdir(), "spectral-update-"));
396
+ const currentPath = execPath;
397
+ const installDir = dirname(currentPath);
398
+ // The manifest only pins a zip when the archive was actually published.
399
+ const zipUrl = manifest.zipUrl?.trim() ?? "";
400
+ const zipSha = manifest.zipSha256?.trim() ?? "";
401
+ if (!!zipUrl !== !!zipSha) {
402
+ out(pc.yellow("Release manifest pins only part of the archive (zipUrl/zipSha256) —\n" +
403
+ "installing the verified raw binary instead.\n"));
404
+ }
405
+ // ----- preferred path: release zip (binary + companion assets) -------
406
+ if (zipUrl && zipSha) {
407
+ const zipPath = join(tempDir, "release.zip");
408
+ out(pc.dim(`Downloading v${latest} (release zip)…\n`));
409
+ await download(zipUrl, zipPath);
410
+ await stat(zipPath);
411
+ out(pc.dim("Verifying archive checksum…\n"));
412
+ const actualZip = await sha256OfFile(zipPath);
413
+ if (actualZip.toLowerCase() !== zipSha.toLowerCase()) {
414
+ err(pc.red(`Archive checksum mismatch. Expected ${zipSha.toLowerCase()}, got ${actualZip}.\n` +
415
+ "The downloaded zip may be corrupted or tampered with. Nothing was installed.\n"));
416
+ return;
417
+ }
418
+ const unpacked = await unpackReleaseZip({ zipPath, tempDir, run });
419
+ if (unpacked.ok && unpacked.destDir) {
420
+ const stageDir = unpacked.destDir;
421
+ const stagedSource = await findStagedBinary(stageDir, basename(currentPath));
422
+ if (!stagedSource) {
423
+ err(pc.red(`Release zip does not contain the ${basename(currentPath)} binary at its root. ` +
424
+ "Nothing was installed.\n"));
425
+ return;
426
+ }
427
+ const newBinary = join(tempDir, basename(currentPath) || "spectral-new");
428
+ await cp(stagedSource, newBinary, { force: true });
429
+ // The zip digest already pins the archive; re-checking the binary inside
430
+ // also guards against a mismatched archive (same as install.sh).
431
+ if (manifest.sha256) {
432
+ out(pc.dim("Verifying binary checksum…\n"));
433
+ const actualBin = await sha256OfFile(newBinary);
434
+ if (actualBin.toLowerCase() !== manifest.sha256.toLowerCase()) {
435
+ err(pc.red(`Binary checksum mismatch. Expected ${manifest.sha256.toLowerCase()}, got ${actualBin}.\n` +
436
+ "Nothing was installed.\n"));
437
+ return;
438
+ }
439
+ }
440
+ out(pc.dim("Installing…\n"));
441
+ if (process.platform === "win32") {
442
+ // A running .exe cannot be swapped from within itself, so the companion
443
+ // assets are left untouched too — refreshing them while the old binary
444
+ // is still in place would split the pair.
445
+ await atomicReplace(currentPath, newBinary, out);
446
+ out(pc.yellow("Companion assets were NOT updated. Re-run the installer to refresh them:\n" +
447
+ ` curl -fsSL ${INSTALLER_URL} | sh\n`));
448
+ return;
449
+ }
450
+ // Binary first: `atomicReplace` is the step most likely to fail (it needs
451
+ // write access to the install directory), and if it does nothing else has
452
+ // been touched. The companion assets only move once the new binary is in
453
+ // place, so a failed update never leaves new assets next to an old binary.
454
+ await atomicReplace(currentPath, newBinary, out);
455
+ const { dataDirName, replaced } = await swapCompanionAssets({ stageDir, installDir });
456
+ await writeVersionStamp(installDir, latest);
457
+ out(pc.green(`Spectral updated to v${latest}. Restart spectral to use the new version.\n`));
458
+ if (replaced.length) {
459
+ out(pc.dim(`Companion assets refreshed: ${[dataDirName, ...replaced].join(", ")}.\n`));
460
+ }
461
+ else {
462
+ out(pc.yellow("WARNING: the release zip contained no *-data directory —\n" +
463
+ "companion assets were left as they were.\n"));
464
+ }
465
+ return;
466
+ }
467
+ // No usable archive tool on this machine: fall through to the raw binary.
468
+ out(pc.yellow(`Could not unpack the release zip (${unpacked.notes.join("; ")}) —\n` +
469
+ "falling back to replacing the binary only.\n"));
470
+ }
471
+ // ----- fallback path: raw binary only --------------------------------
248
472
  const downloadedPath = join(tempDir, "spectral-new");
249
- process.stdout.write(pc.dim(`Downloading v${latest}…\n`));
250
- await downloadToFile(manifest.url, downloadedPath);
473
+ out(pc.dim(`Downloading v${latest}…\n`));
474
+ await download(manifest.url, downloadedPath);
251
475
  // Verify the download is complete and readable.
252
476
  await stat(downloadedPath);
253
477
  // SHA256 verification.
254
478
  if (manifest.sha256) {
255
- process.stdout.write(pc.dim("Verifying checksum…\n"));
479
+ out(pc.dim("Verifying checksum…\n"));
256
480
  const actual = await sha256OfFile(downloadedPath);
257
481
  const expected = manifest.sha256.toLowerCase();
258
482
  if (actual.toLowerCase() !== expected) {
259
- process.stderr.write(pc.red(`Checksum mismatch. Expected ${expected}, got ${actual}.\n` +
483
+ err(pc.red(`Checksum mismatch. Expected ${expected}, got ${actual}.\n` +
260
484
  "The downloaded file may be corrupted or tampered with. Aborting.\n"));
261
485
  return;
262
486
  }
263
487
  }
264
- const currentPath = process.execPath;
265
- process.stdout.write(pc.dim("Installing…\n"));
266
- await atomicReplace(currentPath, downloadedPath);
267
- process.stdout.write(pc.green(`Spectral updated to v${latest}. Restart spectral to use the new version.\n`));
488
+ out(pc.dim("Installing…\n"));
489
+ await atomicReplace(currentPath, downloadedPath, out);
490
+ // The stamp means "this version is installed *with* its companion assets".
491
+ // Only the binary moved, so drop it — a stale stamp would make install.sh
492
+ // report "already installed" and never refresh data/ + mcp/.
493
+ await clearVersionStamp(installDir);
494
+ out(pc.green(`Spectral updated to v${latest}. Restart spectral to use the new version.\n`));
495
+ out(pc.yellow("WARNING: only the binary was replaced — companion assets (design systems,\n" +
496
+ "MCP app bridge) may be out of date. Refresh them by re-running:\n" +
497
+ ` curl -fsSL ${INSTALLER_URL} | sh\n`));
268
498
  }
269
- catch (err) {
270
- const msg = err instanceof Error ? err.message : String(err);
271
- process.stderr.write(pc.red(`Update failed: ${msg}\n`));
499
+ catch (failure) {
500
+ const msg = failure instanceof Error ? failure.message : String(failure);
501
+ err(pc.red(`Update failed: ${msg}\n`));
272
502
  }
273
503
  finally {
274
504
  if (tempDir) {
@@ -1 +1 @@
1
- {"version":3,"file":"macos.d.ts","sourceRoot":"","sources":["../../../src/extensions/desktop-control/macos.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAOH,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAmB,MAAM,YAAY,CAAC;AA+CjF,wBAAgB,aAAa,CAAC,MAAM,EAAE,OAAO,CAAC,aAAa,EAAE;IAAE,IAAI,EAAE,YAAY,GAAG,aAAa,GAAG,YAAY,GAAG,QAAQ,CAAA;CAAE,CAAC,GAAG,MAAM,CA2CtI;AAED,uGAAuG;AACvG,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,CAW/E;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,MAAM,CAUlE;AAED,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAanF;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAOjE;AAUD,8EAA8E;AAC9E,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAW9D;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAsB3E;AAuED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAiB5D;AAMD,eAAO,MAAM,sBAAsB,QAI4B,CAAC;AAEhE;;;;GAIG;AACH,wBAAgB,8BAA8B,IAAI,MAAM,CAMvD;AAoBD,eAAO,MAAM,YAAY,EAAE,cA8E1B,CAAC"}
1
+ {"version":3,"file":"macos.d.ts","sourceRoot":"","sources":["../../../src/extensions/desktop-control/macos.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAOH,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAmB,MAAM,YAAY,CAAC;AA+CjF,wBAAgB,aAAa,CAAC,MAAM,EAAE,OAAO,CAAC,aAAa,EAAE;IAAE,IAAI,EAAE,YAAY,GAAG,aAAa,GAAG,YAAY,GAAG,QAAQ,CAAA;CAAE,CAAC,GAAG,MAAM,CA2CtI;AAED,uGAAuG;AACvG,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,CAW/E;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,MAAM,CAUlE;AAED,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAanF;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAOjE;AAUD,8EAA8E;AAC9E,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAW9D;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAsB3E;AAuED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAiB5D;AAMD,eAAO,MAAM,sBAAsB,QAIuC,CAAC;AAE3E;;;;GAIG;AACH,wBAAgB,8BAA8B,IAAI,MAAM,CAMvD;AAoBD,eAAO,MAAM,YAAY,EAAE,cA8E1B,CAAC"}
@@ -268,7 +268,7 @@ export function accessibilityError(rawStderr) {
268
268
  if (s.includes("assistive access") || s.includes("accessib")) {
269
269
  return ("macOS blocked desktop control: Accessibility permission is missing.\n" +
270
270
  "Fix: System Settings > Privacy & Security > Accessibility -> add (and enable) the app that runs " +
271
- "spectral (your terminal, or the Electron host). Then retry. " +
271
+ "spectral (your terminal, or the spectral desktop wrapper). Then retry. " +
272
272
  "You can verify from the terminal with: osascript -e 'tell application \"System Events\" to get name of first process whose frontmost is true'");
273
273
  }
274
274
  if (s.includes("not allowed to send apple events") || s.includes("-1743") || s.includes("automat")) {
@@ -283,7 +283,7 @@ export function accessibilityError(rawStderr) {
283
283
  export const SCREEN_RECORDING_ERROR = "macOS blocked the safety screenshots: Screen Recording permission is missing.\n" +
284
284
  "Without it the BEFORE/AFTER captures silently show only the desktop wallpaper, so the action was refused before mutating anything.\n" +
285
285
  "Fix: System Settings > Privacy & Security > Screen Recording -> add (and enable) the app that runs " +
286
- "spectral (your terminal, or the Electron host). Then retry.";
286
+ "spectral (your terminal, or the spectral desktop wrapper). Then retry.";
287
287
  /**
288
288
  * One-shot JXA probe: CGPreflightScreenCaptureAccess() — true when Screen
289
289
  * Recording TCC is granted. The function is missing from CoreGraphics'
@@ -1 +1 @@
1
- {"version":3,"file":"server-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/server-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAKnE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7E,OAAO,KAAK,EACV,OAAO,EACP,WAAW,EACX,gBAAgB,EAChB,mCAAmC,EACnC,SAAS,EACV,MAAM,YAAY,CAAC;AAMpB,OAAO,EAA2B,KAAK,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAG3F,UAAU,gBAAgB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,SAAS,CAAC;IACrB,UAAU,EAAE,gBAAgB,CAAC;IAC7B,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,SAAS,EAAE,WAAW,EAAE,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,WAAW,GAAG,QAAQ,GAAG,YAAY,CAAC;CAC/C;AAED,KAAK,gBAAgB,GAAG,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,mCAAmC,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC;AAElH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,WAAW,CAAuC;IAC1D,OAAO,CAAC,eAAe,CAAgD;IACvE,OAAO,CAAC,iBAAiB,CAAuC;IAChE,OAAO,CAAC,cAAc,CAAmC;IAEzD,iBAAiB,CAAC,MAAM,EAAE,oBAAoB,GAAG,SAAS,GAAG,IAAI;IAI3D,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC;YAyBtE,gBAAgB;IAmF9B,OAAO,CAAC,YAAY;YAWN,mBAAmB;YAyEnB,aAAa;YAab,iBAAiB;IAkB/B,OAAO,CAAC,iCAAiC;IAQzC,wBAAwB,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,gBAAgB,GAAG,IAAI;IAI/E,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAI3C,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAgBpE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAelC,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAK/B,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAIzD,iBAAiB,IAAI,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAIlD,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAOzB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAOrC,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAOrC,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO;CAMjD"}
1
+ {"version":3,"file":"server-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/server-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAKnE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7E,OAAO,KAAK,EACV,OAAO,EACP,WAAW,EACX,gBAAgB,EAChB,mCAAmC,EACnC,SAAS,EACV,MAAM,YAAY,CAAC;AAMpB,OAAO,EAA2B,KAAK,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAI3F,UAAU,gBAAgB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,SAAS,CAAC;IACrB,UAAU,EAAE,gBAAgB,CAAC;IAC7B,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,SAAS,EAAE,WAAW,EAAE,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,WAAW,GAAG,QAAQ,GAAG,YAAY,CAAC;CAC/C;AAED,KAAK,gBAAgB,GAAG,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,mCAAmC,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC;AAElH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,WAAW,CAAuC;IAC1D,OAAO,CAAC,eAAe,CAAgD;IACvE,OAAO,CAAC,iBAAiB,CAAuC;IAChE,OAAO,CAAC,cAAc,CAAmC;IAEzD,iBAAiB,CAAC,MAAM,EAAE,oBAAoB,GAAG,SAAS,GAAG,IAAI;IAI3D,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC;YAyBtE,gBAAgB;IAmF9B,OAAO,CAAC,YAAY;YAWN,mBAAmB;YAyEnB,aAAa;YAab,iBAAiB;IAkB/B,OAAO,CAAC,iCAAiC;IAQzC,wBAAwB,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,gBAAgB,GAAG,IAAI;IAI/E,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAI3C,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAgBpE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAelC,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAK/B,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAIzD,iBAAiB,IAAI,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAIlD,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAOzB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAOrC,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAOrC,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO;CAMjD"}
@@ -10,6 +10,7 @@ import { McpOAuthProvider } from "./mcp-oauth-provider.js";
10
10
  import { supportsOAuth } from "./mcp-auth-flow.js";
11
11
  import { registerSamplingHandler } from "./sampling-handler.js";
12
12
  import { interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.js";
13
+ import { augmentPath, getPathEnvKey } from "../utils/augment-path.js";
13
14
  export class McpServerManager {
14
15
  connections = new Map();
15
16
  connectPromises = new Map();
@@ -293,6 +294,11 @@ function resolveEnv(env) {
293
294
  resolved[key] = value;
294
295
  }
295
296
  }
297
+ // MCP stdio servers are spawned by name (`npx -y pkg`, `node server.js`), so
298
+ // they live or die by PATH. When spectral itself was launched from a GUI
299
+ // context that PATH has no node/npm in it — augment it for the child only,
300
+ // leaving our own process env untouched.
301
+ resolved[getPathEnvKey(resolved)] = augmentPath(resolved[getPathEnvKey(resolved)]);
296
302
  if (!env)
297
303
  return resolved;
298
304
  const overrides = interpolateEnvRecord(env);
@@ -1 +1 @@
1
- {"version":3,"file":"receive-agent-observations.d.ts","sourceRoot":"","sources":["../../../src/memory/tools/receive-agent-observations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAC7C,OAAO,EAAc,KAAK,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAIhF,eAAO,MAAM,oCAAoC,+BAA+B,CAAC;AAEjF,MAAM,WAAW,uBAAuB;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,CAAC;IAClD,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,aAAa,GAAG,YAAY,CAAC;IACzC,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,eAAO,MAAM,4BAA4B;;;iFAqEvC,CAAC;AAEH,wBAAgB,oCAAoC,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAE5E"}
1
+ {"version":3,"file":"receive-agent-observations.d.ts","sourceRoot":"","sources":["../../../src/memory/tools/receive-agent-observations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAC7C,OAAO,EAAc,KAAK,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAIhF,eAAO,MAAM,oCAAoC,+BAA+B,CAAC;AAEjF,MAAM,WAAW,uBAAuB;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,CAAC;IAClD,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,aAAa,GAAG,YAAY,CAAC;IACzC,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,eAAO,MAAM,4BAA4B;;;iFAkFvC,CAAC;AAEH,wBAAgB,oCAAoC,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAE5E"}
@@ -8,7 +8,8 @@ export const receiveAgentObservationsTool = defineTool({
8
8
  label: "Receive observations from other agents",
9
9
  description: "Poll for observations shared by other active sessions on this machine working in the " +
10
10
  "same directory (same-machine, same-cwd; no cross-machine or team relay). " +
11
- "Call this before starting work or when you suspect another agent has shared context.",
11
+ "Call this before starting work or when you suspect another agent has shared context. " +
12
+ "Delivery is per-recipient: each session polls its own inbox, and you never see your own messages.",
12
13
  promptSnippet: "Use receive_agent_observations() to pull shared observations from other active sessions on this machine.",
13
14
  promptGuidelines: [
14
15
  "Messages are same-machine + same-cwd only; there is no cross-machine or team fan-out.",
@@ -29,7 +30,13 @@ export const receiveAgentObservationsTool = defineTool({
29
30
  };
30
31
  }
31
32
  const projectId = store.getProjectByCwd(ctx.cwd);
32
- const sessionId = ctx.sessionManager.getSessionId?.();
33
+ // Subagents receive a DISTINCT derived observation session id on the
34
+ // SubagentToolContext so they poll their own inbox; primary sessions
35
+ // fall back to the session manager's id. Never the bare parent id for
36
+ // subagents — per-recipient delivery would let a subagent consume the
37
+ // parent's broadcast observations.
38
+ const subCtx = ctx;
39
+ const sessionId = subCtx.observationSessionId ?? subCtx.sessionManager?.getSessionId?.();
33
40
  if (!projectId || !sessionId) {
34
41
  return {
35
42
  content: [{ type: "text", text: "No project/session context." }],
@@ -40,6 +47,9 @@ export const receiveAgentObservationsTool = defineTool({
40
47
  projectId,
41
48
  channel: "memory",
42
49
  recipientSessionId: sessionId,
50
+ // Self-filtering: never surface the requesting session's own
51
+ // observations back to it (live subscribe already does this).
52
+ excludeSenderSessionId: sessionId,
43
53
  since: params.since,
44
54
  limit: params.limit ?? 20,
45
55
  markDelivered: true,
@@ -1 +1 @@
1
- {"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/relay/dispatcher.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/relay/dispatcher.ts"],"names":[],"mappings":"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"}
@@ -291,7 +291,11 @@ export async function handleClientMessage(frame, deps) {
291
291
  // Set autonomous iterative loop state before firing the prompt.
292
292
  // loop:true → start/renew loop with the current content as original prompt;
293
293
  // loop:false → stop any active loop.
294
- manager.setLoopActive(sessionId, isLoop, content, loopMaxIterations, loopGoal);
294
+ // Arm loop state BEFORE any await: for non-reference prompts this is
295
+ // synchronous, preserving the client_message/subscribe interleave below.
296
+ // For "@preset:<name>" prompts this returns a promise that we await just
297
+ // before firing the turn, so the loop replays the EXPANDED prompt.
298
+ const armed = manager.setLoopActive(sessionId, isLoop, content, loopMaxIterations, loopGoal);
295
299
  // 2. Attach (idempotent). On first attach we capture the replay payload
296
300
  // and synthesize a `session_ready` ws_event so the browser sees the
297
301
  // same first frame it would have on a direct WS connection.
@@ -350,6 +354,30 @@ export async function handleClientMessage(frame, deps) {
350
354
  });
351
355
  }
352
356
  }
357
+ // "@preset:<name>" loop prompts: resolve the preset before the turn fires
358
+ // (undefined = already armed synchronously — no yield, no reordering).
359
+ // The arm path resolves to the EXPANDED prompt; `null` means it failed or
360
+ // was aborted (loop state changed mid-resolution) — fall back to the raw
361
+ // content in those cases. The promise is caught, not just awaited: serve
362
+ // calls this handler fire-and-forget, so a rejection (e.g. a sync throw
363
+ // from teamIdForSession inside the manager) would otherwise surface as an
364
+ // unhandled rejection instead of a visible error event.
365
+ let expandedLoopPrompt = null;
366
+ if (armed instanceof Promise) {
367
+ try {
368
+ expandedLoopPrompt = await armed;
369
+ }
370
+ catch (err) {
371
+ const msg = err instanceof Error ? err.message : String(err);
372
+ logger.error?.(`[dispatcher] loop arming failed for ${sessionId}:`, err);
373
+ relay.send({
374
+ kind: "ws_event",
375
+ sessionId,
376
+ event: { type: "error", message: humanizeProviderError(msg) },
377
+ });
378
+ }
379
+ }
380
+ const promptContent = expandedLoopPrompt ?? content;
353
381
  // 3. Fire the prompt. `prompt()` resolves on `agent_end`; errors are
354
382
  // converted to `error` events by the manager itself, so we just log.
355
383
  // `modelId` (Phase 3 — Available Models whitelist) is forwarded as
@@ -359,7 +387,7 @@ export async function handleClientMessage(frame, deps) {
359
387
  //
360
388
  // When `loop: true`, loop state is set before prompting; the normal
361
389
  // model is used — loop replay is handled by session-stream on agent_end.
362
- manager.prompt(sessionId, content, modelId, validImages, reasoningEffort).catch((err) => {
390
+ manager.prompt(sessionId, promptContent, modelId, validImages, reasoningEffort).catch((err) => {
363
391
  logger.error?.(`[dispatcher] manager.prompt failed for ${sessionId}:`, err);
364
392
  });
365
393
  // Arm the interval scheduler after the first (immediate) launch.
@@ -108,6 +108,14 @@ export interface SubagentToolContext {
108
108
  cwd: string;
109
109
  getSystemPrompt(): string;
110
110
  sessionManager?: Pick<ReadonlySessionManager, "getBranch" | "getSessionId">;
111
+ /**
112
+ * Session identity for inter-agent observation tools (polling the shared
113
+ * inter-agent inbox). Subagents receive a DISTINCT derived id
114
+ * (`${parentSessionId}#sub:${agentName}:${suffix}`) so they never consume
115
+ * or misattribute the parent session's inbox. Absent in primary sessions —
116
+ * tools fall back to `sessionManager.getSessionId()`.
117
+ */
118
+ observationSessionId?: string;
111
119
  }
112
120
  /**
113
121
  * Extended context for command handlers.