@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
@@ -0,0 +1,462 @@
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
+ // Back off while the box stays empty: 250ms -> 2s. Every poll is a readFileSync
190
+ // plus a JSON.parse of the whole inbox, so an idle eight-member team burned
191
+ // 32 sync file reads a second, forever. Delivery is still prompt — a message
192
+ // lands at most one backoff step (~2s) late, which is imperceptible for a
193
+ // person typing the next instruction.
194
+ let wait = pollMs;
195
+ for (;;) {
196
+ if (shouldStop && shouldStop()) return null;
197
+ const unread = readUnread(teamName, agentName);
198
+ if (unread.length) return unread;
199
+ if (Date.now() >= deadline) return null;
200
+ await new Promise((r) => setTimeout(r, wait));
201
+ wait = Math.min(wait * 2, 2000);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Run one teammate to completion.
207
+ *
208
+ * `deps` carries what the runner cannot import: the built agent config, the tool set, and the
209
+ * callbacks that reach the TUI. Passing them in keeps this module free of the 17k-line TUI it
210
+ * would otherwise have to import, and makes the loop testable with a stub agent.
211
+ */
212
+ export async function runTeammate({
213
+ teamName,
214
+ memberName,
215
+ cfg,
216
+ makeState,
217
+ toolNote = () => {},
218
+ shouldStop = () => false,
219
+ // 0 = wait forever. See `waitForMail`: a member that quits after a quiet spell cannot be
220
+ // given work later, and the lead is a person typing rather than a service on a deadline.
221
+ idleTimeoutMs = 0,
222
+ makeAgent,
223
+ onStart,
224
+ onFinish,
225
+ // Live output from this member's turn: `{ kind, text }` / `{ kind: 'tool', name, detail }`.
226
+ //
227
+ // Without it a teammate was a black box — the roster said idle→working→idle and nothing about what
228
+ // it did in between. A person watching a team needs to see the work, not only its conclusion: a
229
+ // member that spends forty seconds running tests looks identical to one that hung.
230
+ onStream = () => {},
231
+ // Ask the person before a tool runs, via the lead's mailbox.
232
+ // `async (toolName, args) => boolean | { approved, error }`.
233
+ //
234
+ // An injected hook rather than a hard-wired call: `runTeammate` is exercised in tests with a fake agent
235
+ // and no team, and a permission round-trip there would block for the full timeout on every tool.
236
+ askPermission,
237
+ }) {
238
+ const identity = currentAgent();
239
+ if (!identity || identity.name !== memberName) {
240
+ return { ok: false, error: `runTeammate must be called inside runAs() for "${memberName}"` };
241
+ }
242
+
243
+ const team = readTeamFile(teamName);
244
+ if (!team) return { ok: false, error: `no team "${teamName}"` };
245
+ const member = team.members.find((m) => m.name === memberName);
246
+ if (!member) return { ok: false, error: `"${memberName}" is not on team "${teamName}"` };
247
+
248
+ // A task may have been assigned before the teammate started, or be waiting to be claimed.
249
+ let task = null;
250
+ if (member.taskId) {
251
+ task = readTasks(teamName).find((t) => t.id === member.taskId) || null;
252
+ }
253
+ if (!task) {
254
+ const free = unclaimedTasks(teamName);
255
+ if (free.length) {
256
+ const claimed = claimTask(teamName, free[0].id, memberName);
257
+ if (claimed.ok) task = claimed.task;
258
+ }
259
+ }
260
+
261
+ const taskList = readTasks(teamName);
262
+ // The system entry goes FIRST and the task text after it. The Agent has no `systemPrompt`
263
+ // option — the prompt is an ordinary system message the caller supplies, which is how the
264
+ // TUI builds one per turn.
265
+ //
266
+ // THE FIRST PROMPT MUST NOT BE EMPTY. An empty user turn gives the model nothing to answer, so the
267
+ // round produces no reply and the member looks dead. That happened whenever `firstPrompt` had nothing
268
+ // to say — no task, no assignment, and no lead to report to — which is the NORMAL start for the first
269
+ // member of a team, because `addMember` makes the first member the lead and "report to the lead" then
270
+ // resolves to itself and returns ''.
271
+ const opening = firstPrompt({
272
+ team,
273
+ member,
274
+ task,
275
+ taskList: taskList.length ? taskList.map((t) => `${t.id} ${t.status} ${t.subject}`).join('\n') : '',
276
+ assignment: member.prompt || '',
277
+ }) || 'You are on the team and have no task yet. Read the messages in your next input, answer the lead, and use SendMessage to reply.';
278
+ const messages = [
279
+ { role: 'system', content: teammateSystemPrompt(team, member) },
280
+ { role: 'user', content: opening },
281
+ ];
282
+
283
+ onStart && onStart({ member, task });
284
+
285
+ const result = { ok: true, reports: [], task: task && task.id };
286
+ let idle = false;
287
+
288
+ for (;;) {
289
+ if (shouldStop()) break;
290
+
291
+ updateMember(teamName, memberName, { isActive: true, phase: 'thinking' });
292
+
293
+ // Run the agent for this round. `messages` is the running transcript OF THIS TEAMMATE,
294
+ // so it remembers what it already did across the rounds of one assignment.
295
+ // One round of the teammate's work. `makeAgent` is injectable so the loop can be tested
296
+ // without a model; production passes nothing and gets a real Agent.
297
+ const build = makeAgent || ((opts) => new Agent(opts));
298
+ // Whether THIS round invoked SendMessage. Reset per round because the fallback below only applies
299
+ // to a round that stayed silent — a member that answered with the tool has already been delivered.
300
+ let sentThisRound = false;
301
+ const agent = build({
302
+ cfg,
303
+ messages,
304
+ maxSteps: TEAMMATE_MAX_STEPS,
305
+ // ASK THE PERSON, THROUGH THE LEAD, BEFORE ANY TOOL RUNS.
306
+ //
307
+ // A teammate can write files and run commands, and until this existed it had exactly two options:
308
+ // do anything, or be unable to ask. This forwards the question to the lead's mailbox and BLOCKS
309
+ // here until the answer comes back — the runner is synchronous per round, so a refusal has to
310
+ // arrive as a value, not as a later event.
311
+ //
312
+ // `askPermission` is undefined for a team whose lead has no panel; the call then resolves to a
313
+ // refusal with a reason, which the model can act on.
314
+ onApproval: askPermission,
315
+ onEvent: (e) => {
316
+ if (e.type === 'tool_start' && /^SendMessage$/.test(String(e.name || ''))) sentThisRound = true;
317
+ if (e.type === 'tool_start') toolNote(memberName, e.name, e.args);
318
+ // The one moment a member is unambiguously working, as opposed to blocked on the
319
+ // model — which is what most of a round is.
320
+ updateMember(teamName, memberName, { isActive: true, phase: 'working' });
321
+
322
+ // LIVE OUTPUT — as TRANSCRIPT ENTRIES, not status blips.
323
+ //
324
+ // Claude Code renders a teammate by feeding `viewedAgentTask.messages` to the SAME <Messages>
325
+ // component the main transcript uses (REPL.tsx: `displayedMessages = viewedAgentTask.messages`),
326
+ // so its answer, its tool calls and their results read as one conversation. Relaying bare
327
+ // strings meant the panel had nothing to show but a single activity line — which is exactly the
328
+ // complaint: "I switch to a member and see no messages, though it is working."
329
+ try {
330
+ if (e.type === 'data' && e.text) {
331
+ onStream({ kind: 'text', name: memberName, text: String(e.text) });
332
+ } else if (e.type === 'think' && e.text) {
333
+ onStream({ kind: 'think', name: memberName, text: String(e.text) });
334
+ } else if (e.type === 'tool_start') {
335
+ onStream({ kind: 'tool', name: memberName, tool: String(e.name || ''), id: e.id || '' });
336
+ } else if (e.type === 'tool_result') {
337
+ // The WHOLE result. The panel wraps and clips at render time, where the available height is
338
+ // known; trimming to the first line here threw away the body a reader came for.
339
+ onStream({
340
+ kind: 'tool_result',
341
+ name: memberName,
342
+ tool: String(e.name || ''),
343
+ id: e.id || '',
344
+ detail: String(e.content || ''),
345
+ editDiff: e.editDiff || null,
346
+ });
347
+ }
348
+ } catch { /* a display hook must never fail a member's turn */ }
349
+ },
350
+ });
351
+ // A ROUND THAT THROWS MUST BE REPORTED, not swallowed.
352
+ //
353
+ // `agent.run()` reaches the model, so it fails for every reason the network and the provider can
354
+ // produce: no model configured, a bad key, a 429, a dropped connection. Without this the error left
355
+ // `runTeammate` entirely — no reply, no log the user could see, and the member simply looked
356
+ // unresponsive. That is indistinguishable from "the team is ignoring me", which is exactly how it was
357
+ // reported.
358
+ //
359
+ // The message goes to the lead's inbox, which reaches the TUI through the same onLeadMail path as an
360
+ // ordinary reply, so a failure is visible where a reply would have been.
361
+ try {
362
+ await agent.run();
363
+ } catch (e) {
364
+ const why = (e && e.message) || String(e);
365
+ writeToMailbox(teamName, TEAM_LEAD_NAME, {
366
+ from: memberName,
367
+ text: `I could not complete this round: ${why}`,
368
+ summary: 'round failed',
369
+ color: member.color,
370
+ });
371
+ result.failed = (result.failed || 0) + 1;
372
+ result.lastError = why;
373
+ updateMember(teamName, memberName, { isActive: false, phase: 'idle' });
374
+ onFinish && onFinish({ member, task });
375
+ result.ok = false;
376
+ // Back to waiting rather than exiting: a transient failure (a rate limit) must not end the member.
377
+ // `waitForMail` below decides whether it stops, on the same terms as a successful round.
378
+ const mail = await waitForMail(teamName, memberName, { timeoutMs: idleTimeoutMs, shouldStop });
379
+ if (!mail) { idle = true; break; }
380
+ continue;
381
+ }
382
+
383
+ // The Agent kept the SAME array and appended to it, so the transcript already carries
384
+ // this round. Nothing to copy — `messages` IS `agent.messages`.
385
+ // A round that answered in TEXT instead of calling SendMessage must still reach the lead.
386
+ //
387
+ // The tool is how a member is TOLD to reply, and most rounds use it. A model that answers
388
+ // conversationally instead — "I'm here, got your message" — produces the same words and sends them
389
+ // nowhere, so the person who asked gets silence while the member considers itself done. From outside
390
+ // that is indistinguishable from being ignored, which is how it was reported.
391
+ //
392
+ // The guard: only when `SendMessage` did NOT run this round. `sentThisRound` is set by the agent's
393
+ // tool events, so a round that replied properly is not answered twice.
394
+ if (!sentThisRound) {
395
+ const text = lastAssistantText(agent);
396
+ if (text) {
397
+ writeToMailbox(teamName, TEAM_LEAD_NAME, {
398
+ from: memberName,
399
+ text,
400
+ summary: 'reply',
401
+ color: member.color,
402
+ });
403
+ }
404
+ }
405
+
406
+ // A tool named SendMessage already delivered its message (see the tool's own handler), so
407
+ // here we only report completion to the lead if the agent did not.
408
+ if (task) {
409
+ const t = readTasks(teamName).find((x) => x.id === task.id);
410
+ if (t && t.status === 'in_progress') {
411
+ updateTask(teamName, task.id, {
412
+ status: 'completed',
413
+ result: lastAssistantText(agent) || '',
414
+ });
415
+ // The team's OWN lead: a member reports to whoever leads it, which is not
416
+ // necessarily the default name.
417
+ writeToMailbox(teamName, leadOf(teamName), {
418
+ from: memberName,
419
+ text: `Done with ${task.id}: ${task.subject}\n\n${lastAssistantText(agent) || '(no summary)'}`,
420
+ summary: `done ${task.id}`,
421
+ color: member.color,
422
+ });
423
+ }
424
+ }
425
+
426
+ updateMember(teamName, memberName, { isActive: false, phase: 'idle' });
427
+ onFinish && onFinish({ member, task });
428
+
429
+ const mail = await waitForMail(teamName, memberName, { timeoutMs: idleTimeoutMs, shouldStop });
430
+ if (!mail) { idle = true; break; }
431
+
432
+ // Only the batch that was just read is marked. Marking the whole box would also
433
+ // mark anything delivered since `waitForMail` returned, and that message would
434
+ // then never be seen — it is unread, so it is not in `mail`, and it is now read,
435
+ // so the next `readUnread` skips it.
436
+ markAllRead(teamName, memberName, mail);
437
+
438
+ // A shutdown or a team-ending notice stops the loop before the agent sees it as a request.
439
+ const stop = mail.find((m) => /\[(shutdown|team ending)\]/i.test(m.text));
440
+ if (stop) { result.stoppedBy = stop.from; break; }
441
+
442
+ messages.push({ role: 'user', content: formatInbox(mail) });
443
+ result.reports.push(...mail.map((m) => ({ from: m.from, summary: m.summary })));
444
+ }
445
+
446
+ updateMember(teamName, memberName, { isActive: false });
447
+ return { ...result, idle };
448
+ }
449
+
450
+ /** The teammate's last assistant turn, as plain text. */
451
+ function lastAssistantText(agent) {
452
+ const msgs = agent.messages || [];
453
+ for (let i = msgs.length - 1; i >= 0; i--) {
454
+ const m = msgs[i];
455
+ if (m.role !== 'assistant') continue;
456
+ const text = typeof m.content === 'string'
457
+ ? m.content
458
+ : (Array.isArray(m.content) ? m.content.filter((b) => b.type === 'text').map((b) => b.text).join('') : '');
459
+ if (text.trim()) return text.trim();
460
+ }
461
+ return '';
462
+ }