@nexrall/code-core 1.4.44 → 1.4.46

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.
@@ -3123,7 +3123,7 @@ async function runAgentLoop(initialMessages, options) {
3123
3123
  // 2. Try MCP tools (serverName__toolName)
3124
3124
  }
3125
3125
  else if (options.mcpManager?.isMcpTool(name)) {
3126
- const mcpOutput = await options.mcpManager.callTool(name, input);
3126
+ const mcpOutput = await options.mcpManager.callTool(name, input, options.abortSignal);
3127
3127
  result = { output: mcpOutput ?? '' };
3128
3128
  // 3. Fall through to built-in executor
3129
3129
  }
@@ -25,8 +25,26 @@ export declare class McpManager {
25
25
  connectAll(config: McpConfig): Promise<void>;
26
26
  /** Returns all MCP tools as Anthropic-compatible tool definitions. */
27
27
  getAnthropicTools(): AnthropicTool[];
28
- /** Execute an MCP tool call. Returns null if this tool is not an MCP tool. */
29
- callTool(prefixedName: string, input: Record<string, unknown>): Promise<string | null>;
28
+ /**
29
+ * Execute an MCP tool call. Returns null if this tool is not an MCP tool.
30
+ *
31
+ * `abortSignal` is optional and best-effort: the underlying transports (stdio
32
+ * JSON-RPC / HTTP / SSE) have no cancellation message in this client, so an
33
+ * abort here does NOT stop the server from finishing its work — it only stops
34
+ * THIS CALL from making the caller wait for it. Before this was added, Ctrl+C
35
+ * during an MCP tool call was silently swallowed: loop.ts set
36
+ * `abortSignal.aborted = true` but nothing downstream of `options.mcpManager
37
+ * .callTool(name, input)` ever looked at it, so the user's only way out was
38
+ * to wait out the full 60s MCP_TOOL_TIMEOUT_MS (or however long the server
39
+ * actually took). Racing the same abortSignal the 60s timeout already races
40
+ * against makes Ctrl+C return control immediately instead of up to a minute
41
+ * later — matching how the `bash` tool already behaves (its own 200ms abort
42
+ * poll in executor.ts), just without the ability to also kill a remote
43
+ * server's in-flight work the way `bash` can kill a local child process.
44
+ */
45
+ callTool(prefixedName: string, input: Record<string, unknown>, abortSignal?: {
46
+ aborted: boolean;
47
+ }): Promise<string | null>;
30
48
  /** True if this tool name belongs to an MCP server. */
31
49
  isMcpTool(name: string): boolean;
32
50
  get serverNames(): string[];
@@ -1 +1 @@
1
- {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../src/mcp/manager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAa,eAAe,EAAE,iBAAiB,EAAgB,MAAM,UAAU,CAAC;AAOvF,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CAC7C;AAGD,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE;QACZ,IAAI,EAAE,QAAQ,CAAC;QACf,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACrC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;CACH;AAID,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAmC;IAEnD,OAAO,CAAC,QAAQ,CAA6B;IAE7C,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,OAAO,CAA6B;IAE5C,OAAO,CAAC,UAAU,CAA6B;IAE/C,+FAA+F;IAC/F,OAAO,CAAC,MAAM,CAAC,UAAU;IAMzB,oGAAoG;IACpG,MAAM,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS;IA2B7C,mFAAmF;IAC7E,UAAU,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IA6BlD,sEAAsE;IACtE,iBAAiB,IAAI,aAAa,EAAE;IAkBpC,8EAA8E;IACxE,QAAQ,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAkB5F,uDAAuD;IACvD,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIhC,IAAI,WAAW,IAAI,MAAM,EAAE,CAE1B;IAED,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,OAAO,CAAC,MAAM,CAAC,WAAW;IAK1B,8FAA8F;IAC9F,SAAS,IAAI,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;QAAC,KAAK,EAAE,iBAAiB,EAAE,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAY9J,uFAAuF;IACvF,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAMnC,+CAA+C;IAC/C,MAAM,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,MAAM;IAMrE,OAAO,CAAC,MAAM,CAAC,cAAc;IAS7B,OAAO,CAAC,MAAM,CAAC,eAAe;IAK9B,iEAAiE;IACjE,MAAM,CAAC,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,KAAK,GAAE,SAAS,GAAG,MAAkB,GAAG,IAAI;IAOrH,+FAA+F;IAC/F,MAAM,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO;IAc3D,aAAa,IAAI,IAAI;CAQtB"}
1
+ {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../src/mcp/manager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAa,eAAe,EAAE,iBAAiB,EAAgB,MAAM,UAAU,CAAC;AAOvF,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CAC7C;AAGD,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE;QACZ,IAAI,EAAE,QAAQ,CAAC;QACf,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACrC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;CACH;AAID,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAmC;IAEnD,OAAO,CAAC,QAAQ,CAA6B;IAE7C,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,OAAO,CAA6B;IAE5C,OAAO,CAAC,UAAU,CAA6B;IAE/C,+FAA+F;IAC/F,OAAO,CAAC,MAAM,CAAC,UAAU;IAMzB,oGAAoG;IACpG,MAAM,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS;IA2B7C,mFAAmF;IAC7E,UAAU,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IA6BlD,sEAAsE;IACtE,iBAAiB,IAAI,aAAa,EAAE;IAkBpC;;;;;;;;;;;;;;;;OAgBG;IACG,QAAQ,CACZ,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GACjC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAoCzB,uDAAuD;IACvD,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIhC,IAAI,WAAW,IAAI,MAAM,EAAE,CAE1B;IAED,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,OAAO,CAAC,MAAM,CAAC,WAAW;IAK1B,8FAA8F;IAC9F,SAAS,IAAI,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;QAAC,KAAK,EAAE,iBAAiB,EAAE,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAY9J,uFAAuF;IACvF,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAMnC,+CAA+C;IAC/C,MAAM,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,MAAM;IAMrE,OAAO,CAAC,MAAM,CAAC,cAAc;IAS7B,OAAO,CAAC,MAAM,CAAC,eAAe;IAK9B,iEAAiE;IACjE,MAAM,CAAC,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,KAAK,GAAE,SAAS,GAAG,MAAkB,GAAG,IAAI;IAOrH,+FAA+F;IAC/F,MAAM,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO;IAc3D,aAAa,IAAI,IAAI;CAQtB"}
@@ -132,8 +132,24 @@ class McpManager {
132
132
  }
133
133
  return tools;
134
134
  }
135
- /** Execute an MCP tool call. Returns null if this tool is not an MCP tool. */
136
- async callTool(prefixedName, input) {
135
+ /**
136
+ * Execute an MCP tool call. Returns null if this tool is not an MCP tool.
137
+ *
138
+ * `abortSignal` is optional and best-effort: the underlying transports (stdio
139
+ * JSON-RPC / HTTP / SSE) have no cancellation message in this client, so an
140
+ * abort here does NOT stop the server from finishing its work — it only stops
141
+ * THIS CALL from making the caller wait for it. Before this was added, Ctrl+C
142
+ * during an MCP tool call was silently swallowed: loop.ts set
143
+ * `abortSignal.aborted = true` but nothing downstream of `options.mcpManager
144
+ * .callTool(name, input)` ever looked at it, so the user's only way out was
145
+ * to wait out the full 60s MCP_TOOL_TIMEOUT_MS (or however long the server
146
+ * actually took). Racing the same abortSignal the 60s timeout already races
147
+ * against makes Ctrl+C return control immediately instead of up to a minute
148
+ * later — matching how the `bash` tool already behaves (its own 200ms abort
149
+ * poll in executor.ts), just without the ability to also kill a remote
150
+ * server's in-flight work the way `bash` can kill a local child process.
151
+ */
152
+ async callTool(prefixedName, input, abortSignal) {
137
153
  const serverName = this._toolMap.get(prefixedName);
138
154
  if (!serverName)
139
155
  return null;
@@ -145,7 +161,24 @@ class McpManager {
145
161
  // Enforce a timeout — a hung MCP server would otherwise block the agent loop forever.
146
162
  const MCP_TOOL_TIMEOUT_MS = 60000;
147
163
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool "${prefixedName}" timed out after ${MCP_TOOL_TIMEOUT_MS / 1000}s`)), MCP_TOOL_TIMEOUT_MS));
148
- return Promise.race([client.callTool(toolName, input), timeoutPromise]);
164
+ const racers = [client.callTool(toolName, input), timeoutPromise];
165
+ if (abortSignal) {
166
+ let pollId;
167
+ const abortPromise = new Promise((_, reject) => {
168
+ pollId = setInterval(() => {
169
+ if (abortSignal.aborted) {
170
+ clearInterval(pollId);
171
+ reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
172
+ }
173
+ }, 100);
174
+ });
175
+ racers.push(abortPromise);
176
+ // Clearing the poll on whichever branch wins avoids leaving a live interval
177
+ // (and the closure it holds) running for the rest of the process lifetime —
178
+ // the same leak-prevention pattern client.ts uses for its own abort poll.
179
+ return Promise.race(racers).finally(() => clearInterval(pollId));
180
+ }
181
+ return Promise.race(racers);
149
182
  }
150
183
  /** True if this tool name belongs to an MCP server. */
151
184
  isMcpTool(name) {
@@ -1 +1 @@
1
- {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAqzEtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GACjC,OAAO,CAAC,UAAU,CAAC,CAoBrB"}
1
+ {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAg3EtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GACjC,OAAO,CAAC,UAAU,CAAC,CA6BrB"}
@@ -1617,7 +1617,7 @@ function fetchBlocklistCheck(parsedUrl) {
1617
1617
  }
1618
1618
  return null;
1619
1619
  }
1620
- async function fetchUrl(input, _workDir, _redirectCount = 0) {
1620
+ async function fetchUrl(input, _workDir, _redirectCount = 0, abortSignal) {
1621
1621
  const url = typeof input.url === 'string' ? input.url : '';
1622
1622
  if (!url)
1623
1623
  return { error: 'Missing required parameter: url' };
@@ -1674,8 +1674,28 @@ async function fetchUrl(input, _workDir, _redirectCount = 0) {
1674
1674
  if (settled)
1675
1675
  return;
1676
1676
  settled = true;
1677
+ if (abortPoll !== undefined)
1678
+ clearInterval(abortPoll);
1677
1679
  resolve(result);
1678
1680
  };
1681
+ // Poll for Ctrl+C the same way the `bash` tool does (executor.ts's own
1682
+ // 200ms abort poll below this function) — fetch_url had NO abort wiring at
1683
+ // all before this, so a user hitting Ctrl+C while the model waited on a slow
1684
+ // page had to sit through the full DEFAULT_TIMEOUT_MS (60s) with no way out.
1685
+ // `selfAborted` (already used for our own intentional destroy() below) is
1686
+ // reused here so an abort-triggered destroy() doesn't ALSO surface as a
1687
+ // generic "socket hang up" error — it reports the clearer "stopped by user"
1688
+ // message instead.
1689
+ let abortPoll;
1690
+ if (abortSignal) {
1691
+ abortPoll = setInterval(() => {
1692
+ if (abortSignal.aborted && !settled) {
1693
+ selfAborted = true;
1694
+ req.destroy();
1695
+ finish({ error: 'Request stopped by user', interrupted: true });
1696
+ }
1697
+ }, 100);
1698
+ }
1679
1699
  const req = transport.get(url, {
1680
1700
  timeout: DEFAULT_TIMEOUT_MS,
1681
1701
  lookup: guardedLookup,
@@ -1699,7 +1719,7 @@ async function fetchUrl(input, _workDir, _redirectCount = 0) {
1699
1719
  finish({ error: `Invalid redirect location: ${res.headers.location}` });
1700
1720
  return;
1701
1721
  }
1702
- fetchUrl({ url: nextUrl }, undefined, _redirectCount + 1).then(finish);
1722
+ fetchUrl({ url: nextUrl }, undefined, _redirectCount + 1, abortSignal).then(finish);
1703
1723
  return;
1704
1724
  }
1705
1725
  const contentType = res.headers['content-type'] ?? '';
@@ -2082,37 +2102,70 @@ const VALID_ASPECT_RATIOS = new Set(['1:1', '9:16', '16:9', '3:4', '4:3', '3:2',
2082
2102
  // Minimal HTTPS helpers (mirrors the Node built-in pattern used by fetchUrl above).
2083
2103
  // We avoid `node-fetch` here because executor.ts targets both Node 18+ (global fetch)
2084
2104
  // and older runtimes; using `https` directly is always safe.
2085
- function _httpsPost(url, body, headers, timeoutMs = 30000) {
2105
+ //
2106
+ // `abortSignal` is optional and, like fetchUrl's, best-effort: it stops the CALLER
2107
+ // from waiting on the request (destroys the socket, rejects immediately) rather than
2108
+ // waiting out the full timeoutMs. Before this was added, generate_image (170s),
2109
+ // stock_photo, and web_search had NO abort wiring at all — Ctrl+C during any of them
2110
+ // set `abortSignal.aborted = true` but nothing here ever looked at it, so the only way
2111
+ // out was to wait for the request to finish or time out on its own.
2112
+ function _httpsPost(url, body, headers, timeoutMs = 30000, abortSignal) {
2086
2113
  return new Promise((resolve, reject) => {
2087
2114
  const u = new URL(url);
2088
2115
  const chunks = [];
2116
+ let settled = false;
2117
+ let abortPoll;
2118
+ const done = (fn) => { if (settled)
2119
+ return; settled = true; if (abortPoll)
2120
+ clearInterval(abortPoll); fn(); };
2089
2121
  const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, timeout: timeoutMs }, (res) => {
2090
2122
  res.on('data', (c) => chunks.push(c));
2091
- res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }));
2123
+ res.on('end', () => done(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })));
2092
2124
  });
2093
- req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out after ${timeoutMs}ms`)); });
2094
- req.on('error', reject);
2125
+ req.on('timeout', () => { req.destroy(); done(() => reject(new Error(`Request timed out after ${timeoutMs}ms`))); });
2126
+ req.on('error', (err) => done(() => reject(err)));
2127
+ if (abortSignal) {
2128
+ abortPoll = setInterval(() => {
2129
+ if (abortSignal.aborted && !settled) {
2130
+ req.destroy();
2131
+ done(() => reject(Object.assign(new Error('Stopped by user'), { name: 'AbortError' })));
2132
+ }
2133
+ }, 100);
2134
+ }
2095
2135
  req.write(body);
2096
2136
  req.end();
2097
2137
  });
2098
2138
  }
2099
- function _httpsGet(url, headers, timeoutMs = 20000) {
2139
+ function _httpsGet(url, headers, timeoutMs = 20000, abortSignal) {
2100
2140
  return new Promise((resolve, reject) => {
2101
2141
  const u = new URL(url);
2102
2142
  const transport = u.protocol === 'https:' ? https : http;
2103
2143
  const chunks = [];
2144
+ let settled = false;
2145
+ let abortPoll;
2146
+ const done = (fn) => { if (settled)
2147
+ return; settled = true; if (abortPoll)
2148
+ clearInterval(abortPoll); fn(); };
2104
2149
  const req = transport.get({ hostname: u.hostname, path: u.pathname + u.search, headers, timeout: timeoutMs }, (res) => {
2105
2150
  // Follow redirects once
2106
2151
  if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
2107
2152
  req.destroy();
2108
- _httpsGet(new URL(res.headers.location, url).href, headers, timeoutMs).then(resolve, reject);
2153
+ done(() => { _httpsGet(new URL(res.headers.location, url).href, headers, timeoutMs, abortSignal).then(resolve, reject); });
2109
2154
  return;
2110
2155
  }
2111
2156
  res.on('data', (c) => chunks.push(c));
2112
- res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks), contentType: res.headers['content-type'] ?? '' }));
2157
+ res.on('end', () => done(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks), contentType: res.headers['content-type'] ?? '' })));
2113
2158
  });
2114
- req.on('timeout', () => { req.destroy(); reject(new Error(`Download timed out after ${timeoutMs}ms`)); });
2115
- req.on('error', reject);
2159
+ req.on('timeout', () => { req.destroy(); done(() => reject(new Error(`Download timed out after ${timeoutMs}ms`))); });
2160
+ req.on('error', (err) => done(() => reject(err)));
2161
+ if (abortSignal) {
2162
+ abortPoll = setInterval(() => {
2163
+ if (abortSignal.aborted && !settled) {
2164
+ req.destroy();
2165
+ done(() => reject(Object.assign(new Error('Stopped by user'), { name: 'AbortError' })));
2166
+ }
2167
+ }, 100);
2168
+ }
2116
2169
  });
2117
2170
  }
2118
2171
  /**
@@ -2122,7 +2175,7 @@ function _httpsGet(url, headers, timeoutMs = 20000) {
2122
2175
  *
2123
2176
  * { prompt, path, aspect_ratio? }
2124
2177
  */
2125
- async function generateImage(input, workDir) {
2178
+ async function generateImage(input, workDir, abortSignal) {
2126
2179
  const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
2127
2180
  const relPath = typeof input.path === 'string' ? input.path.trim() : '';
2128
2181
  const aspectRatio = typeof input.aspect_ratio === 'string' && VALID_ASPECT_RATIOS.has(input.aspect_ratio)
@@ -2135,12 +2188,14 @@ async function generateImage(input, workDir) {
2135
2188
  if (!token)
2136
2189
  return { error: 'Not authenticated. Run `nexrall-code login` first.' };
2137
2190
  // 1. Request the backend to generate the image (async task + poll handled server-side).
2138
- // Backend may take up to ~2.5 min — use a 170s timeout with room to spare.
2191
+ // Backend may take up to ~2.5 min — use a 170s timeout with room to spare. Without
2192
+ // abortSignal wired through here, Ctrl+C during this call previously had no effect
2193
+ // for up to the full 170s — the longest silent Ctrl+C stall of any tool.
2139
2194
  let imageUrl;
2140
2195
  let cost;
2141
2196
  let balance;
2142
2197
  try {
2143
- const r = await _httpsPost(`${client_1.API_BASE}/api/code/assets/generate`, JSON.stringify({ prompt, aspect_ratio: aspectRatio }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 170000);
2198
+ const r = await _httpsPost(`${client_1.API_BASE}/api/code/assets/generate`, JSON.stringify({ prompt, aspect_ratio: aspectRatio }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 170000, abortSignal);
2144
2199
  let json = {};
2145
2200
  try {
2146
2201
  json = JSON.parse(r.body);
@@ -2155,6 +2210,8 @@ async function generateImage(input, workDir) {
2155
2210
  return { error: 'Image generation succeeded but no URL was returned.' };
2156
2211
  }
2157
2212
  catch (err) {
2213
+ if (err.name === 'AbortError')
2214
+ return { error: 'generate_image stopped by user', interrupted: true };
2158
2215
  return { error: `generate_image failed: ${err.message}` };
2159
2216
  }
2160
2217
  // 2. Download the image URL into the workspace.
@@ -2164,7 +2221,7 @@ async function generateImage(input, workDir) {
2164
2221
  }
2165
2222
  catch (_) { }
2166
2223
  try {
2167
- const dl = await _httpsGet(imageUrl, { 'User-Agent': FETCH_USER_AGENT }, 60000);
2224
+ const dl = await _httpsGet(imageUrl, { 'User-Agent': FETCH_USER_AGENT }, 60000, abortSignal);
2168
2225
  if (dl.status !== 200)
2169
2226
  return { error: `Failed to download generated image (HTTP ${dl.status}).` };
2170
2227
  fs.writeFileSync(destPath, dl.body);
@@ -2173,6 +2230,8 @@ async function generateImage(input, workDir) {
2173
2230
  return { output: `Generated and saved image to ${destPath} (${kb} KB)${costNote}.` };
2174
2231
  }
2175
2232
  catch (err) {
2233
+ if (err.name === 'AbortError')
2234
+ return { error: 'generate_image stopped by user (image was generated and billed, but the download was interrupted)', interrupted: true };
2176
2235
  return { error: `Failed to save image to ${destPath}: ${err.message}` };
2177
2236
  }
2178
2237
  }
@@ -2182,7 +2241,7 @@ async function generateImage(input, workDir) {
2182
2241
  *
2183
2242
  * { query, path?, count?, orientation? }
2184
2243
  */
2185
- async function stockPhoto(input, workDir) {
2244
+ async function stockPhoto(input, workDir, abortSignal) {
2186
2245
  const query = typeof input.query === 'string' ? input.query.trim() : '';
2187
2246
  const relPath = typeof input.path === 'string' ? input.path.trim() : '';
2188
2247
  const count = typeof input.count === 'number' ? Math.min(Math.max(Math.floor(input.count), 1), 10) : 3;
@@ -2200,7 +2259,7 @@ async function stockPhoto(input, workDir) {
2200
2259
  u.searchParams.set('count', String(count));
2201
2260
  if (orientation)
2202
2261
  u.searchParams.set('orientation', orientation);
2203
- const r = await _httpsGet(u.toString(), { Authorization: `Bearer ${token}` });
2262
+ const r = await _httpsGet(u.toString(), { Authorization: `Bearer ${token}` }, 20000, abortSignal);
2204
2263
  let json = {};
2205
2264
  try {
2206
2265
  json = JSON.parse(r.body.toString('utf-8'));
@@ -2213,6 +2272,8 @@ async function stockPhoto(input, workDir) {
2213
2272
  return { output: `No stock photos found for "${query}".` };
2214
2273
  }
2215
2274
  catch (err) {
2275
+ if (err.name === 'AbortError')
2276
+ return { error: 'stock_photo stopped by user', interrupted: true };
2216
2277
  return { error: `stock_photo search failed: ${err.message}` };
2217
2278
  }
2218
2279
  // 2a. If path given — download the top result into the workspace.
@@ -2226,7 +2287,7 @@ async function stockPhoto(input, workDir) {
2226
2287
  }
2227
2288
  catch (_) { }
2228
2289
  try {
2229
- const dl = await _httpsGet(top.url, { 'User-Agent': FETCH_USER_AGENT });
2290
+ const dl = await _httpsGet(top.url, { 'User-Agent': FETCH_USER_AGENT }, 20000, abortSignal);
2230
2291
  if (dl.status !== 200)
2231
2292
  return { error: `Failed to download photo (HTTP ${dl.status}).` };
2232
2293
  fs.writeFileSync(destPath, dl.body);
@@ -2235,6 +2296,8 @@ async function stockPhoto(input, workDir) {
2235
2296
  return { output: `Downloaded "${top.alt || query}" to ${destPath} (${kb} KB).${credit}` };
2236
2297
  }
2237
2298
  catch (err) {
2299
+ if (err.name === 'AbortError')
2300
+ return { error: 'stock_photo download stopped by user', interrupted: true };
2238
2301
  return { error: `Failed to save photo to ${destPath}: ${err.message}` };
2239
2302
  }
2240
2303
  }
@@ -2259,7 +2322,7 @@ async function stockPhoto(input, workDir) {
2259
2322
  *
2260
2323
  * { query }
2261
2324
  */
2262
- async function webSearch(input) {
2325
+ async function webSearch(input, _workDir, abortSignal) {
2263
2326
  const query = typeof input.query === 'string' ? input.query.trim() : '';
2264
2327
  if (!query)
2265
2328
  return { error: 'Missing required parameter: query' };
@@ -2267,7 +2330,7 @@ async function webSearch(input) {
2267
2330
  if (!token)
2268
2331
  return { error: 'Not authenticated. Run `nexrall-code login` first.' };
2269
2332
  try {
2270
- const r = await _httpsPost(`${client_1.API_BASE}/api/code/tools/web_search`, JSON.stringify({ query }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 30000);
2333
+ const r = await _httpsPost(`${client_1.API_BASE}/api/code/tools/web_search`, JSON.stringify({ query }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 30000, abortSignal);
2271
2334
  let json = {};
2272
2335
  try {
2273
2336
  json = JSON.parse(r.body);
@@ -2281,6 +2344,8 @@ async function webSearch(input) {
2281
2344
  return { output: text };
2282
2345
  }
2283
2346
  catch (err) {
2347
+ if (err.name === 'AbortError')
2348
+ return { error: 'web_search stopped by user', interrupted: true };
2284
2349
  return { error: `web_search failed: ${err.message}` };
2285
2350
  }
2286
2351
  }
@@ -2415,11 +2480,13 @@ const TOOL_MAP = {
2415
2480
  };
2416
2481
  // Tools that accept a workDir for correct relative-path resolution.
2417
2482
  // TOOL_MAP stores the base signatures; executeTool injects workDir at call time.
2483
+ // generate_image / stock_photo used to be listed here, but now go through
2484
+ // their own dedicated branch above (which passes abortSignal too) — kept out
2485
+ // of this set so there's exactly one dispatch path per tool, not two.
2418
2486
  const WORKDIR_TOOLS = new Set([
2419
2487
  'read_file', 'write_file', 'list_directory', 'search_files', 'glob',
2420
2488
  'create_directory', 'edit_file', 'multi_edit', 'copy_file', 'move_file',
2421
2489
  'delete_file', 'notebook_read', 'notebook_edit',
2422
- 'generate_image', 'stock_photo',
2423
2490
  'get_symbols', 'get_workspace_symbols',
2424
2491
  'go_to_definition', 'find_references', 'get_hover',
2425
2492
  'memory_write', 'memory_read', 'use_skill',
@@ -2439,6 +2506,19 @@ async function executeTool(name, input, abortSignal, sandbox, workDir, agentScop
2439
2506
  return await todoWrite(input, agentScope);
2440
2507
  if (name === 'todo_read')
2441
2508
  return await todoRead(input, agentScope);
2509
+ // Network-bound tools get abortSignal explicitly (not through the generic
2510
+ // WORKDIR_TOOLS branch below, whose signature is (input, workDir) only) —
2511
+ // these are the tools that previously had NO way to stop early on Ctrl+C,
2512
+ // silently making the user wait out their own timeout (up to 170s for
2513
+ // generate_image) instead of returning control immediately.
2514
+ if (name === 'fetch_url')
2515
+ return await fetchUrl(input, workDir, 0, abortSignal);
2516
+ if (name === 'web_search')
2517
+ return await webSearch(input, workDir, abortSignal);
2518
+ if (name === 'generate_image')
2519
+ return await generateImage(input, workDir, abortSignal);
2520
+ if (name === 'stock_photo')
2521
+ return await stockPhoto(input, workDir, abortSignal);
2442
2522
  // Inject workDir into all file-system tools so relative paths resolve
2443
2523
  // against the workspace root rather than process.cwd().
2444
2524
  if (WORKDIR_TOOLS.has(name)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexrall/code-core",
3
- "version": "1.4.44",
3
+ "version": "1.4.46",
4
4
  "description": "Core agent loop, tools, and extension primitives for Nexrall Code — embed an AI coding agent in any Node.js application.",
5
5
  "license": "MIT",
6
6
  "author": "Nexrall <support@nexrall.com> (https://nexrall.com)",