@aexol/spectral 0.9.211 → 0.9.215

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 (115) hide show
  1. package/dist/agent/agents.d.ts.map +1 -1
  2. package/dist/agent/agents.js +40 -0
  3. package/dist/backend/generated-images.d.ts +90 -0
  4. package/dist/backend/generated-images.d.ts.map +1 -0
  5. package/dist/backend/generated-images.js +214 -0
  6. package/dist/backend/machine-routing.d.ts +25 -0
  7. package/dist/backend/machine-routing.d.ts.map +1 -0
  8. package/dist/backend/machine-routing.js +35 -0
  9. package/dist/commands/serve.d.ts.map +1 -1
  10. package/dist/commands/serve.js +31 -6
  11. package/dist/extensions/browser/browser-service.d.ts +75 -1
  12. package/dist/extensions/browser/browser-service.d.ts.map +1 -1
  13. package/dist/extensions/browser/browser-service.js +600 -34
  14. package/dist/extensions/browser/index.d.ts +10 -4
  15. package/dist/extensions/browser/index.d.ts.map +1 -1
  16. package/dist/extensions/browser/index.js +44 -5
  17. package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
  18. package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
  19. package/dist/extensions/browser/stream/stream-tool.js +42 -14
  20. package/dist/extensions/browser/tools/core.d.ts.map +1 -1
  21. package/dist/extensions/browser/tools/core.js +63 -2
  22. package/dist/extensions/desktop-control/index.d.ts +55 -0
  23. package/dist/extensions/desktop-control/index.d.ts.map +1 -0
  24. package/dist/extensions/desktop-control/index.js +633 -0
  25. package/dist/extensions/desktop-control/keys.d.ts +33 -0
  26. package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
  27. package/dist/extensions/desktop-control/keys.js +170 -0
  28. package/dist/extensions/desktop-control/linux.d.ts +26 -0
  29. package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
  30. package/dist/extensions/desktop-control/linux.js +195 -0
  31. package/dist/extensions/desktop-control/macos.d.ts +53 -0
  32. package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
  33. package/dist/extensions/desktop-control/macos.js +382 -0
  34. package/dist/extensions/desktop-control/safety.d.ts +89 -0
  35. package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
  36. package/dist/extensions/desktop-control/safety.js +157 -0
  37. package/dist/extensions/desktop-control/types.d.ts +79 -0
  38. package/dist/extensions/desktop-control/types.d.ts.map +1 -0
  39. package/dist/extensions/desktop-control/types.js +10 -0
  40. package/dist/extensions/desktop-control/windows.d.ts +31 -0
  41. package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
  42. package/dist/extensions/desktop-control/windows.js +276 -0
  43. package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
  44. package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
  45. package/dist/extensions/desktop-screenshot/index.js +363 -17
  46. package/dist/extensions/image-generation/index.d.ts.map +1 -1
  47. package/dist/extensions/image-generation/index.js +133 -34
  48. package/dist/extensions/seo/index.d.ts.map +1 -1
  49. package/dist/extensions/seo/index.js +4 -0
  50. package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
  51. package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
  52. package/dist/extensions/seo/tools/backend-bridge.js +378 -0
  53. package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
  54. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  55. package/dist/extensions/seo/tools/full-audit.js +1 -1
  56. package/dist/extensions/seo/tools/report.d.ts +10 -0
  57. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  58. package/dist/extensions/seo/tools/report.js +1 -1
  59. package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
  60. package/dist/extensions/spectral-vision-fallback.js +11 -1
  61. package/dist/generated/zeus/const.d.ts.map +1 -1
  62. package/dist/generated/zeus/const.js +148 -4
  63. package/dist/generated/zeus/index.d.ts +649 -0
  64. package/dist/generated/zeus/index.d.ts.map +1 -1
  65. package/dist/mcp/tool-registrar.d.ts.map +1 -1
  66. package/dist/mcp/tool-registrar.js +5 -2
  67. package/dist/mcp/types.d.ts +5 -0
  68. package/dist/mcp/types.d.ts.map +1 -1
  69. package/dist/relay/client.d.ts +11 -0
  70. package/dist/relay/client.d.ts.map +1 -1
  71. package/dist/relay/client.js +24 -6
  72. package/dist/relay/dispatcher.d.ts +12 -1
  73. package/dist/relay/dispatcher.d.ts.map +1 -1
  74. package/dist/relay/dispatcher.js +44 -42
  75. package/dist/sdk/ai/providers/faux.js +1 -1
  76. package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
  77. package/dist/sdk/ai/providers/openai-completions.js +57 -16
  78. package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
  79. package/dist/sdk/ai/providers/openrouter-images.js +45 -9
  80. package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
  81. package/dist/sdk/ai/providers/simple-options.js +2 -0
  82. package/dist/sdk/ai/types.d.ts +37 -1
  83. package/dist/sdk/ai/types.d.ts.map +1 -1
  84. package/dist/sdk/ai/utils/image-source.d.ts +121 -0
  85. package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
  86. package/dist/sdk/ai/utils/image-source.js +410 -0
  87. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
  89. package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
  90. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  91. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  92. package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
  93. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
  94. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
  95. package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
  96. package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
  97. package/dist/server/agent-bridge.d.ts +14 -0
  98. package/dist/server/agent-bridge.d.ts.map +1 -1
  99. package/dist/server/agent-bridge.js +105 -26
  100. package/dist/server/handlers/queue.d.ts.map +1 -1
  101. package/dist/server/handlers/queue.js +11 -34
  102. package/dist/server/image-attachments.d.ts +109 -0
  103. package/dist/server/image-attachments.d.ts.map +1 -0
  104. package/dist/server/image-attachments.js +195 -0
  105. package/dist/server/session-stream.d.ts +69 -0
  106. package/dist/server/session-stream.d.ts.map +1 -1
  107. package/dist/server/session-stream.js +388 -32
  108. package/dist/server/storage.d.ts.map +1 -1
  109. package/dist/server/storage.js +10 -31
  110. package/dist/server/ui/terminal-confirm.d.ts +145 -0
  111. package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
  112. package/dist/server/ui/terminal-confirm.js +333 -0
  113. package/dist/server/wire.d.ts +30 -5
  114. package/dist/server/wire.d.ts.map +1 -1
  115. package/package.json +1 -1
@@ -34,6 +34,7 @@ import { mkdirSync, readFileSync } from "node:fs";
34
34
  import { dirname, join, resolve } from "node:path";
35
35
  import { getConfigDir } from "../config.js";
36
36
  import { stripJsoncComments } from "../studio-binding.js";
37
+ import { coerceImages, parseImagesJson } from "./image-attachments.js";
37
38
  /**
38
39
  * Schema version — informational, stamped into every new database.
39
40
  *
@@ -234,35 +235,6 @@ function applyBindingFields(project, rowBinding) {
234
235
  studioTeamId: binding.teamId,
235
236
  };
236
237
  }
237
- /** Tables we own — used by the migration drop step. */
238
- /** Best-effort parse of the `images_json` column into `ImageAttachment[]`. */
239
- function parseImagesJson(raw) {
240
- if (!raw || raw === "")
241
- return undefined;
242
- try {
243
- const parsed = JSON.parse(raw);
244
- if (!Array.isArray(parsed))
245
- return undefined;
246
- const out = [];
247
- for (const i of parsed) {
248
- if (i !== null &&
249
- typeof i === "object" &&
250
- typeof i.data === "string" &&
251
- typeof i.mimeType === "string") {
252
- out.push({
253
- data: i.data,
254
- mimeType: i.mimeType,
255
- width: typeof i.width === "number" ? i.width : undefined,
256
- height: typeof i.height === "number" ? i.height : undefined,
257
- });
258
- }
259
- }
260
- return out.length > 0 ? out : undefined;
261
- }
262
- catch {
263
- return undefined;
264
- }
265
- }
266
238
  export class SessionStore {
267
239
  path;
268
240
  db;
@@ -1182,7 +1154,14 @@ export class SessionStore {
1182
1154
  const createdAt = msg.createdAt ?? Date.now();
1183
1155
  const eventsJsonl = msg.eventsJsonl ?? "";
1184
1156
  const creditsUsed = msg.creditsUsed ?? null;
1185
- const imagesJson = msg.images && msg.images.length > 0 ? JSON.stringify(msg.images) : "";
1157
+ // Last line of defence: never persist an attachment that carries neither
1158
+ // a url nor data, and never persist megabytes of inline base64 — both
1159
+ // would bloat the DB and the history replay. Ingress paths reject these
1160
+ // earlier; here we only guarantee the invariant.
1161
+ const storedImages = coerceImages(msg.images, {
1162
+ onReject: (reason) => console.warn(`[spectral] warn: not persisting image for ${sessionId}: ${reason}`),
1163
+ });
1164
+ const imagesJson = storedImages && storedImages.length > 0 ? JSON.stringify(storedImages) : "";
1186
1165
  let sequence = 0;
1187
1166
  const tx = this.db.transaction(() => {
1188
1167
  // Idempotent sequence assignment. A rewrite of the same message id must
@@ -1203,7 +1182,7 @@ export class SessionStore {
1203
1182
  events: eventsJsonl,
1204
1183
  createdAt,
1205
1184
  sequence,
1206
- ...(msg.images && msg.images.length > 0 ? { images: msg.images } : {}),
1185
+ ...(storedImages && storedImages.length > 0 ? { images: storedImages } : {}),
1207
1186
  ...(creditsUsed != null ? { creditsUsed } : {}),
1208
1187
  };
1209
1188
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Terminal-native permission confirmations for `spectral serve`.
3
+ *
4
+ * In serve mode there is no TUI, but extensions (notably desktop-control)
5
+ * still call `ctx.ui.confirm(...)` to gate potentially harmful actions. The
6
+ * user is chatting via the web UI, while `spectral serve` itself runs in a
7
+ * real terminal — so the confirmation is rendered there, on serve's stdout,
8
+ * and answered with a single keypress.
9
+ *
10
+ * Behavior:
11
+ * - Non-TTY stdin: resolves `false` immediately (honest headless deny).
12
+ * - The prompt is a boxed render with a key legend: Enter/y = Allow,
13
+ * n/Esc/q = Deny, Ctrl+C = Deny (consumed as data in raw mode — it must
14
+ * never kill the serve process).
15
+ * - Stdin is put into raw mode for the duration of the prompt and always
16
+ * restored afterwards.
17
+ * - Title and message are sanitized before rendering: ANSI escape sequences
18
+ * and control characters are stripped (newlines collapse to spaces) so a
19
+ * malicious extension cannot inject terminal escapes.
20
+ * - Concurrent confirmations (multiple sessions share one terminal) are
21
+ * serialized through a module-level queue; queued requests print a short
22
+ * "waiting" notice.
23
+ * - Timeouts: `SPECTRAL_CONFIRM_TIMEOUT_MS` env (default 5 min) or the
24
+ * per-dialog `opts.timeout`. On timeout the confirmation resolves `false`.
25
+ * - `onOutcome` reports how the dialog ended exactly once, letting callers
26
+ * (e.g. desktop-control) distinguish an explicit user denial from
27
+ * auto-deny causes (timeout, non-TTY, abort) that must not lock sessions.
28
+ *
29
+ * The prompt logic is pure and takes injectable IO (`write` + `readKey`) so
30
+ * tests run without a TTY.
31
+ */
32
+ /** Injectable terminal IO for the confirmation prompt. */
33
+ export interface ConfirmPromptIO {
34
+ /** Write text to the terminal (serve's stdout). */
35
+ write(text: string): void;
36
+ /**
37
+ * Start listening for the next keypress and pass raw stdin data to
38
+ * `handler`. Returns a cancel function that must detach the listener and
39
+ * restore any terminal state (raw mode).
40
+ */
41
+ readKey(handler: (data: string) => void): () => void;
42
+ }
43
+ /** Minimal shape of a stdin stream the default IO implementation needs. */
44
+ export interface ConfirmStdin {
45
+ readonly isTTY?: boolean;
46
+ readonly isRaw?: boolean;
47
+ setRawMode?(mode: boolean): unknown;
48
+ on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
49
+ off(event: "data", listener: (chunk: Buffer | string) => void): unknown;
50
+ resume(): unknown;
51
+ }
52
+ export type ConfirmKeyAction = "allow" | "deny" | "ignore";
53
+ /**
54
+ * How a confirmation ended. `deny` is an explicit human decision (deny key);
55
+ * `timeout`, `no_tty` and `cancelled` mean the dialog ended without a user
56
+ * answer. Same values as `ExtensionConfirmOutcome`.
57
+ */
58
+ export type TerminalConfirmOutcome = "allow" | "deny" | "timeout" | "no_tty" | "cancelled";
59
+ /** Default confirmation timeout: 5 minutes. */
60
+ export declare const DEFAULT_CONFIRM_TIMEOUT_MS = 300000;
61
+ /**
62
+ * Classify a raw keypress. Only single-byte keys are acted on; multi-byte
63
+ * sequences (arrow keys, pastes) are ignored so they can't accidentally
64
+ * answer the prompt.
65
+ */
66
+ export declare function classifyConfirmKey(data: string): ConfirmKeyAction;
67
+ /**
68
+ * Strip ANSI escape sequences and control characters from extension-provided
69
+ * text. Newlines become spaces (the box renders one wrapped block); every
70
+ * other C0 control character (incl. \r and tab), DEL and C1 are removed, as
71
+ * is any stray ESC left over from an unterminated sequence. Keeps the prompt
72
+ * box and result lines safe against terminal escape injection.
73
+ */
74
+ export declare function sanitizeConfirmText(text: string): string;
75
+ /**
76
+ * Render the confirmation prompt as a boxed block of text (with trailing
77
+ * newline). Pure — no IO.
78
+ */
79
+ export declare function renderConfirmBox(title: string, message: string): string;
80
+ /** Read the confirmation timeout from the environment (ms). */
81
+ export declare function parseConfirmTimeoutMs(env?: NodeJS.ProcessEnv): number;
82
+ export interface TerminalConfirmOptions {
83
+ title: string;
84
+ message: string;
85
+ io: ConfirmPromptIO;
86
+ /** Whether stdin is an interactive TTY. Defaults to `process.stdin.isTTY`. */
87
+ isTTY?: boolean;
88
+ /** Timeout in ms. Defaults to `parseConfirmTimeoutMs()` (env-driven). */
89
+ timeoutMs?: number;
90
+ /** AbortSignal to programmatically deny the dialog. */
91
+ signal?: AbortSignal;
92
+ /** Called when the prompt is actually rendered (not while queued). */
93
+ onShown?: () => void;
94
+ /**
95
+ * Called exactly once with the final outcome, before the promise settles.
96
+ * `deny` means an explicit human denial; `timeout`/`no_tty`/`cancelled`
97
+ * are auto-deny causes (no user decision was made).
98
+ */
99
+ onOutcome?: (outcome: TerminalConfirmOutcome) => void;
100
+ }
101
+ /**
102
+ * Run a single terminal confirmation. Resolves `true` (allow) or `false`
103
+ * (deny / timeout / abort / non-TTY). Never throws.
104
+ */
105
+ export declare function runTerminalConfirm(options: TerminalConfirmOptions): Promise<boolean>;
106
+ /**
107
+ * Request a terminal confirmation, serialized with all other pending
108
+ * confirmations in this process. Queued requests print a short notice.
109
+ */
110
+ export declare function requestTerminalConfirm(options: TerminalConfirmOptions): Promise<boolean>;
111
+ /**
112
+ * Default IO bound to the serve process's real terminal: writes to stdout,
113
+ * reads raw keypresses from stdin (entering raw mode for the duration and
114
+ * restoring the previous mode on cancel).
115
+ */
116
+ export declare function createStdinConfirmIO(stdin?: ConfirmStdin): ConfirmPromptIO;
117
+ /** Wire event shape emitted when a confirm prompt is actually shown. */
118
+ export interface BridgeConfirmNotifyEvent {
119
+ type: "agent_notification";
120
+ message: string;
121
+ level: "info" | "warning" | "error";
122
+ }
123
+ /** Per-dialog options as passed by extensions (`ExtensionUIDialogOptions`). */
124
+ export interface BridgeConfirmDialogOptions {
125
+ /** AbortSignal to programmatically deny the dialog. */
126
+ signal?: AbortSignal;
127
+ /** Per-dialog timeout in ms (auto-deny when it fires). */
128
+ timeout?: number;
129
+ /** Outcome side-channel, forwarded to `runTerminalConfirm.onOutcome`. */
130
+ onOutcome?: (outcome: TerminalConfirmOutcome) => void;
131
+ }
132
+ /**
133
+ * Build the `confirm(title, message, opts?)` implementation used by the
134
+ * serve-mode headless UI context: routes through `requestTerminalConfirm`
135
+ * (cross-session queue), maps `opts.timeout` → `timeoutMs` and
136
+ * `opts.signal` → `signal`, forwards `opts.onOutcome` (auto-deny vs explicit
137
+ * user denial side-channel), and emits an `agent_notification` (level
138
+ * "warning") once the prompt is rendered so web users know to look at the
139
+ * terminal. `io` defaults to the real stdin IO; `isTTY` overrides the TTY
140
+ * probe (defaults to `process.stdin.isTTY`, i.e. headless-safe).
141
+ */
142
+ export declare function createBridgeConfirm(emit: (event: BridgeConfirmNotifyEvent) => void, io?: ConfirmPromptIO, opts?: {
143
+ isTTY?: boolean;
144
+ }): (title: string, message: string, dialog?: BridgeConfirmDialogOptions) => Promise<boolean>;
145
+ //# sourceMappingURL=terminal-confirm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terminal-confirm.d.ts","sourceRoot":"","sources":["../../../src/server/ui/terminal-confirm.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Terminal-native permission confirmations for `spectral serve`.
3
+ *
4
+ * In serve mode there is no TUI, but extensions (notably desktop-control)
5
+ * still call `ctx.ui.confirm(...)` to gate potentially harmful actions. The
6
+ * user is chatting via the web UI, while `spectral serve` itself runs in a
7
+ * real terminal — so the confirmation is rendered there, on serve's stdout,
8
+ * and answered with a single keypress.
9
+ *
10
+ * Behavior:
11
+ * - Non-TTY stdin: resolves `false` immediately (honest headless deny).
12
+ * - The prompt is a boxed render with a key legend: Enter/y = Allow,
13
+ * n/Esc/q = Deny, Ctrl+C = Deny (consumed as data in raw mode — it must
14
+ * never kill the serve process).
15
+ * - Stdin is put into raw mode for the duration of the prompt and always
16
+ * restored afterwards.
17
+ * - Title and message are sanitized before rendering: ANSI escape sequences
18
+ * and control characters are stripped (newlines collapse to spaces) so a
19
+ * malicious extension cannot inject terminal escapes.
20
+ * - Concurrent confirmations (multiple sessions share one terminal) are
21
+ * serialized through a module-level queue; queued requests print a short
22
+ * "waiting" notice.
23
+ * - Timeouts: `SPECTRAL_CONFIRM_TIMEOUT_MS` env (default 5 min) or the
24
+ * per-dialog `opts.timeout`. On timeout the confirmation resolves `false`.
25
+ * - `onOutcome` reports how the dialog ended exactly once, letting callers
26
+ * (e.g. desktop-control) distinguish an explicit user denial from
27
+ * auto-deny causes (timeout, non-TTY, abort) that must not lock sessions.
28
+ *
29
+ * The prompt logic is pure and takes injectable IO (`write` + `readKey`) so
30
+ * tests run without a TTY.
31
+ */
32
+ /** Default confirmation timeout: 5 minutes. */
33
+ export const DEFAULT_CONFIRM_TIMEOUT_MS = 300_000;
34
+ /**
35
+ * Classify a raw keypress. Only single-byte keys are acted on; multi-byte
36
+ * sequences (arrow keys, pastes) are ignored so they can't accidentally
37
+ * answer the prompt.
38
+ */
39
+ export function classifyConfirmKey(data) {
40
+ if (data.length !== 1)
41
+ return "ignore";
42
+ if (data === "\r" || data === "\n" || data === "y" || data === "Y") {
43
+ return "allow";
44
+ }
45
+ if (data === "n" ||
46
+ data === "N" ||
47
+ data === "q" ||
48
+ data === "Q" ||
49
+ data === "\x1b" /* Esc */ ||
50
+ data === "\x03" /* Ctrl+C */) {
51
+ return "deny";
52
+ }
53
+ return "ignore";
54
+ }
55
+ const BOX_MAX_WIDTH = 72;
56
+ /**
57
+ * Complete ANSI escape sequences: CSI (…final byte), OSC (BEL- or ST-
58
+ * terminated) and generic two-byte ESC sequences (e.g. ESC c = reset).
59
+ * CSI/OSC are tried first so their full payload is consumed.
60
+ */
61
+ const ANSI_ESCAPE_RE = /\x1b(?:\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-~])/g;
62
+ /**
63
+ * Strip ANSI escape sequences and control characters from extension-provided
64
+ * text. Newlines become spaces (the box renders one wrapped block); every
65
+ * other C0 control character (incl. \r and tab), DEL and C1 are removed, as
66
+ * is any stray ESC left over from an unterminated sequence. Keeps the prompt
67
+ * box and result lines safe against terminal escape injection.
68
+ */
69
+ export function sanitizeConfirmText(text) {
70
+ return text
71
+ .replace(ANSI_ESCAPE_RE, "")
72
+ .replace(/\x1b/g, "")
73
+ .replace(/\n/g, " ")
74
+ .replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, "");
75
+ }
76
+ /** Greedy word-wrap for a single line, fitting `width` columns. */
77
+ function wrapLine(line, width) {
78
+ if (line.length <= width)
79
+ return [line];
80
+ const words = line.split(" ");
81
+ const out = [];
82
+ let current = "";
83
+ for (const word of words) {
84
+ if (current.length === 0) {
85
+ current = word.slice(0, width);
86
+ }
87
+ else if (current.length + 1 + word.length <= width) {
88
+ current += ` ${word}`;
89
+ }
90
+ else {
91
+ out.push(current);
92
+ current = word.slice(0, width);
93
+ }
94
+ }
95
+ if (current.length > 0)
96
+ out.push(current);
97
+ return out.length > 0 ? out : [""];
98
+ }
99
+ /**
100
+ * Render the confirmation prompt as a boxed block of text (with trailing
101
+ * newline). Pure — no IO.
102
+ */
103
+ export function renderConfirmBox(title, message) {
104
+ const cleanTitle = sanitizeConfirmText(title);
105
+ const cleanMessage = sanitizeConfirmText(message);
106
+ const legend = "Enter/y = Allow n/Esc/q = Deny Ctrl+C = Deny";
107
+ const header = `⚠ Confirmation required · ${cleanTitle}`;
108
+ const innerWidth = Math.min(BOX_MAX_WIDTH, Math.max(header.length, legend.length, 40));
109
+ const lines = [header, ""];
110
+ for (const raw of cleanMessage.split(/\r?\n/)) {
111
+ lines.push(...wrapLine(raw, innerWidth));
112
+ }
113
+ lines.push("", legend);
114
+ const pad = (line) => line + " ".repeat(Math.max(0, innerWidth - line.length));
115
+ const border = "─".repeat(innerWidth + 2);
116
+ const body = lines.map((line) => `│ ${pad(line)} │`).join("\n");
117
+ return `┌${border}┐\n${body}\n└${border}┘\n`;
118
+ }
119
+ /** Read the confirmation timeout from the environment (ms). */
120
+ export function parseConfirmTimeoutMs(env = process.env) {
121
+ const raw = env.SPECTRAL_CONFIRM_TIMEOUT_MS;
122
+ if (raw === undefined || raw === "")
123
+ return DEFAULT_CONFIRM_TIMEOUT_MS;
124
+ const parsed = Number(raw);
125
+ if (!Number.isFinite(parsed) || parsed <= 0) {
126
+ return DEFAULT_CONFIRM_TIMEOUT_MS;
127
+ }
128
+ return parsed;
129
+ }
130
+ /**
131
+ * Run a single terminal confirmation. Resolves `true` (allow) or `false`
132
+ * (deny / timeout / abort / non-TTY). Never throws.
133
+ */
134
+ export async function runTerminalConfirm(options) {
135
+ const { title, message, io } = options;
136
+ const isTTY = options.isTTY ?? process.stdin.isTTY === true;
137
+ if (!isTTY) {
138
+ options.onOutcome?.("no_tty");
139
+ return false;
140
+ }
141
+ if (options.signal?.aborted) {
142
+ options.onOutcome?.("cancelled");
143
+ return false;
144
+ }
145
+ // Fail-closed: a broken terminal must deny the request instead of throwing
146
+ // (callers and the cross-session queue rely on this resolving).
147
+ try {
148
+ io.write(renderConfirmBox(title, message));
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ try {
154
+ options.onShown?.();
155
+ }
156
+ catch {
157
+ /* onShown is a side-channel (e.g. wire emit) — never block the prompt. */
158
+ }
159
+ const safeTitle = sanitizeConfirmText(title);
160
+ return await new Promise((resolve) => {
161
+ let settled = false;
162
+ let cancelRead;
163
+ const timeoutMs = options.timeoutMs ?? parseConfirmTimeoutMs();
164
+ const finish = (result, notice, outcome) => {
165
+ if (settled)
166
+ return;
167
+ settled = true;
168
+ clearTimeout(timer);
169
+ options.signal?.removeEventListener("abort", onAbort);
170
+ cancelRead?.();
171
+ options.onOutcome?.(outcome ?? (result ? "allow" : "deny"));
172
+ try {
173
+ if (notice)
174
+ io.write(`${notice}\n`);
175
+ io.write(result ? `✓ Allowed: ${safeTitle}\n\n` : `✗ Denied: ${safeTitle}\n\n`);
176
+ }
177
+ catch {
178
+ /* terminal may be gone — the promise must still settle */
179
+ }
180
+ resolve(result);
181
+ };
182
+ const onKey = (data) => {
183
+ const action = classifyConfirmKey(data);
184
+ if (action === "allow")
185
+ finish(true);
186
+ else if (action === "deny")
187
+ finish(false);
188
+ // "ignore": keep waiting for a meaningful key.
189
+ };
190
+ const onAbort = () => finish(false, "Confirmation aborted — denied.", "cancelled");
191
+ const timer = setTimeout(() => {
192
+ finish(false, `⏰ Confirmation timed out after ${timeoutMs}ms — denied by default.`, "timeout");
193
+ }, timeoutMs);
194
+ // A pending confirmation must not keep the serve process alive on its own.
195
+ if (typeof timer.unref === "function") {
196
+ timer.unref();
197
+ }
198
+ options.signal?.addEventListener("abort", onAbort, { once: true });
199
+ try {
200
+ cancelRead = io.readKey(onKey);
201
+ }
202
+ catch {
203
+ // Could not attach a key listener: deny now instead of hanging until
204
+ // the timeout with no way for the user to answer.
205
+ finish(false, undefined, "cancelled");
206
+ }
207
+ });
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // Cross-session serialization
211
+ //
212
+ // All sessions in one serve process share a single terminal, so confirmations
213
+ // are queued behind each other at the module level.
214
+ // ---------------------------------------------------------------------------
215
+ let queueTail = Promise.resolve();
216
+ let pendingConfirmations = 0;
217
+ const QUEUED_NOTICE = "⏳ Another confirmation is pending in the terminal — request queued.\n";
218
+ /**
219
+ * Request a terminal confirmation, serialized with all other pending
220
+ * confirmations in this process. Queued requests print a short notice.
221
+ */
222
+ export function requestTerminalConfirm(options) {
223
+ const position = pendingConfirmations;
224
+ pendingConfirmations++;
225
+ // `run` is always chained onto the queue before any side-effectful work
226
+ // (like the queued notice), so a throwing write can never leave the
227
+ // counter inflated. It never rejects: fail-closed deny.
228
+ const run = async () => {
229
+ try {
230
+ return await runTerminalConfirm(options);
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ finally {
236
+ pendingConfirmations--;
237
+ }
238
+ };
239
+ const result = queueTail.then(run, run);
240
+ queueTail = result.then(() => undefined, () => undefined);
241
+ if (position > 0) {
242
+ try {
243
+ options.io.write(QUEUED_NOTICE);
244
+ }
245
+ catch {
246
+ /* cosmetic notice — the queued confirm still runs */
247
+ }
248
+ }
249
+ return result;
250
+ }
251
+ /**
252
+ * Default IO bound to the serve process's real terminal: writes to stdout,
253
+ * reads raw keypresses from stdin (entering raw mode for the duration and
254
+ * restoring the previous mode on cancel).
255
+ */
256
+ export function createStdinConfirmIO(stdin = process.stdin) {
257
+ return {
258
+ write: (text) => {
259
+ process.stdout.write(text);
260
+ },
261
+ readKey(handler) {
262
+ const wasRaw = stdin.isRaw === true;
263
+ if (stdin.isTTY && typeof stdin.setRawMode === "function") {
264
+ try {
265
+ stdin.setRawMode(true);
266
+ }
267
+ catch {
268
+ // Raw mode is what turns Ctrl+C into a plain deny key instead of
269
+ // SIGINT. Without it the "Ctrl+C = Deny" legend is a lie — say so.
270
+ process.stderr.write("⚠ Terminal confirm: could not enable raw mode — Ctrl+C may terminate the process instead of denying.\n");
271
+ }
272
+ }
273
+ const onData = (chunk) => {
274
+ handler(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
275
+ };
276
+ const cancel = () => {
277
+ stdin.off("data", onData);
278
+ if (stdin.isTTY && typeof stdin.setRawMode === "function") {
279
+ try {
280
+ stdin.setRawMode(wasRaw);
281
+ }
282
+ catch {
283
+ /* best-effort restore */
284
+ }
285
+ }
286
+ };
287
+ try {
288
+ stdin.on("data", onData);
289
+ stdin.resume();
290
+ }
291
+ catch {
292
+ // Attach/resume failed (e.g. stdin closed mid-flight): detach the
293
+ // listener and restore raw mode right away, but still hand back the
294
+ // cancel function so callers can never leak.
295
+ cancel();
296
+ }
297
+ return cancel;
298
+ },
299
+ };
300
+ }
301
+ /**
302
+ * Build the `confirm(title, message, opts?)` implementation used by the
303
+ * serve-mode headless UI context: routes through `requestTerminalConfirm`
304
+ * (cross-session queue), maps `opts.timeout` → `timeoutMs` and
305
+ * `opts.signal` → `signal`, forwards `opts.onOutcome` (auto-deny vs explicit
306
+ * user denial side-channel), and emits an `agent_notification` (level
307
+ * "warning") once the prompt is rendered so web users know to look at the
308
+ * terminal. `io` defaults to the real stdin IO; `isTTY` overrides the TTY
309
+ * probe (defaults to `process.stdin.isTTY`, i.e. headless-safe).
310
+ */
311
+ export function createBridgeConfirm(emit, io = createStdinConfirmIO(), opts) {
312
+ return (title, message, dialog) => requestTerminalConfirm({
313
+ title,
314
+ message,
315
+ io,
316
+ isTTY: opts?.isTTY,
317
+ timeoutMs: dialog?.timeout,
318
+ signal: dialog?.signal,
319
+ onOutcome: dialog?.onOutcome,
320
+ onShown: () => {
321
+ try {
322
+ emit({
323
+ type: "agent_notification",
324
+ message: `Approval required in terminal: ${title}`,
325
+ level: "warning",
326
+ });
327
+ }
328
+ catch {
329
+ /* best-effort — the prompt must still be shown */
330
+ }
331
+ },
332
+ });
333
+ }
@@ -9,10 +9,35 @@
9
9
  * Naming: client → server messages are `ClientMessage`, server → client
10
10
  * frames are `ServerEvent`. JSON-encoded one-per-frame on the wire.
11
11
  */
12
- /** Base64-encoded image attached to a user message. */
12
+ /**
13
+ * Image attached to a user message.
14
+ *
15
+ * Two carriers, in order of preference:
16
+ *
17
+ * 1. `url` — the image was uploaded to backend storage (S3) and only the
18
+ * pointer travels over the relay. **Preferred**: a screenshot is worth
19
+ * megabytes of base64, a url is ~40 characters.
20
+ * 2. `data` — inline base64 (no data URI prefix). Kept for backward
21
+ * compatibility with small/legacy attachments; rejected above
22
+ * `MAX_INLINE_IMAGE_BASE64_CHARS` (measured on the base64 string, i.e.
23
+ * on the wire payload) so the relay never carries megabytes.
24
+ *
25
+ * At least one of `data` / `url` must be present.
26
+ */
13
27
  export interface ImageAttachment {
14
- /** Raw base64 image data (no data URI prefix). */
15
- data: string;
28
+ /**
29
+ * Raw base64 image data (no data URI prefix). Legacy/small images only —
30
+ * hard-capped at `MAX_INLINE_IMAGE_BASE64_CHARS` (768 KiB of base64) at
31
+ * the wire boundary.
32
+ */
33
+ data?: string;
34
+ /**
35
+ * Hosted image url: backend-relative (`/generated-images/<id>`) or
36
+ * absolute. Preferred over `data` — when both are set the url wins.
37
+ */
38
+ url?: string;
39
+ /** Backend image id, when the attachment came from `uploadImage()`. */
40
+ imageId?: string;
16
41
  /** MIME type, e.g. "image/jpeg" or "image/png". */
17
42
  mimeType: string;
18
43
  /** Compressed width in pixels (optional). */
@@ -30,7 +55,7 @@ export interface WireMessage {
30
55
  createdAt: number;
31
56
  /** Stable per-session insertion sequence used for server-authoritative ordering. */
32
57
  sequence?: number;
33
- /** Base64 images attached to user messages (empty for assistant/system). */
58
+ /** Image attachments on user messages (hosted `url` or inline base64; empty for assistant/system). */
34
59
  images?: ImageAttachment[];
35
60
  /** Credits consumed by this message, persisted independently of the events blob (assistant only). */
36
61
  creditsUsed?: number | null;
@@ -285,7 +310,7 @@ export type ClientMessage = {
285
310
  content: string;
286
311
  /** Enable autonomous iterative loop for this message (Ralph Wiggum pattern). */
287
312
  loop?: boolean;
288
- /** Optional base64-encoded images attached to the message. */
313
+ /** Optional image attachments (hosted `url` preferred, inline base64 legacy). */
289
314
  images?: ImageAttachment[];
290
315
  /** Optional loop max iterations (server caps at MAX_LOOP_ITERATIONS). */
291
316
  loopMaxIterations?: number;