@gjczone/pi-swarm 0.1.2

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/dist/index.d.ts +13 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +99 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/shared/controller.d.ts +86 -0
  8. package/dist/shared/controller.d.ts.map +1 -0
  9. package/dist/shared/controller.js +662 -0
  10. package/dist/shared/controller.js.map +1 -0
  11. package/dist/shared/pi-invoke.d.ts +31 -0
  12. package/dist/shared/pi-invoke.d.ts.map +1 -0
  13. package/dist/shared/pi-invoke.js +54 -0
  14. package/dist/shared/pi-invoke.js.map +1 -0
  15. package/dist/shared/render.d.ts +44 -0
  16. package/dist/shared/render.d.ts.map +1 -0
  17. package/dist/shared/render.js +116 -0
  18. package/dist/shared/render.js.map +1 -0
  19. package/dist/shared/spawner.d.ts +26 -0
  20. package/dist/shared/spawner.d.ts.map +1 -0
  21. package/dist/shared/spawner.js +226 -0
  22. package/dist/shared/spawner.js.map +1 -0
  23. package/dist/shared/types.d.ts +182 -0
  24. package/dist/shared/types.d.ts.map +1 -0
  25. package/dist/shared/types.js +8 -0
  26. package/dist/shared/types.js.map +1 -0
  27. package/dist/state/persistence.d.ts +83 -0
  28. package/dist/state/persistence.d.ts.map +1 -0
  29. package/dist/state/persistence.js +215 -0
  30. package/dist/state/persistence.js.map +1 -0
  31. package/dist/state/recovery.d.ts +35 -0
  32. package/dist/state/recovery.d.ts.map +1 -0
  33. package/dist/state/recovery.js +149 -0
  34. package/dist/state/recovery.js.map +1 -0
  35. package/dist/swarm/command.d.ts +36 -0
  36. package/dist/swarm/command.d.ts.map +1 -0
  37. package/dist/swarm/command.js +113 -0
  38. package/dist/swarm/command.js.map +1 -0
  39. package/dist/swarm/mode.d.ts +58 -0
  40. package/dist/swarm/mode.d.ts.map +1 -0
  41. package/dist/swarm/mode.js +87 -0
  42. package/dist/swarm/mode.js.map +1 -0
  43. package/dist/swarm/tool.d.ts +11 -0
  44. package/dist/swarm/tool.d.ts.map +1 -0
  45. package/dist/swarm/tool.js +190 -0
  46. package/dist/swarm/tool.js.map +1 -0
  47. package/dist/team/command.d.ts +11 -0
  48. package/dist/team/command.d.ts.map +1 -0
  49. package/dist/team/command.js +32 -0
  50. package/dist/team/command.js.map +1 -0
  51. package/dist/team/mailbox.d.ts +61 -0
  52. package/dist/team/mailbox.d.ts.map +1 -0
  53. package/dist/team/mailbox.js +160 -0
  54. package/dist/team/mailbox.js.map +1 -0
  55. package/dist/team/supervisor.d.ts +77 -0
  56. package/dist/team/supervisor.d.ts.map +1 -0
  57. package/dist/team/supervisor.js +195 -0
  58. package/dist/team/supervisor.js.map +1 -0
  59. package/dist/team/task-graph.d.ts +61 -0
  60. package/dist/team/task-graph.d.ts.map +1 -0
  61. package/dist/team/task-graph.js +193 -0
  62. package/dist/team/task-graph.js.map +1 -0
  63. package/dist/team/tool.d.ts +11 -0
  64. package/dist/team/tool.d.ts.map +1 -0
  65. package/dist/team/tool.js +210 -0
  66. package/dist/team/tool.js.map +1 -0
  67. package/dist/tui/permission-prompt.d.ts +26 -0
  68. package/dist/tui/permission-prompt.d.ts.map +1 -0
  69. package/dist/tui/permission-prompt.js +94 -0
  70. package/dist/tui/permission-prompt.js.map +1 -0
  71. package/dist/tui/progress.d.ts +64 -0
  72. package/dist/tui/progress.d.ts.map +1 -0
  73. package/dist/tui/progress.js +260 -0
  74. package/dist/tui/progress.js.map +1 -0
  75. package/dist/tui/swarm-markers.d.ts +20 -0
  76. package/dist/tui/swarm-markers.d.ts.map +1 -0
  77. package/dist/tui/swarm-markers.js +61 -0
  78. package/dist/tui/swarm-markers.js.map +1 -0
  79. package/package.json +58 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * controller — concurrency controller for subagent batches.
3
+ *
4
+ * Ported from MoonshotAI/kimi-code's SubagentBatch.
5
+ *
6
+ * Two-phase scheduling:
7
+ * Normal phase: ramp-up (5 initial, +1 every 700ms).
8
+ * Rate-limit phase: capacity tracking with exponential backoff retries.
9
+ *
10
+ * Environment variables:
11
+ * PI_SWARM_MAX_CONCURRENCY — cap on concurrent subagents (optional).
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ // ---------------------------------------------------------------------------
16
+ // Constants (from kimi-code)
17
+ // ---------------------------------------------------------------------------
18
+ const INITIAL_LAUNCH_LIMIT = 5;
19
+ const INITIAL_LAUNCH_INTERVAL_MS = 700;
20
+ const RATE_LIMIT_RETRY_BASE_MS = 3000;
21
+ const RATE_LIMIT_RETRY_FACTOR = 2;
22
+ const RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS = 2000;
23
+ const RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes
24
+ const AGENT_SWARM_MAX_CONCURRENCY_ENV = "PI_SWARM_MAX_CONCURRENCY";
25
+ const DEFAULT_MAX_CONCURRENCY = 5;
26
+ // ---------------------------------------------------------------------------
27
+ // Abort helpers
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Check whether an abort reason indicates user cancellation
31
+ * (as opposed to a programmatic abort).
32
+ */
33
+ function isUserCancellation(reason) {
34
+ if (reason === undefined)
35
+ return false;
36
+ const msg = reason instanceof Error
37
+ ? reason.message.toLowerCase()
38
+ : String(reason).toLowerCase();
39
+ return (msg.includes("user") ||
40
+ msg.includes("cancel") ||
41
+ msg.includes("interrupt") ||
42
+ msg.includes("abort"));
43
+ }
44
+ function userCancellationReason() {
45
+ return new Error("User cancelled");
46
+ }
47
+ /**
48
+ * Link two AbortSignals so that either one aborting aborts the other.
49
+ * Returns a cleanup function.
50
+ */
51
+ function linkAbortSignal(source, target) {
52
+ const handler = () => {
53
+ target.abort(source.reason);
54
+ };
55
+ if (source.aborted) {
56
+ target.abort(source.reason);
57
+ return () => { };
58
+ }
59
+ source.addEventListener("abort", handler, { once: true });
60
+ return () => source.removeEventListener("abort", handler);
61
+ }
62
+ /**
63
+ * Detect whether an error indicates a provider rate limit.
64
+ */
65
+ function isProviderRateLimitError(error) {
66
+ if (!(error instanceof Error))
67
+ return false;
68
+ const msg = error.message.toLowerCase();
69
+ return (msg.includes("rate limit") ||
70
+ msg.includes("rate_limit") ||
71
+ msg.includes("ratelimit") ||
72
+ msg.includes("429") ||
73
+ msg.includes("too many requests") ||
74
+ msg.includes("quota"));
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // Controller
78
+ // ---------------------------------------------------------------------------
79
+ export class SubagentBatchController {
80
+ launcher;
81
+ states;
82
+ pending;
83
+ results;
84
+ active = new Set();
85
+ controller = new AbortController();
86
+ batchSignal;
87
+ batchAbortListener;
88
+ maxConcurrency;
89
+ // Normal phase state
90
+ normalLaunchCount = 0;
91
+ normalLaunchTimer;
92
+ // Rate-limit phase state
93
+ rateLimitLaunchTimer;
94
+ rateLimitMode = false;
95
+ rateLimitCapacity = 1;
96
+ lastRateLimitAt;
97
+ lastCapacityShrinkAt;
98
+ lastCapacityRecoveryAt;
99
+ globalRetryIntervalMs = RATE_LIMIT_RETRY_BASE_MS;
100
+ nextRateLimitLaunchAt = 0;
101
+ // Promise control
102
+ resolve;
103
+ reject;
104
+ finished = false;
105
+ started = false;
106
+ startedSuccessCount = 0;
107
+ constructor(launcher, tasks, options = {}) {
108
+ this.launcher = launcher;
109
+ this.maxConcurrency = options.maxConcurrency;
110
+ this.states = tasks.map((task, index) => ({
111
+ index,
112
+ task,
113
+ retryCount: 0,
114
+ retryReadyAt: 0,
115
+ started: false,
116
+ }));
117
+ this.pending = [...this.states];
118
+ this.results = Array.from({
119
+ length: tasks.length,
120
+ }).fill(undefined);
121
+ // Use the first task's signal as the batch signal
122
+ this.batchSignal = tasks.find((t) => t.signal !== undefined)?.signal;
123
+ this.batchAbortListener = () => {
124
+ this.controller.abort(this.batchSignal?.reason);
125
+ if (isUserCancellation(this.batchSignal?.reason)) {
126
+ this.finishWithUserCancellation();
127
+ }
128
+ else {
129
+ this.fail(this.batchSignal?.reason ?? new Error("Aborted"));
130
+ }
131
+ };
132
+ }
133
+ // -----------------------------------------------------------------------
134
+ // Public API
135
+ // -----------------------------------------------------------------------
136
+ /**
137
+ * Run the batch. Returns a promise that resolves when all tasks
138
+ * have a terminal result, or rejects on non-user cancellation.
139
+ */
140
+ run() {
141
+ if (this.started) {
142
+ throw new Error("SubagentBatchController.run() can only be called once.");
143
+ }
144
+ this.started = true;
145
+ return new Promise((resolve, reject) => {
146
+ this.resolve = resolve;
147
+ this.reject = reject;
148
+ if (this.states.length === 0) {
149
+ this.finish([]);
150
+ return;
151
+ }
152
+ if (this.batchSignal?.aborted) {
153
+ this.batchAbortListener();
154
+ return;
155
+ }
156
+ this.batchSignal?.addEventListener("abort", this.batchAbortListener, { once: true });
157
+ this.schedule();
158
+ });
159
+ }
160
+ // -----------------------------------------------------------------------
161
+ // Scheduling
162
+ // -----------------------------------------------------------------------
163
+ schedule() {
164
+ if (this.finished)
165
+ return;
166
+ if (this.finishIfComplete())
167
+ return;
168
+ if (this.controller.signal.aborted)
169
+ return;
170
+ if (this.rateLimitMode) {
171
+ this.scheduleRateLimitLaunch();
172
+ }
173
+ else {
174
+ this.scheduleNormalLaunch();
175
+ }
176
+ }
177
+ scheduleNormalLaunch() {
178
+ // Launch up to INITIAL_LAUNCH_LIMIT immediately
179
+ while (this.normalLaunchCount < INITIAL_LAUNCH_LIMIT &&
180
+ this.pending.length > 0 &&
181
+ !this.rateLimitMode &&
182
+ !this.isAtConcurrencyLimit()) {
183
+ const state = this.pending.shift();
184
+ if (state) {
185
+ this.startAttempt(state);
186
+ this.normalLaunchCount += 1;
187
+ }
188
+ }
189
+ // Schedule next ramp launch if work remains
190
+ if (this.pending.length === 0 ||
191
+ this.rateLimitMode ||
192
+ this.normalLaunchTimer !== undefined ||
193
+ this.isAtConcurrencyLimit()) {
194
+ return;
195
+ }
196
+ this.normalLaunchTimer = setTimeout(() => {
197
+ this.normalLaunchTimer = undefined;
198
+ if (this.finished ||
199
+ this.rateLimitMode ||
200
+ this.pending.length === 0)
201
+ return;
202
+ if (this.isAtConcurrencyLimit())
203
+ return;
204
+ const state = this.pending.shift();
205
+ if (state) {
206
+ this.startAttempt(state);
207
+ this.normalLaunchCount += 1;
208
+ }
209
+ this.schedule();
210
+ }, INITIAL_LAUNCH_INTERVAL_MS);
211
+ }
212
+ isAtConcurrencyLimit() {
213
+ return (this.maxConcurrency !== undefined &&
214
+ this.active.size >= this.maxConcurrency);
215
+ }
216
+ scheduleRateLimitLaunch() {
217
+ this.clearRateLimitTimer();
218
+ if (this.pending.length === 0)
219
+ return;
220
+ const now = Date.now();
221
+ this.recoverRateLimitCapacity(now);
222
+ if (this.active.size >= this.rateLimitCapacity) {
223
+ this.scheduleRateLimitWakeup(this.nextRateLimitCapacityRecoveryAt(), now);
224
+ return;
225
+ }
226
+ const nextAllowedAt = Math.max(this.nextRateLimitLaunchAt, this.nextPendingReadyAt());
227
+ const nextWakeupAt = Math.min(nextAllowedAt, this.nextRateLimitCapacityRecoveryAt());
228
+ if (nextWakeupAt > now) {
229
+ this.scheduleRateLimitWakeup(nextWakeupAt, now);
230
+ return;
231
+ }
232
+ const pendingIndex = this.pending.findIndex((state) => state.retryReadyAt <= now);
233
+ if (pendingIndex === -1)
234
+ return;
235
+ const [state] = this.pending.splice(pendingIndex, 1);
236
+ if (state) {
237
+ this.startAttempt(state);
238
+ this.nextRateLimitLaunchAt = now + this.globalRetryIntervalMs;
239
+ this.scheduleNextRateLimitWakeup(now);
240
+ }
241
+ }
242
+ // -----------------------------------------------------------------------
243
+ // Attempt lifecycle
244
+ // -----------------------------------------------------------------------
245
+ startAttempt(state) {
246
+ if (this.finished || this.controller.signal.aborted)
247
+ return;
248
+ const attempt = {
249
+ state,
250
+ controller: new AbortController(),
251
+ cleanup: () => { },
252
+ ready: false,
253
+ timedOut: false,
254
+ };
255
+ attempt.cleanup = this.linkAttemptSignals(attempt, state.task);
256
+ this.active.add(attempt);
257
+ this.runAttempt(attempt).then((outcome) => {
258
+ this.handleAttemptOutcome(attempt, outcome);
259
+ }, (error) => {
260
+ this.handleAttemptError(attempt, error);
261
+ });
262
+ }
263
+ async runAttempt(attempt) {
264
+ const task = attempt.state.task;
265
+ const runOptions = {
266
+ parentToolCallId: task.parentToolCallId,
267
+ parentToolCallUuid: task.parentToolCallUuid,
268
+ prompt: task.prompt,
269
+ description: task.description,
270
+ swarmIndex: task.swarmIndex,
271
+ runInBackground: task.runInBackground,
272
+ signal: attempt.controller.signal,
273
+ onReady: () => {
274
+ this.markAttemptReady(attempt);
275
+ },
276
+ suppressRateLimitFailureEvent: true,
277
+ timeout: task.timeout,
278
+ };
279
+ let handle;
280
+ try {
281
+ attempt.controller.signal.throwIfAborted();
282
+ if (attempt.state.retryAgentId !== undefined) {
283
+ handle = await this.launcher.retry(attempt.state.retryAgentId, runOptions);
284
+ }
285
+ else if (task.kind === "resume") {
286
+ handle = await this.launcher.resume(task.resumeAgentId, runOptions);
287
+ }
288
+ else {
289
+ const spawnOptions = {
290
+ profileName: task.profileName,
291
+ swarmItem: task.swarmItem,
292
+ ...runOptions,
293
+ };
294
+ handle = await this.launcher.spawn(spawnOptions);
295
+ }
296
+ }
297
+ catch (error) {
298
+ return this.failedAttemptOutcome(attempt, error);
299
+ }
300
+ attempt.state.agentId = handle.agentId;
301
+ try {
302
+ const completion = await handle.completion;
303
+ return {
304
+ task,
305
+ agentId: handle.agentId,
306
+ status: "completed",
307
+ result: completion.result,
308
+ };
309
+ }
310
+ catch (error) {
311
+ if (isProviderRateLimitError(error)) {
312
+ return {
313
+ type: "rate_limited",
314
+ task,
315
+ agentId: handle.agentId,
316
+ status: "failed",
317
+ error: String(error),
318
+ };
319
+ }
320
+ return this.failedAttemptOutcome(attempt, error);
321
+ }
322
+ }
323
+ // -----------------------------------------------------------------------
324
+ // Outcome handling
325
+ // -----------------------------------------------------------------------
326
+ handleAttemptOutcome(attempt, outcome) {
327
+ attempt.cleanup();
328
+ this.active.delete(attempt);
329
+ if (outcome.type === "rate_limited") {
330
+ this.handleRateLimit(attempt, outcome);
331
+ return;
332
+ }
333
+ this.results[attempt.state.index] = outcome;
334
+ this.schedule();
335
+ }
336
+ handleAttemptError(attempt, error) {
337
+ attempt.cleanup();
338
+ this.active.delete(attempt);
339
+ if (isProviderRateLimitError(error)) {
340
+ this.handleRateLimit(attempt, {
341
+ type: "rate_limited",
342
+ task: attempt.state.task,
343
+ agentId: attempt.state.agentId,
344
+ status: "failed",
345
+ error: String(error),
346
+ });
347
+ return;
348
+ }
349
+ const result = this.failedAttemptOutcome(attempt, error);
350
+ this.results[attempt.state.index] = result;
351
+ this.schedule();
352
+ }
353
+ handleRateLimit(attempt, _outcome) {
354
+ const state = attempt.state;
355
+ // If this is the only remaining task, fail fast.
356
+ if (this.pending.length === 0 && this.active.size === 0) {
357
+ this.results[state.index] = this.failedAttemptOutcome(attempt, new Error("Rate limit exceeded with no remaining work."));
358
+ this.schedule();
359
+ return;
360
+ }
361
+ // Save agent id for retry and requeue at front
362
+ state.retryAgentId = state.agentId ?? state.retryAgentId;
363
+ state.retryCount += 1;
364
+ // Exponential backoff
365
+ const delay = RATE_LIMIT_RETRY_BASE_MS *
366
+ Math.pow(RATE_LIMIT_RETRY_FACTOR, state.retryCount - 1);
367
+ state.retryReadyAt = Date.now() + delay;
368
+ this.pending.unshift(state);
369
+ // Enter rate-limit phase
370
+ if (!this.rateLimitMode) {
371
+ this.enterRateLimitPhase();
372
+ }
373
+ this.shrinkRateLimitCapacity(Date.now());
374
+ this.schedule();
375
+ }
376
+ failedAttemptOutcome(attempt, error) {
377
+ const task = attempt.state.task;
378
+ const isAbort = error instanceof Error &&
379
+ (error.message.includes("abort") ||
380
+ error.message.includes("cancel") ||
381
+ error.name === "AbortError");
382
+ const status = isAbort
383
+ ? "aborted"
384
+ : "failed";
385
+ let errorMessage;
386
+ if (attempt.timedOut && task.timeout !== undefined) {
387
+ errorMessage = "Subagent timed out.";
388
+ }
389
+ else if (isAbort) {
390
+ errorMessage =
391
+ "The user manually interrupted this subagent batch.";
392
+ }
393
+ else {
394
+ errorMessage =
395
+ error instanceof Error
396
+ ? error.message
397
+ : String(error);
398
+ }
399
+ return {
400
+ task,
401
+ agentId: attempt.state.agentId,
402
+ status,
403
+ state: attempt.state.started ? "started" : "not_started",
404
+ error: errorMessage,
405
+ };
406
+ }
407
+ // -----------------------------------------------------------------------
408
+ // Rate-limit phase
409
+ // -----------------------------------------------------------------------
410
+ enterRateLimitPhase() {
411
+ this.rateLimitMode = true;
412
+ this.clearNormalTimer();
413
+ this.rateLimitCapacity = Math.max(1, this.countReadyActive());
414
+ this.lastRateLimitAt = Date.now();
415
+ this.globalRetryIntervalMs = RATE_LIMIT_RETRY_BASE_MS;
416
+ this.nextRateLimitLaunchAt = Date.now() + RATE_LIMIT_RETRY_BASE_MS;
417
+ }
418
+ shrinkRateLimitCapacity(now) {
419
+ if (this.lastCapacityShrinkAt !== undefined &&
420
+ now - this.lastCapacityShrinkAt <
421
+ RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS) {
422
+ return;
423
+ }
424
+ this.lastCapacityShrinkAt = now;
425
+ this.rateLimitCapacity = Math.max(1, this.rateLimitCapacity - 1);
426
+ this.globalRetryIntervalMs = Math.min(this.globalRetryIntervalMs * RATE_LIMIT_RETRY_FACTOR, 120_000);
427
+ }
428
+ recoverRateLimitCapacity(now) {
429
+ if (this.lastRateLimitAt === undefined)
430
+ return;
431
+ const quietPeriod = now - this.lastRateLimitAt;
432
+ if (quietPeriod < RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS)
433
+ return;
434
+ const lastRecovery = this.lastCapacityRecoveryAt ?? 0;
435
+ if (now - lastRecovery < RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS)
436
+ return;
437
+ this.lastCapacityRecoveryAt = now;
438
+ this.rateLimitCapacity += 1;
439
+ }
440
+ // -----------------------------------------------------------------------
441
+ // Completion
442
+ // -----------------------------------------------------------------------
443
+ finishIfComplete() {
444
+ const allDone = this.results.every((r) => r !== undefined);
445
+ if (allDone) {
446
+ this.finish(this.results);
447
+ return true;
448
+ }
449
+ return false;
450
+ }
451
+ finish(results) {
452
+ if (this.finished)
453
+ return;
454
+ this.finished = true;
455
+ this.clearNormalTimer();
456
+ this.clearRateLimitTimer();
457
+ this.batchSignal?.removeEventListener("abort", this.batchAbortListener);
458
+ this.resolve?.(results);
459
+ }
460
+ finishWithUserCancellation() {
461
+ if (this.finished)
462
+ return;
463
+ // Preserve existing results
464
+ for (let i = 0; i < this.states.length; i += 1) {
465
+ if (this.results[i] !== undefined)
466
+ continue;
467
+ const state = this.states[i];
468
+ if (state.started || state.agentId !== undefined) {
469
+ this.results[i] = {
470
+ task: state.task,
471
+ agentId: state.agentId,
472
+ status: "aborted",
473
+ state: "started",
474
+ error: "Cancelled by user.",
475
+ };
476
+ }
477
+ else {
478
+ this.results[i] = {
479
+ task: state.task,
480
+ status: "aborted",
481
+ state: "not_started",
482
+ error: "Cancelled by user.",
483
+ };
484
+ }
485
+ }
486
+ this.finish(this.results);
487
+ }
488
+ fail(error) {
489
+ if (this.finished)
490
+ return;
491
+ this.finished = true;
492
+ this.clearNormalTimer();
493
+ this.clearRateLimitTimer();
494
+ // Abort all active attempts
495
+ for (const attempt of this.active) {
496
+ attempt.cleanup();
497
+ }
498
+ this.active.clear();
499
+ this.batchSignal?.removeEventListener("abort", this.batchAbortListener);
500
+ this.reject?.(error);
501
+ }
502
+ // -----------------------------------------------------------------------
503
+ // Timer management
504
+ // -----------------------------------------------------------------------
505
+ clearNormalTimer() {
506
+ if (this.normalLaunchTimer !== undefined) {
507
+ clearTimeout(this.normalLaunchTimer);
508
+ this.normalLaunchTimer = undefined;
509
+ }
510
+ }
511
+ clearRateLimitTimer() {
512
+ if (this.rateLimitLaunchTimer !== undefined) {
513
+ clearTimeout(this.rateLimitLaunchTimer);
514
+ this.rateLimitLaunchTimer = undefined;
515
+ }
516
+ }
517
+ scheduleRateLimitWakeup(wakeAt, now) {
518
+ const delay = Math.max(0, wakeAt - now);
519
+ this.rateLimitLaunchTimer = setTimeout(() => {
520
+ this.rateLimitLaunchTimer = undefined;
521
+ this.schedule();
522
+ }, delay);
523
+ }
524
+ scheduleNextRateLimitWakeup(now) {
525
+ const next = Math.min(this.nextRateLimitLaunchAt, this.nextPendingReadyAt(), this.nextRateLimitCapacityRecoveryAt());
526
+ if (next > now) {
527
+ this.scheduleRateLimitWakeup(next, now);
528
+ }
529
+ }
530
+ // -----------------------------------------------------------------------
531
+ // Helpers
532
+ // -----------------------------------------------------------------------
533
+ markAttemptReady(attempt) {
534
+ attempt.ready = true;
535
+ if (!this.rateLimitMode &&
536
+ this.startedSuccessCount === 0) {
537
+ this.startedSuccessCount = 1;
538
+ }
539
+ }
540
+ countReadyActive() {
541
+ let count = 0;
542
+ for (const a of this.active) {
543
+ if (a.ready)
544
+ count += 1;
545
+ }
546
+ return count;
547
+ }
548
+ nextPendingReadyAt() {
549
+ let earliest = Infinity;
550
+ for (const state of this.pending) {
551
+ if (state.retryReadyAt < earliest) {
552
+ earliest = state.retryReadyAt;
553
+ }
554
+ }
555
+ return earliest === Infinity ? 0 : earliest;
556
+ }
557
+ nextRateLimitCapacityRecoveryAt() {
558
+ if (this.lastRateLimitAt === undefined)
559
+ return Infinity;
560
+ const nextRecovery = (this.lastCapacityRecoveryAt ?? this.lastRateLimitAt) +
561
+ RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS;
562
+ return nextRecovery;
563
+ }
564
+ linkAttemptSignals(attempt, task) {
565
+ const abortFromBatch = () => {
566
+ attempt.controller.abort(this.controller.signal.reason);
567
+ };
568
+ const abortFromTask = () => {
569
+ attempt.controller.abort(task.signal?.reason);
570
+ };
571
+ let timeoutHandle;
572
+ if (task.timeout !== undefined && task.timeout > 0) {
573
+ timeoutHandle = setTimeout(() => {
574
+ attempt.timedOut = true;
575
+ attempt.controller.abort(new Error("Aborted"));
576
+ }, task.timeout);
577
+ }
578
+ if (this.controller.signal.aborted) {
579
+ abortFromBatch();
580
+ }
581
+ else if (task.signal?.aborted) {
582
+ abortFromTask();
583
+ }
584
+ else {
585
+ this.controller.signal.addEventListener("abort", abortFromBatch, { once: true });
586
+ task.signal?.addEventListener("abort", abortFromTask, {
587
+ once: true,
588
+ });
589
+ }
590
+ return () => {
591
+ if (timeoutHandle !== undefined)
592
+ clearTimeout(timeoutHandle);
593
+ this.controller.signal.removeEventListener("abort", abortFromBatch);
594
+ task.signal?.removeEventListener("abort", abortFromTask);
595
+ };
596
+ }
597
+ }
598
+ // ---------------------------------------------------------------------------
599
+ // Environment helpers
600
+ // ---------------------------------------------------------------------------
601
+ /**
602
+ * Resolve the optional swarm max concurrency from pi settings.json
603
+ * or the environment variable.
604
+ *
605
+ * Priority:
606
+ * 1. `.pi/settings.json` → `pi-swarm.maxConcurrency` (project-local)
607
+ * 2. `~/.pi/agent/settings.json` → `pi-swarm.maxConcurrency` (global)
608
+ * 3. `PI_SWARM_MAX_CONCURRENCY` env var
609
+ *
610
+ * Returns `undefined` when unset. A present value must be a positive
611
+ * integer; invalid input throws so a misconfigured cap never silently
612
+ * reverts to uncapped.
613
+ */
614
+ export function resolveSwarmMaxConcurrency(cwd) {
615
+ // 1. Project-local settings
616
+ const projectSettings = readPiSettings(path.join(cwd ?? process.cwd(), ".pi", "settings.json"));
617
+ const projectValue = getSettingsMaxConcurrency(projectSettings);
618
+ if (projectValue !== undefined) {
619
+ return validateConcurrency(projectValue, ".pi/settings.json");
620
+ }
621
+ // 2. Global settings
622
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "~";
623
+ const globalSettings = readPiSettings(path.join(home, ".pi", "agent", "settings.json"));
624
+ const globalValue = getSettingsMaxConcurrency(globalSettings);
625
+ if (globalValue !== undefined) {
626
+ return validateConcurrency(globalValue, "~/.pi/agent/settings.json");
627
+ }
628
+ // 3. Environment variable
629
+ const raw = process.env[AGENT_SWARM_MAX_CONCURRENCY_ENV];
630
+ if (raw !== undefined && raw.trim() !== "") {
631
+ return validateConcurrency(Number(raw), AGENT_SWARM_MAX_CONCURRENCY_ENV);
632
+ }
633
+ // 4. Default
634
+ return DEFAULT_MAX_CONCURRENCY;
635
+ }
636
+ function validateConcurrency(value, source) {
637
+ if (value === undefined || value === null)
638
+ return undefined;
639
+ const num = Number(value);
640
+ if (!Number.isInteger(num) || num <= 0) {
641
+ throw new Error(`pi-swarm.maxConcurrency in ${source} must be a positive integer, got ${JSON.stringify(value)}.`);
642
+ }
643
+ return num;
644
+ }
645
+ function readPiSettings(filePath) {
646
+ try {
647
+ if (!fs.existsSync(filePath))
648
+ return null;
649
+ const raw = fs.readFileSync(filePath, "utf-8");
650
+ return JSON.parse(raw);
651
+ }
652
+ catch {
653
+ return null;
654
+ }
655
+ }
656
+ function getSettingsMaxConcurrency(settings) {
657
+ if (!settings)
658
+ return undefined;
659
+ const swarm = settings["pi-swarm"];
660
+ return swarm?.maxConcurrency;
661
+ }
662
+ //# sourceMappingURL=controller.js.map