chainlesschain 0.42.3 → 0.43.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.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Sub-Agent Registry — lifecycle tracking for sub-agents.
3
+ *
4
+ * Tracks active sub-agents, maintains completion history (ring buffer),
5
+ * and provides statistics. Singleton pattern for process-wide access.
6
+ *
7
+ * @module sub-agent-registry
8
+ */
9
+
10
+ // ─── Ring Buffer ────────────────────────────────────────────────────────────
11
+
12
+ class RingBuffer {
13
+ constructor(capacity = 100) {
14
+ this._buffer = new Array(capacity);
15
+ this._capacity = capacity;
16
+ this._head = 0;
17
+ this._size = 0;
18
+ }
19
+
20
+ push(item) {
21
+ this._buffer[this._head] = item;
22
+ this._head = (this._head + 1) % this._capacity;
23
+ if (this._size < this._capacity) this._size++;
24
+ }
25
+
26
+ toArray() {
27
+ if (this._size === 0) return [];
28
+ if (this._size < this._capacity) {
29
+ return this._buffer.slice(0, this._size);
30
+ }
31
+ // Wrap around — oldest first
32
+ return [
33
+ ...this._buffer.slice(this._head),
34
+ ...this._buffer.slice(0, this._head),
35
+ ];
36
+ }
37
+
38
+ get size() {
39
+ return this._size;
40
+ }
41
+
42
+ clear() {
43
+ this._buffer = new Array(this._capacity);
44
+ this._head = 0;
45
+ this._size = 0;
46
+ }
47
+ }
48
+
49
+ // ─── SubAgentRegistry ───────────────────────────────────────────────────────
50
+
51
+ export class SubAgentRegistry {
52
+ static _instance = null;
53
+
54
+ /**
55
+ * Get or create the singleton instance.
56
+ * @returns {SubAgentRegistry}
57
+ */
58
+ static getInstance() {
59
+ if (!SubAgentRegistry._instance) {
60
+ SubAgentRegistry._instance = new SubAgentRegistry();
61
+ }
62
+ return SubAgentRegistry._instance;
63
+ }
64
+
65
+ /**
66
+ * Reset singleton (for testing).
67
+ */
68
+ static resetInstance() {
69
+ SubAgentRegistry._instance = null;
70
+ }
71
+
72
+ constructor() {
73
+ /** @type {Map<string, import("./sub-agent-context.js").SubAgentContext>} */
74
+ this._active = new Map();
75
+
76
+ /** @type {RingBuffer} */
77
+ this._completed = new RingBuffer(100);
78
+
79
+ this._totalTokens = 0;
80
+ this._totalDurationMs = 0;
81
+ this._completedCount = 0;
82
+ }
83
+
84
+ /**
85
+ * Register an active sub-agent.
86
+ * @param {import("./sub-agent-context.js").SubAgentContext} subCtx
87
+ */
88
+ register(subCtx) {
89
+ this._active.set(subCtx.id, subCtx);
90
+ }
91
+
92
+ /**
93
+ * Mark a sub-agent as completed and move to history.
94
+ * @param {string} id - Sub-agent ID
95
+ * @param {object} result - { summary, artifacts, tokenCount, toolsUsed, iterationCount }
96
+ */
97
+ complete(id, result) {
98
+ const subCtx = this._active.get(id);
99
+ if (!subCtx) return;
100
+
101
+ this._active.delete(id);
102
+
103
+ const record = {
104
+ id: subCtx.id,
105
+ parentId: subCtx.parentId,
106
+ role: subCtx.role,
107
+ task: subCtx.task,
108
+ status: subCtx.status,
109
+ summary: result?.summary || "(no summary)",
110
+ toolsUsed: result?.toolsUsed || [],
111
+ tokenCount: result?.tokenCount || 0,
112
+ iterationCount: result?.iterationCount || 0,
113
+ createdAt: subCtx.createdAt,
114
+ completedAt: subCtx.completedAt || new Date().toISOString(),
115
+ durationMs: subCtx.completedAt
116
+ ? new Date(subCtx.completedAt) - new Date(subCtx.createdAt)
117
+ : Date.now() - new Date(subCtx.createdAt).getTime(),
118
+ };
119
+
120
+ this._completed.push(record);
121
+ this._totalTokens += record.tokenCount;
122
+ this._totalDurationMs += record.durationMs;
123
+ this._completedCount++;
124
+ }
125
+
126
+ /**
127
+ * Get all active sub-agents.
128
+ * @returns {Array<object>}
129
+ */
130
+ getActive() {
131
+ return [...this._active.values()].map((ctx) => ctx.toJSON());
132
+ }
133
+
134
+ /**
135
+ * Get completion history (most recent last).
136
+ * @returns {Array<object>}
137
+ */
138
+ getHistory() {
139
+ return this._completed.toArray();
140
+ }
141
+
142
+ /**
143
+ * Force-complete all sub-agents belonging to a session.
144
+ * Used by ws-session-manager on session close.
145
+ *
146
+ * @param {string} [sessionId] - If provided, only force-complete agents whose parentId matches
147
+ */
148
+ forceCompleteAll(sessionId) {
149
+ for (const [id, subCtx] of this._active) {
150
+ if (!sessionId || subCtx.parentId === sessionId) {
151
+ subCtx.forceComplete("session-closed");
152
+ this.complete(id, subCtx.result);
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Clean up stale entries older than maxAgeMs.
159
+ * @param {number} [maxAgeMs=600000] - Max age in ms (default: 10 minutes)
160
+ */
161
+ cleanup(maxAgeMs = 600000) {
162
+ const cutoff = Date.now() - maxAgeMs;
163
+ for (const [id, subCtx] of this._active) {
164
+ if (new Date(subCtx.createdAt).getTime() < cutoff) {
165
+ subCtx.forceComplete("timeout");
166
+ this.complete(id, subCtx.result);
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Get registry statistics.
173
+ */
174
+ getStats() {
175
+ return {
176
+ active: this._active.size,
177
+ completed: this._completedCount,
178
+ historySize: this._completed.size,
179
+ totalTokens: this._totalTokens,
180
+ avgDurationMs:
181
+ this._completedCount > 0
182
+ ? Math.round(this._totalDurationMs / this._completedCount)
183
+ : 0,
184
+ };
185
+ }
186
+ }
@@ -19,6 +19,7 @@ import {
19
19
  listSessions as dbListSessions,
20
20
  } from "./session-manager.js";
21
21
  import { buildSystemPrompt } from "./agent-core.js";
22
+ import { SubAgentRegistry } from "./sub-agent-registry.js";
22
23
 
23
24
  /**
24
25
  * @typedef {object} Session
@@ -266,6 +267,13 @@ export class WSSessionManager {
266
267
  }
267
268
  }
268
269
 
270
+ // Force-complete any active sub-agents for this session
271
+ try {
272
+ SubAgentRegistry.getInstance().forceCompleteAll(sessionId);
273
+ } catch (_err) {
274
+ // Non-critical
275
+ }
276
+
269
277
  // Clean up plan manager listeners
270
278
  if (session.planManager) {
271
279
  session.planManager.removeAllListeners();
@@ -262,6 +262,9 @@ export async function startAgentRepl(options = {}) {
262
262
  ` ${chalk.cyan("/plan approve")} Approve and execute the plan`,
263
263
  );
264
264
  logger.log(` ${chalk.cyan("/plan reject")} Reject the plan`);
265
+ logger.log(
266
+ ` ${chalk.cyan("/sub-agents")} Show active/completed sub-agents`,
267
+ );
265
268
  logger.log(chalk.bold("\nCapabilities:"));
266
269
  logger.log(" Read, write, and edit files");
267
270
  logger.log(" Run shell commands (git, npm, etc.)");
@@ -275,6 +278,48 @@ export async function startAgentRepl(options = {}) {
275
278
  return;
276
279
  }
277
280
 
281
+ if (trimmed === "/sub-agents" || trimmed === "/subagents") {
282
+ try {
283
+ const { SubAgentRegistry } =
284
+ await import("../lib/sub-agent-registry.js");
285
+ const registry = SubAgentRegistry.getInstance();
286
+ const active = registry.getActive();
287
+ const history = registry.getHistory();
288
+ const stats = registry.getStats();
289
+
290
+ logger.log(chalk.bold("\nSub-Agent Registry:"));
291
+ logger.log(
292
+ ` Active: ${chalk.yellow(active.length)} Completed: ${chalk.green(stats.completed)} Tokens: ${stats.totalTokens} Avg Duration: ${stats.avgDurationMs}ms`,
293
+ );
294
+
295
+ if (active.length > 0) {
296
+ logger.log(chalk.bold("\n Active Sub-Agents:"));
297
+ for (const a of active) {
298
+ logger.log(
299
+ ` ${chalk.cyan(a.id)} [${a.role}] ${a.task.substring(0, 50)} (iter: ${a.iterationCount})`,
300
+ );
301
+ }
302
+ }
303
+
304
+ if (history.length > 0) {
305
+ logger.log(chalk.bold("\n Recent History (last 10):"));
306
+ for (const h of history.slice(-10)) {
307
+ const status =
308
+ h.status === "completed" ? chalk.green("✓") : chalk.red("✗");
309
+ logger.log(
310
+ ` ${status} ${chalk.dim(h.id)} [${h.role}] ${(h.summary || "").substring(0, 60)}`,
311
+ );
312
+ }
313
+ }
314
+
315
+ logger.log("");
316
+ } catch (_err) {
317
+ logger.log(chalk.dim("Sub-agent registry not available."));
318
+ }
319
+ prompt();
320
+ return;
321
+ }
322
+
278
323
  if (trimmed.startsWith("/model")) {
279
324
  const arg = trimmed.slice(6).trim();
280
325
  if (arg) {