@hncode/hncode 0.5.2 → 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.
- package/package.json +2 -2
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +813 -590
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +17 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +88 -88
- package/src/tui.js +1815 -628
- package/src/update.js +38 -3
package/src/swarm-progress.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// kimi does NOT render a swarm as a tool line plus a text dump. It renders a live
|
|
4
4
|
// BLOCK in the transcript:
|
|
5
5
|
//
|
|
6
|
-
// ─ Agent Swarm ─
|
|
6
|
+
// ─ Agent Swarm ─ two subagents each replied hi ─ coder
|
|
7
7
|
//
|
|
8
8
|
// 001 [#---------------] Working… Inspecting src/tui.js
|
|
9
9
|
// 002 [###-------------] Queued…
|
package/src/team/mailbox.js
CHANGED
|
@@ -1,139 +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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|