@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.
- package/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- 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 +19 -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 +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- 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
|
+
}
|