chainlesschain 0.45.66 → 0.45.67

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.
@@ -0,0 +1,20 @@
1
+ export function createAbortError(message = "Agent loop interrupted") {
2
+ const error = new Error(message);
3
+ error.name = "AbortError";
4
+ return error;
5
+ }
6
+
7
+ export function isAbortError(error) {
8
+ return (
9
+ error?.name === "AbortError" ||
10
+ error?.code === "ABORT_ERR" ||
11
+ (typeof error?.message === "string" &&
12
+ /aborted|interrupted/i.test(error.message))
13
+ );
14
+ }
15
+
16
+ export function throwIfAborted(signal, message = "Agent loop interrupted") {
17
+ if (signal?.aborted) {
18
+ throw signal.reason || createAbortError(message);
19
+ }
20
+ }
@@ -33,6 +33,7 @@ import {
33
33
  import { createToolContext } from "../tools/tool-context.js";
34
34
  import { createToolTelemetryRecord } from "../tools/tool-telemetry.js";
35
35
  import { DEFAULT_TOOL_DESCRIPTORS } from "../tools/registry.js";
36
+ import { isAbortError, throwIfAborted } from "./abort-utils.js";
36
37
 
37
38
  const { isReadOnlyGitCommand, normalizeGitCommand } = sharedCodingAgentPolicy;
38
39
  const { evaluateShellCommandPolicy } = sharedShellPolicy;
@@ -719,6 +720,7 @@ export async function executeTool(name, args, context = {}) {
719
720
  cwd,
720
721
  parentMessages: context.parentMessages,
721
722
  interaction: context.interaction,
723
+ sessionId: context.sessionId || null,
722
724
  hostManagedToolPolicy: context.hostManagedToolPolicy || null,
723
725
  externalToolDescriptors: context.externalToolDescriptors || null,
724
726
  externalToolExecutors: context.externalToolExecutors || null,
@@ -784,6 +786,7 @@ async function executeToolInner(
784
786
  cwd,
785
787
  parentMessages,
786
788
  interaction,
789
+ sessionId,
787
790
  hostManagedToolPolicy,
788
791
  externalToolDescriptors,
789
792
  externalToolExecutors,
@@ -957,7 +960,13 @@ async function executeToolInner(
957
960
 
958
961
  case "spawn_sub_agent": {
959
962
  return attachDescriptor(
960
- await _executeSpawnSubAgent(args, { skillLoader, cwd, parentMessages }),
963
+ await _executeSpawnSubAgent(args, {
964
+ skillLoader,
965
+ cwd,
966
+ parentMessages,
967
+ interaction,
968
+ sessionId,
969
+ }),
961
970
  );
962
971
  }
963
972
 
@@ -1393,7 +1402,7 @@ async function _executeRunCode(args, cwd) {
1393
1402
  * Creates an isolated SubAgentContext, runs it, and returns only the summary.
1394
1403
  *
1395
1404
  * @param {object} args - { role, task, context?, tools? }
1396
- * @param {object} ctx - { skillLoader, cwd }
1405
+ * @param {object} ctx - { skillLoader, cwd, parentMessages, interaction, sessionId }
1397
1406
  * @returns {Promise<object>}
1398
1407
  */
1399
1408
  async function _executeSpawnSubAgent(args, ctx) {
@@ -1415,14 +1424,35 @@ async function _executeSpawnSubAgent(args, ctx) {
1415
1424
  }
1416
1425
  }
1417
1426
 
1427
+ // Link child to parent session so registry-scoped queries and
1428
+ // session-close cascade cleanup can find it.
1429
+ const parentSessionId = ctx.sessionId || null;
1430
+ const interaction = ctx.interaction || null;
1431
+
1418
1432
  const subCtx = SubAgentContext.create({
1419
1433
  role,
1420
1434
  task,
1435
+ parentId: parentSessionId,
1421
1436
  inheritedContext: resolvedContext,
1422
1437
  allowedTools: allowedTools || null,
1423
1438
  cwd: ctx.cwd,
1424
1439
  });
1425
1440
 
1441
+ const emit = (type, payload) => {
1442
+ if (!interaction || typeof interaction.emit !== "function") return;
1443
+ try {
1444
+ interaction.emit(type, {
1445
+ sessionId: parentSessionId,
1446
+ subAgentId: subCtx.id,
1447
+ parentSessionId,
1448
+ role: subCtx.role,
1449
+ ...payload,
1450
+ });
1451
+ } catch (_err) {
1452
+ // Event emission is best-effort — never break the tool call
1453
+ }
1454
+ };
1455
+
1426
1456
  try {
1427
1457
  // Notify registry if available
1428
1458
  const { SubAgentRegistry } = await import("./sub-agent-registry.js").catch(
@@ -1436,6 +1466,13 @@ async function _executeSpawnSubAgent(args, ctx) {
1436
1466
  }
1437
1467
  }
1438
1468
 
1469
+ emit("sub-agent.started", {
1470
+ task: subCtx.task,
1471
+ allowedTools: allowedTools || null,
1472
+ maxIterations: subCtx.maxIterations,
1473
+ createdAt: subCtx.createdAt,
1474
+ });
1475
+
1439
1476
  const result = await subCtx.run(task);
1440
1477
 
1441
1478
  // Complete in registry
@@ -1447,10 +1484,21 @@ async function _executeSpawnSubAgent(args, ctx) {
1447
1484
  }
1448
1485
  }
1449
1486
 
1487
+ emit("sub-agent.completed", {
1488
+ status: subCtx.status,
1489
+ summary: result.summary,
1490
+ toolsUsed: result.toolsUsed,
1491
+ iterationCount: result.iterationCount,
1492
+ tokenCount: result.tokenCount,
1493
+ artifactCount: result.artifacts.length,
1494
+ completedAt: subCtx.completedAt,
1495
+ });
1496
+
1450
1497
  return {
1451
1498
  success: true,
1452
1499
  subAgentId: subCtx.id,
1453
1500
  role: subCtx.role,
1501
+ parentSessionId,
1454
1502
  summary: result.summary,
1455
1503
  toolsUsed: result.toolsUsed,
1456
1504
  iterationCount: result.iterationCount,
@@ -1458,10 +1506,18 @@ async function _executeSpawnSubAgent(args, ctx) {
1458
1506
  };
1459
1507
  } catch (err) {
1460
1508
  subCtx.forceComplete(err.message);
1509
+
1510
+ emit("sub-agent.failed", {
1511
+ status: subCtx.status,
1512
+ error: err.message,
1513
+ completedAt: subCtx.completedAt,
1514
+ });
1515
+
1461
1516
  return {
1462
1517
  error: `Sub-agent failed: ${err.message}`,
1463
1518
  subAgentId: subCtx.id,
1464
1519
  role: subCtx.role,
1520
+ parentSessionId,
1465
1521
  };
1466
1522
  }
1467
1523
  }
@@ -1477,7 +1533,14 @@ async function _executeSpawnSubAgent(args, ctx) {
1477
1533
  * @returns {Promise<object>} response with .message
1478
1534
  */
1479
1535
  export async function chatWithTools(rawMessages, options) {
1480
- const { provider, model, baseUrl, apiKey, contextEngine: ce } = options;
1536
+ const {
1537
+ provider,
1538
+ model,
1539
+ baseUrl,
1540
+ apiKey,
1541
+ contextEngine: ce,
1542
+ signal,
1543
+ } = options;
1481
1544
 
1482
1545
  const persona = _loadProjectPersona(options.cwd);
1483
1546
  const tools = getAgentToolDefinitions({
@@ -1496,10 +1559,13 @@ export async function chatWithTools(rawMessages, options) {
1496
1559
  })
1497
1560
  : rawMessages;
1498
1561
 
1562
+ throwIfAborted(signal);
1563
+
1499
1564
  if (provider === "ollama") {
1500
1565
  const response = await fetch(`${baseUrl}/api/chat`, {
1501
1566
  method: "POST",
1502
1567
  headers: { "Content-Type": "application/json" },
1568
+ signal,
1503
1569
  body: JSON.stringify({
1504
1570
  model,
1505
1571
  messages,
@@ -1548,6 +1614,7 @@ export async function chatWithTools(rawMessages, options) {
1548
1614
  "x-api-key": key,
1549
1615
  "anthropic-version": "2023-06-01",
1550
1616
  },
1617
+ signal,
1551
1618
  body: JSON.stringify(body),
1552
1619
  });
1553
1620
 
@@ -1608,6 +1675,7 @@ export async function chatWithTools(rawMessages, options) {
1608
1675
  "Content-Type": "application/json",
1609
1676
  Authorization: `Bearer ${key}`,
1610
1677
  },
1678
+ signal,
1611
1679
  body: JSON.stringify({
1612
1680
  model: model || defaultModels[provider] || "gpt-4o-mini",
1613
1681
  messages,
@@ -1667,6 +1735,7 @@ function _normalizeAnthropicResponse(data) {
1667
1735
  */
1668
1736
  export async function* agentLoop(messages, options) {
1669
1737
  const MAX_ITERATIONS = 15;
1738
+ const signal = options.signal || null;
1670
1739
  const toolContext = {
1671
1740
  hookDb: options.hookDb || null,
1672
1741
  skillLoader: options.skillLoader || _defaultSkillLoader,
@@ -1681,6 +1750,8 @@ export async function* agentLoop(messages, options) {
1681
1750
  interaction: options.interaction || null,
1682
1751
  };
1683
1752
 
1753
+ throwIfAborted(signal);
1754
+
1684
1755
  // ── Slot-filling phase ──────────────────────────────────────────────
1685
1756
  // Before calling the LLM, check if the user's message matches a known
1686
1757
  // intent with missing required parameters. If so, interactively fill them
@@ -1721,14 +1792,19 @@ export async function* agentLoop(messages, options) {
1721
1792
  }
1722
1793
  }
1723
1794
  }
1724
- } catch (_err) {
1795
+ } catch (error) {
1796
+ if (isAbortError(error) || signal?.aborted) {
1797
+ throw error;
1798
+ }
1725
1799
  // Slot-filling failure is non-critical — proceed to LLM
1726
1800
  }
1727
1801
  }
1728
1802
  }
1729
1803
 
1730
1804
  for (let i = 0; i < MAX_ITERATIONS; i++) {
1805
+ throwIfAborted(signal);
1731
1806
  const result = await chatWithTools(messages, options);
1807
+ throwIfAborted(signal);
1732
1808
  const msg = result?.message;
1733
1809
 
1734
1810
  if (!msg) {
@@ -1747,6 +1823,7 @@ export async function* agentLoop(messages, options) {
1747
1823
  messages.push(msg);
1748
1824
 
1749
1825
  for (const call of toolCalls) {
1826
+ throwIfAborted(signal);
1750
1827
  const fn = call.function;
1751
1828
  const toolName = fn.name;
1752
1829
  let toolArgs;
@@ -1771,6 +1848,8 @@ export async function* agentLoop(messages, options) {
1771
1848
  toolError = err.message;
1772
1849
  }
1773
1850
 
1851
+ throwIfAborted(signal);
1852
+
1774
1853
  yield {
1775
1854
  type: "tool-result",
1776
1855
  tool: toolName,
@@ -13,6 +13,7 @@ import {
13
13
  CODING_AGENT_EVENT_TYPES,
14
14
  LEGACY_TO_UNIFIED_TYPE,
15
15
  } from "../runtime/runtime-events.js";
16
+ import { createAbortError } from "./abort-utils.js";
16
17
 
17
18
  // Whitelist of event types the CLI runtime should emit as unified envelopes
18
19
  // (with source: "cli-runtime"). Anything not in this set keeps the legacy
@@ -104,7 +105,7 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
104
105
  super();
105
106
  this.ws = ws;
106
107
  this.sessionId = sessionId;
107
- /** @type {Map<string, {resolve: Function, reject: Function}>} */
108
+ /** @type {Map<string, {resolve: Function, reject: Function, timeoutId: ReturnType<typeof setTimeout>|null}>} */
108
109
  this._pending = new Map();
109
110
  // Per-instance sequence tracker so monotonic sequences are scoped to
110
111
  // this WS session instead of leaking across sessions via the process-
@@ -127,24 +128,24 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
127
128
  _request(message, options = {}) {
128
129
  return new Promise((resolve, reject) => {
129
130
  const requestId = this._requestId();
130
- this._pending.set(requestId, { resolve, reject });
131
+ const timeoutId = setTimeout(
132
+ () => {
133
+ const pending = this._pending.get(requestId);
134
+ if (!pending) {
135
+ return;
136
+ }
137
+ this._pending.delete(requestId);
138
+ reject(new Error("Question timed out"));
139
+ },
140
+ options.timeoutMs || 5 * 60 * 1000,
141
+ );
142
+ this._pending.set(requestId, { resolve, reject, timeoutId });
131
143
 
132
144
  this._sendWs({
133
145
  ...message,
134
146
  sessionId: this.sessionId,
135
147
  requestId,
136
148
  });
137
-
138
- // Timeout after 5 minutes
139
- setTimeout(
140
- () => {
141
- if (this._pending.has(requestId)) {
142
- this._pending.delete(requestId);
143
- reject(new Error("Question timed out"));
144
- }
145
- },
146
- options.timeoutMs || 5 * 60 * 1000,
147
- );
148
149
  });
149
150
  }
150
151
 
@@ -179,11 +180,7 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
179
180
  * Resolves the corresponding pending promise.
180
181
  */
181
182
  resolveAnswer(requestId, answer) {
182
- const pending = this._pending.get(requestId);
183
- if (pending) {
184
- this._pending.delete(requestId);
185
- pending.resolve(answer);
186
- }
183
+ this._resolvePending(requestId, answer);
187
184
  }
188
185
 
189
186
  async requestHostTool(toolName, args = {}, extra = {}) {
@@ -199,10 +196,19 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
199
196
  }
200
197
 
201
198
  resolveHostTool(requestId, payload) {
202
- const pending = this._pending.get(requestId);
203
- if (pending) {
199
+ this._resolvePending(requestId, payload);
200
+ }
201
+
202
+ rejectAllPending(reason = createAbortError("Interaction interrupted")) {
203
+ const error =
204
+ reason instanceof Error ? reason : createAbortError(String(reason));
205
+
206
+ for (const [requestId, pending] of this._pending.entries()) {
204
207
  this._pending.delete(requestId);
205
- pending.resolve(payload);
208
+ if (pending.timeoutId) {
209
+ clearTimeout(pending.timeoutId);
210
+ }
211
+ pending.reject(error);
206
212
  }
207
213
  }
208
214
 
@@ -245,4 +251,17 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
245
251
  }
246
252
  }
247
253
  }
254
+
255
+ _resolvePending(requestId, payload) {
256
+ const pending = this._pending.get(requestId);
257
+ if (!pending) {
258
+ return;
259
+ }
260
+
261
+ this._pending.delete(requestId);
262
+ if (pending.timeoutId) {
263
+ clearTimeout(pending.timeoutId);
264
+ }
265
+ pending.resolve(payload);
266
+ }
248
267
  }
@@ -131,6 +131,40 @@ export class SubAgentRegistry {
131
131
  return [...this._active.values()].map((ctx) => ctx.toJSON());
132
132
  }
133
133
 
134
+ /**
135
+ * Get a single sub-agent snapshot by id — checks active first, then history.
136
+ * @param {string} id
137
+ * @returns {object|null}
138
+ */
139
+ getById(id) {
140
+ if (!id) return null;
141
+ const active = this._active.get(id);
142
+ if (active) return active.toJSON();
143
+ const historyEntry = this._completed
144
+ .toArray()
145
+ .find((record) => record.id === id);
146
+ return historyEntry || null;
147
+ }
148
+
149
+ /**
150
+ * Get active + recent sub-agents belonging to a parent session.
151
+ * Used by the WS sub-agent-list query and by UI consumers that need to
152
+ * render only the child agents spawned from a specific parent turn.
153
+ *
154
+ * @param {string} parentId
155
+ * @returns {{ active: Array<object>, history: Array<object> }}
156
+ */
157
+ getByParent(parentId) {
158
+ if (!parentId) return { active: [], history: [] };
159
+ const active = [...this._active.values()]
160
+ .filter((ctx) => ctx.parentId === parentId)
161
+ .map((ctx) => ctx.toJSON());
162
+ const history = this._completed
163
+ .toArray()
164
+ .filter((record) => record.parentId === parentId);
165
+ return { active, history };
166
+ }
167
+
134
168
  /**
135
169
  * Get completion history (most recent last).
136
170
  * @returns {Array<object>}
@@ -10,6 +10,7 @@ import { agentLoop, formatToolArgs } from "./agent-core.js";
10
10
  import { detectTaskType, selectModelForTask } from "./task-model-selector.js";
11
11
  import { PlanState } from "./plan-mode.js";
12
12
  import { CLISlotFiller } from "./slot-filler.js";
13
+ import { createAbortError, isAbortError } from "./abort-utils.js";
13
14
 
14
15
  export class WSAgentHandler {
15
16
  /**
@@ -23,6 +24,8 @@ export class WSAgentHandler {
23
24
  this.interaction = interaction;
24
25
  this.db = db || null;
25
26
  this._processing = false;
27
+ this._abortController = null;
28
+ this._activeRequestId = null;
26
29
  }
27
30
 
28
31
  /**
@@ -42,6 +45,9 @@ export class WSAgentHandler {
42
45
  }
43
46
 
44
47
  this._processing = true;
48
+ const abortController = new AbortController();
49
+ this._abortController = abortController;
50
+ this._activeRequestId = requestId || null;
45
51
 
46
52
  try {
47
53
  const { session } = this;
@@ -93,6 +99,7 @@ export class WSAgentHandler {
93
99
  mcpClient: session.mcpClient || null,
94
100
  slotFiller,
95
101
  interaction: this.interaction,
102
+ signal: abortController.signal,
96
103
  };
97
104
 
98
105
  for await (const event of agentLoop(session.messages, loopOptions)) {
@@ -141,6 +148,10 @@ export class WSAgentHandler {
141
148
  // Update last activity
142
149
  session.lastActivity = new Date().toISOString();
143
150
  } catch (err) {
151
+ if (isAbortError(err) || abortController.signal.aborted) {
152
+ return;
153
+ }
154
+
144
155
  this.interaction.emit("error", {
145
156
  requestId,
146
157
  code: "AGENT_ERROR",
@@ -156,6 +167,43 @@ export class WSAgentHandler {
156
167
  }
157
168
  } finally {
158
169
  this._processing = false;
170
+ if (this._abortController === abortController) {
171
+ this._abortController = null;
172
+ }
173
+ if (this._activeRequestId === requestId) {
174
+ this._activeRequestId = null;
175
+ }
176
+ }
177
+ }
178
+
179
+ async interrupt() {
180
+ const wasProcessing = this._processing;
181
+ const interruptedRequestId = this._activeRequestId || null;
182
+ const reason = createAbortError("Session interrupted by client");
183
+
184
+ if (this._abortController && !this._abortController.signal.aborted) {
185
+ this._abortController.abort(reason);
186
+ }
187
+
188
+ if (typeof this.interaction?.rejectAllPending === "function") {
189
+ this.interaction.rejectAllPending(reason);
190
+ }
191
+
192
+ return {
193
+ sessionId: this.session?.id || null,
194
+ interrupted: true,
195
+ wasProcessing,
196
+ interruptedRequestId,
197
+ };
198
+ }
199
+
200
+ destroy() {
201
+ const reason = createAbortError("Session closed");
202
+ if (this._abortController && !this._abortController.signal.aborted) {
203
+ this._abortController.abort(reason);
204
+ }
205
+ if (typeof this.interaction?.rejectAllPending === "function") {
206
+ this.interaction.rejectAllPending(reason);
159
207
  }
160
208
  }
161
209
 
@@ -28,8 +28,19 @@ import {
28
28
  handleSessionPolicyUpdate,
29
29
  handleSessionList,
30
30
  handleSessionClose,
31
+ handleSessionInterrupt,
31
32
  handleSessionAnswer,
32
33
  handleHostToolResult,
34
+ handleSubAgentList,
35
+ handleSubAgentGet,
36
+ handleReviewEnter,
37
+ handleReviewSubmit,
38
+ handleReviewResolve,
39
+ handleReviewStatus,
40
+ handlePatchPropose,
41
+ handlePatchApply,
42
+ handlePatchReject,
43
+ handlePatchSummary,
33
44
  } from "../gateways/ws/session-protocol.js";
34
45
  import {
35
46
  handleSlashCommand,
@@ -599,6 +610,11 @@ export class ChainlessChainWSServer extends EventEmitter {
599
610
  return handleSessionClose(this, id, ws, message);
600
611
  }
601
612
 
613
+ /** @private */
614
+ _handleSessionInterrupt(id, ws, message) {
615
+ return handleSessionInterrupt(this, id, ws, message);
616
+ }
617
+
602
618
  /** @private */
603
619
  _handleSlashCommand(id, ws, message) {
604
620
  return handleSlashCommand(this, id, ws, message);
@@ -613,6 +629,56 @@ export class ChainlessChainWSServer extends EventEmitter {
613
629
  return handleHostToolResult(this, id, ws, message);
614
630
  }
615
631
 
632
+ /** @private */
633
+ _handleSubAgentList(id, ws, message) {
634
+ return handleSubAgentList(this, id, ws, message);
635
+ }
636
+
637
+ /** @private */
638
+ _handleSubAgentGet(id, ws, message) {
639
+ return handleSubAgentGet(this, id, ws, message);
640
+ }
641
+
642
+ /** @private */
643
+ _handleReviewEnter(id, ws, message) {
644
+ return handleReviewEnter(this, id, ws, message);
645
+ }
646
+
647
+ /** @private */
648
+ _handleReviewSubmit(id, ws, message) {
649
+ return handleReviewSubmit(this, id, ws, message);
650
+ }
651
+
652
+ /** @private */
653
+ _handleReviewResolve(id, ws, message) {
654
+ return handleReviewResolve(this, id, ws, message);
655
+ }
656
+
657
+ /** @private */
658
+ _handleReviewStatus(id, ws, message) {
659
+ return handleReviewStatus(this, id, ws, message);
660
+ }
661
+
662
+ /** @private */
663
+ _handlePatchPropose(id, ws, message) {
664
+ return handlePatchPropose(this, id, ws, message);
665
+ }
666
+
667
+ /** @private */
668
+ _handlePatchApply(id, ws, message) {
669
+ return handlePatchApply(this, id, ws, message);
670
+ }
671
+
672
+ /** @private */
673
+ _handlePatchReject(id, ws, message) {
674
+ return handlePatchReject(this, id, ws, message);
675
+ }
676
+
677
+ /** @private */
678
+ _handlePatchSummary(id, ws, message) {
679
+ return handlePatchSummary(this, id, ws, message);
680
+ }
681
+
616
682
  /** @private — ping/pong heartbeat to detect dead connections */
617
683
  async _ensureTaskManager() {
618
684
  if (this._taskManager) return this._taskManager;