@bubblebrain-ai/bubble 0.0.21 → 0.0.22

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 (58) hide show
  1. package/dist/agent/abort-errors.d.ts +14 -0
  2. package/dist/agent/abort-errors.js +21 -0
  3. package/dist/agent/budget-ledger.d.ts +41 -0
  4. package/dist/agent/budget-ledger.js +64 -0
  5. package/dist/agent/child-runner.d.ts +55 -0
  6. package/dist/agent/child-runner.js +312 -0
  7. package/dist/agent/profiles.d.ts +8 -0
  8. package/dist/agent/profiles.js +27 -5
  9. package/dist/agent/result-integrator.d.ts +22 -0
  10. package/dist/agent/result-integrator.js +50 -0
  11. package/dist/agent/subagent-control.d.ts +31 -0
  12. package/dist/agent/subagent-control.js +27 -0
  13. package/dist/agent/subagent-lifecycle-reminder.js +11 -2
  14. package/dist/agent/subagent-scheduler.d.ts +95 -0
  15. package/dist/agent/subagent-scheduler.js +256 -0
  16. package/dist/agent/subagent-store.d.ts +41 -0
  17. package/dist/agent/subagent-store.js +149 -0
  18. package/dist/agent/subagent-summary.d.ts +30 -0
  19. package/dist/agent/subagent-summary.js +74 -0
  20. package/dist/agent/worktree.d.ts +29 -0
  21. package/dist/agent/worktree.js +73 -0
  22. package/dist/agent.d.ts +63 -5
  23. package/dist/agent.js +360 -287
  24. package/dist/approval/controller.js +9 -1
  25. package/dist/approval/tool-helper.js +2 -0
  26. package/dist/approval/types.d.ts +17 -1
  27. package/dist/config.d.ts +8 -0
  28. package/dist/config.js +17 -0
  29. package/dist/feishu/agent-host/approval-card.js +9 -0
  30. package/dist/feishu/agent-host/run-driver.js +1 -0
  31. package/dist/main.js +34 -0
  32. package/dist/network/errors.d.ts +28 -0
  33. package/dist/network/errors.js +24 -0
  34. package/dist/orchestrator/default-hooks.js +5 -1
  35. package/dist/prompt/compose.js +3 -0
  36. package/dist/prompt/delegation.d.ts +14 -0
  37. package/dist/prompt/delegation.js +64 -0
  38. package/dist/prompt/task-reminders.d.ts +5 -1
  39. package/dist/prompt/task-reminders.js +10 -2
  40. package/dist/provider-anthropic.js +23 -0
  41. package/dist/provider.js +23 -3
  42. package/dist/slash-commands/commands.js +29 -2
  43. package/dist/slash-commands/types.d.ts +2 -0
  44. package/dist/tools/agent-lifecycle.d.ts +29 -3
  45. package/dist/tools/agent-lifecycle.js +394 -40
  46. package/dist/tools/child-tools.d.ts +31 -0
  47. package/dist/tools/child-tools.js +106 -0
  48. package/dist/tools/index.js +1 -1
  49. package/dist/tui/run.d.ts +11 -1
  50. package/dist/tui/run.js +92 -4
  51. package/dist/tui/session-picker-data.d.ts +18 -0
  52. package/dist/tui/session-picker-data.js +21 -0
  53. package/dist/tui/wordmark.d.ts +2 -0
  54. package/dist/tui/wordmark.js +31 -4
  55. package/dist/tui-ink/approval/approval-dialog.js +10 -0
  56. package/dist/tui-opentui/approval/approval-dialog.js +10 -0
  57. package/dist/types.d.ts +17 -0
  58. package/package.json +1 -1
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ResultIntegrator — turns background child completions into ingestion
3
+ * notices injected before the parent's next inference turn (design doc §5).
4
+ *
5
+ * Injection marks the child as delivered (§3.3), so the lifecycle reminder
6
+ * demotes it to a one-liner and the same summary never appears twice in full
7
+ * form in the parent transcript.
8
+ */
9
+ import type { SubagentThreadRecord } from "./subagent-control.js";
10
+ import type { SubagentStore } from "./subagent-store.js";
11
+ export declare class ResultIntegrator {
12
+ private readonly pending;
13
+ enqueue(agentId: string): void;
14
+ hasPending(): boolean;
15
+ /**
16
+ * Builds notices for children whose results have not yet reached parent
17
+ * context, marking them delivered. Already-delivered children (e.g. the
18
+ * model wait_agent-ed first) are skipped silently.
19
+ */
20
+ drainNotices(store: SubagentStore, now?: number): string[];
21
+ }
22
+ export declare function buildIngestionNotice(record: SubagentThreadRecord): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ResultIntegrator — turns background child completions into ingestion
3
+ * notices injected before the parent's next inference turn (design doc §5).
4
+ *
5
+ * Injection marks the child as delivered (§3.3), so the lifecycle reminder
6
+ * demotes it to a one-liner and the same summary never appears twice in full
7
+ * form in the parent transcript.
8
+ */
9
+ import { fenceChildOutput } from "./subagent-summary.js";
10
+ export class ResultIntegrator {
11
+ pending = [];
12
+ enqueue(agentId) {
13
+ if (!this.pending.includes(agentId)) {
14
+ this.pending.push(agentId);
15
+ }
16
+ }
17
+ hasPending() {
18
+ return this.pending.length > 0;
19
+ }
20
+ /**
21
+ * Builds notices for children whose results have not yet reached parent
22
+ * context, marking them delivered. Already-delivered children (e.g. the
23
+ * model wait_agent-ed first) are skipped silently.
24
+ */
25
+ drainNotices(store, now = Date.now()) {
26
+ const ids = this.pending.splice(0, this.pending.length);
27
+ const notices = [];
28
+ for (const id of ids) {
29
+ const record = store.get(id);
30
+ if (!record || record.deliveredAt !== undefined)
31
+ continue;
32
+ notices.push(buildIngestionNotice(record));
33
+ store.markDelivered(id, now);
34
+ }
35
+ return notices;
36
+ }
37
+ }
38
+ export function buildIngestionNotice(record) {
39
+ const lines = [
40
+ `subagent ${record.nickname} (agent_id: ${record.agentId}) ${record.status}.`,
41
+ ];
42
+ if (record.error) {
43
+ lines.push(`error: ${record.error}`);
44
+ }
45
+ if (record.summary) {
46
+ lines.push(fenceChildOutput(record.summary));
47
+ }
48
+ lines.push("Full result via wait_agent. Do not redo this delegated work.");
49
+ return lines.join("\n");
50
+ }
@@ -1,7 +1,24 @@
1
1
  import type { AgentProfile, AgentProfileSource, SubagentRunResult } from "./profiles.js";
2
2
  import type { ResolvedSubagentRoute } from "./categories.js";
3
+ import type { SubagentWorktree } from "./worktree.js";
3
4
  import type { AgentEvent, ContentPart, Message, ToolUpdate } from "../types.js";
4
5
  export type SubagentThreadStatus = "queued" | "running" | "completed" | "failed" | "blocked" | "cancelled" | "closed";
6
+ /**
7
+ * Why a child run ended. Drives the `resumable` flag and the guidance line
8
+ * rendered in lifecycle tool replies (design doc §3.1) — a resume hint is
9
+ * emitted iff the runtime judged the run resumable, never as a blanket string.
10
+ */
11
+ export type SubagentFinalReason = "completed" | "failed_transient" | "failed_fatal" | "rate_limited_exhausted" | "blocked" | "cancelled_interrupt" | "cancelled_user" | "cancelled_budget" | "cancelled_parent_abort";
12
+ export declare function isResumableReason(reason: SubagentFinalReason): boolean;
13
+ /** Per-child token budget, fixed at dispatch time (design doc §6). */
14
+ export interface SubagentTokenCap {
15
+ /** Soft cap: crossing it injects a wrap-up reminder into the child. */
16
+ soft: number;
17
+ /** Hard cap: crossing it aborts this child only. Updated at turn checks. */
18
+ hard: number;
19
+ /** Ledger tokens already attributed to this child when the run started. */
20
+ baseline: number;
21
+ }
5
22
  export interface SubagentThreadSnapshot {
6
23
  agentId: string;
7
24
  runId: string;
@@ -11,6 +28,8 @@ export interface SubagentThreadSnapshot {
11
28
  category?: string;
12
29
  route?: ResolvedSubagentRoute;
13
30
  status: SubagentThreadStatus;
31
+ finalReason?: SubagentFinalReason;
32
+ resumable?: boolean;
14
33
  task: string;
15
34
  summary: string;
16
35
  toolNotes: string[];
@@ -18,6 +37,12 @@ export interface SubagentThreadSnapshot {
18
37
  error?: string;
19
38
  createdAt: number;
20
39
  updatedAt: number;
40
+ deliveredAt?: number;
41
+ /** 1-based position in the scheduler queue while status is "queued". */
42
+ queuePosition?: number;
43
+ tokenCap?: SubagentTokenCap;
44
+ /** Present for write_worktree children: where the isolated checkout lives. */
45
+ worktree?: SubagentWorktree;
21
46
  }
22
47
  export interface SubagentThreadRecord {
23
48
  agentId: string;
@@ -29,6 +54,7 @@ export interface SubagentThreadRecord {
29
54
  parentToolCallId: string;
30
55
  parentToolName: string;
31
56
  status: SubagentThreadStatus;
57
+ finalReason?: SubagentFinalReason;
32
58
  task: string;
33
59
  summary: string;
34
60
  toolNotes: string[];
@@ -36,6 +62,9 @@ export interface SubagentThreadRecord {
36
62
  error?: string;
37
63
  createdAt: number;
38
64
  updatedAt: number;
65
+ deliveredAt?: number;
66
+ tokenCap?: SubagentTokenCap;
67
+ worktree?: SubagentWorktree;
39
68
  abortController: AbortController;
40
69
  waiters: Set<() => void>;
41
70
  agent?: {
@@ -43,6 +72,7 @@ export interface SubagentThreadRecord {
43
72
  injectSystemReminder(content: string): void;
44
73
  run(input: string | ContentPart[], cwd: string, options?: {
45
74
  abortSignal?: AbortSignal;
75
+ resumeWithoutInput?: boolean;
46
76
  }): AsyncIterable<AgentEvent>;
47
77
  };
48
78
  messages?: Message[];
@@ -55,3 +85,4 @@ export interface PendingSubagentToolUpdate {
55
85
  }
56
86
  export declare function snapshotSubagentThread(record: SubagentThreadRecord): SubagentThreadSnapshot;
57
87
  export declare function subagentResultFromThread(record: SubagentThreadRecord): SubagentRunResult;
88
+ export declare function isFinalSubagentThreadStatus(status: SubagentThreadStatus): boolean;
@@ -1,3 +1,18 @@
1
+ export function isResumableReason(reason) {
2
+ switch (reason) {
3
+ case "failed_transient":
4
+ case "rate_limited_exhausted":
5
+ case "cancelled_interrupt":
6
+ case "cancelled_user":
7
+ case "cancelled_parent_abort":
8
+ return true;
9
+ case "completed":
10
+ case "failed_fatal":
11
+ case "blocked":
12
+ case "cancelled_budget":
13
+ return false;
14
+ }
15
+ }
1
16
  export function snapshotSubagentThread(record) {
2
17
  return {
3
18
  agentId: record.agentId,
@@ -8,6 +23,8 @@ export function snapshotSubagentThread(record) {
8
23
  category: record.category,
9
24
  route: record.route,
10
25
  status: record.status,
26
+ finalReason: record.finalReason,
27
+ resumable: record.finalReason !== undefined ? isResumableReason(record.finalReason) : undefined,
11
28
  task: record.task,
12
29
  summary: record.summary,
13
30
  toolNotes: [...record.toolNotes],
@@ -15,6 +32,9 @@ export function snapshotSubagentThread(record) {
15
32
  error: record.error,
16
33
  createdAt: record.createdAt,
17
34
  updatedAt: record.updatedAt,
35
+ deliveredAt: record.deliveredAt,
36
+ tokenCap: record.tokenCap ? { ...record.tokenCap } : undefined,
37
+ worktree: record.worktree ? { ...record.worktree } : undefined,
18
38
  };
19
39
  }
20
40
  export function subagentResultFromThread(record) {
@@ -40,3 +60,10 @@ export function subagentResultFromThread(record) {
40
60
  error: record.error,
41
61
  };
42
62
  }
63
+ export function isFinalSubagentThreadStatus(status) {
64
+ return status === "completed"
65
+ || status === "failed"
66
+ || status === "blocked"
67
+ || status === "cancelled"
68
+ || status === "closed";
69
+ }
@@ -1,6 +1,11 @@
1
+ const FINAL_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "closed"]);
1
2
  const STATUS_ORDER = ["queued", "running", "completed", "blocked", "failed", "cancelled", "closed"];
2
3
  export function buildSubagentLifecycleReminder(snapshots, toolResults) {
3
- const subagents = collectUniqueSubagents(snapshots, toolResults);
4
+ // Closed children whose result already reached parent context carry no
5
+ // remaining information — pruning them keeps the reminder from growing by
6
+ // one line per finished child forever (design §3.3).
7
+ const subagents = collectUniqueSubagents(snapshots, toolResults)
8
+ .filter((subagent) => !(subagent.status === "closed" && subagent.delivered));
4
9
  if (subagents.length === 0)
5
10
  return undefined;
6
11
  const counts = statusCounts(subagents);
@@ -37,6 +42,7 @@ function collectUniqueSubagents(snapshots, toolResults) {
37
42
  status: snapshot.status,
38
43
  summary: snapshot.summary,
39
44
  error: snapshot.error,
45
+ delivered: snapshot.deliveredAt !== undefined,
40
46
  });
41
47
  }
42
48
  return [...byId.values()].sort((a, b) => a.agentId.localeCompare(b.agentId));
@@ -87,7 +93,10 @@ function formatSubagentLine(subagent) {
87
93
  const label = subagent.nickname || subagent.agentName || subagent.agentId;
88
94
  const role = [subagent.agentName, subagent.category ? `/${subagent.category}` : ""].join("") || "default";
89
95
  const status = subagent.status || "unknown";
90
- const note = subagent.error || subagent.summary;
96
+ // A delivered final result already reached parent context in full — repeat
97
+ // only the id and status, never the summary again (design §3.3).
98
+ const demoted = subagent.delivered && !!subagent.status && FINAL_STATUSES.has(subagent.status);
99
+ const note = demoted ? undefined : subagent.error || subagent.summary;
91
100
  const suffix = note ? `; note=${truncateForReminder(oneLine(note))}` : "";
92
101
  return ` - ${label} (${role}) agent_id=${subagent.agentId} status=${status}${suffix}`;
93
102
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * SubagentScheduler — the single dispatch point for child runs.
3
+ *
4
+ * Every code path that starts or restarts a child thread (spawn_agent,
5
+ * send_input restarts, team members, rate-limit retries) submits a
6
+ * DispatchRequest here; nothing calls the thread runner directly
7
+ * (design doc §4). Responsibilities:
8
+ *
9
+ * - admission queueing with a global active cap and per-category caps,
10
+ * released eligibility-FIFO (a blocked queue head never starves entries of
11
+ * other categories, §4.3);
12
+ * - launch-rate throttling: an initial burst starts immediately, further
13
+ * launches are spaced out — this throttles launch *rate*, not concurrency;
14
+ * - queued-entry cancellation: aborting a queued entry removes it atomically
15
+ * and never consumes a slot (§4.4);
16
+ * - rate-limit retry: a run that reports `rate_limited` keeps its agent
17
+ * instance, re-enters the queue after a backoff honoring retryAfterMs, and
18
+ * the scheduler is the only 429 backoff layer (§4.5);
19
+ * - AIMD capacity: each 429 shrinks effective global capacity by 1 (min 1, at
20
+ * most once per window), a quiet period grows it back (§4.5).
21
+ */
22
+ export type SubagentRunOutcome = {
23
+ kind: "final";
24
+ } | {
25
+ kind: "rate_limited";
26
+ retryAfterMs?: number;
27
+ };
28
+ export interface DispatchRequest {
29
+ agentId: string;
30
+ category?: string;
31
+ /** Composed abort signal (parent ∪ child controller ∪ budget). */
32
+ signal?: AbortSignal;
33
+ /** Runs the child thread. attempt is 1-based; >1 means rate-limit re-entry. */
34
+ run: (ctx: {
35
+ attempt: number;
36
+ }) => Promise<SubagentRunOutcome>;
37
+ /** Finalize the record when the entry is aborted while still queued. */
38
+ onCancelledWhileQueued: (reason: unknown) => void;
39
+ /** Finalize the record when rate-limit retries are exhausted. */
40
+ onRateLimitExhausted: (attempts: number) => void;
41
+ }
42
+ export interface SubagentSchedulerOptions {
43
+ maxActiveSubagents?: number;
44
+ /** Per-category concurrency limits; undefined means no category cap. */
45
+ getCategoryLimit?: (category: string) => number | undefined;
46
+ /** Number of immediate launches before rate spacing applies. Default 4. */
47
+ launchBurst?: number;
48
+ /** Minimum spacing between launches beyond the burst. Default 500ms (0 under NODE_ENV=test). */
49
+ launchIntervalMs?: number;
50
+ rateLimitMaxAttempts?: number;
51
+ /** Backoff per attempt when the provider gave no retry-after. Default 3s/6s/12s (0 under NODE_ENV=test). */
52
+ rateLimitBackoffMs?: number[];
53
+ /** AIMD: minimum spacing between capacity decreases. Default 2s. */
54
+ aimdDecreaseIntervalMs?: number;
55
+ /** AIMD: quiet period after which capacity grows by 1. Default 3min. */
56
+ aimdIncreaseAfterMs?: number;
57
+ now?: () => number;
58
+ }
59
+ export declare class SubagentScheduler {
60
+ private readonly queue;
61
+ private readonly activeIds;
62
+ private readonly activeByCategory;
63
+ private readonly maxActive;
64
+ private readonly getCategoryLimit;
65
+ private readonly launchBurst;
66
+ private readonly launchIntervalMs;
67
+ private readonly rateLimitMaxAttempts;
68
+ private readonly rateLimitBackoffMs;
69
+ private readonly aimdDecreaseIntervalMs;
70
+ private readonly aimdIncreaseAfterMs;
71
+ private readonly now;
72
+ private burstRemaining;
73
+ private nextLaunchAt;
74
+ private aimdCapacity;
75
+ private lastCapacityDecreaseAt;
76
+ private lastRateLimitAt;
77
+ private pumpTimer;
78
+ constructor(options?: SubagentSchedulerOptions);
79
+ /** Resolves when the child run reaches a final state (or queue cancellation). */
80
+ dispatch(request: DispatchRequest): Promise<void>;
81
+ queuePosition(agentId: string): number | undefined;
82
+ activeCount(): number;
83
+ queuedCount(): number;
84
+ /** Effective global concurrency cap, AIMD-adjusted. */
85
+ effectiveCapacity(): number;
86
+ private cancelQueuedEntry;
87
+ private maybeGrowCapacity;
88
+ private notifyRateLimited;
89
+ private eligible;
90
+ private pump;
91
+ private scheduleWakeup;
92
+ private launch;
93
+ private releaseSlot;
94
+ private settle;
95
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * SubagentScheduler — the single dispatch point for child runs.
3
+ *
4
+ * Every code path that starts or restarts a child thread (spawn_agent,
5
+ * send_input restarts, team members, rate-limit retries) submits a
6
+ * DispatchRequest here; nothing calls the thread runner directly
7
+ * (design doc §4). Responsibilities:
8
+ *
9
+ * - admission queueing with a global active cap and per-category caps,
10
+ * released eligibility-FIFO (a blocked queue head never starves entries of
11
+ * other categories, §4.3);
12
+ * - launch-rate throttling: an initial burst starts immediately, further
13
+ * launches are spaced out — this throttles launch *rate*, not concurrency;
14
+ * - queued-entry cancellation: aborting a queued entry removes it atomically
15
+ * and never consumes a slot (§4.4);
16
+ * - rate-limit retry: a run that reports `rate_limited` keeps its agent
17
+ * instance, re-enters the queue after a backoff honoring retryAfterMs, and
18
+ * the scheduler is the only 429 backoff layer (§4.5);
19
+ * - AIMD capacity: each 429 shrinks effective global capacity by 1 (min 1, at
20
+ * most once per window), a quiet period grows it back (§4.5).
21
+ */
22
+ const TEST_ENV = process.env.NODE_ENV === "test";
23
+ export class SubagentScheduler {
24
+ queue = [];
25
+ activeIds = new Set();
26
+ activeByCategory = new Map();
27
+ maxActive;
28
+ getCategoryLimit;
29
+ launchBurst;
30
+ launchIntervalMs;
31
+ rateLimitMaxAttempts;
32
+ rateLimitBackoffMs;
33
+ aimdDecreaseIntervalMs;
34
+ aimdIncreaseAfterMs;
35
+ now;
36
+ burstRemaining;
37
+ nextLaunchAt = 0;
38
+ aimdCapacity = null;
39
+ lastCapacityDecreaseAt = 0;
40
+ lastRateLimitAt = 0;
41
+ pumpTimer;
42
+ constructor(options = {}) {
43
+ this.maxActive = Math.max(1, options.maxActiveSubagents ?? 8);
44
+ this.getCategoryLimit = options.getCategoryLimit ?? (() => undefined);
45
+ this.launchBurst = Math.max(1, options.launchBurst ?? 4);
46
+ this.launchIntervalMs = options.launchIntervalMs ?? (TEST_ENV ? 0 : 500);
47
+ this.rateLimitMaxAttempts = Math.max(1, options.rateLimitMaxAttempts ?? 3);
48
+ this.rateLimitBackoffMs = options.rateLimitBackoffMs ?? (TEST_ENV ? [0, 0, 0] : [3_000, 6_000, 12_000]);
49
+ this.aimdDecreaseIntervalMs = options.aimdDecreaseIntervalMs ?? 2_000;
50
+ this.aimdIncreaseAfterMs = options.aimdIncreaseAfterMs ?? 180_000;
51
+ this.now = options.now ?? Date.now;
52
+ this.burstRemaining = this.launchBurst;
53
+ }
54
+ /** Resolves when the child run reaches a final state (or queue cancellation). */
55
+ dispatch(request) {
56
+ return new Promise((resolve) => {
57
+ const entry = {
58
+ request,
59
+ attempt: 0,
60
+ notBefore: 0,
61
+ resolve,
62
+ };
63
+ if (request.signal) {
64
+ if (request.signal.aborted) {
65
+ request.onCancelledWhileQueued(request.signal.reason);
66
+ resolve();
67
+ return;
68
+ }
69
+ const onAbort = () => this.cancelQueuedEntry(entry, request.signal?.reason);
70
+ request.signal.addEventListener("abort", onAbort, { once: true });
71
+ entry.removeAbortListener = () => request.signal?.removeEventListener("abort", onAbort);
72
+ }
73
+ this.queue.push(entry);
74
+ this.pump();
75
+ });
76
+ }
77
+ queuePosition(agentId) {
78
+ const index = this.queue.findIndex((entry) => entry.request.agentId === agentId);
79
+ return index >= 0 ? index + 1 : undefined;
80
+ }
81
+ activeCount() {
82
+ return this.activeIds.size;
83
+ }
84
+ queuedCount() {
85
+ return this.queue.length;
86
+ }
87
+ /** Effective global concurrency cap, AIMD-adjusted. */
88
+ effectiveCapacity() {
89
+ return this.aimdCapacity !== null ? Math.min(this.aimdCapacity, this.maxActive) : this.maxActive;
90
+ }
91
+ cancelQueuedEntry(entry, reason) {
92
+ const index = this.queue.indexOf(entry);
93
+ if (index < 0)
94
+ return; // already launched; the run's own signal handles it
95
+ this.queue.splice(index, 1);
96
+ entry.removeAbortListener?.();
97
+ entry.request.onCancelledWhileQueued(reason);
98
+ entry.resolve();
99
+ this.pump();
100
+ }
101
+ maybeGrowCapacity(now) {
102
+ if (this.aimdCapacity === null)
103
+ return;
104
+ if (now - this.lastRateLimitAt < this.aimdIncreaseAfterMs)
105
+ return;
106
+ this.aimdCapacity += 1;
107
+ this.lastRateLimitAt = now; // next increase needs another quiet window
108
+ if (this.aimdCapacity >= this.maxActive) {
109
+ this.aimdCapacity = null;
110
+ }
111
+ }
112
+ notifyRateLimited(now) {
113
+ if (this.aimdCapacity === null) {
114
+ this.aimdCapacity = Math.max(1, this.activeIds.size);
115
+ this.lastCapacityDecreaseAt = now;
116
+ }
117
+ else if (now - this.lastCapacityDecreaseAt >= this.aimdDecreaseIntervalMs) {
118
+ this.aimdCapacity = Math.max(1, this.aimdCapacity - 1);
119
+ this.lastCapacityDecreaseAt = now;
120
+ }
121
+ this.lastRateLimitAt = now;
122
+ }
123
+ eligible(entry, now) {
124
+ if (entry.notBefore > now)
125
+ return false;
126
+ if (this.activeIds.size >= this.effectiveCapacity())
127
+ return false;
128
+ const category = entry.request.category;
129
+ if (category) {
130
+ const limit = this.getCategoryLimit(category);
131
+ if (limit !== undefined && (this.activeByCategory.get(category) ?? 0) >= limit) {
132
+ return false;
133
+ }
134
+ }
135
+ return true;
136
+ }
137
+ pump() {
138
+ const now = this.now();
139
+ this.maybeGrowCapacity(now);
140
+ while (true) {
141
+ if (this.queue.length === 0) {
142
+ if (this.activeIds.size === 0) {
143
+ this.burstRemaining = this.launchBurst;
144
+ }
145
+ return;
146
+ }
147
+ // Eligibility-FIFO: first entry that satisfies both the global and its
148
+ // category limit launches; a capacity-blocked head does not starve the rest.
149
+ const entry = this.queue.find((candidate) => this.eligible(candidate, now));
150
+ if (!entry) {
151
+ this.scheduleWakeup(now);
152
+ return;
153
+ }
154
+ // Launch-rate throttle (burst, then spacing).
155
+ if (this.burstRemaining > 0) {
156
+ this.burstRemaining -= 1;
157
+ }
158
+ else if (now >= this.nextLaunchAt) {
159
+ this.nextLaunchAt = now + this.launchIntervalMs;
160
+ }
161
+ else {
162
+ this.scheduleWakeup(now, this.nextLaunchAt);
163
+ return;
164
+ }
165
+ this.launch(entry);
166
+ }
167
+ }
168
+ scheduleWakeup(now, explicitAt) {
169
+ const candidates = [];
170
+ if (explicitAt !== undefined)
171
+ candidates.push(explicitAt);
172
+ for (const entry of this.queue) {
173
+ if (entry.notBefore > now)
174
+ candidates.push(entry.notBefore);
175
+ }
176
+ if (this.aimdCapacity !== null) {
177
+ candidates.push(this.lastRateLimitAt + this.aimdIncreaseAfterMs);
178
+ }
179
+ if (candidates.length === 0)
180
+ return; // a slot release will pump
181
+ const at = Math.min(...candidates);
182
+ const delay = Math.max(1, at - now);
183
+ if (this.pumpTimer)
184
+ clearTimeout(this.pumpTimer);
185
+ this.pumpTimer = setTimeout(() => {
186
+ this.pumpTimer = undefined;
187
+ this.pump();
188
+ }, delay);
189
+ if (typeof this.pumpTimer === "object" && "unref" in this.pumpTimer) {
190
+ this.pumpTimer.unref();
191
+ }
192
+ }
193
+ launch(entry) {
194
+ const index = this.queue.indexOf(entry);
195
+ if (index >= 0)
196
+ this.queue.splice(index, 1);
197
+ entry.removeAbortListener?.();
198
+ entry.removeAbortListener = undefined;
199
+ const { request } = entry;
200
+ this.activeIds.add(request.agentId);
201
+ if (request.category) {
202
+ this.activeByCategory.set(request.category, (this.activeByCategory.get(request.category) ?? 0) + 1);
203
+ }
204
+ entry.attempt += 1;
205
+ void request
206
+ .run({ attempt: entry.attempt })
207
+ .then((outcome) => this.settle(entry, outcome))
208
+ .catch(() => this.settle(entry, { kind: "final" }))
209
+ .finally(() => {
210
+ // Slot release lives here so every exit path — completion, failure,
211
+ // cancellation, thrown errors, early returns inside the runner —
212
+ // releases exactly once before the next pump.
213
+ this.releaseSlot(request);
214
+ this.pump();
215
+ });
216
+ }
217
+ releaseSlot(request) {
218
+ this.activeIds.delete(request.agentId);
219
+ if (request.category) {
220
+ const current = this.activeByCategory.get(request.category) ?? 0;
221
+ if (current <= 1)
222
+ this.activeByCategory.delete(request.category);
223
+ else
224
+ this.activeByCategory.set(request.category, current - 1);
225
+ }
226
+ }
227
+ settle(entry, outcome) {
228
+ if (outcome.kind === "final") {
229
+ entry.resolve();
230
+ return;
231
+ }
232
+ const now = this.now();
233
+ this.notifyRateLimited(now);
234
+ if (entry.attempt >= this.rateLimitMaxAttempts) {
235
+ entry.request.onRateLimitExhausted(entry.attempt);
236
+ entry.resolve();
237
+ return;
238
+ }
239
+ if (entry.request.signal?.aborted) {
240
+ entry.request.onCancelledWhileQueued(entry.request.signal.reason);
241
+ entry.resolve();
242
+ return;
243
+ }
244
+ const backoff = outcome.retryAfterMs
245
+ ?? this.rateLimitBackoffMs[Math.min(entry.attempt - 1, this.rateLimitBackoffMs.length - 1)]
246
+ ?? 0;
247
+ entry.notBefore = now + Math.max(0, backoff);
248
+ if (entry.request.signal) {
249
+ const onAbort = () => this.cancelQueuedEntry(entry, entry.request.signal?.reason);
250
+ entry.request.signal.addEventListener("abort", onAbort, { once: true });
251
+ entry.removeAbortListener = () => entry.request.signal?.removeEventListener("abort", onAbort);
252
+ }
253
+ this.queue.push(entry);
254
+ // Re-entries skip the burst accounting; spacing alone applies.
255
+ }
256
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * SubagentStore — single source of truth for child thread state.
3
+ *
4
+ * `list_agents`, the lifecycle reminder, TUI metadata, and persistence all
5
+ * read from this store; there is never a second copy of state (design §2).
6
+ *
7
+ * Persistence (design §7): final-state children are written to
8
+ * `<persistDir>/<agentId>.json` as snapshot + compacted message history, so a
9
+ * later process can resume them via send_input. The on-disk schema carries
10
+ * `finalReason` / `resumable` / `deliveredAt` / `tokenCap` — the fields the
11
+ * reply protocol and delivery dedup depend on. Child transcripts never mix
12
+ * into the parent transcript.
13
+ */
14
+ import { type SubagentThreadRecord, type SubagentThreadStatus } from "./subagent-control.js";
15
+ export declare class SubagentStore {
16
+ private readonly persistDir?;
17
+ private readonly threads;
18
+ constructor(persistDir?: string | undefined);
19
+ get(agentId: string): SubagentThreadRecord | undefined;
20
+ set(record: SubagentThreadRecord): void;
21
+ values(): SubagentThreadRecord[];
22
+ active(): SubagentThreadRecord[];
23
+ activeCount(): number;
24
+ byStatus(status: SubagentThreadStatus): SubagentThreadRecord[];
25
+ /**
26
+ * Marks the moment a child's full summary first reached parent context
27
+ * (via a wait_agent reply or an ingestion notice). Used to deduplicate the
28
+ * three delivery channels (design §3.3). Idempotent.
29
+ */
30
+ markDelivered(agentId: string, at?: number): void;
31
+ notifyWaiters(record: SubagentThreadRecord): void;
32
+ /** Writes a final-state child to disk so a later process can resume it. */
33
+ persist(record: SubagentThreadRecord): void;
34
+ /**
35
+ * Loads previously persisted children. Records come back in their final
36
+ * state with the child history staged on `record.messages`; the next
37
+ * dispatch rebuilds an Agent instance from it (cross-restart resume, §7).
38
+ * In-memory records always win over disk.
39
+ */
40
+ loadPersisted(): void;
41
+ }