@hncode/hncode 0.5.1 → 0.5.4

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 (60) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +2 -2
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +279 -48
  6. package/src/cache.js +175 -130
  7. package/src/cell-buffer.js +5 -0
  8. package/src/clipboard.js +111 -8
  9. package/src/context-bar.js +46 -5
  10. package/src/doctor.js +7 -2
  11. package/src/git.js +35 -4
  12. package/src/index.js +29 -5
  13. package/src/keybindings.js +25 -3
  14. package/src/llm.js +60 -6
  15. package/src/mcp.js +419 -384
  16. package/src/permissions.js +29 -7
  17. package/src/plugin.js +95 -10
  18. package/src/registry-browser.js +21 -6
  19. package/src/remote.js +307 -289
  20. package/src/session-core.js +203 -0
  21. package/src/session-host.js +213 -0
  22. package/src/session-view.js +110 -0
  23. package/src/settings-schema.js +12 -9
  24. package/src/shell-safety.js +610 -473
  25. package/src/swarm-progress.js +348 -293
  26. package/src/tasks-browser.js +4 -1
  27. package/src/team/constants.js +20 -0
  28. package/src/team/context.js +89 -0
  29. package/src/team/mailbox.js +216 -0
  30. package/src/team/permission.js +121 -0
  31. package/src/team/protocol.js +167 -0
  32. package/src/team/runner.js +462 -0
  33. package/src/team/spawn.js +305 -0
  34. package/src/team/store.js +172 -0
  35. package/src/team/tasks.js +177 -0
  36. package/src/team/team.js +346 -0
  37. package/src/team-panel.js +624 -0
  38. package/src/term-caps.js +457 -410
  39. package/src/term.js +162 -19
  40. package/src/tool-result.js +70 -7
  41. package/src/tools/agent-swarm.js +114 -11
  42. package/src/tools/agent.js +464 -386
  43. package/src/tools/bash.js +53 -14
  44. package/src/tools/diagnostics.js +13 -0
  45. package/src/tools/edit.js +428 -50
  46. package/src/tools/git.js +15 -3
  47. package/src/tools/glob.js +5 -3
  48. package/src/tools/grep.js +19 -5
  49. package/src/tools/ignore.js +17 -3
  50. package/src/tools/index.js +19 -3
  51. package/src/tools/read.js +2 -1
  52. package/src/tools/shell.js +145 -134
  53. package/src/tools/tasks.js +214 -141
  54. package/src/tools/team.js +411 -0
  55. package/src/tools/utils.js +12 -3
  56. package/src/tools/web-search.js +53 -40
  57. package/src/tui.js +7496 -5366
  58. package/src/update.js +320 -115
  59. package/src/web-daemon.js +27 -4
  60. package/src/web-live.js +250 -0
@@ -200,11 +200,14 @@ export function renderTasksBrowser(state, cols, rows) {
200
200
  // hitbox to aim at — those are recorded in state._taskHits below.
201
201
  const hot = state.hoverIndex === i;
202
202
  const ptr = selected ? C.cyan + '\u276f ' + C.reset : (hot ? C.hover + '\u276f ' + C.reset : ' ');
203
+ // A sub-task is drawn under its parent, indented, so a swarm reads as one unit of work with
204
+ // visible parts rather than as N unrelated rows. `parentTaskId` is set by AgentSwarm.
205
+ const lead = t.parentTaskId ? C.gray + ' \u2514 ' + C.reset : '';
203
206
  const idCol = t.kind === 'agent' ? C.green : t.kind === 'question' ? C.orange : C.blue;
204
207
  const idText = selected ? idCol + C.bold + t.taskId + C.reset : idCol + t.taskId + C.reset;
205
208
  const pad = ' '.repeat(Math.max(0, 20 - visWidth(t.taskId)));
206
209
  const badge = statusColorToken(t.status) + (STATUS_LABEL[t.status] || t.status) + C.reset;
207
- const prefix = ptr + idText + pad + ' ' + badge;
210
+ const prefix = lead + ptr + idText + pad + ' ' + badge;
208
211
  const budget = Math.max(0, listInnerW - visWidth(prefix) - 1);
209
212
  if (budget < 4) return fitExactly(prefix, listInnerW);
210
213
  const desc = singleLine(t.description) || singleLine(t.command) || '(no description)';
@@ -0,0 +1,20 @@
1
+ /** The name every team's leader is known by. Not a member name a user can pick. */
2
+ export const TEAM_LEAD_NAME = 'team-lead';
3
+
4
+ /** Broadcast address in SendMessage: everyone on the team except the sender. */
5
+ export const BROADCAST = '*';
6
+
7
+ /** How many teammates one team may hold. A team that grows past this cannot be reasoned about. */
8
+ export const MAX_TEAMMATES = 8;
9
+
10
+ /** How many tool steps a teammate may take in one run before it is stopped. */
11
+ export const TEAMMATE_MAX_STEPS = 40;
12
+
13
+ /**
14
+ * How long a teammate waits for a message before ending its run.
15
+ *
16
+ * 0 = indefinitely, which is the default: the lead is a person typing, and a member that
17
+ * quits after a quiet spell cannot be given work later. A caller that genuinely wants a
18
+ * bounded run passes a positive value (`spawnTeammate({ idleTimeoutMs: 60_000 })`).
19
+ */
20
+ export const TEAMMATE_IDLE_TIMEOUT_MS = 0;
@@ -0,0 +1,89 @@
1
+ // Who is running right now.
2
+ //
3
+ // A teammate runs in the SAME process as its lead, so "am I a teammate" cannot live on a module
4
+ // variable — every member would overwrite it and the last one to start would answer for all of
5
+ // them. `AsyncLocalStorage` gives each async execution chain its own view, which is the shape
6
+ // needed: a teammate's whole run happens inside one `runAs` call, so every function it reaches
7
+ // sees ITS identity while the lead's sees none.
8
+ //
9
+ // WHY NOT A STACK
10
+ // ---------------
11
+ // A stack would be popped by any interleaving, and teammate runs interleave freely — they await
12
+ // tool calls, network, timers. AsyncLocalStorage follows the async context rather than the call
13
+ // stack, so it survives those awaits.
14
+ import { AsyncLocalStorage } from 'node:async_hooks';
15
+ import { TEAM_LEAD_NAME } from './constants.js';
16
+
17
+ const storage = new AsyncLocalStorage();
18
+
19
+ /**
20
+ * The identity of the member whose async context we are inside, or undefined for the lead.
21
+ *
22
+ * `undefined` IS the lead: the person at the keyboard is not a teammate. Code that needs "am I a
23
+ * teammate" asks `currentAgent()`, rather than a separate flag that could disagree with this.
24
+ */
25
+ export function currentAgent() {
26
+ return storage.getStore() || undefined;
27
+ }
28
+
29
+ export function isTeammate() {
30
+ return !!storage.getStore();
31
+ }
32
+
33
+ /** The calling member's name, or the lead's when there is none. */
34
+ export function currentAgentName() {
35
+ const ctx = storage.getStore();
36
+ return ctx ? ctx.name : TEAM_LEAD_NAME;
37
+ }
38
+
39
+ /** The team the calling member belongs to, or undefined for a lead with no team. */
40
+ export function currentTeamName() {
41
+ const ctx = storage.getStore();
42
+ return ctx ? ctx.teamName : undefined;
43
+ }
44
+
45
+ /**
46
+ * Run `fn` as `identity`.
47
+ *
48
+ * Every await inside `fn` sees the same identity — that is the whole point, and why this must
49
+ * WRAP the teammate's run rather than be set before it.
50
+ */
51
+ export function runAs(identity, fn) {
52
+ return storage.run(identity, fn);
53
+ }
54
+
55
+ /** Build the identity record for a member. */
56
+ export function makeIdentity({ name, teamName, color, agentType, model, taskId = null }) {
57
+ return {
58
+ name,
59
+ teamName,
60
+ color,
61
+ agentType: agentType || 'general',
62
+ model: model || '',
63
+ // Set when this teammate was spawned to work on a specific task, so it can report back to
64
+ // that task rather than to the team at large.
65
+ taskId,
66
+ };
67
+ }
68
+
69
+ // Distinct enough to tell apart in a log line.
70
+ export const MEMBER_COLORS = ['cyan', 'green', 'yellow', 'magenta', 'blue', 'red', 'orange'];
71
+
72
+ /**
73
+ * A colour for a member: stable per name, avoiding what the team already uses.
74
+ *
75
+ * Stable because a colour that changed between messages would make the transcript unreadable —
76
+ * the eye tracks a member BY its colour.
77
+ */
78
+ export function pickColor(name, taken = []) {
79
+ const used = new Set(taken);
80
+ const n = MEMBER_COLORS.length;
81
+ const start = [...String(name)].reduce((a, c) => a + c.charCodeAt(0), 0) % n;
82
+ for (let i = 0; i < n; i++) {
83
+ const c = MEMBER_COLORS[(start + i) % n];
84
+ if (!used.has(c)) return c;
85
+ }
86
+ // Every colour taken: share one rather than returning undefined, which would leave the member
87
+ // uncoloured and indistinguishable.
88
+ return MEMBER_COLORS[start];
89
+ }
@@ -0,0 +1,216 @@
1
+ // The mailbox: one inbox file per member, addressed by name.
2
+ //
3
+ // ~/.hncode/teams/<team>/inboxes/<name>.json -> TeammateMessage[]
4
+ //
5
+ // WHY A FILE PER RECIPIENT
6
+ // ------------------------
7
+ // A single shared log would need every reader to track its own watermark, and a member that
8
+ // restarts would re-read everything it had already seen. Per-recipient files make "what is
9
+ // waiting for me" a plain read, and marking read is a write to your own file only — no member
10
+ // contends with another over the same one.
11
+ //
12
+ // WRITES ARE ATOMIC
13
+ // -----------------
14
+ // Write to a temp file, then rename. `rename` is atomic on every platform we run on, so a
15
+ // reader either sees the whole previous inbox or the whole new one — never a half-written JSON
16
+ // that parses as nothing and silently drops the mail. This also covers the cross-process case
17
+ // (two hncode sessions in the same team), where an in-process mutex would not.
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { inboxDir, inboxPath } from './store.js';
21
+
22
+ /**
23
+ * A message as it sits in an inbox.
24
+ *
25
+ * `read` lives on the message rather than in a separate watermark file: it travels with the
26
+ * message when the inbox is rewritten, and a member reading its own inbox is the only writer.
27
+ */
28
+ export const MAX_INBOX = 500;
29
+
30
+ function writeAtomic(file, text) {
31
+ const dir = path.dirname(file);
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
34
+ fs.writeFileSync(tmp, text, 'utf8');
35
+ try {
36
+ fs.renameSync(tmp, file);
37
+ } catch (e) {
38
+ try { fs.unlinkSync(tmp); } catch { /* the tmp is worthless now */ }
39
+ throw e;
40
+ }
41
+ }
42
+
43
+ /** Every message in a member's inbox, oldest first. Never throws.
44
+ *
45
+ * Cached by (mtimeMs, size): the panel renders rosters and traffic per frame,
46
+ * and waitForMail polls 4x/second — a stat per read replaces the full
47
+ * readFileSync + JSON.parse. Writes go through writeAtomic (rename), which
48
+ * always changes the mtime, so nothing stale survives. The cached array is
49
+ * copied on the way out: a caller that mutates the result without writing
50
+ * back must not corrupt the cache. */
51
+ const _mbCache = new Map(); // inbox file -> { key, box }
52
+ export function readMailbox(teamName, agentName) {
53
+ const file = inboxPath(teamName, agentName);
54
+ let st = null;
55
+ try { st = fs.statSync(file); } catch { /* absent */ }
56
+ if (!st) return [];
57
+ const key = `${st.mtimeMs}:${st.size}`;
58
+ const hit = _mbCache.get(file);
59
+ if (hit && hit.key === key) return hit.box.slice();
60
+ try {
61
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
62
+ const box = Array.isArray(parsed) ? parsed : [];
63
+ _mbCache.set(file, { key, box });
64
+ if (_mbCache.size > 64) { // bounded: one entry per inbox ever read
65
+ const first = _mbCache.keys().next().value;
66
+ _mbCache.delete(first);
67
+ }
68
+ return box.slice();
69
+ } catch {
70
+ _mbCache.delete(file);
71
+ return [];
72
+ }
73
+ }
74
+
75
+ /** Only what the recipient has not seen. */
76
+ export function readUnread(teamName, agentName) {
77
+ return readMailbox(teamName, agentName).filter((m) => !m.read);
78
+ }
79
+
80
+ /**
81
+ * A monotonic counter for ordering messages ACROSS mailboxes.
82
+ *
83
+ * `timestamp` cannot do it: it has millisecond resolution, and two messages written in the same
84
+ * millisecond in two different inbox files compare equal — the sort then falls back to file position,
85
+ * which is only meaningful WITHIN one file. That is how a user's two messages appeared above the reply
86
+ * that answered the first of them.
87
+ *
88
+ * Process-local is enough: every inbox write in a team goes through this module in one process.
89
+ */
90
+ let seqCounter = 0;
91
+
92
+
93
+ /**
94
+ * Deliver a message.
95
+ *
96
+ * The sender's name and colour are stamped here rather than passed by the call site, so every
97
+ * message carries them whether it came from the lead, a teammate, or a future caller.
98
+ */
99
+ export function writeToMailbox(teamName, recipientName, { from, text, summary, color }) {
100
+ const file = inboxPath(teamName, recipientName);
101
+ const box = readMailbox(teamName, recipientName);
102
+ seqCounter += 1;
103
+ const msg = {
104
+ from: String(from || 'unknown'),
105
+ text: String(text == null ? '' : text),
106
+ timestamp: new Date().toISOString(),
107
+ // Ordering key. Milliseconds alone leave two boxes' messages tied, and the panel then interleaves
108
+ // them wrongly.
109
+ seq: seqCounter,
110
+ read: false,
111
+ };
112
+ if (summary) msg.summary = String(summary);
113
+ if (color) msg.color = String(color);
114
+ box.push(msg);
115
+ // Bounded: an inbox nobody drains must not grow without limit and turn every read into a
116
+ // parse of the whole session's chatter.
117
+ const kept = box.length > MAX_INBOX ? box.slice(box.length - MAX_INBOX) : box;
118
+ writeAtomic(file, JSON.stringify(kept, null, 2));
119
+ return msg;
120
+ }
121
+
122
+ /**
123
+ * Mark the given messages read. Returns how many changed.
124
+ *
125
+ * `ids` are MESSAGE IDENTITIES (seq, falling back to timestamp for pre-seq boxes), not
126
+ * array positions: the box is re-read here, and a message delivered in between would
127
+ * shift every later index — marking by position would then silence the wrong message,
128
+ * or one that was never delivered at all.
129
+ *
130
+ * When called with no `ids` every message is marked, which is what a caller that
131
+ * genuinely drained the whole box wants. A caller that PROCESSED a specific batch must
132
+ * pass it: blanket-marking re-reads the box and marks whatever arrived since the
133
+ * batch was taken, and those messages are then never delivered.
134
+ */
135
+ export function markAllRead(teamName, agentName, ids = null) {
136
+ const box = readMailbox(teamName, agentName);
137
+ let n = 0;
138
+ // Keyed by `seq` — the same identity removeMail uses. Two messages delivered in
139
+ // the same millisecond share a timestamp (a broadcast and a direct mail landing
140
+ // together), and keying on the timestamp marked the sibling read too, so it was
141
+ // never delivered to the model. Timestamps remain a fallback for boxes written
142
+ // by an older build, whose messages carry no seq.
143
+ const wantSeq = new Set();
144
+ const wantTs = new Set();
145
+ if (Array.isArray(ids)) {
146
+ for (const id of ids) {
147
+ if (id && typeof id === 'object') {
148
+ if (id.seq != null) wantSeq.add(Number(id.seq));
149
+ else if (id.timestamp) wantTs.add(id.timestamp);
150
+ } else if (id != null) wantTs.add(id);
151
+ }
152
+ }
153
+ for (const m of box) {
154
+ if (m.read) continue;
155
+ if (Array.isArray(ids)) {
156
+ const bySeq = m.seq != null && wantSeq.has(Number(m.seq));
157
+ if (!bySeq && !wantTs.has(m.timestamp)) continue;
158
+ }
159
+ m.read = true;
160
+ n++;
161
+ }
162
+ if (n) writeAtomic(inboxPath(teamName, agentName), JSON.stringify(box, null, 2));
163
+ return n;
164
+ }
165
+
166
+ /** Empty a member's inbox. Used when a teammate is removed from the team. */
167
+ export function clearMailbox(teamName, agentName) {
168
+ try { fs.unlinkSync(inboxPath(teamName, agentName)); return true; } catch { return false; }
169
+ }
170
+
171
+ /**
172
+ * Delete specific messages from a box, by `seq`. Returns how many went.
173
+ *
174
+ * A permission RESPONSE is addressed to one member and answers one request, so it must be CONSUMED: a
175
+ * plain read flag is not enough, because the member polls in a loop and would see the same answer again
176
+ * on its next request — authorising a call the person never approved.
177
+ *
178
+ * By `seq`, not by index or timestamp: the box is re-read here and a message delivered in between would
179
+ * shift every later index, while two messages in the same millisecond share a timestamp.
180
+ */
181
+ export function removeMail(teamName, agentName, seqs) {
182
+ const want = new Set((seqs || []).map(Number));
183
+ if (!want.size) return 0;
184
+ const box = readMailbox(teamName, agentName);
185
+ const kept = box.filter((m) => !want.has(Number(m.seq)));
186
+ const removed = box.length - kept.length;
187
+ if (removed) writeAtomic(inboxPath(teamName, agentName), JSON.stringify(kept, null, 2));
188
+ return removed;
189
+ }
190
+
191
+ /**
192
+ * Render unread mail the way a member should see it: as an addressable block, not as prose.
193
+ *
194
+ * The `from` line matters — a teammate's next action is often "reply to whoever asked", and a
195
+ * block of text with no name attached leaves it guessing.
196
+ */
197
+ export function formatInbox(messages) {
198
+ if (!messages.length) return '';
199
+ const out = [];
200
+ for (const m of messages) {
201
+ out.push(`[message from ${m.from}]`);
202
+ if (m.summary) out.push(`(${m.summary})`);
203
+ out.push(m.text);
204
+ out.push('');
205
+ }
206
+ return out.join('\n').trimEnd();
207
+ }
208
+
209
+ /** Every inbox file for a team, so the rota can be cleaned with the team. */
210
+ export function listInboxes(teamName) {
211
+ try {
212
+ return fs.readdirSync(inboxDir(teamName)).filter((f) => f.endsWith('.json'));
213
+ } catch {
214
+ return [];
215
+ }
216
+ }
@@ -0,0 +1,121 @@
1
+ // Permission requests, routed from a member to the person through the team mailbox.
2
+ //
3
+ // WHY THIS FILE EXISTS
4
+ // --------------------
5
+ // A teammate runs tools that can write files and execute commands. Until now it had exactly two options:
6
+ // be allowed to do anything, or be unable to ask. There was no middle — nothing carried "may I run this?"
7
+ // from the member to the person who could answer, and nothing carried the answer back.
8
+ //
9
+ // The protocol is the mailbox, because it is the only channel that already reaches both ends:
10
+ //
11
+ // 1. the member blocks on a tool it may not run and writes a `permission_request` to the lead's box
12
+ // 2. the TUI shows it (the lead's inbox is already drained every turn)
13
+ // 3. the person approves or refuses; a `permission_response` goes to the member's box
14
+ // 4. the member's runner polls for the response, matches it by `request_id`, and continues
15
+ //
16
+ // `request_id` is what makes this correct rather than a race: a member may have several calls in flight,
17
+ // and a response that arrived for an earlier one must not authorise a later one.
18
+ import { writeToMailbox, readMailbox, removeMail } from './mailbox.js';
19
+ import { newRequestId, parseProtocol } from './protocol.js';
20
+
21
+ /** How long a member waits for an answer before giving up. Long: a person may be away. */
22
+ export const PERMISSION_TIMEOUT_MS = 10 * 60 * 1000;
23
+
24
+ /** How often the member looks for its answer. */
25
+ const POLL_MS = 250;
26
+
27
+ /**
28
+ * A member asks for permission and BLOCKS until the lead answers.
29
+ *
30
+ * Resolves to `{ approved: false, error }` on refusal and on timeout — never throws, because the caller is
31
+ * mid-turn and an exception here would end a member that merely needed a decision. A timeout is reported
32
+ * as a refusal WITH ITS REASON, so the model is told the difference between "no" and "nobody answered".
33
+ */
34
+ export async function requestPermission({
35
+ teamName,
36
+ memberName,
37
+ leadName,
38
+ toolName,
39
+ toolUseId,
40
+ description,
41
+ input,
42
+ timeoutMs = PERMISSION_TIMEOUT_MS,
43
+ shouldStop = () => false,
44
+ }) {
45
+ const requestId = newRequestId('perm');
46
+ writeToMailbox(teamName, leadName, {
47
+ from: memberName,
48
+ text: JSON.stringify({
49
+ type: 'permission_request',
50
+ request_id: requestId,
51
+ agent_id: memberName,
52
+ tool_name: toolName,
53
+ tool_use_id: toolUseId || '',
54
+ description: description || '',
55
+ input: input && typeof input === 'object' ? input : {},
56
+ permission_suggestions: [],
57
+ }),
58
+ });
59
+
60
+ const deadline = Date.now() + timeoutMs;
61
+ while (Date.now() < deadline) {
62
+ if (shouldStop()) return { approved: false, error: 'cancelled' };
63
+ const answer = findResponse(teamName, memberName, requestId);
64
+ if (answer) return answer;
65
+ await new Promise((r) => setTimeout(r, POLL_MS));
66
+ }
67
+ return {
68
+ approved: false,
69
+ error: `no answer from ${leadName} within ${Math.round(timeoutMs / 1000)}s`,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Look for the answer to one request, consuming it.
75
+ /**
76
+ * Look for the answer to one request, and CONSUME it.
77
+ *
78
+ * Deleted rather than flagged read: the member polls in a loop, so a response left in the box would
79
+ * authorise its NEXT request too — a decision the person never made.
80
+ */
81
+ function findResponse(teamName, memberName, requestId) {
82
+ const box = readMailbox(teamName, memberName);
83
+ for (const m of box) {
84
+ const msg = parseProtocol(m.text);
85
+ if (!msg || msg.type !== 'permission_response') continue;
86
+ if (String(msg.request_id) !== String(requestId)) continue;
87
+ removeMail(teamName, memberName, [m.seq]);
88
+ if (msg.subtype === 'success') {
89
+ return { approved: true, updatedInput: msg.response && msg.response.updated_input };
90
+ }
91
+ return { approved: false, error: String(msg.error || 'Permission denied') };
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * The lead's side: does this inbox message ask for permission?
98
+ *
99
+ * Returns the request when it does, so the caller can put it in front of the person. A message that is
100
+ * not a permission request returns null and is handled as ordinary chat by the listener.
101
+ */
102
+ export function asPermissionRequest(text) {
103
+ const msg = parseProtocol(text);
104
+ if (!msg || msg.type !== 'permission_request') return null;
105
+ return {
106
+ requestId: String(msg.request_id || ''),
107
+ agentId: String(msg.agent_id || ''),
108
+ toolName: String(msg.tool_name || ''),
109
+ toolUseId: String(msg.tool_use_id || ''),
110
+ description: String(msg.description || ''),
111
+ input: msg.input && typeof msg.input === 'object' ? msg.input : {},
112
+ };
113
+ }
114
+
115
+ /** The lead answers a request. */
116
+ export function answerPermission({ teamName, memberName, requestId, approved, error, updatedInput }) {
117
+ const msg = approved
118
+ ? { type: 'permission_response', request_id: requestId, subtype: 'success', response: updatedInput ? { updated_input: updatedInput } : {} }
119
+ : { type: 'permission_response', request_id: requestId, subtype: 'error', error: String(error || 'Permission denied') };
120
+ writeToMailbox(teamName, memberName, { from: 'team-lead', text: JSON.stringify(msg) });
121
+ }
@@ -0,0 +1,167 @@
1
+ // Structured messages between team members.
2
+ //
3
+ // WHY THIS FILE EXISTS
4
+ // --------------------
5
+ // Everything a team does between its members travels through the mailbox, and almost all of it needs to
6
+ // be more than prose: a task assignment names a task ID, a shutdown request carries a correlation ID the
7
+ // reply must echo, and a permission request has to BLOCK the sender until a matching response comes back.
8
+ // Free text cannot carry any of that, so every consumer was reduced to matching substrings — "does this
9
+ // message look like a shutdown?" — which works until someone writes the word in a sentence.
10
+ //
11
+ // The wire format is a JSON object in the message body, discriminated by `type`. A human sentence has no
12
+ // `type` field, so plain chat and protocol traffic share one mailbox without ambiguity: parsing either
13
+ // succeeds and says what it is, or the message is chat.
14
+ //
15
+ // SHAPES follow Claude Code's (`src/utils/teammateMailbox.ts`) field for field. That matters more than it
16
+ // looks: `request_id` correlation, `task_assignment{taskId, subject, description, assignedBy}` and the
17
+ // `permission_request{tool_name, tool_use_id, input}` triple are what let a leader's UI approve the exact
18
+ // call a worker is blocked on. Inventing our own names here would make every one of those unanswerable.
19
+
20
+ /** How a message body is recognised as protocol traffic rather than chat. */
21
+ export const PROTOCOL_TYPES = Object.freeze([
22
+ 'task_assignment',
23
+ 'shutdown_request',
24
+ 'shutdown_approved',
25
+ 'shutdown_rejected',
26
+ 'permission_request',
27
+ 'permission_response',
28
+ 'plan_approval_request',
29
+ 'plan_approval_response',
30
+ 'mode_set_request',
31
+ ]);
32
+
33
+ /**
34
+ * Parse a message body as a structured message.
35
+ *
36
+ * Returns `null` for plain chat — which is the common case, so this must be cheap and must never throw.
37
+ * A body that happens to be JSON but carries no known `type` is also chat: the mailbox is shared, and a
38
+ * member discussing a JSON payload should not have its words reclassified.
39
+ */
40
+ export function parseProtocol(text) {
41
+ const raw = String(text == null ? '' : text).trim();
42
+ if (!raw || raw[0] !== '{') return null;
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ } catch {
47
+ return null;
48
+ }
49
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
50
+ if (!PROTOCOL_TYPES.includes(parsed.type)) return null;
51
+ return parsed;
52
+ }
53
+
54
+ /** Is this body protocol traffic at all? */
55
+ export function isProtocol(text) {
56
+ return parseProtocol(text) !== null;
57
+ }
58
+
59
+ /** A task was handed to a member. */
60
+ export function taskAssignment({ taskId, subject, description, assignedBy }) {
61
+ return {
62
+ type: 'task_assignment',
63
+ taskId: String(taskId || ''),
64
+ subject: String(subject || ''),
65
+ description: String(description || ''),
66
+ assignedBy: String(assignedBy || ''),
67
+ timestamp: new Date().toISOString(),
68
+ };
69
+ }
70
+
71
+ /** A leader asks a member to stop. The member answers with `shutdown_approved` or `_rejected`. */
72
+ export function shutdownRequest({ requestId, from, reason }) {
73
+ return {
74
+ type: 'shutdown_request',
75
+ requestId: String(requestId || ''),
76
+ from: String(from || ''),
77
+ reason: reason ? String(reason) : undefined,
78
+ timestamp: new Date().toISOString(),
79
+ };
80
+ }
81
+
82
+ export function shutdownApproved({ requestId, from }) {
83
+ return {
84
+ type: 'shutdown_approved',
85
+ requestId: String(requestId || ''),
86
+ from: String(from || ''),
87
+ timestamp: new Date().toISOString(),
88
+ };
89
+ }
90
+
91
+ export function shutdownRejected({ requestId, from, reason }) {
92
+ return {
93
+ type: 'shutdown_rejected',
94
+ requestId: String(requestId || ''),
95
+ from: String(from || ''),
96
+ reason: String(reason || ''),
97
+ timestamp: new Date().toISOString(),
98
+ };
99
+ }
100
+
101
+ /** A member wants permission for a tool call and cannot proceed without an answer. */
102
+ export function permissionRequest({ requestId, agentId, toolName, toolUseId, description, input }) {
103
+ return {
104
+ type: 'permission_request',
105
+ request_id: String(requestId || ''),
106
+ agent_id: String(agentId || ''),
107
+ tool_name: String(toolName || ''),
108
+ tool_use_id: String(toolUseId || ''),
109
+ description: String(description || ''),
110
+ input: input && typeof input === 'object' ? input : {},
111
+ permission_suggestions: [],
112
+ };
113
+ }
114
+
115
+ /**
116
+ * The leader's answer.
117
+ *
118
+ * Two shapes, discriminated by `subtype` rather than a nullable `approved`: a refusal carries a REASON,
119
+ * and the member shows it to the model so the next attempt can differ. A bare boolean loses that.
120
+ */
121
+ export function permissionResponse({ requestId, approved, error, updatedInput }) {
122
+ if (!approved) {
123
+ return {
124
+ type: 'permission_response',
125
+ request_id: String(requestId || ''),
126
+ subtype: 'error',
127
+ error: String(error || 'Permission denied'),
128
+ };
129
+ }
130
+ return {
131
+ type: 'permission_response',
132
+ request_id: String(requestId || ''),
133
+ subtype: 'success',
134
+ response: updatedInput ? { updated_input: updatedInput } : {},
135
+ };
136
+ }
137
+
138
+ /** A member proposes a plan; the leader approves or refuses it. */
139
+ export function planApprovalRequest({ requestId, from, plan }) {
140
+ return {
141
+ type: 'plan_approval_request',
142
+ requestId: String(requestId || ''),
143
+ from: String(from || ''),
144
+ plan: String(plan || ''),
145
+ timestamp: new Date().toISOString(),
146
+ };
147
+ }
148
+
149
+ export function planApprovalResponse({ requestId, approved, feedback }) {
150
+ return {
151
+ type: 'plan_approval_response',
152
+ requestId: String(requestId || ''),
153
+ approved: Boolean(approved),
154
+ feedback: feedback ? String(feedback) : undefined,
155
+ timestamp: new Date().toISOString(),
156
+ };
157
+ }
158
+
159
+ /** Change a member's permission mode without restarting it. */
160
+ export function modeSetRequest({ mode, from }) {
161
+ return { type: 'mode_set_request', mode: String(mode || ''), from: String(from || '') };
162
+ }
163
+
164
+ /** Correlation id for a request/response pair. */
165
+ export function newRequestId(prefix = 'r') {
166
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
167
+ }