@hncode/hncode 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +1 -1
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +115 -35
  6. package/src/cell-buffer.js +5 -0
  7. package/src/clipboard.js +42 -6
  8. package/src/colors.js +478 -387
  9. package/src/config.js +38 -11
  10. package/src/context-bar.js +46 -5
  11. package/src/doctor.js +214 -11
  12. package/src/git.js +35 -4
  13. package/src/llm.js +6 -0
  14. package/src/memory-profile.js +247 -0
  15. package/src/permissions.js +29 -7
  16. package/src/registry-browser.js +21 -6
  17. package/src/row-cache.js +147 -142
  18. package/src/session-core.js +203 -0
  19. package/src/session-host.js +213 -0
  20. package/src/session-hub.js +1 -0
  21. package/src/session-view.js +110 -0
  22. package/src/settings-schema.js +304 -0
  23. package/src/shell-safety.js +95 -6
  24. package/src/swarm-progress.js +348 -293
  25. package/src/tasks-browser.js +4 -1
  26. package/src/team/constants.js +20 -0
  27. package/src/team/context.js +89 -0
  28. package/src/team/mailbox.js +139 -0
  29. package/src/team/runner.js +347 -0
  30. package/src/team/spawn.js +247 -0
  31. package/src/team/store.js +145 -0
  32. package/src/team/tasks.js +164 -0
  33. package/src/team/team.js +311 -0
  34. package/src/team-panel.js +226 -0
  35. package/src/term.js +27 -0
  36. package/src/tool-result.js +279 -236
  37. package/src/tools/agent-swarm.js +114 -11
  38. package/src/tools/agent.js +53 -3
  39. package/src/tools/edit.js +591 -436
  40. package/src/tools/git.js +15 -3
  41. package/src/tools/index.js +2 -0
  42. package/src/tools/team.js +398 -0
  43. package/src/tools/web-search.js +88 -75
  44. package/src/tui.js +6187 -4780
  45. package/src/update.js +285 -115
  46. package/src/web-daemon.js +27 -4
  47. package/src/web-live.js +250 -0
  48. package/src/web.js +3 -1
@@ -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,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
+ export function readMailbox(teamName, agentName) {
45
+ try {
46
+ const parsed = JSON.parse(fs.readFileSync(inboxPath(teamName, agentName), 'utf8'));
47
+ return Array.isArray(parsed) ? parsed : [];
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ /** Only what the recipient has not seen. */
54
+ export function readUnread(teamName, agentName) {
55
+ return readMailbox(teamName, agentName).filter((m) => !m.read);
56
+ }
57
+
58
+ /**
59
+ * Deliver a message.
60
+ *
61
+ * The sender's name and colour are stamped here rather than passed by the call site, so every
62
+ * message carries them whether it came from the lead, a teammate, or a future caller.
63
+ */
64
+ export function writeToMailbox(teamName, recipientName, { from, text, summary, color }) {
65
+ const file = inboxPath(teamName, recipientName);
66
+ const box = readMailbox(teamName, recipientName);
67
+ const msg = {
68
+ from: String(from || 'unknown'),
69
+ text: String(text == null ? '' : text),
70
+ timestamp: new Date().toISOString(),
71
+ read: false,
72
+ };
73
+ if (summary) msg.summary = String(summary);
74
+ if (color) msg.color = String(color);
75
+ box.push(msg);
76
+ // Bounded: an inbox nobody drains must not grow without limit and turn every read into a
77
+ // parse of the whole session's chatter.
78
+ const kept = box.length > MAX_INBOX ? box.slice(box.length - MAX_INBOX) : box;
79
+ writeAtomic(file, JSON.stringify(kept, null, 2));
80
+ return msg;
81
+ }
82
+
83
+ /**
84
+ * Mark the given messages read. Returns how many changed.
85
+ *
86
+ * `ids` are timestamps, not array positions: the box is re-read here, and a message
87
+ * delivered in between would shift every later index — marking by position would then
88
+ * silence the wrong message, or one that was never delivered at all.
89
+ *
90
+ * When called with no `ids` every message is marked, which is what a caller that
91
+ * genuinely drained the whole box wants. A caller that PROCESSED a specific batch must
92
+ * pass it: blanket-marking re-reads the box and marks whatever arrived since the
93
+ * batch was taken, and those messages are then never delivered.
94
+ */
95
+ export function markAllRead(teamName, agentName, ids = null) {
96
+ const box = readMailbox(teamName, agentName);
97
+ const want = Array.isArray(ids) ? new Set(ids.map((m) => (m && m.timestamp) || m)) : null;
98
+ let n = 0;
99
+ for (const m of box) {
100
+ if (m.read) continue;
101
+ if (want && !want.has(m.timestamp)) continue;
102
+ m.read = true;
103
+ n++;
104
+ }
105
+ if (n) writeAtomic(inboxPath(teamName, agentName), JSON.stringify(box, null, 2));
106
+ return n;
107
+ }
108
+
109
+ /** Empty a member's inbox. Used when a teammate is removed from the team. */
110
+ export function clearMailbox(teamName, agentName) {
111
+ try { fs.unlinkSync(inboxPath(teamName, agentName)); return true; } catch { return false; }
112
+ }
113
+
114
+ /**
115
+ * Render unread mail the way a member should see it: as an addressable block, not as prose.
116
+ *
117
+ * The `from` line matters — a teammate's next action is often "reply to whoever asked", and a
118
+ * block of text with no name attached leaves it guessing.
119
+ */
120
+ export function formatInbox(messages) {
121
+ if (!messages.length) return '';
122
+ const out = [];
123
+ for (const m of messages) {
124
+ out.push(`[message from ${m.from}]`);
125
+ if (m.summary) out.push(`(${m.summary})`);
126
+ out.push(m.text);
127
+ out.push('');
128
+ }
129
+ return out.join('\n').trimEnd();
130
+ }
131
+
132
+ /** Every inbox file for a team, so the rota can be cleaned with the team. */
133
+ export function listInboxes(teamName) {
134
+ try {
135
+ return fs.readdirSync(inboxDir(teamName)).filter((f) => f.endsWith('.json'));
136
+ } catch {
137
+ return [];
138
+ }
139
+ }
@@ -0,0 +1,347 @@
1
+ // Running one teammate.
2
+ //
3
+ // A teammate's life is a loop, not a single call: it is given a job, works, reports, and then
4
+ // WAITS for more mail instead of ending. That is what makes it a team rather than a batch — the
5
+ // lead can send a correction after seeing the first result, and the teammate is still there to
6
+ // receive it.
7
+ //
8
+ // claim/be given a task -> run the agent -> report to the lead -> wait -> (repeat)
9
+ //
10
+ // WHY IT ENDS AT ALL
11
+ // ------------------
12
+ // Three ways out, and each has to exist: the lead sends a shutdown, the idle timeout expires
13
+ // with nobody asking anything, or the run is interrupted. Without the timeout a forgotten
14
+ // teammate would hold its slot forever, and a single-threaded event loop full of never-ending
15
+ // agents cannot be reasoned about.
16
+ import { Agent } from '../agent.js';
17
+ import { readUnread, writeToMailbox, markAllRead, formatInbox } from './mailbox.js';
18
+ import { readTeamFile, updateMember } from './store.js';
19
+ import { leadOf } from './team.js';
20
+ import { claimTask, updateTask, readTasks, unclaimedTasks } from './tasks.js';
21
+ import { currentAgent } from './context.js';
22
+ import { TEAM_LEAD_NAME, TEAMMATE_MAX_STEPS, TEAMMATE_IDLE_TIMEOUT_MS } from './constants.js';
23
+
24
+ /**
25
+ * Who this member reports to, or '' when it IS the one in charge.
26
+ *
27
+ * The lead is the first member added and carries `role: 'lead'`. `team.leadName` records the same
28
+ * name, so when the two agree AND they are the member being asked about, this member is the lead —
29
+ * and a lead has nobody above it to ask for work.
30
+ */
31
+ export function reportsTo(team, member) {
32
+ const self = member && member.name;
33
+ const byRole = (team.members || []).find((m) => m.role === 'lead');
34
+ const leadName = (byRole && byRole.name) || team.leadName || '';
35
+ if (!leadName || leadName === self) return '';
36
+ return leadName;
37
+ }
38
+
39
+ /**
40
+ * Build what a teammate sees on its first turn.
41
+ *
42
+ * The ROSTER is deliberately NOT here. It used to be pasted in as a list, which looked convenient
43
+ * and was wrong three ways:
44
+ *
45
+ * * it cost tokens on every round, since this message is part of the transcript;
46
+ * * it went STALE — a member that joined or left afterwards changed the roster, while the copy in
47
+ * the conversation kept describing the old one;
48
+ * * it leaked into the model's reasoning about itself. A lead read its own name in that list and
49
+ * concluded it should ask "the lead" for work, i.e. ask itself, then reported the contradiction
50
+ * back to the user.
51
+ *
52
+ * The current roster is injected by `TEAMMATE_ADDENDUM`'s caller (see `teammateSystemPrompt`),
53
+ * which recomputes it each round from the file.
54
+ */
55
+ export function firstPrompt({ team, member, task, taskList, assignment }) {
56
+ const parts = [];
57
+ const leadName = reportsTo(team, member);
58
+ if (task) {
59
+ parts.push(`Your task (${task.id}): ${task.subject}`);
60
+ if (task.description) parts.push(task.description);
61
+ } else if (assignment) {
62
+ // The prompt the member was spawned with IS its assignment. Without this the teammate opened
63
+ // by asking the lead what to do — a round spent answering a question the spawner already knew.
64
+ parts.push(`Your assignment: ${assignment}`);
65
+ } else if (leadName) {
66
+ // An instruction, not a statement of fact. The old wording ("You have no task yet. Ask the
67
+ // lead what to work on.") read as a description of the situation, while the system prompt says
68
+ // to message the lead when FINISHING or BLOCKED — neither of which is true yet. A model
69
+ // following that rule stayed silent, so the lead received nothing and the team looked dead.
70
+ parts.push(`You have no task yet. Your FIRST action is to send ${leadName} a short message`);
71
+ parts.push(`asking for work: SendMessage with to="${leadName}". Do that before anything else.`);
72
+ }
73
+ if (taskList && taskList.length) {
74
+ parts.push('');
75
+ parts.push('The full task list (do not start anything already owned):');
76
+ parts.push(taskList);
77
+ }
78
+ return parts.join('\n').trim();
79
+ }
80
+
81
+ /** The system prompt a teammate runs under: the normal one, plus how a teammate behaves. */
82
+ export const TEAMMATE_ADDENDUM = `
83
+ # You are a teammate
84
+
85
+ You are one member of a team. Someone directs the work — the LEAD, an agent whose name is on your
86
+ roster — and you report to it. If YOU are the lead, nobody directs you: the person using hncode
87
+ does, and you direct the others. The other members are your peers. You are addressed by name, and
88
+ you address others by name.
89
+
90
+ ## Talking to the team
91
+
92
+ Use SendMessage: \`to\` a name sends to that member; \`to\` "*" reaches everyone else and should be
93
+ rare, because a broadcast interrupts each of them.
94
+
95
+ WHO YOU ANSWER is set by who wrote to you, not by a fixed name:
96
+ - a message from the person using hncode carries the name \`team-lead\`. **Answer it with
97
+ \`SendMessage\` to \`team-lead\`.** They are outside the roster, so they are the one recipient
98
+ that is never "another member". This is the reply people wait for, and a person who gets no
99
+ answer concludes the whole team is broken
100
+ - a message from a member is answered to that member
101
+ - a message from the lead is answered to the lead
102
+
103
+ Answer anything sent to you. A greeting, a question, a correction — each is a message somebody is
104
+ waiting on, and none of them is covered by "only report when you finish". Being silent about a
105
+ direct message is the one failure that reads as a dead teammate.
106
+
107
+ Report to the lead when:
108
+ - you START, with no task yet — ask what to work on. Say so ONCE and then wait; the lead
109
+ cannot answer a question you never sent, and silence looks exactly like being broken
110
+ - you FINISH a piece of work — what you did, and where the result is
111
+ - you are BLOCKED — what stopped you and what you need. That is more useful than a partial
112
+ result with no explanation
113
+ - something needs a decision you should not make alone
114
+
115
+ Otherwise, report ONCE and then stop talking. A message that only says "still working" costs a
116
+ turn and tells it nothing — work, then report. Do not narrate your progress.
117
+
118
+ ## Doing the work
119
+
120
+ TeamTaskList shows the shared work and TeamTaskUpdate claims it. Claim before you start, so two
121
+ members do not take the same task, and mark it done with a short result — that is what the lead
122
+ reads to see the team moving.
123
+
124
+ If you are given a task, do it. If you cannot, say so plainly.`;
125
+
126
+ /**
127
+ * The system prompt for one member: the shared addendum, plus WHO it is and WHO is on the team.
128
+ *
129
+ * Identity and the roster belong HERE rather than in the first user message, and the difference is
130
+ * not cosmetic:
131
+ *
132
+ * * a system prompt is rebuilt for every request, so it is always the CURRENT roster — a member
133
+ * that joined or left cannot leave a stale list behind in the transcript;
134
+ * * the conversation stays a record of the WORK. A pasted roster there is re-sent every round
135
+ * and reads as something that was said, so the model reasons about it as content rather than
136
+ * as configuration;
137
+ * * being told its own role here is what stops a LEAD reading the roster and concluding it
138
+ * should ask "the lead" — itself — for work, then reporting the contradiction to the user.
139
+ *
140
+ * `reportsTo` returning '' is what makes a lead a lead: it has nobody above it, so it is never
141
+ * told to ask for an assignment, and is told instead that assigning work is its own job.
142
+ */
143
+ export function teammateSystemPrompt(team, member) {
144
+ const peers = (team.members || []).filter((m) => m && m.name && m.name !== member.name);
145
+ const leadName = reportsTo(team, member);
146
+ const lines = [TEAMMATE_ADDENDUM, ''];
147
+ lines.push('# You, right now');
148
+ lines.push('');
149
+ if (leadName) {
150
+ lines.push(`You are "${member.name}". ${leadName} is the lead — it decides what the team works on,`);
151
+ lines.push('and it is who you report to.');
152
+ } else {
153
+ lines.push(`You are "${member.name}", and you ARE the lead of this team. You decide what the team`);
154
+ lines.push('works on. Nobody assigns you work: take the user\'s instruction, break it into tasks,');
155
+ lines.push('and hand those to the other members. Do not ask anyone for an assignment.');
156
+ }
157
+ if (peers.length) {
158
+ lines.push('');
159
+ lines.push('On the team with you:');
160
+ for (const m of peers) {
161
+ lines.push(` ${m.name}${m.role === 'lead' ? ' (lead)' : ''}${m.agentType ? ` — ${m.agentType}` : ''}`);
162
+ }
163
+ } else {
164
+ lines.push('');
165
+ lines.push('You are currently the only member of this team.');
166
+ }
167
+ return lines.join('\n');
168
+ }
169
+
170
+ /**
171
+ * Wait for mail.
172
+ *
173
+ * Polls the inbox rather than blocking on a watch: the delivery is a file rename from another
174
+ * async context in this same process, and `fs.watch` on Windows reports a rename as two events
175
+ * (the tmp appearing and the target changing) which would need de-duplicating anyway. A poll is
176
+ * both simpler and more portable.
177
+ *
178
+ * `timeoutMs` of 0 (or less) means WAIT INDEFINITELY, and that is now the default for a
179
+ * teammate: a member that gives up after two quiet minutes cannot be given work later, which
180
+ * is the opposite of what a team is for. The lead is a person typing, and the gap between "you
181
+ * are hired" and "here is the job" is not a failure. `shouldStop` is still honoured on every
182
+ * poll, so a disband or an interrupt ends the wait promptly.
183
+ *
184
+ * Returns the unread messages, or null when the caller must stop.
185
+ */
186
+ export async function waitForMail(teamName, agentName, { timeoutMs = 0, pollMs = 250, shouldStop } = {}) {
187
+ const forever = !(timeoutMs > 0);
188
+ const deadline = forever ? Infinity : Date.now() + timeoutMs;
189
+ for (;;) {
190
+ if (shouldStop && shouldStop()) return null;
191
+ const unread = readUnread(teamName, agentName);
192
+ if (unread.length) return unread;
193
+ if (Date.now() >= deadline) return null;
194
+ await new Promise((r) => setTimeout(r, pollMs));
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Run one teammate to completion.
200
+ *
201
+ * `deps` carries what the runner cannot import: the built agent config, the tool set, and the
202
+ * callbacks that reach the TUI. Passing them in keeps this module free of the 17k-line TUI it
203
+ * would otherwise have to import, and makes the loop testable with a stub agent.
204
+ */
205
+ export async function runTeammate({
206
+ teamName,
207
+ memberName,
208
+ cfg,
209
+ makeState,
210
+ toolNote = () => {},
211
+ shouldStop = () => false,
212
+ // 0 = wait forever. See `waitForMail`: a member that quits after a quiet spell cannot be
213
+ // given work later, and the lead is a person typing rather than a service on a deadline.
214
+ idleTimeoutMs = 0,
215
+ makeAgent,
216
+ onStart,
217
+ onFinish,
218
+ }) {
219
+ const identity = currentAgent();
220
+ if (!identity || identity.name !== memberName) {
221
+ return { ok: false, error: `runTeammate must be called inside runAs() for "${memberName}"` };
222
+ }
223
+
224
+ const team = readTeamFile(teamName);
225
+ if (!team) return { ok: false, error: `no team "${teamName}"` };
226
+ const member = team.members.find((m) => m.name === memberName);
227
+ if (!member) return { ok: false, error: `"${memberName}" is not on team "${teamName}"` };
228
+
229
+ // A task may have been assigned before the teammate started, or be waiting to be claimed.
230
+ let task = null;
231
+ if (member.taskId) {
232
+ task = readTasks(teamName).find((t) => t.id === member.taskId) || null;
233
+ }
234
+ if (!task) {
235
+ const free = unclaimedTasks(teamName);
236
+ if (free.length) {
237
+ const claimed = claimTask(teamName, free[0].id, memberName);
238
+ if (claimed.ok) task = claimed.task;
239
+ }
240
+ }
241
+
242
+ const taskList = readTasks(teamName);
243
+ // The system entry goes FIRST and the task text after it. The Agent has no `systemPrompt`
244
+ // option — the prompt is an ordinary system message the caller supplies, which is how the
245
+ // TUI builds one per turn.
246
+ const messages = [
247
+ { role: 'system', content: teammateSystemPrompt(team, member) },
248
+ {
249
+ role: 'user',
250
+ content: firstPrompt({
251
+ team,
252
+ member,
253
+ task,
254
+ taskList: taskList.length ? taskList.map((t) => `${t.id} ${t.status} ${t.subject}`).join('\n') : '',
255
+ assignment: member.prompt || '',
256
+ }),
257
+ },
258
+ ];
259
+
260
+ onStart && onStart({ member, task });
261
+
262
+ const result = { ok: true, reports: [], task: task && task.id };
263
+ let idle = false;
264
+
265
+ for (;;) {
266
+ if (shouldStop()) break;
267
+
268
+ updateMember(teamName, memberName, { isActive: true, phase: 'thinking' });
269
+
270
+ // Run the agent for this round. `messages` is the running transcript OF THIS TEAMMATE,
271
+ // so it remembers what it already did across the rounds of one assignment.
272
+ // One round of the teammate's work. `makeAgent` is injectable so the loop can be tested
273
+ // without a model; production passes nothing and gets a real Agent.
274
+ const build = makeAgent || ((opts) => new Agent(opts));
275
+ const agent = build({
276
+ cfg,
277
+ messages,
278
+ maxSteps: TEAMMATE_MAX_STEPS,
279
+ onEvent: (e) => {
280
+ if (e.type === 'tool_start') toolNote(memberName, e.name, e.args);
281
+ // The one moment a member is unambiguously working, as opposed to blocked on the
282
+ // model — which is what most of a round is.
283
+ updateMember(teamName, memberName, { isActive: true, phase: 'working' });
284
+ },
285
+ });
286
+ await agent.run();
287
+
288
+ // The Agent kept the SAME array and appended to it, so the transcript already carries
289
+ // this round. Nothing to copy — `messages` IS `agent.messages`.
290
+
291
+ // A tool named SendMessage already delivered its message (see the tool's own handler), so
292
+ // here we only report completion to the lead if the agent did not.
293
+ if (task) {
294
+ const t = readTasks(teamName).find((x) => x.id === task.id);
295
+ if (t && t.status === 'in_progress') {
296
+ updateTask(teamName, task.id, {
297
+ status: 'completed',
298
+ result: lastAssistantText(agent) || '',
299
+ });
300
+ // The team's OWN lead: a member reports to whoever leads it, which is not
301
+ // necessarily the default name.
302
+ writeToMailbox(teamName, leadOf(teamName), {
303
+ from: memberName,
304
+ text: `Done with ${task.id}: ${task.subject}\n\n${lastAssistantText(agent) || '(no summary)'}`,
305
+ summary: `done ${task.id}`,
306
+ color: member.color,
307
+ });
308
+ }
309
+ }
310
+
311
+ updateMember(teamName, memberName, { isActive: false, phase: 'idle' });
312
+ onFinish && onFinish({ member, task });
313
+
314
+ const mail = await waitForMail(teamName, memberName, { timeoutMs: idleTimeoutMs, shouldStop });
315
+ if (!mail) { idle = true; break; }
316
+
317
+ // Only the batch that was just read is marked. Marking the whole box would also
318
+ // mark anything delivered since `waitForMail` returned, and that message would
319
+ // then never be seen — it is unread, so it is not in `mail`, and it is now read,
320
+ // so the next `readUnread` skips it.
321
+ markAllRead(teamName, memberName, mail);
322
+
323
+ // A shutdown or a team-ending notice stops the loop before the agent sees it as a request.
324
+ const stop = mail.find((m) => /\[(shutdown|team ending)\]/i.test(m.text));
325
+ if (stop) { result.stoppedBy = stop.from; break; }
326
+
327
+ messages.push({ role: 'user', content: formatInbox(mail) });
328
+ result.reports.push(...mail.map((m) => ({ from: m.from, summary: m.summary })));
329
+ }
330
+
331
+ updateMember(teamName, memberName, { isActive: false });
332
+ return { ...result, idle };
333
+ }
334
+
335
+ /** The teammate's last assistant turn, as plain text. */
336
+ function lastAssistantText(agent) {
337
+ const msgs = agent.messages || [];
338
+ for (let i = msgs.length - 1; i >= 0; i--) {
339
+ const m = msgs[i];
340
+ if (m.role !== 'assistant') continue;
341
+ const text = typeof m.content === 'string'
342
+ ? m.content
343
+ : (Array.isArray(m.content) ? m.content.filter((b) => b.type === 'text').map((b) => b.text).join('') : '');
344
+ if (text.trim()) return text.trim();
345
+ }
346
+ return '';
347
+ }