@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
package/README.md CHANGED
@@ -53,6 +53,11 @@ Requires Node.js **20 or newer**.
53
53
  can create projects, open sessions, and chat with the agent on that machine
54
54
  from any tab.
55
55
 
56
+ Steps 2 and 3 are one command: `spectral desktop`. It starts `serve` as a
57
+ background daemon (survives closing the terminal), waits until the relay is
58
+ genuinely connected, then opens the UI in your default browser — see
59
+ [`spectral desktop`](#spectral-desktop).
60
+
56
61
  You can also run Spectral as a plain local TUI without the relay — just invoke
57
62
  `spectral` (no subcommand) and it acts as a normal pi terminal session with the
58
63
  Aexol MCP extension auto-loaded.
@@ -65,6 +70,7 @@ Aexol MCP extension auto-loaded.
65
70
  | `spectral login` | Interactive auth. Verifies the key against the MCP backend and stores it. |
66
71
  | `spectral logout` | Removes `~/.spectral/config.json`. Idempotent. |
67
72
  | `spectral serve` | Connect this machine to the Aexol relay. Stays up; survives reconnects. |
73
+ | `spectral desktop` | Start `serve` as a daemon, wait for it, open the UI. `status` / `stop` too. |
68
74
  | `spectral --version` | Print version. |
69
75
  | `spectral --help` | Print Spectral header, then pi's full help. |
70
76
 
@@ -74,6 +80,39 @@ Aexol MCP extension auto-loaded.
74
80
  | ----------------------- | ----------------------------------------------------- |
75
81
  | `--machine-name <name>` | Override the display name (default: `os.hostname()`). |
76
82
 
83
+ ### `spectral desktop`
84
+
85
+ The headless replacement for the removed Electron app: same lifecycle, no
86
+ bundled browser, and the UI is your own browser at
87
+ `https://aexol.ai/agent` (override with `SPECTRAL_LANDING_URL`).
88
+
89
+ | Command | Description |
90
+ | ----------------------- | ---------------------------------------------------------------------- |
91
+ | `spectral desktop` | Start the agent, or reuse the one already running, then open the UI. |
92
+ | `spectral desktop status` | Report pid / version / readiness signal. Exit `3` when not running. |
93
+ | `spectral desktop stop` | SIGTERM, then SIGKILL after 10s. Removes the pid / ready files. |
94
+
95
+ Mechanics worth knowing:
96
+
97
+ - Single instance via `~/.spectral/serve.pid`, claimed atomically (`O_EXCL`)
98
+ *before* the daemon is spawned — two simultaneous `spectral desktop` runs
99
+ start exactly one daemon. A dead pid is detected and pruned, so a crashed
100
+ daemon never blocks the next start, and a pid file held by a process this
101
+ command did not launch is never overwritten or deleted.
102
+ - If `~/.spectral/config.json` has no Studio token, `desktop` runs the
103
+ interactive `spectral login` flow first (and refuses to prompt when there is
104
+ no terminal, telling you to run `spectral login` yourself).
105
+ - Readiness is a real signal, not a timeout: the daemon prints
106
+ `SPECTRAL_SERVE_READY` once the relay WebSocket opens and writes
107
+ `serve-ready.json`. Opening the UI only after that avoids the
108
+ `machine_disconnected` flash you get if the page subscribes before the socket
109
+ exists.
110
+ - `~/.spectral/serve.log` is created `0600` and rotated to `serve.log.old` at
111
+ start once it passes 10 MB (`SPECTRAL_DESKTOP_LOG_MAX_BYTES`).
112
+ - The daemon is spawned detached with an augmented `PATH` (`/etc/paths`,
113
+ `getconf PATH`, Homebrew/nvm/Node dirs), so MCP servers declared as
114
+ `npx -y <pkg>` work even when Spectral was launched from a GUI context.
115
+
77
116
  Anything that isn't a Spectral subcommand is forwarded verbatim to pi, so any pi
78
117
  flag you know works. Example: `spectral -p "summarize this repo"`.
79
118
 
@@ -139,10 +178,18 @@ behaves like a vanilla pi install.
139
178
  | `~/.spectral/config.json` | Aexol MCP URL + team API key. Created by `spectral login`. Mode `0600`. |
140
179
  | `~/.spectral/machine.json` | Machine identity + relay JWT. Created on first `spectral serve`. |
141
180
  | `~/.spectral/sessions.db` | Local SQLite for projects, sessions, messages. |
181
+ | `~/.spectral/serve.pid` | Relay daemon pid (single-instance lock). Pruned when stale. |
182
+ | `~/.spectral/serve-ready.json` | `{pid, ts, version}` written when the relay socket opens. |
183
+ | `~/.spectral/serve.log` | Daemon stdout/stderr, written by `spectral desktop`. |
142
184
  | `SPECTRAL_CONFIG_DIR` | Override the directory above. |
143
185
  | `SPECTRAL_MCP_URL` | Override the MCP URL at login time. |
144
186
  | `SPECTRAL_BACKEND_URL` | Override the backend HTTP base for `spectral serve`. |
145
187
  | `SPECTRAL_RELAY_URL` | Override the derived relay WebSocket URL. |
188
+ | `SPECTRAL_LANDING_URL` | UI that `spectral desktop` opens (default `https://aexol.ai/agent`). |
189
+ | `SPECTRAL_SERVE_LOG_PATH` | Daemon log path for `spectral desktop`. |
190
+ | `SPECTRAL_DESKTOP_READY_TIMEOUT_MS` | Readiness wait before `desktop` gives up (default 15000). |
191
+ | `SPECTRAL_DESKTOP_STOP_TIMEOUT_MS` | SIGTERM grace before SIGKILL in `desktop stop` (default 10000). |
192
+ | `SPECTRAL_DESKTOP_LOG_MAX_BYTES` | Serve-log size that triggers rotation at start (default 10485760). |
146
193
 
147
194
  Pi's own auth state for the local TUI path (Codex subscription) lives in
148
195
  `~/.pi/agent/auth.json` on the same machine. Spectral never reads it and never
@@ -24,14 +24,16 @@
24
24
  * - Signal propagation (abort passthrough)
25
25
  * - Access to observational memory via the `recall` and
26
26
  * `write_project_observation` tools, which run against a parent-session
27
- * point-in-time snapshot (branch + session id); other observation tools
28
- * (`share_project_observation`, `receive_agent_observations`,
29
- * `compact_context`, `subagent`) remain excluded
27
+ * point-in-time snapshot (branch + session id). `receive_agent_observations`
28
+ * also runs inside subagents but polls with a DISTINCT derived
29
+ * observation session id (never the bare parent id); `share_project_observation`,
30
+ * `compact_context`, and `subagent` remain excluded
30
31
  */
31
- import type { AgentTool } from "../sdk/agent-core/index.js";
32
+ import type { AgentEvent, AgentTool } from "../sdk/agent-core/index.js";
32
33
  import { type ExtensionAPI, type ExtensionContext } from "../sdk/coding-agent/index.js";
33
34
  import { type AgentConfig } from "./agents.js";
34
35
  import type { ServerEvent } from "../server/wire.ts";
36
+ import { type TpsUsageFields } from "../server/agent-bridge/tps-metrics.ts";
35
37
  import type { ReadonlySessionManager } from "../sdk/coding-agent/core/session-manager.js";
36
38
  /**
37
39
  * Build the system prompt for a subagent session.
@@ -49,7 +51,32 @@ export declare function buildSubagentSystemPrompt(baseSystemPrompt: string | und
49
51
  * SessionManager. Each getBranch call returns fresh clones of the snapshot.
50
52
  */
51
53
  export declare function createSubagentSessionManagerSnapshot(sessionManager: ExtensionContext["sessionManager"]): Pick<ReadonlySessionManager, "getBranch" | "getSessionId">;
52
- export declare function resolveSubagentTools(cwd: string, ext: ExtensionAPI, ctx: ExtensionContext): AgentTool[];
54
+ export declare function resolveSubagentTools(cwd: string, ext: ExtensionAPI, ctx: ExtensionContext, agentName?: string): AgentTool[];
55
+ /**
56
+ * Runs a child agent with an independent per-subagent timeout.
57
+ *
58
+ * Combines the parent's abort signal with a fresh timeout AbortController so
59
+ * that no single subagent can exhaust the parent prompt's full timeout budget.
60
+ * The budget covers the WHOLE child run including any resume retries (see
61
+ * `runSingleAgent`), so wall time stays bounded by SUBAGENT_TIMEOUT_MS.
62
+ * On timeout, returns a clear error result instead of throwing so the parent
63
+ * agent can continue (e.g. stop a chain, or surface the error as the tool
64
+ * result for parallel/single modes).
65
+ */
66
+ /**
67
+ * LIVE TPS snapshot emitted by the subagent runner after each LLM round that
68
+ * produced tokens (subagent_progress contract). Carried through
69
+ * `onInternalEvent` alongside real `AgentEvent`s; the subagent extension maps
70
+ * it to the `subagent_tps` wire event for the bridge, which folds it into the
71
+ * throttled `subagent_progress` frames keyed by the parent tool call id.
72
+ */
73
+ export interface SubagentTpsSnapshotEvent {
74
+ type: "subagent_tps_snapshot";
75
+ /** Token-weighted TPS fields so far (omitted fields = no sound data yet). */
76
+ tps: TpsUsageFields;
77
+ }
78
+ /** Internal events the subagent runner forwards through `onInternalEvent`. */
79
+ export type SubagentInternalEvent = AgentEvent | SubagentTpsSnapshotEvent;
53
80
  /** Test hook: replace the run-level retry sleep (e.g. with an instant/collecting stub). */
54
81
  export declare function setSubagentRetrySleepForTesting(fn?: (ms: number, signal?: AbortSignal) => Promise<void>): void;
55
82
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/agent/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,EAIX,SAAS,EAET,MAAM,4BAA4B,CAAC;AAqBpC,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,gBAAgB,EAKrB,MAAM,8BAA8B,CAAC;AAItC,OAAO,EACN,KAAK,WAAW,EAKhB,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAQrD,OAAO,KAAK,EAAE,sBAAsB,EAAgB,MAAM,6CAA6C,CAAC;AAyGxG;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACxC,gBAAgB,EAAE,MAAM,GAAG,SAAS,EACpC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,GAAG,cAAc,GAAG,OAAO,GAAG,QAAQ,CAAC,GACpF,MAAM,CAYR;AAiMD;;;GAGG;AACH,wBAAgB,oCAAoC,CACnD,cAAc,EAAE,gBAAgB,CAAC,gBAAgB,CAAC,GAChD,IAAI,CAAC,sBAAsB,EAAE,WAAW,GAAG,cAAc,CAAC,CAe5D;AA6BD,wBAAgB,oBAAoB,CACnC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,YAAY,EACjB,GAAG,EAAE,gBAAgB,GACnB,SAAS,EAAE,CAwCb;AAqHD,2FAA2F;AAC3F,wBAAgB,+BAA+B,CAC9C,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,GACtD,IAAI,CAEN;AAmBD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACzC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,QAAQ,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GAC9D,MAAM,CAiBR;AAogBD,MAAM,CAAC,OAAO,UAAU,iBAAiB,CACxC,GAAG,EAAE,YAAY,EACjB,IAAI,CAAC,EAAE;IACN,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB,GACC,IAAI,CAqcN"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/agent/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAEX,UAAU,EAEV,SAAS,EAET,MAAM,4BAA4B,CAAC;AAqBpC,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,gBAAgB,EAKrB,MAAM,8BAA8B,CAAC;AAItC,OAAO,EACN,KAAK,WAAW,EAKhB,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAGN,KAAK,cAAc,EACnB,MAAM,uCAAuC,CAAC;AAS/C,OAAO,KAAK,EAAE,sBAAsB,EAAgB,MAAM,6CAA6C,CAAC;AAyGxG;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACxC,gBAAgB,EAAE,MAAM,GAAG,SAAS,EACpC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,GAAG,cAAc,GAAG,OAAO,GAAG,QAAQ,CAAC,GACpF,MAAM,CAYR;AA2ND;;;GAGG;AACH,wBAAgB,oCAAoC,CACnD,cAAc,EAAE,gBAAgB,CAAC,gBAAgB,CAAC,GAChD,IAAI,CAAC,sBAAsB,EAAE,WAAW,GAAG,cAAc,CAAC,CAe5D;AAgCD,wBAAgB,oBAAoB,CACnC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,YAAY,EACjB,GAAG,EAAE,gBAAgB,EACrB,SAAS,CAAC,EAAE,MAAM,GAChB,SAAS,EAAE,CAkDb;AAyBD;;;;;;;;;;GAUG;AACH;;;;;;GAMG;AACH,MAAM,WAAW,wBAAwB;IACxC,IAAI,EAAE,uBAAuB,CAAC;IAC9B,6EAA6E;IAC7E,GAAG,EAAE,cAAc,CAAC;CACpB;AAED,8EAA8E;AAC9E,MAAM,MAAM,qBAAqB,GAAG,UAAU,GAAG,wBAAwB,CAAC;AAmF1E,2FAA2F;AAC3F,wBAAgB,+BAA+B,CAC9C,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,GACtD,IAAI,CAEN;AAmBD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACzC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,QAAQ,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GAC9D,MAAM,CAiBR;AAmmBD,MAAM,CAAC,OAAO,UAAU,iBAAiB,CACxC,GAAG,EAAE,YAAY,EACjB,IAAI,CAAC,EAAE;IACN,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB,GACC,IAAI,CA+cN"}
@@ -24,9 +24,10 @@
24
24
  * - Signal propagation (abort passthrough)
25
25
  * - Access to observational memory via the `recall` and
26
26
  * `write_project_observation` tools, which run against a parent-session
27
- * point-in-time snapshot (branch + session id); other observation tools
28
- * (`share_project_observation`, `receive_agent_observations`,
29
- * `compact_context`, `subagent`) remain excluded
27
+ * point-in-time snapshot (branch + session id). `receive_agent_observations`
28
+ * also runs inside subagents but polls with a DISTINCT derived
29
+ * observation session id (never the bare parent id); `share_project_observation`,
30
+ * `compact_context`, and `subagent` remain excluded
30
31
  */
31
32
  import { agentLoop, agentLoopContinue, } from "../sdk/agent-core/index.js";
32
33
  import { buildIsolatedRequestTimePrunedMessagesResult, COMPACT_CONTEXT_TOOL_NAME, } from "../memory/tools/compact-context.js";
@@ -37,6 +38,8 @@ import { sleep } from "../sdk/coding-agent/utils/sleep.js";
37
38
  import { Type } from "typebox";
38
39
  import { discoverAgents, agentMetadataBlock, } from "./agents.js";
39
40
  import { loadAgentSettings } from "../server/handlers/agent-settings.js";
41
+ import { buildTpsUsageFields, computeTpsMetrics, } from "../server/agent-bridge/tps-metrics.js";
42
+ import { randomUUID } from "node:crypto";
40
43
  // ---------------------------------------------------------------------------
41
44
  // Built-in tool resolution (no spawn needed — tools are registered in-process)
42
45
  // ---------------------------------------------------------------------------
@@ -294,14 +297,16 @@ export function createSubagentSessionManagerSnapshot(sessionManager) {
294
297
  * - `compact_context`: depends on parent session/UI state (sessionManager,
295
298
  * settingsManager, ui, compaction actions) that is unavailable inside the
296
299
  * isolated `{ cwd, getSystemPrompt }` subagent context.
297
- * - `receive_agent_observations` / `share_project_observation`: require the
298
- * parent session id and branch, which are not exposed to the isolated
299
- * subagent context.
300
+ * - `share_project_observation`: requires the parent session identity to
301
+ * attribute shared observations; exposing it to subagents would let them
302
+ * misattribute messages and create share/receive feedback loops.
303
+ * - `receive_agent_observations` is NOT excluded: it runs with a derived
304
+ * `observationSessionId` (see `resolveSubagentTools`) so a subagent polls
305
+ * its own inbox instead of consuming the parent session's.
300
306
  */
301
307
  const SUBAGENT_EXCLUDED_TOOL_NAMES = new Set([
302
308
  "subagent",
303
309
  COMPACT_CONTEXT_TOOL_NAME,
304
- RECEIVE_AGENT_OBSERVATIONS_TOOL_NAME,
305
310
  SHARE_PROJECT_OBSERVATION_TOOL_NAME,
306
311
  ]);
307
312
  /**
@@ -311,8 +316,9 @@ const SUBAGENT_EXCLUDED_TOOL_NAMES = new Set([
311
316
  const SUBAGENT_SESSION_SNAPSHOT_TOOL_NAMES = new Set([
312
317
  RECALL_OBSERVATION_TOOL_NAME,
313
318
  WRITE_PROJECT_OBSERVATION_TOOL_NAME,
319
+ RECEIVE_AGENT_OBSERVATIONS_TOOL_NAME,
314
320
  ]);
315
- export function resolveSubagentTools(cwd, ext, ctx) {
321
+ export function resolveSubagentTools(cwd, ext, ctx, agentName) {
316
322
  // Subagents receive every tool registered on the primary agent. Built-in
317
323
  // tools are created fresh with the subagent's isolated cwd/jobs, and
318
324
  // extension-registered tools are wrapped with a `SubagentToolContext`:
@@ -325,6 +331,16 @@ export function resolveSubagentTools(cwd, ext, ctx) {
325
331
  // One shared snapshot per invocation so all snapshot-routed tools observe
326
332
  // the same parent-session epoch (branch + session id).
327
333
  const sharedSnapshot = createSubagentSessionManagerSnapshot(ctx.sessionManager);
334
+ // Distinct observation identity for inter-agent tools. Derived from the
335
+ // parent session id so the inbox stays project/session-scoped, but never
336
+ // equal to it: per-recipient delivery means a subagent polling with the
337
+ // bare parent id would consume (and mark delivered) the parent's
338
+ // broadcast observations. The suffix is per-invocation, so parallel and
339
+ // retried subagent runs each get their own inbox view.
340
+ const parentSessionId = ctx.sessionManager?.getSessionId?.();
341
+ const observationSessionId = parentSessionId
342
+ ? `${parentSessionId}#sub:${(agentName ?? "agent").replace(/^@/, "")}:${randomUUID().slice(0, 8)}`
343
+ : undefined;
328
344
  for (const toolInfo of ext.getAllTools()) {
329
345
  const name = toolInfo.name;
330
346
  if (SUBAGENT_EXCLUDED_TOOL_NAMES.has(name))
@@ -345,8 +361,8 @@ export function resolveSubagentTools(cwd, ext, ctx) {
345
361
  if (!toolDef)
346
362
  continue;
347
363
  const isolatedContext = SUBAGENT_SESSION_SNAPSHOT_TOOL_NAMES.has(name)
348
- ? { cwd, sessionManager: sharedSnapshot, getSystemPrompt: ctx.getSystemPrompt }
349
- : { cwd, getSystemPrompt: ctx.getSystemPrompt };
364
+ ? { cwd, sessionManager: sharedSnapshot, getSystemPrompt: ctx.getSystemPrompt, observationSessionId }
365
+ : { cwd, getSystemPrompt: ctx.getSystemPrompt, observationSessionId };
350
366
  tools.push(wrapToolDefinition(toolDef, () => isolatedContext));
351
367
  }
352
368
  return tools;
@@ -371,17 +387,6 @@ async function mapWithConcurrencyLimit(items, concurrency, fn) {
371
387
  await Promise.all(workers);
372
388
  return results;
373
389
  }
374
- /**
375
- * Runs a child agent with an independent per-subagent timeout.
376
- *
377
- * Combines the parent's abort signal with a fresh timeout AbortController so
378
- * that no single subagent can exhaust the parent prompt's full timeout budget.
379
- * The budget covers the WHOLE child run including any resume retries (see
380
- * `runSingleAgent`), so wall time stays bounded by SUBAGENT_TIMEOUT_MS.
381
- * On timeout, returns a clear error result instead of throwing so the parent
382
- * agent can continue (e.g. stop a chain, or surface the error as the tool
383
- * result for parallel/single modes).
384
- */
385
390
  async function runSingleAgentWithTimeout(defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails, parentToolCallId, onInternalEvent, ext, ctx, maxRetries) {
386
391
  const timeoutMs = SUBAGENT_TIMEOUT_MS;
387
392
  const timeoutSeconds = timeoutMs / 1000;
@@ -545,7 +550,7 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
545
550
  // per-agent tool filtering). Built-ins (read, bash, apply_patch) plus
546
551
  // observational-memory tools (recall, read-project-observations) are
547
552
  // always available so subagents can actually do work.
548
- const tools = resolveSubagentTools(effectiveCwd, ext, ctx);
553
+ const tools = resolveSubagentTools(effectiveCwd, ext, ctx, agentName);
549
554
  const currentResult = {
550
555
  agent: agentName,
551
556
  agentSource: agent.source,
@@ -571,6 +576,18 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
571
576
  // Responses API reasoning metadata that lets the UI classify accumulated
572
577
  // subagent thinking as activity (mirrors the main-session thinking metadata).
573
578
  let thinkingMetadata;
579
+ // Per-invocation TPS accumulation (subagent_end contract). Declared before
580
+ // the try block so both the settled and thrown return paths can attach it;
581
+ // survives the run-level resume retry so retried rounds count like their
582
+ // usage does.
583
+ const tpsAccumulator = {
584
+ durationMsSum: 0,
585
+ outputTokens: 0,
586
+ outputDurationMs: 0,
587
+ ttftMsSum: 0,
588
+ ttftRounds: 0,
589
+ lastRoundTps: null,
590
+ };
574
591
  const emitUpdate = () => {
575
592
  if (onUpdate) {
576
593
  const liveResult = {
@@ -653,6 +670,11 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
653
670
  const collectedMessages = [];
654
671
  let hadError = false;
655
672
  let segments = 0;
673
+ // Per-LLM-round timing stamps (TPS contract), mirroring the bridge's
674
+ // main-session `pending` stamps: message_start opens the round, the
675
+ // first text/thinking delta ends the TTFT window, message_end closes it.
676
+ let roundStartAt;
677
+ let roundFirstDeltaAt;
656
678
  // One child run may span several segments: the first one starts the
657
679
  // loop, any later one resumes it. Tool results already recorded in
658
680
  // `collectedMessages` are part of the resumed context, so tools are
@@ -664,15 +686,25 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
664
686
  break;
665
687
  }
666
688
  onInternalEvent?.(event, parentToolCallId);
689
+ if (event.type === "message_start" && event.message?.role === "assistant") {
690
+ // New LLM round begins. Reset the delta stamp so a stale
691
+ // first-delta timestamp from a previous round cannot poison TTFT.
692
+ roundStartAt = Date.now();
693
+ roundFirstDeltaAt = undefined;
694
+ }
667
695
  if (event.type === "message_update" && event.message?.role === "assistant") {
668
696
  // Forward streaming text/thinking deltas so the UI can show the
669
697
  // subagent process as it's generated, not just the final result.
670
698
  const inner = event.assistantMessageEvent;
671
699
  if (inner?.type === "text_delta" && inner.delta) {
700
+ if (roundFirstDeltaAt === undefined)
701
+ roundFirstDeltaAt = Date.now();
672
702
  streamingText += inner.delta;
673
703
  emitUpdate();
674
704
  }
675
705
  else if (inner?.type === "thinking_delta" && inner.delta) {
706
+ if (roundFirstDeltaAt === undefined)
707
+ roundFirstDeltaAt = Date.now();
676
708
  thinkingText += inner.delta;
677
709
  if (inner.metadata) {
678
710
  thinkingMetadata = { ...(thinkingMetadata ?? {}), ...inner.metadata };
@@ -682,6 +714,9 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
682
714
  continue;
683
715
  }
684
716
  if (event.type === "message_end" && event.message) {
717
+ // Stamp at handler entry so the round metrics below measure the
718
+ // whole stream (same source as the bridge's main-session path).
719
+ const roundEndAt = Date.now();
685
720
  const msg = event.message;
686
721
  collectedMessages.push(msg);
687
722
  if (msg.role === "assistant") {
@@ -703,6 +738,36 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
703
738
  currentResult.stopReason = msg.stopReason;
704
739
  if (msg.errorMessage)
705
740
  currentResult.errorMessage = msg.errorMessage;
741
+ // Per-round TPS metrics (mirrors the main-session token_usage
742
+ // stamping): message_start → first delta → message_end with this
743
+ // round's output tokens. Zero-token rounds contribute timing but
744
+ // are skipped in the TPS numerator/denominator.
745
+ const roundMetrics = computeTpsMetrics(roundStartAt, roundFirstDeltaAt, roundEndAt, usage?.output ?? 0);
746
+ if (roundMetrics.durationMs != null) {
747
+ tpsAccumulator.durationMsSum += roundMetrics.durationMs;
748
+ }
749
+ if (roundMetrics.tokensPerSecond != null &&
750
+ roundMetrics.durationMs != null) {
751
+ tpsAccumulator.outputTokens += usage?.output ?? 0;
752
+ tpsAccumulator.outputDurationMs += roundMetrics.durationMs;
753
+ tpsAccumulator.lastRoundTps = roundMetrics.tokensPerSecond;
754
+ }
755
+ if (roundMetrics.ttftMs != null) {
756
+ tpsAccumulator.ttftMsSum += roundMetrics.ttftMs;
757
+ tpsAccumulator.ttftRounds += 1;
758
+ }
759
+ // LIVE TPS snapshot (subagent_progress contract): after each LLM
760
+ // round that produced tokens, push the token-weighted
761
+ // accumulator snapshot through onInternalEvent so the bridge can
762
+ // fold it into the throttled subagent_progress frames keyed by
763
+ // the parent tool call id. Omitted until the first round with
764
+ // tokens; unsound fields are omitted by buildTpsUsageFields.
765
+ if (roundMetrics.tokensPerSecond != null) {
766
+ onInternalEvent?.({
767
+ type: "subagent_tps_snapshot",
768
+ tps: buildTpsUsageFields(tpsAccumulator),
769
+ }, parentToolCallId);
770
+ }
706
771
  if (msg.stopReason === "error" || msg.stopReason === "aborted") {
707
772
  hadError = true;
708
773
  }
@@ -730,6 +795,10 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
730
795
  // shows the resumed segment's reasoning, not stale text.
731
796
  thinkingText = "";
732
797
  thinkingMetadata = undefined;
798
+ // Drop round stamps from the previous segment so a resumed segment's
799
+ // first assistant round measures from its own message_start onward.
800
+ roundStartAt = undefined;
801
+ roundFirstDeltaAt = undefined;
733
802
  currentResult.stopReason = undefined;
734
803
  currentResult.errorMessage = undefined;
735
804
  currentResult.stderr = "";
@@ -810,6 +879,15 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
810
879
  currentResult.errorMessage = `${currentResult.errorMessage} (failed after ${segments} attempts)`;
811
880
  }
812
881
  }
882
+ if (tpsAccumulator.durationMsSum > 0 || tpsAccumulator.ttftRounds > 0) {
883
+ currentResult.tps = {
884
+ ...buildTpsUsageFields(tpsAccumulator),
885
+ outputTokens: tpsAccumulator.outputTokens,
886
+ outputDurationMs: tpsAccumulator.outputDurationMs,
887
+ ttftMsSum: tpsAccumulator.ttftMsSum,
888
+ ttftRounds: tpsAccumulator.ttftRounds,
889
+ };
890
+ }
813
891
  return currentResult;
814
892
  }
815
893
  catch (err) {
@@ -818,6 +896,15 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
818
896
  currentResult.stopReason = "error";
819
897
  currentResult.errorMessage = errorMsg;
820
898
  currentResult.stderr = errorMsg;
899
+ if (tpsAccumulator.durationMsSum > 0 || tpsAccumulator.ttftRounds > 0) {
900
+ currentResult.tps = {
901
+ ...buildTpsUsageFields(tpsAccumulator),
902
+ outputTokens: tpsAccumulator.outputTokens,
903
+ outputDurationMs: tpsAccumulator.outputDurationMs,
904
+ ttftMsSum: tpsAccumulator.ttftMsSum,
905
+ ttftRounds: tpsAccumulator.ttftRounds,
906
+ };
907
+ }
821
908
  return currentResult;
822
909
  }
823
910
  }
@@ -1000,6 +1087,17 @@ export default function subagentExtension(ext, opts) {
1000
1087
  finishedAt,
1001
1088
  });
1002
1089
  }
1090
+ else if (event.type === "subagent_tps_snapshot") {
1091
+ // LIVE TPS snapshot from the runner (subagent_progress
1092
+ // contract): forward as the internal `subagent_tps` wire
1093
+ // event; the bridge folds it into the throttled
1094
+ // subagent_progress frames for this tool call.
1095
+ emitSubagentEvent({
1096
+ type: "subagent_tps",
1097
+ toolCallId: parentToolCallId,
1098
+ ...event.tps,
1099
+ });
1100
+ }
1003
1101
  }
1004
1102
  : undefined;
1005
1103
  if (modeCount !== 1) {
package/dist/cli.d.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  * - `spectral login` → interactive auth flow (writes ~/.spectral/config.json)
9
9
  * - `spectral logout` → deletes ~/.spectral/config.json
10
10
  * - `spectral serve` → start the Aexol relay WebSocket server (headless)
11
+ * - `spectral desktop`→ start `serve` detached + open the Studio UI in the
12
+ * browser (the thin replacement for the old desktop app)
11
13
  * - `spectral bind` → link directory to an Aexol Studio project
12
14
  * - `spectral unbind` → remove Aexol Studio project binding
13
15
  * - `spectral --version` / `--help` → branded short-circuits, no auth needed.
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;GAaG"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;GAeG"}
package/dist/cli.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * - `spectral login` → interactive auth flow (writes ~/.spectral/config.json)
9
9
  * - `spectral logout` → deletes ~/.spectral/config.json
10
10
  * - `spectral serve` → start the Aexol relay WebSocket server (headless)
11
+ * - `spectral desktop`→ start `serve` detached + open the Studio UI in the
12
+ * browser (the thin replacement for the old desktop app)
11
13
  * - `spectral bind` → link directory to an Aexol Studio project
12
14
  * - `spectral unbind` → remove Aexol Studio project binding
13
15
  * - `spectral --version` / `--help` → branded short-circuits, no auth needed.
@@ -16,8 +18,7 @@
16
18
  import { getVersion } from "./version.js";
17
19
  // ---- Read our own version ----------------------------------------------------
18
20
  // Centralized in src/version.ts so the standalone Bun binary, the npm
19
- // install, and the electron app all resolve the same real version instead of
20
- // silently falling back to "0.0.0".
21
+ // install all resolve the same real version instead of falling back to "0.0.0".
21
22
  const VERSION = getVersion();
22
23
  const TAGLINE = "Coding agent that never sleeps";
23
24
  // ---- Branded helpers ---------------------------------------------------------
@@ -33,6 +34,7 @@ function printHeader() {
33
34
  " spectral login Interactive authentication (team API key or OAuth)",
34
35
  " spectral logout Remove stored Aexol credentials",
35
36
  " spectral serve Connect this machine to the Aexol relay backend",
37
+ " spectral desktop Start the agent in the background and open the UI",
36
38
  " spectral bind Link this directory to an Aexol Studio project",
37
39
  " spectral unbind Remove the Aexol Studio project binding",
38
40
  " spectral update Check for and install the latest Spectral version",
@@ -40,8 +42,9 @@ function printHeader() {
40
42
  " spectral --version Print version and exit",
41
43
  " spectral --help Print this help and exit",
42
44
  "",
43
- "TUI/interactive mode has been removed. Use `spectral serve` and connect",
44
- "via the Aexol Studio web frontend or relay WebSocket.",
45
+ "TUI/interactive mode has been removed. Use `spectral desktop` to work in",
46
+ "the Aexol Studio web frontend, or `spectral serve` to only connect this",
47
+ "machine to the relay backend.",
45
48
  "",
46
49
  ].join("\n"));
47
50
  }
@@ -99,6 +102,17 @@ async function main() {
99
102
  await runServeCli(args.slice(1));
100
103
  return;
101
104
  }
105
+ if (first === "desktop") {
106
+ const { runDesktop } = await import("./commands/desktop.js");
107
+ // Thin wrapper: it spawns a detached `spectral serve`, waits for its
108
+ // readiness marker, opens the browser, then exits. The exit code tells the
109
+ // shell whether the agent came up.
110
+ // `exitCode` (not `process.exit`) so buffered stdout is flushed and any
111
+ // pending cleanup still runs — the wrapper has nothing left open, but a
112
+ // hard exit here would truncate the message on a slow terminal.
113
+ process.exitCode = await runDesktop(args.slice(1));
114
+ return;
115
+ }
102
116
  if (first === "bind") {
103
117
  const { runBind } = await import("./commands/bind.js");
104
118
  await runBind(args.slice(1));
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `spectral desktop` — the thin, headless replacement for the Electron shell.
3
+ *
4
+ * What the Electron app actually contributed was four things, all reproduced
5
+ * here without a bundled Chromium:
6
+ *
7
+ * 1. single-instance guarantee → `~/.spectral/serve.pid` + liveness check
8
+ * 1. single-instance guarantee → `~/.spectral/serve.pid`, claimed
9
+ * atomically (`O_EXCL`) before the spawn
10
+ * 2. start the local relay daemon → detached `spectral serve`, unref'd,
11
+ * stdout+stderr appended to serve.log
12
+ * 3. "wait until it is really up" → the `SPECTRAL_SERVE_READY` stdout marker
13
+ * and `serve-ready.json` (see
14
+ * src/utils/serve-state.ts), replacing the
15
+ * old "assume ready after 3 s" heuristic
16
+ * 4. show the UI → open the system browser at
17
+ * https://aexol.ai/agent
18
+ * (override: SPECTRAL_LANDING_URL)
19
+ *
20
+ * The PATH augmentation the app performed for its child process now happens in
21
+ * `augmentPath()` (src/utils/augment-path.ts) and is applied to the spawned
22
+ * daemon, so MCP servers launched via `npx` keep working.
23
+ *
24
+ * What is deliberately NOT reproduced (see the removal notes): the splash
25
+ * window, in-app auto-update (use `spectral update`), the native app menu, and
26
+ * the JWT-via-IPC login bridge — authentication goes through the existing
27
+ * `spectral login` browser flow, which already writes `userJwt` to
28
+ * `~/.spectral/config.json`.
29
+ *
30
+ * Exit codes: 0 success / already running, 1 failure, 3 stopped-state for
31
+ * `status` when the daemon is not running.
32
+ */
33
+ import { type SpectralConfig } from "../config.js";
34
+ import { type OpenBrowserResult } from "../utils/open-browser.js";
35
+ import { type ServeReadyResult, type ServeGenealogy } from "../utils/serve-state.js";
36
+ /** Where the Studio agent UI lives. */
37
+ export declare const DEFAULT_LANDING_URL = "https://aexol.ai/agent";
38
+ /** How long to wait for the daemon's readiness signal. */
39
+ export declare const DEFAULT_READY_TIMEOUT_MS = 15000;
40
+ /** Grace period between SIGTERM and SIGKILL in `desktop stop`. */
41
+ export declare const DEFAULT_STOP_TIMEOUT_MS = 10000;
42
+ /**
43
+ * Set in the daemon's env to the wrapper's own pid. The daemon sees the
44
+ * placeholder we left in `serve.pid` and must not read it as "another serve is
45
+ * running" — it is the claim of the process that just launched it.
46
+ */
47
+ export declare const SERVE_PID_CLAIM_ENV = "SPECTRAL_SERVE_PID_CLAIM";
48
+ export declare function getLandingUrl(env?: NodeJS.ProcessEnv): string;
49
+ /** Render a grace window the way it is actually configured (10s, 250ms). */
50
+ export declare function formatGraceMs(ms: number): string;
51
+ export interface ServeSpawnPlan {
52
+ command: string;
53
+ args: string[];
54
+ cwd: string;
55
+ env: NodeJS.ProcessEnv;
56
+ logFile: string;
57
+ }
58
+ interface ResolveCommandInput {
59
+ execPath?: string;
60
+ execArgv?: readonly string[];
61
+ argv1?: string;
62
+ standalone?: boolean;
63
+ /** Entry points to try, in order (already-resolved absolute paths). */
64
+ candidates?: string[];
65
+ exists?: (path: string) => boolean;
66
+ }
67
+ /**
68
+ * Decide how to launch a detached `spectral serve`.
69
+ *
70
+ * Three launch shapes must all work:
71
+ * - standalone Bun binary → exec the binary itself with `serve`
72
+ * - npm install (`spectral` bin → dist/cli.js) → `node <cli.js> serve`
73
+ * - `npm run dev` (tsx src/cli.ts) → re-run the very entry the user invoked,
74
+ * with `process.execArgv` so the tsx loader is carried over
75
+ *
76
+ * @returns the spawn plan's command/args portion. Falls back to whatever
77
+ * `spectral` is on PATH when no entry point can be identified.
78
+ */
79
+ export declare function resolveServeCommand(input?: ResolveCommandInput): {
80
+ command: string;
81
+ args: string[];
82
+ };
83
+ /** Compose the full plan for spawning the daemon. */
84
+ export declare function planServeSpawn(env?: NodeJS.ProcessEnv, input?: ResolveCommandInput): ServeSpawnPlan;
85
+ /**
86
+ * Will this spawn re-exec into the daemon instead of becoming it directly?
87
+ *
88
+ * Matters for readiness: a standalone binary and `node dist/cli.js serve` are
89
+ * the daemon, so their exit is a hard failure we can report instantly. Under
90
+ * `npm run dev` (`tsx src/cli.ts`) the child is a loader wrapper that execs
91
+ * something else — its death tells us nothing, and only the ready signal or the
92
+ * timeout may decide.
93
+ */
94
+ export declare function spawnExpectsReExec(plan: ServeSpawnPlan): boolean;
95
+ /**
96
+ * Launch the daemon detached from this terminal so it survives the wrapper
97
+ * exiting (that is the whole point: close the shell, the agent stays up).
98
+ * Returns the child pid, or null when the spawn itself failed.
99
+ */
100
+ export declare function spawnDetachedServe(plan: ServeSpawnPlan): number | null;
101
+ export interface DesktopDeps {
102
+ env?: NodeJS.ProcessEnv;
103
+ spawnServe?: (plan: ServeSpawnPlan) => number | null;
104
+ waitForReady?: (opts: {
105
+ pid: number;
106
+ logFile: string;
107
+ logOffset: number;
108
+ /** Timestamp captured before the spawn, for ready-signal freshness. */
109
+ sinceMs: number;
110
+ /** True when the child re-execs, so its own exit proves nothing. */
111
+ expectReExec: boolean;
112
+ timeoutMs: number;
113
+ }) => Promise<ServeReadyResult>;
114
+ openUrl?: (url: string) => Promise<OpenBrowserResult>;
115
+ /** Interactive `spectral login`. */
116
+ runLogin?: () => Promise<void>;
117
+ readCfg?: () => Promise<SpectralConfig | null>;
118
+ signal?: (pid: number, signal: NodeJS.Signals) => void;
119
+ alive?: (pid: number) => boolean;
120
+ sleep?: (ms: number) => Promise<void>;
121
+ /** Override the SIGTERM→SIGKILL grace window (tests, exotic supervisors). */
122
+ stopGraceMs?: number;
123
+ /**
124
+ * Injectable genealogy verdict for the serve.pid hand-off confirmation. The
125
+ * real probe walks `ps -o ppid=` and can transiently fail under parallel
126
+ * test load, so tests pin the verdict to keep the
127
+ * `daemon`/`foreign`/`unknown` classification deterministic.
128
+ */
129
+ genealogy?: (pid: number, ancestors: number[]) => ServeGenealogy;
130
+ planInput?: ResolveCommandInput;
131
+ out?: (text: string) => void;
132
+ err?: (text: string) => void;
133
+ isInteractive?: () => boolean;
134
+ }
135
+ export interface TerminateAgentOptions {
136
+ /** Pid to stop — always the one from the pid file, never a stale handle. */
137
+ pid: number;
138
+ /** SIGTERM grace window before SIGKILL. */
139
+ graceMs: number;
140
+ alive: (pid: number) => boolean;
141
+ signal: (pid: number, signal: NodeJS.Signals) => void;
142
+ sleep: (ms: number) => Promise<void>;
143
+ out?: (text: string) => void;
144
+ /** Poll interval while waiting for the daemon to drain. */
145
+ pollMs?: number;
146
+ /** How long to wait after SIGKILL before giving up. */
147
+ killWaitMs?: number;
148
+ }
149
+ export type TerminateAgentOutcome = "stopped" | "killed" | "failed";
150
+ /**
151
+ * SIGTERM → wait out the drain → SIGKILL. Shared by `desktop stop` and by the
152
+ * start path's "it never came up, do not leave it half-started" branch — the
153
+ * old start path fired a bare SIGTERM and walked away, which left a detached
154
+ * daemon that ignored TERM (or won the race) running behind a failed command.
155
+ *
156
+ * The wait is bounded by a poll COUNT rather than a wall-clock deadline, so a
157
+ * caller with a synchronous `sleep` (tests) is both fast and deterministic.
158
+ */
159
+ export declare function terminateAgentPid(opts: TerminateAgentOptions): Promise<TerminateAgentOutcome>;
160
+ /**
161
+ * `spectral desktop [start|status|stop]`
162
+ *
163
+ * @returns process exit code; `cli.ts` assigns it to `process.exitCode`.
164
+ */
165
+ export declare function runDesktop(args?: string[], deps?: DesktopDeps): Promise<number>;
166
+ export {};
167
+ //# sourceMappingURL=desktop.d.ts.map