scalix 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  All notable changes to Scalix Coder will be documented in this file.
4
4
 
5
+ ## [1.6.0] - 2026-11-11
6
+
7
+ ### Added
8
+ - **`/marketplace` — the Scalix curated plugin catalog.** Browse, inspect and install catalog listings from the TUI: MCP servers into `mcp-services.json`, skills into `.scalix/skills/`, rules into `.scalix/rules/`, subagents into `.scalix/agents/`. Every listing is re-validated client-side before anything touches disk (https-only public-host remote entries, bare stdio command names, relative file paths, env-var names only — never credential values), stdio installs require explicit confirmation, and install telemetry is best-effort and anonymous. New module `src/marketplace.ts`; 21 unit tests.
9
+
10
+ ### Security
11
+ - **Repo-supplied slash commands are trust-gated and run with a sanitized environment.** `.scalix/commands/*.md` templates were loaded in every front end without the workspace-trust check that plugins and hooks get, and their `` !`cmd` `` inline-bash expansions executed with the full parent environment — API keys readable. Inline bash now runs with `sanitizeEnvForCommand()`, loading is gated on `isWorkspaceTrusted()` (with a skip notice), and a repo carrying `commands/` now triggers the trust prompt. (Audit F1)
12
+ - **`browser_session_save` / `restore` reject traversal names.** The `name` argument became a filename via `path.join` without validation, so `../../..` wrote and read JSON outside the sandbox. Names are now restricted to `[A-Za-z0-9._-]` at the schema and in `saveSessionState`. (F2)
13
+ - **`search_files` ripgrep fast path is sandbox-contained.** The rg branch took any absolute `path` and read it; the JS fallback already validated. The target now goes through `sandboxPath()` before any backend runs, with rg output relativized to preserve the old shape. (F3)
14
+ - **Project `settings.local.json` can no longer escalate permissions.** A trusted repo could set `permissionMode: "yolo"`, reclassify tools via `toolOverrides`, disable blocklist/injection patterns, or add scoped "allow" rules. Project files may now only tighten: `plan` mode, deny-only scoped rules, and a whitelist of tightening security fields; escalation attempts are ignored with a notice. Managed settings (`managed-settings.json`) are now validated with the same validators as user config. (F4)
15
+ - **`@file` prompt references are sandbox-contained.** `@/abs/path.ext` in a prompt read any file on disk in the TUI and was attacker-influenceable via `--pipe`; both `agent.ts` and `commands.ts` expansion now route through the sandbox check. (F5)
16
+ - **Alibaba Cloud metadata IP `100.100.100.200` is treated as private** by `web_fetch` and the browser tools — it sits outside every previously-listed range and exposed instance credentials on ECS. (F9)
17
+ - MCP `serverUrl` values are redacted (userinfo stripped) before logging; MCP fetch now downgrades 301/302/303 redirects of POST requests to GET and drops the body per the fetch spec. (F12 + redirect semantics)
18
+ - IPC `setPermissionMode` validates the mode against `PERMISSION_MODES` instead of applying any string. (F14)
19
+
20
+ ### Fixed
21
+ - **A user abort no longer pollutes the model circuit breaker.** Aborting mid-request was recorded as a model failure — five Escapes within the reset window silently rotated the model. The abort path now returns `'[interrupted]'` consistently whether it lands between tool calls or mid-request. (Audit: agent abort contract)
22
+ - **Retry backoff, stream-fallback waits, and tool-confirmation prompts are abort-aware.** Cancelling during a Retry-After wait (up to 60s) or a pending confirmation (up to 300s) returns immediately instead of parking the loop.
23
+ - **Conflicted sub-agent worktree merges keep their branch.** Teardown ran `git branch -D` unconditionally, destroying the sub-agent's unmerged commits right after reporting the conflict; removal now uses a safe delete and keeps unmerged `scalix-agent/*` branches for recovery. A failed merge probe is reported as a failure (with `error`) instead of `{merged: true, commitCount: 0}`.
24
+ - **`/delegate` works on BYOK sessions.** The headless sub-agent config omitted `provider` (and cloud credentials), so the sub-agent called the Scalix URL shape against a third-party base URL and 404'd on every request.
25
+ - **Per-request timeout defaults to 300s.** The comment promised it but unset `SCALIX_REQUEST_TIMEOUT_MS` meant *no* timeout; a wedged connection hung the turn forever.
26
+ - The capability probe (`streamsToolCalls`) can no longer fail a turn — errors default to the conservative non-streaming answer.
27
+ - `clearHistory()` / `loadMessages()` reset the output-format instruction index, so a `/load` followed by a JSON-format request can no longer overwrite an unrelated system message.
28
+ - Streaming TTFT and per-turn metrics reset each loop iteration, so `turn:summary` reports the turn that produced the final response.
29
+ - `scalix run` no longer double-prints errors (REPORTED_ERROR dedup applied to headless).
30
+
31
+ ### VS Code extension
32
+ - **Fixed the send dead-latch.** The chat panel set the tab's `isRunning` flag before reading it, so the direct-send branch was dead code: every message queued as "Queued: …" and was only released when an unrelated engine status event arrived.
33
+ - **Chat tabs stay in sync with the engine.** Switching tabs now posts `switchSession` (the host previously kept sending to the previous tab's session, and background-tab streams were dropped); `send`/`abort` honor the message's own `sessionId` with the host value as fallback. Queued messages now carry their images through the queue.
34
+ - **Feedback is actually stored.** The submit-feedback flow thanked the user and discarded everything; it now records through `FeedbackService` (local history, capped at 100 — nothing leaves the machine).
35
+ - **Tool-approval handler cleanup on dispose.** With a sidebar and an editor-tab chat open, closing the editor tab left its dead confirm handler registered, so in-panel approvals silently stopped for the surviving surface.
36
+ - `scalix.cliPath`, `scalix.baseUrl`, `scalix.openaiBaseUrl`, `scalix.provider`, and `scalix.permissionMode` are now machine-scoped — a repo's `.vscode/settings.json` can no longer point the engine spawn at arbitrary code or redirect the API key's destination endpoint.
37
+
38
+ ### Known issues (from the 2026-10-14 audit, not yet addressed)
39
+ - The webview's per-tab `innerHTML` cache destroys event listeners on restored tabs (dead confirm-card buttons after a tab round-trip) — needs the data-driven re-render refactor.
40
+ - Background-tab stream events are still dropped rather than buffered per session.
41
+ - The OS command sandbox (seatbelt/bwrap) remains opt-in and default-off; the regex command blocklist is explicitly not a security boundary.
42
+ - DNS-rebinding TOCTOU remains in `web_fetch`/MCP fetch (validation and fetch resolve DNS independently).
43
+ - `vscode-extension/tests` are still not wired into either publish workflow.
44
+
5
45
  ## [1.5.1] - 2026-08-08
6
46
 
7
47
  ### Added
package/README.md CHANGED
@@ -99,6 +99,7 @@ First launch opens a setup wizard to configure your Scalix API key, endpoint, an
99
99
  | `SCALIX_MODEL` | Comma-separated model IDs |
100
100
  | `SCALIX_BASE_URL` | API base URL |
101
101
  | `SCALIX_PROJECT` | Project ID for org-wide keys (sent as `x-scalix-project` on platform tool calls) |
102
+ | `SCALIX_COMMAND_SANDBOX` | OS-level command sandbox: `off` \| `filesystem` \| `strict` (overrides config) |
102
103
  | `BRAVE_API_KEY` | Brave Search API key |
103
104
  | `TAVILY_API_KEY` | Tavily Search API key |
104
105
 
@@ -122,6 +123,7 @@ scalix run "generate a migration" --output migration.sql --format patch
122
123
  | `/models` | List active models |
123
124
  | `/model` | Switch model |
124
125
  | `/mode` | Set permission mode |
126
+ | `/sandbox` | Set the OS command sandbox (off \| filesystem \| strict) |
125
127
  | `/compact` | Compress context |
126
128
  | `/cost` | Token usage & cost |
127
129
  | `/budget` | Budget status |
@@ -224,7 +226,9 @@ scalix run "generate a migration" --output migration.sql --format patch
224
226
  | `notebook_edit` | Jupyter notebook operations |
225
227
  | `monitor` | Background process output |
226
228
  | `agent` | Spawn sub-agents |
227
- | `add_mcp_server` | Register MCP servers |
229
+ | `write_artifact` | Write a reviewable plan / walkthrough / tasklist / summary to `.scalix/artifacts/` |
230
+ | `list_artifacts` | List previously written artifacts (newest first) |
231
+ | `add_mcp_server` | Register MCP servers |
228
232
 
229
233
  ## Configuration
230
234
 
@@ -255,6 +259,25 @@ Config is stored in `~/.scalix-coder/config.json`.
255
259
  | `.scalix/rules/*.md` | Scoped rules |
256
260
  | `mcp-services.json` | MCP server config |
257
261
 
262
+ ### Command sandbox (opt-in)
263
+
264
+ `run_command` can execute inside a kernel-enforced sandbox — the OS-level wall
265
+ behind the logical checks (blocklist, injection detection, path containment).
266
+ Set `commandSandbox` in `~/.scalix-coder/config.json` (or `SCALIX_COMMAND_SANDBOX`):
267
+
268
+ | Mode | What it does |
269
+ | ------------ | ------------ |
270
+ | `off` | Default. No sandbox — behaviour unchanged. |
271
+ | `filesystem` | macOS seatbelt / Linux bubblewrap: writes confined to the workspace + system temp, reads unrestricted, network allowed. |
272
+ | `strict` | `filesystem` plus no network (unshared netns / seatbelt `deny network*`). For untrusted builds. |
273
+
274
+ Notes: Windows is not supported — with a mode set there, commands fail closed
275
+ with the reason instead of running unsandboxed. Package managers that cache in
276
+ `$HOME` (pip, cargo) will fail cache writes in `filesystem` mode; that is the
277
+ wall working. The backend binary must exist (`sandbox-exec` on macOS ships with
278
+ the OS; `bwrap` must be installed on Linux) — a missing backend is reported,
279
+ never skipped.
280
+
258
281
  ### MCP servers
259
282
 
260
283
  Servers listed in `mcp-services.json` (project root, or `.scalix/` as a
@@ -284,6 +307,26 @@ is accepted too, so an existing config can be dropped in unchanged.
284
307
  - MCP tools are external, so they require confirmation under the default
285
308
  permission mode.
286
309
 
310
+ ### Scalix Marketplace (`/marketplace`)
311
+
312
+ Browse the curated plugin catalog and install it in one command:
313
+
314
+ ```
315
+ /marketplace featured listings
316
+ /marketplace <query> search the catalog
317
+ /marketplace info <id> detail + what an install would write
318
+ /marketplace install <id> install (stdio MCP needs `--confirm`)
319
+ ```
320
+
321
+ Four artifact kinds are installed into this workspace: **MCP servers**
322
+ (`mcp-services.json`), **skills** (`.scalix/skills/<name>/`), **rules**
323
+ (`.scalix/rules/`) and **subagents** (`.scalix/agents/`). Every listing carries
324
+ a trust tier — ★ official, ✔ verified, · community — and an env-var reference
325
+ is stored by NAME only (you set the value in your environment). The catalog is
326
+ fetched from `https://api.scalix.world/v1/marketplace` (override with
327
+ `SCALIX_MARKETPLACE_URL`); payloads are re-validated before anything is
328
+ written to disk. Full contract: `scalix-cloud/docs/MARKETPLACE.md`.
329
+
287
330
  ## VS Code Extension
288
331
 
289
332
  Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) — or from [Open VSX](https://open-vsx.org/extension/scalix/scalix) if you use Cursor, Windsurf, or VSCodium:
package/dist/agent.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ApiError } from './api-client.js';
2
2
  import { EventEmitter } from 'eventemitter3';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { getToolPermissionWithArgs, getMaxImageSize } from './sandbox.js';
4
+ import { getToolPermissionWithArgs, getMaxImageSize, sandboxPathAsync } from './sandbox.js';
5
5
  import { logger } from './logger.js';
6
6
  import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, CONTEXT_DEFAULTS, SCALIX_PLATFORM_SECTION, SCALIX_CLOUD_RESOURCES_SECTION, SCALIX_DEPLOY_PLAYBOOK_SECTION, SCALIX_PLATFORM_PLAYBOOK_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.js';
7
7
  import { estimateMessageTokens as estimateTokenCount, estimateTokens } from './token-counter.js';
@@ -12,6 +12,8 @@ import { AgentReplay } from './agent-replay.js';
12
12
  import { loadHooks, runHooks } from './hooks.js';
13
13
  import { isWorkspaceTrusted } from './workspace-trust.js';
14
14
  import { setAgentToolConfig } from './tools-advanced.js';
15
+ import { setCommandSandboxMode } from './tools.js';
16
+ import { resolveCommandSandboxMode } from './command-sandbox.js';
15
17
  import { AgentRegistry, generateAgentId } from './agent-registry.js';
16
18
  import { getPreset } from './agent-presets.js';
17
19
  import { runVerification, VERIFY_DEFAULTS } from './auto-verify.js';
@@ -170,7 +172,13 @@ export class Agent extends EventEmitter {
170
172
  configuredContextTokens;
171
173
  toolSelector = null;
172
174
  _aborted = false;
175
+ // Resolved when abort() fires — lets in-flight sleeps (retry backoff, tool
176
+ // confirmation) return immediately instead of ignoring cancellation.
177
+ _abortWaiters = new Set();
173
178
  _thinkingActive = false;
179
+ // Reasoning text accumulated across the current turn (all model calls) —
180
+ // what "what is the agent thinking" renders from.
181
+ _reasoningText = '';
174
182
  replay;
175
183
  boardRefresher;
176
184
  boardPrimed;
@@ -250,6 +258,11 @@ export class Agent extends EventEmitter {
250
258
  workspaceRoot: config.workspaceRoot ?? process.cwd(),
251
259
  };
252
260
  this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
261
+ // OS-level command sandbox: the tool module is process-global, so every
262
+ // front end (CLI, IPC sessions, headless) inherits the mode the moment an
263
+ // agent is constructed. SCALIX_COMMAND_SANDBOX overrides the config value.
264
+ // Per-session override is deliberate — this is machine policy, not chat state.
265
+ setCommandSandboxMode(resolveCommandSandboxMode(config.commandSandbox, process.env.SCALIX_COMMAND_SANDBOX));
253
266
  this.contextManager = new ContextManager(config.contextManager);
254
267
  // Kept separately because syncContextLimit() overwrites the manager's own
255
268
  // value on every read and would otherwise have nothing to fall back to.
@@ -451,8 +464,14 @@ export class Agent extends EventEmitter {
451
464
  this.messages = [];
452
465
  this.verifyFailCounts.clear();
453
466
  this.modelFailures.clear();
467
+ // The old index points into the discarded history — keeping it would make
468
+ // the next addOutputFormatInstructions() overwrite an unrelated message.
469
+ this.outputFormatInstructionIndex = null;
470
+ }
471
+ loadMessages(messages) {
472
+ this.messages = [...messages];
473
+ this.outputFormatInstructionIndex = null;
454
474
  }
455
- loadMessages(messages) { this.messages = [...messages]; }
456
475
  getInstructions() { return this.config.instructions; }
457
476
  setInstructions(instructions) { this.config.instructions = instructions; }
458
477
  getPermissionMode() { return this.config.permissionMode; }
@@ -900,6 +919,12 @@ export class Agent extends EventEmitter {
900
919
  return {
901
920
  apiKey: this.config.apiKey,
902
921
  baseUrl: this.client.baseURL,
922
+ // Sub-agents must inherit the provider itself, not just its URL: without
923
+ // it a BYOK session's sub-agent would default to provider 'scalix' while
924
+ // keeping the third-party baseUrl and 404 on <byok-host>/v1/ai/...
925
+ provider: this.providerId,
926
+ cloudApiKey: this.cloudApiKey,
927
+ cloudBaseUrl: this.cloudBaseUrl,
903
928
  models: this.config.models,
904
929
  permissionMode: this.config.permissionMode,
905
930
  confirmToolCall: this.config.confirmToolCall,
@@ -911,8 +936,26 @@ export class Agent extends EventEmitter {
911
936
  this._abortController.abort();
912
937
  this._abortController = null;
913
938
  }
939
+ const waiters = [...this._abortWaiters];
940
+ this._abortWaiters.clear();
941
+ for (const w of waiters)
942
+ w();
914
943
  this.emitThinkingEnd();
915
944
  }
945
+ /**
946
+ * Sleep that returns early when abort() fires. Unlike a bare setTimeout,
947
+ * cancellation during retry backoff or a confirmation wait is observed
948
+ * immediately rather than after the full delay.
949
+ */
950
+ abortableSleep(ms) {
951
+ if (this._aborted)
952
+ return Promise.resolve();
953
+ return new Promise((resolve) => {
954
+ const waiter = () => { clearTimeout(timer); resolve(); };
955
+ const timer = setTimeout(() => { this._abortWaiters.delete(waiter); resolve(); }, ms);
956
+ this._abortWaiters.add(waiter);
957
+ });
958
+ }
916
959
  removeModel(model) {
917
960
  const idx = this.config.models.indexOf(model);
918
961
  if (idx !== -1 && this.config.models.length > 1) {
@@ -935,7 +978,11 @@ export class Agent extends EventEmitter {
935
978
  const imageParts = [];
936
979
  for (const filePath of matches) {
937
980
  try {
938
- const resolved = path.resolve(process.cwd(), filePath);
981
+ // @file mentions expand file contents into the prompt — route them
982
+ // through the same containment as read_file, and resolve against the
983
+ // configured workspace root (not necessarily process.cwd()).
984
+ const root = this.config.workspaceRoot ?? process.cwd();
985
+ const resolved = await sandboxPathAsync(path.resolve(root, filePath));
939
986
  if (isImageFile(filePath)) {
940
987
  const dataUrl = await encodeImageToDataUrl(resolved);
941
988
  imageParts.push({ type: 'image_url', image_url: { url: dataUrl, detail: 'auto' } });
@@ -1051,6 +1098,7 @@ export class Agent extends EventEmitter {
1051
1098
  this.messages.push(userMessage);
1052
1099
  this.emit('message:user', userMessage);
1053
1100
  this._thinkingActive = true;
1101
+ this._reasoningText = '';
1054
1102
  this.emit('thinking:start');
1055
1103
  this.truncateMessages();
1056
1104
  this.replay.record('user_message', { content });
@@ -1137,6 +1185,15 @@ export class Agent extends EventEmitter {
1137
1185
  }
1138
1186
  this.checkInbox();
1139
1187
  turnCount++;
1188
+ // Per-turn metrics reset here so streaming TTFT (and token/duration
1189
+ // counters) reflect the turn that actually produced the final response,
1190
+ // not the first turn of the request.
1191
+ _turnToolDurationMs = 0;
1192
+ _turnLlmStartMs = 0;
1193
+ _turnTtftMs = 0;
1194
+ _firstDeltaSeen = false;
1195
+ _turnInputTokens = 0;
1196
+ _turnOutputTokens = 0;
1140
1197
  let fullText = '';
1141
1198
  const toolCalls = new Map();
1142
1199
  try {
@@ -1211,8 +1268,10 @@ export class Agent extends EventEmitter {
1211
1268
  // An endpoint that does not carry tool calls over SSE would answer an
1212
1269
  // agentic turn in prose and lose the calls, so those turns go
1213
1270
  // non-streaming there and the user watches a spinner instead of output.
1214
- // Text-only turns stream either way.
1215
- const useStreaming = !forceNonStreaming && (await this.streamsToolCalls()
1271
+ // Text-only turns stream either way. The probe must never fail a turn:
1272
+ // any error defaults to the conservative non-streaming answer.
1273
+ const streams = await this.streamsToolCalls().catch(() => false);
1274
+ const useStreaming = !forceNonStreaming && (streams
1216
1275
  ? true
1217
1276
  : (!apiTools || apiTools.length === 0) && !hasToolMessages);
1218
1277
  if (useStreaming) {
@@ -1243,6 +1302,20 @@ export class Agent extends EventEmitter {
1243
1302
  fullText += delta.content;
1244
1303
  this.emit('stream:delta', delta.content, fullText);
1245
1304
  }
1305
+ // Reasoning deltas (deepseek-reasoner, glm thinking models,
1306
+ // OpenRouter :thinking, the Scalix router's thinking_level) were
1307
+ // dropped — the "what is the agent thinking" display streams
1308
+ // from these. Some models emit reasoning unconditionally; when
1309
+ // it arrives after content started, re-open the thinking phase.
1310
+ const reasoningPiece = delta.reasoning_content ?? delta.reasoning ?? null;
1311
+ if (reasoningPiece) {
1312
+ if (!this._thinkingActive) {
1313
+ this._thinkingActive = true;
1314
+ this.emit('thinking:start');
1315
+ }
1316
+ this._reasoningText += reasoningPiece;
1317
+ this.emit('reasoning:update', this._reasoningText);
1318
+ }
1246
1319
  if (delta.tool_calls) {
1247
1320
  for (const tc of delta.tool_calls) {
1248
1321
  const idx = tc.index ?? toolCalls.size;
@@ -1299,6 +1372,12 @@ export class Agent extends EventEmitter {
1299
1372
  fullText = choice.message.content;
1300
1373
  this.emit('stream:delta', fullText, fullText);
1301
1374
  }
1375
+ // Non-streaming path carries reasoning the same way.
1376
+ const msg = choice.message;
1377
+ if (msg.reasoning || msg.reasoning_content) {
1378
+ this._reasoningText += String(msg.reasoning_content ?? msg.reasoning ?? '');
1379
+ this.emit('reasoning:update', this._reasoningText);
1380
+ }
1302
1381
  if (choice.message.tool_calls) {
1303
1382
  for (const tc of choice.message.tool_calls) {
1304
1383
  toolCalls.set(toolCalls.size, {
@@ -1381,10 +1460,16 @@ export class Agent extends EventEmitter {
1381
1460
  this.messages.push(assistantMessage);
1382
1461
  this.emit('message:assistant', assistantMessage);
1383
1462
  if (resolvedToolCalls.length > 0) {
1384
- for (const call of resolvedToolCalls) {
1463
+ // Multiple sub-agent spawns in one turn run concurrently — the
1464
+ // Kilo Code behavior for parallel Task calls. Every spawn is an
1465
+ // independent `agent` call, so sequencing them only adds wall-clock
1466
+ // time; any other tool mix stays sequential because write ordering
1467
+ // matters. Each call still goes through the same permission check,
1468
+ // confirmation, hooks and result plumbing as a solo call.
1469
+ const runCall = async (call) => {
1385
1470
  if (this._aborted) {
1386
1471
  reqLog.info('Agent aborted, skipping remaining tool calls');
1387
- break;
1472
+ return;
1388
1473
  }
1389
1474
  const tool = this.config.tools.find((t) => (t.function?.name || t.name) === call.name);
1390
1475
  let result;
@@ -1399,7 +1484,7 @@ export class Agent extends EventEmitter {
1399
1484
  this.emit('tool:blocked', call.name, 'Blocked by hook');
1400
1485
  this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
1401
1486
  this.emit('tool:result', call.name, result);
1402
- continue;
1487
+ return;
1403
1488
  }
1404
1489
  }
1405
1490
  // Parse tool arguments once, up front, so the permission decision,
@@ -1419,7 +1504,7 @@ export class Agent extends EventEmitter {
1419
1504
  this.emit('tool:blocked', call.name, 'Malformed arguments');
1420
1505
  this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
1421
1506
  this.emit('tool:result', call.name, result);
1422
- continue;
1507
+ return;
1423
1508
  }
1424
1509
  }
1425
1510
  // Args-aware permission so scoped allow/deny rules are enforced.
@@ -1435,7 +1520,7 @@ export class Agent extends EventEmitter {
1435
1520
  name: call.name,
1436
1521
  });
1437
1522
  this.emit('tool:result', call.name, result);
1438
- continue;
1523
+ return;
1439
1524
  }
1440
1525
  if (permission === 'ask') {
1441
1526
  if (!this.config.confirmToolCall) {
@@ -1453,24 +1538,36 @@ export class Agent extends EventEmitter {
1453
1538
  name: call.name,
1454
1539
  });
1455
1540
  this.emit('tool:result', call.name, result);
1456
- continue;
1541
+ return;
1457
1542
  }
1458
1543
  }
1459
1544
  else {
1460
1545
  const args = parsedArgs;
1461
1546
  const CONFIRM_TIMEOUT_MS = 300_000;
1462
1547
  const confirmed = await new Promise((resolve) => {
1463
- const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
1464
- this.config.confirmToolCall(call.name, args).then((v) => {
1548
+ let settled = false;
1549
+ const done = (v) => {
1550
+ if (settled)
1551
+ return;
1552
+ settled = true;
1465
1553
  clearTimeout(timer);
1554
+ this._abortWaiters.delete(waiter);
1466
1555
  resolve(v);
1556
+ };
1557
+ // An abort during the wait denies the tool immediately; the
1558
+ // loop's `if (this._aborted) break` below then exits —
1559
+ // otherwise the loop parks here for up to 5 minutes.
1560
+ const waiter = () => done(false);
1561
+ const timer = setTimeout(() => done(false), CONFIRM_TIMEOUT_MS);
1562
+ this._abortWaiters.add(waiter);
1563
+ this.config.confirmToolCall(call.name, args).then((v) => {
1564
+ done(v);
1467
1565
  }).catch(() => {
1468
- clearTimeout(timer);
1469
- resolve(false);
1566
+ done(false);
1470
1567
  });
1471
1568
  });
1472
1569
  if (this._aborted)
1473
- break;
1570
+ return;
1474
1571
  if (!confirmed) {
1475
1572
  result = { denied: true, message: `User denied execution of ${call.name}` };
1476
1573
  reqLog.info('Tool call denied by user', { tool: call.name });
@@ -1482,7 +1579,7 @@ export class Agent extends EventEmitter {
1482
1579
  name: call.name,
1483
1580
  });
1484
1581
  this.emit('tool:result', call.name, result);
1485
- continue;
1582
+ return;
1486
1583
  }
1487
1584
  }
1488
1585
  }
@@ -1529,13 +1626,7 @@ export class Agent extends EventEmitter {
1529
1626
  workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
1530
1627
  permissionMode: this.config.permissionMode,
1531
1628
  agentId: this.agentId,
1532
- agentConfig: {
1533
- apiKey: this.config.apiKey,
1534
- baseUrl: this.client.baseURL,
1535
- models: this.config.models,
1536
- permissionMode: this.config.permissionMode,
1537
- confirmToolCall: this.config.confirmToolCall,
1538
- },
1629
+ agentConfig: this.getAgentConfig(),
1539
1630
  }), toolTimeout, call.name);
1540
1631
  const toolDurationMs = Date.now() - toolStart;
1541
1632
  _turnToolDurationMs += toolDurationMs;
@@ -1675,6 +1766,20 @@ export class Agent extends EventEmitter {
1675
1766
  }
1676
1767
  }
1677
1768
  }
1769
+ };
1770
+ if (resolvedToolCalls.length > 1 && resolvedToolCalls.every((c) => c.name === 'agent')) {
1771
+ await Promise.all(resolvedToolCalls.map((c) => runCall(c).catch((err) => {
1772
+ reqLog.error('Sub-agent spawn failed', { tool: c.name, error: err?.message });
1773
+ })));
1774
+ }
1775
+ else {
1776
+ for (const call of resolvedToolCalls) {
1777
+ if (this._aborted) {
1778
+ reqLog.info('Agent aborted, skipping remaining tool calls');
1779
+ break;
1780
+ }
1781
+ await runCall(call);
1782
+ }
1678
1783
  }
1679
1784
  if (this._aborted)
1680
1785
  return '[interrupted]';
@@ -1721,6 +1826,15 @@ export class Agent extends EventEmitter {
1721
1826
  }
1722
1827
  catch (err) {
1723
1828
  this._abortController = null;
1829
+ // A user-initiated abort is not a model failure — recording it would
1830
+ // open the circuit breaker after a few Escapes and silently rotate
1831
+ // the user's model. An abort mid-request mirrors the between-iteration
1832
+ // contract: deregister and return '[interrupted]'.
1833
+ if (this._aborted || err?.name === 'AbortError') {
1834
+ reqLog.info('Agent aborted during request');
1835
+ this.deregisterFromRegistry('aborted');
1836
+ return '[interrupted]';
1837
+ }
1724
1838
  this.recordModelFailure(currentModel);
1725
1839
  const errMsg = (err?.message || '').toLowerCase();
1726
1840
  const isStreamError = errMsg.includes('stream') || errMsg.includes('sse') || errMsg.includes('no response body');
@@ -1728,7 +1842,7 @@ export class Agent extends EventEmitter {
1728
1842
  if (isStreamError && !forceNonStreaming) {
1729
1843
  forceNonStreaming = true;
1730
1844
  reqLog.warn('Streaming failed, retrying with non-streaming mode', { error: err.message });
1731
- await sleep(500);
1845
+ await this.abortableSleep(500);
1732
1846
  continue;
1733
1847
  }
1734
1848
  if (this.shouldFallbackToNext(err) && retryAttempt < this.config.maxRetries && turnCount < this.config.maxSteps) {
@@ -1749,7 +1863,7 @@ export class Agent extends EventEmitter {
1749
1863
  status: err instanceof ApiError ? err.status : undefined,
1750
1864
  reason: err.message,
1751
1865
  });
1752
- await sleep(delay);
1866
+ await this.abortableSleep(delay);
1753
1867
  currentModel = this.pickModel();
1754
1868
  continue;
1755
1869
  }
@@ -12,6 +12,62 @@ export class ApiError extends Error {
12
12
  this.retryAfterMs = retryAfterMs;
13
13
  }
14
14
  }
15
+ // ─── Request body guards ───
16
+ // Two confirmed ways a request dies with the gateway's generic
17
+ // "invalid JSON body" (400) before the model ever sees it:
18
+ //
19
+ // 1. A body over the wall. The gateway and the proxies in front of it
20
+ // refuse bodies past ~200KB — measured 192KB passing and 256KB
21
+ // failing — and the refusal is "invalid JSON body", not 413, which
22
+ // reads like a truncated stream. A single tool result is allowed to
23
+ // reach 500K chars, so one fat command output is enough to cross it.
24
+ //
25
+ // 2. A lone surrogate escape. JSON.stringify serializes half of a cut
26
+ // surrogate pair as \ud83d. JavaScript's JSON.parse accepts that;
27
+ // strict parsers (serde_json, Go's encoding/json) reject it. undici
28
+ // would silently map it to U+FFFD on the wire anyway, so the model
29
+ // never saw the half a character either.
30
+ //
31
+ // Both are fixed here rather than at every truncation site: the body
32
+ // is sanitized, and tool results are shrunk until the request fits.
33
+ const MAX_BODY_BYTES = 180_000;
34
+ function utf8ByteLength(s) {
35
+ if (typeof Buffer !== 'undefined')
36
+ return Buffer.byteLength(s, 'utf8');
37
+ return new TextEncoder().encode(s).length;
38
+ }
39
+ function sanitizeLoneSurrogates(body) {
40
+ return body.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD');
41
+ }
42
+ /** Cut tool-result contents, largest first, until the serialized
43
+ * request fits. Tool results are the bulk of an oversized request,
44
+ * and trimming their text keeps tool_call/result pairing intact —
45
+ * dropping whole messages would break the pairing the wire requires.
46
+ * Rounds, not one pass: a single command output can be many times
47
+ * the wall, so the largest result is re-halved until the check
48
+ * converges. */
49
+ function shrinkToFit(req, maxBytes) {
50
+ let out = req;
51
+ for (;;) {
52
+ if (utf8ByteLength(JSON.stringify(out)) <= maxBytes)
53
+ return out;
54
+ const biggest = out.messages
55
+ .map((m, i) => ({ m, i }))
56
+ .filter(({ m }) => m.role === 'tool' && typeof m.content === 'string' && m.content.length > 400)
57
+ .sort((a, b) => b.m.content.length - a.m.content.length)[0];
58
+ // Nothing left worth cutting — the request is over for a reason
59
+ // this pass cannot fix, so send what remains.
60
+ if (!biggest)
61
+ return out;
62
+ const text = biggest.m.content;
63
+ const messages = [...out.messages];
64
+ messages[biggest.i] = {
65
+ ...biggest.m,
66
+ content: text.slice(0, Math.max(200, Math.floor(text.length / 2))) + '\n...[trimmed so the request fits the endpoint]',
67
+ };
68
+ out = { ...out, messages };
69
+ }
70
+ }
15
71
  /**
16
72
  * Parse a Retry-After header value into milliseconds. Accepts both forms from
17
73
  * RFC 9110: delta-seconds ("120") and HTTP-date. Returns undefined for absent
@@ -31,11 +87,13 @@ export function parseRetryAfter(value) {
31
87
  // Per-request abort. 300s matches the platform's own proxy walls; the old
32
88
  // 120s default aborted the client before a slow first token could arrive on
33
89
  // CPU-served models (the request was fine, we hung up on it). For reference,
34
- // the OpenAI and Anthropic SDKs both default to 600s.
90
+ // the OpenAI and Anthropic SDKs both default to 600s. The default applies
91
+ // when SCALIX_REQUEST_TIMEOUT_MS is unset — 0 would mean "no timeout", which
92
+ // lets a wedged connection hang the turn forever.
35
93
  const DEFAULT_TIMEOUT_MS = (() => {
36
94
  const v = process.env.SCALIX_REQUEST_TIMEOUT_MS;
37
95
  const n = v ? parseInt(v, 10) : NaN;
38
- return Number.isFinite(n) ? n : 0;
96
+ return Number.isFinite(n) && n > 0 ? n : 300_000;
39
97
  })();
40
98
  // The capability probe only decides whether a turn streams. It must never be
41
99
  // the reason a turn is slow to start, so it gives up quickly and falls back to
@@ -123,7 +181,8 @@ export class ScalixClient {
123
181
  // as many times as there are narrowings left.
124
182
  for (;;) {
125
183
  const prepared = this.prepareRequest(req);
126
- const body = JSON.stringify(prepared);
184
+ const wire = shrinkToFit(prepared, MAX_BODY_BYTES);
185
+ const body = sanitizeLoneSurrogates(JSON.stringify(wire));
127
186
  const timeoutSignal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : undefined;
128
187
  const combined = signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal || timeoutSignal);
129
188
  const response = await fetch(url, {