@aragon-agent/cli 0.5.2 → 0.5.3

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 (83) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +128 -1
  3. package/dist/agent/controller.d.ts +51 -1
  4. package/dist/agent/controller.js +177 -2
  5. package/dist/agent/headless.d.ts +12 -0
  6. package/dist/agent/headless.js +41 -1
  7. package/dist/agent/reducer.d.ts +48 -0
  8. package/dist/agent/reducer.js +91 -2
  9. package/dist/agent/system-prompt.d.ts +17 -0
  10. package/dist/agent/system-prompt.js +4 -0
  11. package/dist/cli.js +66 -2
  12. package/dist/commands/builtins.js +82 -0
  13. package/dist/config/app-paths.d.ts +17 -0
  14. package/dist/config/app-paths.js +21 -0
  15. package/dist/config/cli-commands.d.ts +26 -2
  16. package/dist/config/cli-commands.js +63 -4
  17. package/dist/config/env.d.ts +2 -2
  18. package/dist/config/env.js +28 -2
  19. package/dist/config/history-commands.d.ts +20 -0
  20. package/dist/config/history-commands.js +71 -0
  21. package/dist/config/load.d.ts +13 -0
  22. package/dist/config/load.js +65 -5
  23. package/dist/config/migrate-state-out-of-config.d.ts +32 -0
  24. package/dist/config/migrate-state-out-of-config.js +85 -0
  25. package/dist/config/prompt-history.d.ts +86 -0
  26. package/dist/config/prompt-history.js +332 -0
  27. package/dist/config/schema.d.ts +112 -16
  28. package/dist/config/schema.js +89 -6
  29. package/dist/config/store.js +13 -4
  30. package/dist/config/ui-state.d.ts +52 -0
  31. package/dist/config/ui-state.js +149 -0
  32. package/dist/logging/install.d.ts +85 -0
  33. package/dist/logging/install.js +94 -0
  34. package/dist/logging/logger.d.ts +1 -1
  35. package/dist/session/persist.d.ts +16 -0
  36. package/dist/session/persist.js +19 -1
  37. package/dist/team/activity.d.ts +67 -0
  38. package/dist/team/activity.js +130 -0
  39. package/dist/team/bus.d.ts +190 -0
  40. package/dist/team/bus.js +366 -0
  41. package/dist/team/comm-tools.d.ts +61 -0
  42. package/dist/team/comm-tools.js +221 -0
  43. package/dist/team/human-queue.d.ts +55 -0
  44. package/dist/team/human-queue.js +72 -0
  45. package/dist/team/limits.d.ts +123 -0
  46. package/dist/team/limits.js +128 -0
  47. package/dist/team/normalize.d.ts +39 -0
  48. package/dist/team/normalize.js +106 -0
  49. package/dist/team/panel-rows.d.ts +48 -0
  50. package/dist/team/panel-rows.js +75 -0
  51. package/dist/team/prompt.d.ts +49 -0
  52. package/dist/team/prompt.js +87 -0
  53. package/dist/team/report.d.ts +58 -0
  54. package/dist/team/report.js +205 -0
  55. package/dist/team/retry.d.ts +48 -0
  56. package/dist/team/retry.js +56 -0
  57. package/dist/team/runtime.d.ts +122 -0
  58. package/dist/team/runtime.js +406 -0
  59. package/dist/team/subagent.d.ts +90 -0
  60. package/dist/team/subagent.js +294 -0
  61. package/dist/team/task-tool.d.ts +47 -0
  62. package/dist/team/task-tool.js +118 -0
  63. package/dist/team/types.d.ts +172 -0
  64. package/dist/team/types.js +13 -0
  65. package/dist/tools/index.d.ts +48 -2
  66. package/dist/tools/index.js +26 -1
  67. package/dist/ui/App.js +97 -13
  68. package/dist/ui/StatusBar.d.ts +21 -0
  69. package/dist/ui/StatusBar.js +5 -2
  70. package/dist/ui/TeamPanel.d.ts +57 -0
  71. package/dist/ui/TeamPanel.js +134 -0
  72. package/dist/ui/Transcript.js +10 -0
  73. package/dist/ui/entries/TeamCard.d.ts +31 -0
  74. package/dist/ui/entries/TeamCard.js +39 -0
  75. package/dist/ui/glyphs.d.ts +10 -0
  76. package/dist/ui/glyphs.js +6 -0
  77. package/dist/ui/layout/AppShell.d.ts +11 -1
  78. package/dist/ui/layout/AppShell.js +3 -3
  79. package/dist/ui/overlays/HelpOverlay.js +12 -0
  80. package/dist/ui/transcript-text.js +20 -0
  81. package/dist/ui/use-startup-notices.d.ts +1 -1
  82. package/dist/ui/use-startup-notices.js +2 -1
  83. package/package.json +2 -2
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Activity strings for the live roster (team-live-activity F-1).
3
+ *
4
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope. That rule binds
5
+ * the LITERALS IN THIS FILE, not the runtime data flowing through it - a child
6
+ * may well be reading a CJK path, and `run.description` already carries model
7
+ * text into the same row.
8
+ */
9
+ import { TEAM_LIMITS } from './limits.js';
10
+ /** Pick at capture time. Called once per `tool_execution_start`. */
11
+ export function pickActivityArgs(args) {
12
+ const a = (args ?? {});
13
+ const str = (v) => typeof v === 'string' && v.length > 0 ? v.slice(0, TEAM_LIMITS.activityArgChars) : undefined;
14
+ const picked = {};
15
+ const path = str(a.path);
16
+ const pattern = str(a.pattern);
17
+ const command = str(a.command);
18
+ const to = str(a.to);
19
+ const from = str(a.from);
20
+ if (path !== undefined)
21
+ picked.path = path;
22
+ if (pattern !== undefined)
23
+ picked.pattern = pattern;
24
+ if (command !== undefined)
25
+ picked.command = command;
26
+ if (to !== undefined)
27
+ picked.to = to;
28
+ if (from !== undefined)
29
+ picked.from = from;
30
+ return picked;
31
+ }
32
+ /**
33
+ * Strip anything that could corrupt the frame, collapse whitespace, and clamp.
34
+ *
35
+ * THE CONTROL-CHARACTER STRIP IS A CORRECTNESS BOUNDARY, NOT TIDINESS. This
36
+ * string is model-chosen and reaches an Ink <Text> unescaped. A child running
37
+ * `bash` with an ANSI escape in the command line would otherwise write that
38
+ * escape into the roster: at best a mangled row, at worst a cleared screen or a
39
+ * moved cursor in the middle of someone's transcript. `bash`'s own output never
40
+ * reaches the panel; its ARGUMENTS now do.
41
+ *
42
+ * SURROGATES ARE GUARDED AT BOTH ENDS (P2-3). The trailing guard is the obvious
43
+ * one. The LEADING one is needed because the caller feeds this a rolling tail
44
+ * built with `String.slice(-n)`, which cuts on a UTF-16 code-unit boundary and
45
+ * can therefore hand us a lone LOW surrogate at index 0. Either half of a split
46
+ * pair renders as a replacement character - the same failure `truncateBytes`
47
+ * guards one layer down (report.ts).
48
+ */
49
+ export function sanitizeActivity(raw, maxChars) {
50
+ let src = raw;
51
+ const head = src.charCodeAt(0);
52
+ if (head >= 0xdc00 && head <= 0xdfff)
53
+ src = src.slice(1);
54
+ const flat = src
55
+ // eslint-disable-next-line no-control-regex
56
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
57
+ .replace(/\s+/g, ' ')
58
+ .trim();
59
+ if (maxChars <= 0)
60
+ return '';
61
+ if (flat.length <= maxChars)
62
+ return flat;
63
+ if (maxChars <= 3)
64
+ return flat.slice(0, maxChars);
65
+ let cut = maxChars - 3;
66
+ const code = flat.charCodeAt(cut - 1);
67
+ if (code >= 0xd800 && code <= 0xdbff)
68
+ cut -= 1;
69
+ return `${flat.slice(0, cut)}...`;
70
+ }
71
+ /** The basename of a path, for the narrow form. Pure string work: no `path`. */
72
+ function basename(p) {
73
+ const parts = p.split(/[\\/]/);
74
+ return parts[parts.length - 1] || p;
75
+ }
76
+ /** The program name of a command line, for the narrow form. */
77
+ function program(commandLine) {
78
+ const first = commandLine.trim().split(/\s+/)[0] ?? '';
79
+ return basename(first) || commandLine;
80
+ }
81
+ /**
82
+ * One short phrase describing a tool call.
83
+ *
84
+ * `wide` false is the narrow-terminal form (D-4). For a path-valued argument
85
+ * that is the basename; for `bash` it is the PROGRAM NAME (P1-6). Running the
86
+ * command line through `basename` is wrong twice over: `basename` splits on `/`,
87
+ * so `npm test -w cli` comes back whole and `./scripts/build.sh --prod` comes
88
+ * back as `build.sh --prod`. Neither is shorter and the second is a lie about
89
+ * what ran.
90
+ *
91
+ * Anything not in the table renders as its own name. That is the correct
92
+ * degradation for `skill_find` and for any tool a later round registers on a
93
+ * child: a name is always true, and inventing an argument mapping for a tool
94
+ * this module has not been taught is how a formatter starts lying.
95
+ */
96
+ export function describeToolActivity(toolName, args, wide) {
97
+ const a = args ?? {};
98
+ switch (toolName) {
99
+ case 'read_file':
100
+ return detail('read', a.path, wide, basename);
101
+ case 'write_file':
102
+ return detail('write', a.path, wide, basename);
103
+ case 'edit_file':
104
+ return detail('edit', a.path, wide, basename);
105
+ case 'list_dir':
106
+ return detail('list', a.path ?? '.', wide, basename);
107
+ case 'glob':
108
+ return detail('glob', a.pattern, wide, basename);
109
+ case 'grep':
110
+ return detail('grep', a.pattern, wide, basename);
111
+ case 'bash':
112
+ return detail('bash', a.command, wide, program);
113
+ case 'team_send': {
114
+ const to = (a.to ?? '').trim();
115
+ return to ? `messaging ${to}` : 'messaging';
116
+ }
117
+ case 'team_wait': {
118
+ const from = (a.from ?? '').trim();
119
+ return from ? `waiting for ${from}` : 'waiting for mail';
120
+ }
121
+ default:
122
+ return toolName;
123
+ }
124
+ }
125
+ function detail(verb, value, wide, narrow) {
126
+ const v = (value ?? '').trim();
127
+ if (v.length === 0)
128
+ return verb;
129
+ return `${verb}: ${wide ? v : narrow(v)}`;
130
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * TeamBus — the subagent message channel (team-subagents §3.6).
3
+ *
4
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
5
+ *
6
+ * The requirement asks for "lei si ren lei jiao liu de dian hua ji zhi, dan bu
7
+ * neng tai pin fan" — a real telephone, used sparingly. This module takes both
8
+ * halves literally: there IS a channel, and it is expensive on purpose. The rate
9
+ * limits in `TEAM_LIMITS` are the requirement, not a compromise (R-9).
10
+ *
11
+ * DELIVERY IS PIGGY-BACKED, NEVER PUSHED (D-4). A message for child B is
12
+ * appended to B's NEXT TOOL RESULT by `withMailboxTail`. `Agent.steer()` looks
13
+ * like the natural push channel and is the wrong answer: the core agent loop
14
+ * treats steering that arrives mid-batch by pushing "Tool execution skipped due
15
+ * to steering interrupt." for EVERY REMAINING TOOL CALL in that batch, so a
16
+ * chatty peer would cancel its colleague's work as a side effect of saying
17
+ * hello. Piggy-backing costs zero extra turns, cannot interfere with the loop,
18
+ * and is guaranteed to be seen by any child that is doing anything at all.
19
+ *
20
+ * EVERY REFUSAL IS A NON-ERROR (D-7). An `errorResult` reads to a model as a
21
+ * malfunction and invites an immediate retry, which is precisely the "tai pin
22
+ * fan" behaviour being designed out. The verdicts below carry a `reason` the
23
+ * caller renders as ordinary text.
24
+ */
25
+ import type { TeamMessage } from './types.js';
26
+ /** The lead's stable address. Subagents are addressed by their `label`. */
27
+ export declare const LEAD_KEY = "lead";
28
+ /** Broadcast address: every other participant, the lead included. */
29
+ export declare const ALL_KEY = "all";
30
+ export type SendVerdict = {
31
+ ok: true;
32
+ delivered: string[];
33
+ spent: number;
34
+ remaining: number;
35
+ } | {
36
+ ok: false;
37
+ reason: 'unknown_recipient';
38
+ recipients: string[];
39
+ } | {
40
+ ok: false;
41
+ reason: 'agent_quota';
42
+ used: number;
43
+ max: number;
44
+ } | {
45
+ ok: false;
46
+ reason: 'dispatch_quota';
47
+ used: number;
48
+ max: number;
49
+ } | {
50
+ ok: false;
51
+ reason: 'too_soon';
52
+ retryInSeconds: number;
53
+ };
54
+ /** Why `team_wait` was refused before it ever blocked (F-3). */
55
+ export type WaitRefusal = {
56
+ reason: 'no_peers';
57
+ } | {
58
+ reason: 'unknown_sender';
59
+ asked: string;
60
+ peers: string[];
61
+ } | {
62
+ reason: 'all_finished';
63
+ who: string[];
64
+ } | {
65
+ reason: 'all_blocked';
66
+ who: string[];
67
+ };
68
+ export interface TeamBusOptions {
69
+ /** Injectable clock so the interval rule is testable without real waiting. */
70
+ now?: () => number;
71
+ /** Called once per delivered message, for the `TeamEvent` stream and the log. */
72
+ onMessage?: (message: TeamMessage) => void;
73
+ /**
74
+ * Whether `label` still has a loop that could call `team_send`.
75
+ *
76
+ * Optional and defaulting to `() => true`, so every existing construction site
77
+ * and every existing test keeps its current behaviour exactly. `TeamRuntime`
78
+ * supplies the real answer from its live handles, and it is a NEGATIVE
79
+ * predicate over TERMINAL phases: a `queued` child has not started yet and
80
+ * will, so "the phase is running" - the obvious implementation - would refuse
81
+ * a wait on children 4 and 5 of a five-way dispatch at the shipped defaults.
82
+ */
83
+ canSend?: (label: string) => boolean;
84
+ }
85
+ export declare class TeamBus {
86
+ private readonly labels;
87
+ private readonly mailboxes;
88
+ private readonly waiters;
89
+ private readonly sentBy;
90
+ private readonly lastSentAt;
91
+ private readonly blocked;
92
+ private readonly lead;
93
+ private readonly now;
94
+ private readonly onMessage?;
95
+ private readonly canSend;
96
+ private total;
97
+ private last?;
98
+ constructor(labels: string[], options?: TeamBusOptions);
99
+ /** Every addressable participant, in dispatch order, with the lead last. */
100
+ participants(): string[];
101
+ /** Sibling labels for the `<subagent_role>` block. */
102
+ peersOf(label: string): string[];
103
+ /** Messages addressed to `lead`, for the report's own section (§3.7). */
104
+ leadMail(): TeamMessage[];
105
+ messageCount(): number;
106
+ lastMessage(): TeamMessage | undefined;
107
+ sentCount(label: string): number;
108
+ /**
109
+ * Canonical label for a model-supplied `from`, or `undefined` (P1-3).
110
+ *
111
+ * PUBLIC, because `makeTeamWait` has to hand the SAME string to
112
+ * `checkWaitable` and to `wait()`. `wait()` and `deliverToWaiter` compare
113
+ * `m.from === opts.from` EXACTLY, so a caller that gate-checks `"A2"`
114
+ * case-insensitively and then passes `"A2"` down would wait the full timeout
115
+ * for a message the delivery filter can never match.
116
+ */
117
+ resolveLabel(raw: string): string | undefined;
118
+ /** `team_wait` calls refused as unanswerable, per label (F-3). */
119
+ recordBlockedWait(label: string): void;
120
+ blockedWaitCount(label: string): number;
121
+ /**
122
+ * Spend quota and deliver.
123
+ *
124
+ * Refusal ORDER is deliberate: the permanent conditions are reported before
125
+ * the temporary one. Telling a child to "try again in 9s" when its quota is
126
+ * already spent sends it back for a second refusal it cannot act on.
127
+ */
128
+ send(from: string, to: string, subjectRaw: string, bodyRaw: string): SendVerdict;
129
+ private resolveRecipients;
130
+ private waiterFor;
131
+ /**
132
+ * Decide whether waiting could possibly pay off (F-3).
133
+ *
134
+ * TAKES `timeoutMs`, NOT `deadlineAt` (P1-1). The deadline is derived here,
135
+ * from the one clock this class has, so it is comparable with every registered
136
+ * `Waiter.deadlineAt`.
137
+ *
138
+ * PURE: no counters, no mutation, no waiter registration. `makeTeamWait`
139
+ * records the refusal, because a predicate with a counter inside it is a
140
+ * predicate people stop trusting (D-6).
141
+ *
142
+ * BIASED TOWARD PERMITTING (D-16). Every branch that cannot be decided returns
143
+ * `null`. A wait that should have been refused costs the child seconds it can
144
+ * see; a refusal that should have been a wait destroys a result the child
145
+ * cannot see was ever possible. The asymmetry is total, so the bias is total.
146
+ */
147
+ checkWaitable(key: string, opts: {
148
+ from?: string;
149
+ timeoutMs: number;
150
+ }): WaitRefusal | null;
151
+ /**
152
+ * Shrink `cannotAct` to its fixpoint, in place (P0-3).
153
+ *
154
+ * Everyone acceptable being stuck is NOT yet a deadlock proof, because a
155
+ * blocked peer can be released EARLY by a third party: `a` waits on `b`, `b`
156
+ * waits on `c`, `c` messages `b` at t=10, `b` resumes and messages `a`. A
157
+ * deadline comparison alone refuses `a` at t=0 and throws that outcome away.
158
+ *
159
+ * So a blocked peer stays in the set only while everyone who could wake it is
160
+ * also in it (or is the caller, who is about to block). If growth stops with
161
+ * an acceptable peer outside the set, that peer can still be woken and the
162
+ * wait must be permitted.
163
+ *
164
+ * Terminates because each pass either removes at least one member or stops. At
165
+ * ten participants the worst case is a hundred set lookups, once per
166
+ * `team_wait`, and `team_wait` is itself rate-limited by the message budget.
167
+ */
168
+ private closeCannotAct;
169
+ /** Take everything queued for `key`. Called by `withMailboxTail` (§3.6). */
170
+ drain(key: string): TeamMessage[];
171
+ /**
172
+ * Block until a matching message arrives, the signal aborts, or the deadline
173
+ * elapses. Resolves `null` on both non-delivery paths — from the model's point
174
+ * of view an abandoned wait and an expired one want the same guidance.
175
+ *
176
+ * THE CALLER PAUSES THE CHILD'S IDLE WATCHDOG around this (see
177
+ * `comm-tools.ts`). A deliberate wait is not a wedged agent, and the child
178
+ * emits nothing while it is here.
179
+ */
180
+ wait(key: string, opts: {
181
+ from?: string;
182
+ timeoutMs: number;
183
+ signal?: AbortSignal;
184
+ }): Promise<TeamMessage | null>;
185
+ /** Settle every outstanding wait with `null`. Called on abort and dispose. */
186
+ cancelAllWaits(): void;
187
+ private deliverToWaiter;
188
+ }
189
+ /** Render queued mail as the `<team_mail>` block appended to a tool result. */
190
+ export declare function renderMailBlock(messages: TeamMessage[]): string;
@@ -0,0 +1,366 @@
1
+ /**
2
+ * TeamBus — the subagent message channel (team-subagents §3.6).
3
+ *
4
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
5
+ *
6
+ * The requirement asks for "lei si ren lei jiao liu de dian hua ji zhi, dan bu
7
+ * neng tai pin fan" — a real telephone, used sparingly. This module takes both
8
+ * halves literally: there IS a channel, and it is expensive on purpose. The rate
9
+ * limits in `TEAM_LIMITS` are the requirement, not a compromise (R-9).
10
+ *
11
+ * DELIVERY IS PIGGY-BACKED, NEVER PUSHED (D-4). A message for child B is
12
+ * appended to B's NEXT TOOL RESULT by `withMailboxTail`. `Agent.steer()` looks
13
+ * like the natural push channel and is the wrong answer: the core agent loop
14
+ * treats steering that arrives mid-batch by pushing "Tool execution skipped due
15
+ * to steering interrupt." for EVERY REMAINING TOOL CALL in that batch, so a
16
+ * chatty peer would cancel its colleague's work as a side effect of saying
17
+ * hello. Piggy-backing costs zero extra turns, cannot interfere with the loop,
18
+ * and is guaranteed to be seen by any child that is doing anything at all.
19
+ *
20
+ * EVERY REFUSAL IS A NON-ERROR (D-7). An `errorResult` reads to a model as a
21
+ * malfunction and invites an immediate retry, which is precisely the "tai pin
22
+ * fan" behaviour being designed out. The verdicts below carry a `reason` the
23
+ * caller renders as ordinary text.
24
+ */
25
+ import { TEAM_LIMITS } from './limits.js';
26
+ /** The lead's stable address. Subagents are addressed by their `label`. */
27
+ export const LEAD_KEY = 'lead';
28
+ /** Broadcast address: every other participant, the lead included. */
29
+ export const ALL_KEY = 'all';
30
+ export class TeamBus {
31
+ labels;
32
+ mailboxes = new Map();
33
+ waiters = new Set();
34
+ sentBy = new Map();
35
+ lastSentAt = new Map();
36
+ blocked = new Map();
37
+ lead = [];
38
+ now;
39
+ onMessage;
40
+ canSend;
41
+ total = 0;
42
+ last;
43
+ constructor(labels, options = {}) {
44
+ this.labels = [...labels];
45
+ this.now = options.now ?? (() => Date.now());
46
+ if (options.onMessage)
47
+ this.onMessage = options.onMessage;
48
+ this.canSend = options.canSend ?? (() => true);
49
+ for (const label of this.labels)
50
+ this.mailboxes.set(label, []);
51
+ }
52
+ /** Every addressable participant, in dispatch order, with the lead last. */
53
+ participants() {
54
+ return [...this.labels, LEAD_KEY];
55
+ }
56
+ /** Sibling labels for the `<subagent_role>` block. */
57
+ peersOf(label) {
58
+ return this.labels.filter((l) => l !== label);
59
+ }
60
+ /** Messages addressed to `lead`, for the report's own section (§3.7). */
61
+ leadMail() {
62
+ return [...this.lead];
63
+ }
64
+ messageCount() {
65
+ return this.total;
66
+ }
67
+ lastMessage() {
68
+ return this.last;
69
+ }
70
+ sentCount(label) {
71
+ return this.sentBy.get(label) ?? 0;
72
+ }
73
+ /**
74
+ * Canonical label for a model-supplied `from`, or `undefined` (P1-3).
75
+ *
76
+ * PUBLIC, because `makeTeamWait` has to hand the SAME string to
77
+ * `checkWaitable` and to `wait()`. `wait()` and `deliverToWaiter` compare
78
+ * `m.from === opts.from` EXACTLY, so a caller that gate-checks `"A2"`
79
+ * case-insensitively and then passes `"A2"` down would wait the full timeout
80
+ * for a message the delivery filter can never match.
81
+ */
82
+ resolveLabel(raw) {
83
+ const t = raw.trim().toLowerCase();
84
+ return this.labels.find((l) => l.toLowerCase() === t);
85
+ }
86
+ /** `team_wait` calls refused as unanswerable, per label (F-3). */
87
+ recordBlockedWait(label) {
88
+ this.blocked.set(label, this.blockedWaitCount(label) + 1);
89
+ }
90
+ blockedWaitCount(label) {
91
+ return this.blocked.get(label) ?? 0;
92
+ }
93
+ // -------------------------------------------------------------------------
94
+ // Send
95
+ // -------------------------------------------------------------------------
96
+ /**
97
+ * Spend quota and deliver.
98
+ *
99
+ * Refusal ORDER is deliberate: the permanent conditions are reported before
100
+ * the temporary one. Telling a child to "try again in 9s" when its quota is
101
+ * already spent sends it back for a second refusal it cannot act on.
102
+ */
103
+ send(from, to, subjectRaw, bodyRaw) {
104
+ const target = to.trim().toLowerCase();
105
+ const cost = target === ALL_KEY ? TEAM_LIMITS.broadcastCost : 1;
106
+ const recipients = this.resolveRecipients(from, target);
107
+ if (recipients.length === 0) {
108
+ return { ok: false, reason: 'unknown_recipient', recipients: this.participants() };
109
+ }
110
+ const used = this.sentCount(from);
111
+ if (used + cost > TEAM_LIMITS.messagesPerAgent) {
112
+ return { ok: false, reason: 'agent_quota', used, max: TEAM_LIMITS.messagesPerAgent };
113
+ }
114
+ if (this.total + cost > TEAM_LIMITS.messagesPerDispatch) {
115
+ return {
116
+ ok: false,
117
+ reason: 'dispatch_quota',
118
+ used: this.total,
119
+ max: TEAM_LIMITS.messagesPerDispatch,
120
+ };
121
+ }
122
+ const last = this.lastSentAt.get(from);
123
+ const now = this.now();
124
+ if (last !== undefined && now - last < TEAM_LIMITS.minSendIntervalMs) {
125
+ return {
126
+ ok: false,
127
+ reason: 'too_soon',
128
+ retryInSeconds: Math.max(1, Math.ceil((TEAM_LIMITS.minSendIntervalMs - (now - last)) / 1000)),
129
+ };
130
+ }
131
+ // Clamped, never rejected: an over-long subject is a formatting slip, and
132
+ // refusing the whole message for it would spend a turn to convey nothing.
133
+ const subject = subjectRaw.trim().slice(0, TEAM_LIMITS.subjectChars);
134
+ const body = bodyRaw.trim().slice(0, TEAM_LIMITS.bodyChars);
135
+ this.sentBy.set(from, used + cost);
136
+ this.lastSentAt.set(from, now);
137
+ this.total += cost;
138
+ for (const recipient of recipients) {
139
+ const message = { from, to: recipient, subject, body, at: now };
140
+ this.last = message;
141
+ this.onMessage?.(message);
142
+ if (recipient === LEAD_KEY) {
143
+ // The lead is blocked inside `task` for the whole dispatch (§1.3), so
144
+ // its mail is COLLECTED rather than delivered: it reaches the model in
145
+ // the report, and the panel shows it live. Injecting it into the lead's
146
+ // history mid-tool is what §1.3 rules out.
147
+ this.lead.push(message);
148
+ continue;
149
+ }
150
+ if (!this.deliverToWaiter(recipient, message)) {
151
+ this.mailboxes.get(recipient)?.push(message);
152
+ }
153
+ }
154
+ return {
155
+ ok: true,
156
+ delivered: recipients,
157
+ spent: cost,
158
+ remaining: Math.max(0, TEAM_LIMITS.messagesPerAgent - (used + cost)),
159
+ };
160
+ }
161
+ resolveRecipients(from, target) {
162
+ if (target === ALL_KEY)
163
+ return this.participants().filter((p) => p !== from);
164
+ if (target === LEAD_KEY)
165
+ return [LEAD_KEY];
166
+ const match = this.labels.find((l) => l.toLowerCase() === target);
167
+ if (!match || match === from)
168
+ return [];
169
+ return [match];
170
+ }
171
+ // -------------------------------------------------------------------------
172
+ // Receive
173
+ // -------------------------------------------------------------------------
174
+ waiterFor(label) {
175
+ for (const w of this.waiters)
176
+ if (w.key === label)
177
+ return w;
178
+ return undefined;
179
+ }
180
+ /**
181
+ * Decide whether waiting could possibly pay off (F-3).
182
+ *
183
+ * TAKES `timeoutMs`, NOT `deadlineAt` (P1-1). The deadline is derived here,
184
+ * from the one clock this class has, so it is comparable with every registered
185
+ * `Waiter.deadlineAt`.
186
+ *
187
+ * PURE: no counters, no mutation, no waiter registration. `makeTeamWait`
188
+ * records the refusal, because a predicate with a counter inside it is a
189
+ * predicate people stop trusting (D-6).
190
+ *
191
+ * BIASED TOWARD PERMITTING (D-16). Every branch that cannot be decided returns
192
+ * `null`. A wait that should have been refused costs the child seconds it can
193
+ * see; a refusal that should have been a wait destroys a result the child
194
+ * cannot see was ever possible. The asymmetry is total, so the bias is total.
195
+ */
196
+ checkWaitable(key, opts) {
197
+ const peers = this.peersOf(key);
198
+ if (peers.length === 0)
199
+ return { reason: 'no_peers' };
200
+ let acceptable = peers;
201
+ let want;
202
+ if (opts.from !== undefined) {
203
+ const canonical = this.resolveLabel(opts.from);
204
+ // Covers a typo, `lead` (which never sends - its mail is collected for the
205
+ // report and there is no `team_send` on a lead), and the caller's own
206
+ // label. All three are "this wait can never be satisfied", and none of
207
+ // them is "you have no teammates" (P1-2).
208
+ if (canonical === undefined || canonical === key) {
209
+ return { reason: 'unknown_sender', asked: opts.from.trim(), peers };
210
+ }
211
+ acceptable = [canonical];
212
+ want = canonical;
213
+ }
214
+ // Something is already in the mailbox: `wait()` will return it synchronously,
215
+ // so this is never a pointless wait however dead the peers are. Matched with
216
+ // the SAME canonical label `wait()` will use (P1-3).
217
+ const queued = this.mailboxes.get(key) ?? [];
218
+ if (queued.some((m) => want === undefined || m.from === want))
219
+ return null;
220
+ const deadlineAt = this.now() + Math.max(0, opts.timeoutMs);
221
+ const finished = (p) => !this.canSend(p);
222
+ const blockedPast = (p) => {
223
+ const w = this.waiterFor(p);
224
+ return w !== undefined && w.deadlineAt >= deadlineAt;
225
+ };
226
+ const stuck = [];
227
+ for (const peer of acceptable) {
228
+ if (finished(peer) || blockedPast(peer)) {
229
+ stuck.push(peer);
230
+ continue;
231
+ }
232
+ return null; // this one could still send in time
233
+ }
234
+ const cannotAct = new Set([key, ...peers.filter(finished), ...stuck]);
235
+ this.closeCannotAct(cannotAct, key, finished);
236
+ if (acceptable.some((p) => !cannotAct.has(p)))
237
+ return null;
238
+ const dead = acceptable.filter(finished);
239
+ if (dead.length === acceptable.length)
240
+ return { reason: 'all_finished', who: dead };
241
+ return { reason: 'all_blocked', who: acceptable };
242
+ }
243
+ /**
244
+ * Shrink `cannotAct` to its fixpoint, in place (P0-3).
245
+ *
246
+ * Everyone acceptable being stuck is NOT yet a deadlock proof, because a
247
+ * blocked peer can be released EARLY by a third party: `a` waits on `b`, `b`
248
+ * waits on `c`, `c` messages `b` at t=10, `b` resumes and messages `a`. A
249
+ * deadline comparison alone refuses `a` at t=0 and throws that outcome away.
250
+ *
251
+ * So a blocked peer stays in the set only while everyone who could wake it is
252
+ * also in it (or is the caller, who is about to block). If growth stops with
253
+ * an acceptable peer outside the set, that peer can still be woken and the
254
+ * wait must be permitted.
255
+ *
256
+ * Terminates because each pass either removes at least one member or stops. At
257
+ * ten participants the worst case is a hundred set lookups, once per
258
+ * `team_wait`, and `team_wait` is itself rate-limited by the message budget.
259
+ */
260
+ closeCannotAct(cannotAct, key, finished) {
261
+ for (;;) {
262
+ let shrank = false;
263
+ for (const p of [...cannotAct]) {
264
+ if (p === key || finished(p))
265
+ continue;
266
+ const w = this.waiterFor(p);
267
+ // Not blocked at all -> free to send whenever it likes.
268
+ if (w === undefined) {
269
+ cannotAct.delete(p);
270
+ shrank = true;
271
+ continue;
272
+ }
273
+ // Who could release `p`? Its own acceptable senders - and `peersOf`, NOT
274
+ // `participants`, because the lead is never a sender. Using
275
+ // `participants` here would put `lead` in every from-less waiter's waker
276
+ // set, `lead` is never in `cannotAct`, and `all_blocked` would become
277
+ // unreachable for exactly the from-less waits it was written for.
278
+ const wakers = w.from ? [w.from] : this.peersOf(p);
279
+ if (wakers.some((x) => x !== p && !cannotAct.has(x))) {
280
+ cannotAct.delete(p);
281
+ shrank = true;
282
+ }
283
+ }
284
+ if (!shrank)
285
+ return;
286
+ }
287
+ }
288
+ /** Take everything queued for `key`. Called by `withMailboxTail` (§3.6). */
289
+ drain(key) {
290
+ const box = this.mailboxes.get(key);
291
+ if (!box || box.length === 0)
292
+ return [];
293
+ return box.splice(0, box.length);
294
+ }
295
+ /**
296
+ * Block until a matching message arrives, the signal aborts, or the deadline
297
+ * elapses. Resolves `null` on both non-delivery paths — from the model's point
298
+ * of view an abandoned wait and an expired one want the same guidance.
299
+ *
300
+ * THE CALLER PAUSES THE CHILD'S IDLE WATCHDOG around this (see
301
+ * `comm-tools.ts`). A deliberate wait is not a wedged agent, and the child
302
+ * emits nothing while it is here.
303
+ */
304
+ wait(key, opts) {
305
+ const queued = this.mailboxes.get(key) ?? [];
306
+ const index = queued.findIndex((m) => !opts.from || m.from === opts.from);
307
+ if (index >= 0)
308
+ return Promise.resolve(queued.splice(index, 1)[0] ?? null);
309
+ return new Promise((resolve) => {
310
+ let done = false;
311
+ const waiter = {
312
+ key,
313
+ ...(opts.from ? { from: opts.from } : {}),
314
+ // On THIS bus's clock, so `checkWaitable` can compare deadlines (P1-1).
315
+ deadlineAt: this.now() + Math.max(0, opts.timeoutMs),
316
+ settle: (message) => {
317
+ if (done)
318
+ return;
319
+ done = true;
320
+ clearTimeout(timer);
321
+ opts.signal?.removeEventListener('abort', onAbort);
322
+ this.waiters.delete(waiter);
323
+ resolve(message);
324
+ },
325
+ };
326
+ const onAbort = () => waiter.settle(null);
327
+ const timer = setTimeout(() => waiter.settle(null), Math.max(0, opts.timeoutMs));
328
+ // `unref` keeps a pending wait from holding the process open on exit; it
329
+ // does not exist on the browser timer type, hence the guard.
330
+ timer.unref?.();
331
+ this.waiters.add(waiter);
332
+ if (opts.signal?.aborted) {
333
+ waiter.settle(null);
334
+ return;
335
+ }
336
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
337
+ });
338
+ }
339
+ /** Settle every outstanding wait with `null`. Called on abort and dispose. */
340
+ cancelAllWaits() {
341
+ for (const waiter of [...this.waiters])
342
+ waiter.settle(null);
343
+ }
344
+ deliverToWaiter(key, message) {
345
+ for (const waiter of this.waiters) {
346
+ if (waiter.key !== key)
347
+ continue;
348
+ if (waiter.from && waiter.from !== message.from)
349
+ continue;
350
+ waiter.settle(message);
351
+ return true;
352
+ }
353
+ return false;
354
+ }
355
+ }
356
+ /** Render queued mail as the `<team_mail>` block appended to a tool result. */
357
+ export function renderMailBlock(messages) {
358
+ const lines = ['<team_mail>'];
359
+ for (const m of messages) {
360
+ lines.push(`from ${m.from}: ${m.subject}`);
361
+ if (m.body.length > 0)
362
+ lines.push(m.body);
363
+ }
364
+ lines.push('</team_mail>');
365
+ return lines.join('\n');
366
+ }