@hncode/hncode 0.5.2 → 0.5.5

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 (45) hide show
  1. package/package.json +38 -38
  2. package/src/agent.js +172 -19
  3. package/src/cache.js +175 -130
  4. package/src/clipboard.js +69 -2
  5. package/src/config.js +10 -3
  6. package/src/index.js +29 -5
  7. package/src/keybindings.js +25 -3
  8. package/src/llm.js +54 -6
  9. package/src/mcp.js +419 -384
  10. package/src/plugin.js +95 -10
  11. package/src/remote.js +307 -289
  12. package/src/session-hub.js +37 -0
  13. package/src/settings-schema.js +2 -0
  14. package/src/shell-safety.js +610 -562
  15. package/src/swarm-progress.js +1 -1
  16. package/src/team/mailbox.js +216 -139
  17. package/src/team/permission.js +121 -0
  18. package/src/team/protocol.js +167 -0
  19. package/src/team/runner.js +127 -12
  20. package/src/team/spawn.js +63 -5
  21. package/src/team/store.js +31 -4
  22. package/src/team/tasks.js +15 -2
  23. package/src/team/team.js +51 -16
  24. package/src/team-panel.js +418 -20
  25. package/src/term-caps.js +457 -410
  26. package/src/term.js +142 -26
  27. package/src/tool-result.js +50 -2
  28. package/src/tools/agent.js +464 -436
  29. package/src/tools/bash.js +53 -14
  30. package/src/tools/diagnostics.js +13 -0
  31. package/src/tools/edit.js +727 -577
  32. package/src/tools/fetch-url.js +118 -5
  33. package/src/tools/glob.js +5 -3
  34. package/src/tools/grep.js +19 -5
  35. package/src/tools/ignore.js +17 -3
  36. package/src/tools/index.js +17 -3
  37. package/src/tools/read.js +13 -2
  38. package/src/tools/shell.js +145 -134
  39. package/src/tools/tasks.js +214 -141
  40. package/src/tools/team.js +13 -0
  41. package/src/tools/utils.js +12 -3
  42. package/src/tools/web-search.js +85 -34
  43. package/src/tui.js +2089 -714
  44. package/src/update.js +38 -3
  45. package/src/web.js +47 -1
@@ -186,12 +186,19 @@ export function teammateSystemPrompt(team, member) {
186
186
  export async function waitForMail(teamName, agentName, { timeoutMs = 0, pollMs = 250, shouldStop } = {}) {
187
187
  const forever = !(timeoutMs > 0);
188
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;
189
195
  for (;;) {
190
196
  if (shouldStop && shouldStop()) return null;
191
197
  const unread = readUnread(teamName, agentName);
192
198
  if (unread.length) return unread;
193
199
  if (Date.now() >= deadline) return null;
194
- await new Promise((r) => setTimeout(r, pollMs));
200
+ await new Promise((r) => setTimeout(r, wait));
201
+ wait = Math.min(wait * 2, 2000);
195
202
  }
196
203
  }
197
204
 
@@ -215,6 +222,18 @@ export async function runTeammate({
215
222
  makeAgent,
216
223
  onStart,
217
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,
218
237
  }) {
219
238
  const identity = currentAgent();
220
239
  if (!identity || identity.name !== memberName) {
@@ -243,18 +262,22 @@ export async function runTeammate({
243
262
  // The system entry goes FIRST and the task text after it. The Agent has no `systemPrompt`
244
263
  // option — the prompt is an ordinary system message the caller supplies, which is how the
245
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.';
246
278
  const messages = [
247
279
  { 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
- },
280
+ { role: 'user', content: opening },
258
281
  ];
259
282
 
260
283
  onStart && onStart({ member, task });
@@ -272,21 +295,113 @@ export async function runTeammate({
272
295
  // One round of the teammate's work. `makeAgent` is injectable so the loop can be tested
273
296
  // without a model; production passes nothing and gets a real Agent.
274
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;
275
301
  const agent = build({
276
302
  cfg,
277
303
  messages,
278
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,
279
315
  onEvent: (e) => {
316
+ if (e.type === 'tool_start' && /^SendMessage$/.test(String(e.name || ''))) sentThisRound = true;
280
317
  if (e.type === 'tool_start') toolNote(memberName, e.name, e.args);
281
318
  // The one moment a member is unambiguously working, as opposed to blocked on the
282
319
  // model — which is what most of a round is.
283
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 */ }
284
349
  },
285
350
  });
286
- await agent.run();
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
+ }
287
382
 
288
383
  // The Agent kept the SAME array and appended to it, so the transcript already carries
289
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
+ }
290
405
 
291
406
  // A tool named SendMessage already delivered its message (see the tool's own handler), so
292
407
  // here we only report completion to the lead if the agent did not.
package/src/team/spawn.js CHANGED
@@ -15,7 +15,22 @@ import { runTeammate } from './runner.js';
15
15
  import { addMember, setMemberActive } from './team.js';
16
16
  import { updateMember } from './store.js';
17
17
  import { readTeamFile } from './store.js';
18
- import { TEAMMATE_MAX_STEPS } from './constants.js';
18
+ import { TEAMMATE_MAX_STEPS, TEAM_LEAD_NAME } from './constants.js';
19
+ import { requestPermission } from './permission.js';
20
+ import { leadOf } from './team.js';
21
+
22
+ /**
23
+ * One line describing what a tool call would do, for the permission prompt.
24
+ *
25
+ * A tool NAME tells the person nothing — "Bash" could be `ls` or `rm -rf`. These are the fields Claude
26
+ * Code checks, in the same order, because they are what the common tools put their intent in.
27
+ */
28
+ function describeCall(toolName, args) {
29
+ const a = args && typeof args === 'object' ? args : {};
30
+ const desc = a.description || a.command || a.prompt || a.query || a.pattern || a.file_path || a.path;
31
+ if (desc) return String(desc).split('\n')[0].slice(0, 120);
32
+ return `${toolName} with no description`;
33
+ }
19
34
 
20
35
  /** Live teammates, by agent id. The abort handle and the running promise. */
21
36
  const running = new Map();
@@ -51,7 +66,14 @@ export function spawnTeammate({
51
66
  maxSteps = TEAMMATE_MAX_STEPS,
52
67
  cwd,
53
68
  onEvent,
69
+ // Called when this teammate sends a message to the lead — that is, to the person using hncode. The
70
+ // TUI passes one so the text lands in their transcript as it is sent; see `SendMessage` for what went
71
+ // wrong without it.
72
+ onLeadMail,
54
73
  tasks,
74
+ // 'ask' installs the permission round trip (see below). Anything else leaves the member able to
75
+ // run tools unattended — what a headless spawn wants.
76
+ permissionMode = '',
55
77
  }) {
56
78
  const team = readTeamFile(teamName);
57
79
  if (!team) return { ok: false, error: `no team "${teamName}"` };
@@ -107,6 +129,10 @@ export function spawnTeammate({
107
129
  abort() { abort.aborted = true; for (const l of abort.listeners) { try { l(); } catch { /* best effort */ } } },
108
130
  get signal() { return abort; },
109
131
  };
132
+ // TaskStop reaches a teammate through this handle. Without it the task had
133
+ // neither `_abort` nor `pid`, so a stop settled the task as 'killed' while
134
+ // the member kept polling its inbox forever.
135
+ task._abort = () => controller.abort();
110
136
 
111
137
  appendTaskOutput(task, `[${name}] started on team "${teamName}"\n`);
112
138
  onEvent && onEvent({ type: 'start', name, teamName, taskId: task.taskId, color: member.color });
@@ -120,6 +146,10 @@ export function spawnTeammate({
120
146
  const runCfg = {
121
147
  ...cfg,
122
148
  model: member.model || cfg.subagentModel || cfg.model,
149
+ // Carried on the config so the teammate's own `SendMessage` can reach the TUI. This is the
150
+ // channel that makes a reply appear in the user's transcript the moment it is sent, instead of
151
+ // waiting for their next turn (see the tool's handler for what went wrong without it).
152
+ onLeadMail,
123
153
  };
124
154
  let ok = false;
125
155
  const promise = runAs(identity, async () => {
@@ -138,6 +168,30 @@ export function spawnTeammate({
138
168
  shouldStop: () => abort.aborted,
139
169
  onStart: () => onEvent && onEvent({ type: 'working', name }),
140
170
  onFinish: () => onEvent && onEvent({ type: 'idle', name }),
171
+ // Relay the member's live output upward. The UI decides what to do with it; spawning only
172
+ // carries it, which keeps the display policy in one place.
173
+ onStream: (s) => onEvent && onEvent({ type: 'stream', name, stream: s }),
174
+ // THE PERMISSION ROUND TRIP: the member blocks here until the person answers through the lead's
175
+ // mailbox, and `requestPermission` polls for the matching `request_id`.
176
+ //
177
+ // Only installed when someone can actually answer — a headless spawn has no panel, and blocking
178
+ // for ten minutes on a question nobody can see would look like a hang.
179
+ askPermission: permissionMode === 'ask'
180
+ ? async (toolName, args) => {
181
+ const lead = leadOf(teamName) || TEAM_LEAD_NAME;
182
+ const verdict = await requestPermission({
183
+ teamName,
184
+ memberName: name,
185
+ leadName: lead,
186
+ toolName,
187
+ description: describeCall(toolName, args),
188
+ input: args && typeof args === 'object' ? args : {},
189
+ shouldStop: () => abort.aborted,
190
+ });
191
+ onEvent && onEvent({ type: 'permission', name, toolName, approved: verdict.approved });
192
+ return verdict;
193
+ }
194
+ : undefined,
141
195
  });
142
196
  ok = true;
143
197
  appendTaskOutput(task, `[${name}] finished${result.idle ? ' (idle timeout)' : ''}\n`);
@@ -177,9 +231,6 @@ export function spawnTeammate({
177
231
  teamName,
178
232
  taskId: task.taskId,
179
233
  controller,
180
- teamName,
181
- taskId: task.taskId,
182
- controller,
183
234
  promise,
184
235
  color: member.color,
185
236
  });
@@ -197,7 +248,14 @@ export function spawnMany(teamName, members, opts) {
197
248
  const started = [];
198
249
  const failed = [];
199
250
  for (const m of members) {
200
- const r = spawnTeammate({ teamName, cwd: opts.cwd, cfg: opts.cfg, toolNote: opts.toolNote, onEvent: opts.onEvent, ...m });
251
+ const r = spawnTeammate({
252
+ teamName, cwd: opts.cwd, cfg: opts.cfg, toolNote: opts.toolNote, onEvent: opts.onEvent,
253
+ onLeadMail: opts.onLeadMail,
254
+ // Carried through from the caller. `ask` installs the permission round trip; anything else leaves
255
+ // the member able to run tools unattended, which is what a headless spawn wants.
256
+ permissionMode: opts.permissionMode,
257
+ ...m,
258
+ });
201
259
  if (r.ok) started.push({ name: m.name, taskId: r.taskId });
202
260
  else failed.push({ name: m.name, error: r.error });
203
261
  }
package/src/team/store.js CHANGED
@@ -76,19 +76,46 @@ export function listTeams() {
76
76
  return out.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
77
77
  }
78
78
 
79
- /** The roster, or null. Never throws. */
79
+ /** The roster, or null. Never throws.
80
+ *
81
+ * Cached by (mtimeMs, size): the team panel renders this on EVERY frame, and a
82
+ * readFileSync + JSON.parse per frame sat in the hottest path there is. A stat
83
+ * per frame replaces the full read; any write (tmp+rename) changes the mtime,
84
+ * so nothing stale survives. */
85
+ let _teamCache = { key: '', team: null };
80
86
  export function readTeamFile(teamName) {
87
+ const file = teamFilePath(teamName);
88
+ let st = null;
89
+ try { st = fs.statSync(file); } catch { /* absent */ }
90
+ if (!st) return null;
91
+ const key = `${file}:${st.mtimeMs}:${st.size}`;
92
+ if (_teamCache.key === key) return _teamCache.team;
81
93
  try {
82
- return JSON.parse(fs.readFileSync(teamFilePath(teamName), 'utf8'));
94
+ const team = JSON.parse(fs.readFileSync(file, 'utf8'));
95
+ _teamCache = { key, team };
96
+ return team;
83
97
  } catch {
98
+ _teamCache = { key: '', team: null };
84
99
  return null;
85
100
  }
86
101
  }
87
102
 
88
- /** Write the roster, creating the directory. Throws only on a real IO failure. */
103
+ /** Write the roster, creating the directory. Throws only on a real IO failure.
104
+ *
105
+ * Atomic (tmp + rename), like every other team write: a plain writeFileSync
106
+ * dying mid-write left a truncated config.json, and readTeamFile then returned
107
+ * null — the whole team silently disappeared. */
89
108
  export function writeTeamFile(teamName, teamFile) {
90
109
  fs.mkdirSync(teamDir(teamName), { recursive: true });
91
- fs.writeFileSync(teamFilePath(teamName), JSON.stringify(teamFile, null, 2), 'utf8');
110
+ const target = teamFilePath(teamName);
111
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
112
+ fs.writeFileSync(tmp, JSON.stringify(teamFile, null, 2), 'utf8');
113
+ try {
114
+ fs.renameSync(tmp, target);
115
+ } catch (e) {
116
+ try { fs.unlinkSync(tmp); } catch { /* nothing more to do */ }
117
+ throw e;
118
+ }
92
119
  return teamFile;
93
120
  }
94
121
 
package/src/team/tasks.js CHANGED
@@ -37,11 +37,24 @@ function writeAtomic(file, text) {
37
37
  }
38
38
 
39
39
  /** Every task, oldest first. Never throws. */
40
+ // Cached by (mtimeMs, size) — the team panel reads the task list every frame,
41
+ // and a stat per read replaces the full readFileSync + JSON.parse. writeTasks
42
+ // goes through writeAtomic (rename), which always changes the mtime.
43
+ let _tasksCache = { key: '', tasks: null };
40
44
  export function readTasks(teamName) {
45
+ const file = tasksFile(teamName);
46
+ let st = null;
47
+ try { st = fs.statSync(file); } catch { /* absent */ }
48
+ if (!st) return [];
49
+ const key = `${st.mtimeMs}:${st.size}`;
50
+ if (_tasksCache.key === key) return _tasksCache.tasks.slice();
41
51
  try {
42
- const parsed = JSON.parse(fs.readFileSync(tasksFile(teamName), 'utf8'));
43
- return Array.isArray(parsed) ? parsed : [];
52
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
53
+ const tasks = Array.isArray(parsed) ? parsed : [];
54
+ _tasksCache = { key, tasks };
55
+ return tasks.slice();
44
56
  } catch {
57
+ _tasksCache = { key: '', tasks: null };
45
58
  return [];
46
59
  }
47
60
  }
package/src/team/team.js CHANGED
@@ -69,21 +69,29 @@ export function addMember(teamName, { name, agentType = 'general', model = '', t
69
69
  return { ok: false, error: `team "${teamName}" already has ${others.length} members (max ${MAX_TEAMMATES})` };
70
70
  }
71
71
 
72
- // THE FIRST MEMBER IS THE LEAD.
73
- //
74
- // A team with no leader is not a team — nothing can be reported TO — so the first member takes
75
- // the role, and the team records its name and id. Every later member is a peer.
76
- const isFirst = team.members.length === 0;
72
+ // One sanitized name is one inbox FILE: `Bob` and `bob`, or `a-b` and `a_b`,
73
+ // fold to the same path and would silently SHARE a mailbox, receiving each
74
+ // other's mail. A new member whose folded name collides with an existing one
75
+ // is refused — the caller picks a distinct name instead.
76
+ const folded = sanitizeName(memberName);
77
+ const clash = others.find((m) => sanitizeName(m.name) === folded);
78
+ if (clash) {
79
+ return { ok: false, error: `"${memberName}" folds to the same mailbox file as "${clash.name}" — pick a name that differs beyond case, dashes and underscores` };
80
+ }
77
81
 
78
82
  const color = pickColor(memberName, others.map((m) => m.color));
79
83
  const member = {
80
84
  agentId: formatAgentId(memberName, teamName),
81
85
  name: memberName,
82
86
  agentType,
83
- // RANK, not trade. `agentType` says what a member does; this says where it sits. Kept as a
84
- // field rather than derived from the name, so a lead can be renamed and a team can name its
85
- // own leader.
86
- role: isFirst ? 'lead' : 'member',
87
+ // RANK, not trade. `agentType` says what a member does; this says where it sits.
88
+ //
89
+ // ALWAYS `member`. The first person added was made `lead`, on the reasoning that a team with no
90
+ // leader is not a team — but that got it backwards for the only caller there is: `/team add`
91
+ // recruits someone to DO the work, and the person typing is already in charge. Labelling the first
92
+ // recruit a lead made it believe nobody had assigned it anything and that no message could have
93
+ // arrived, which is exactly what it then reported.
94
+ role: 'member',
87
95
  model,
88
96
  color,
89
97
  joinedAt: Date.now(),
@@ -95,10 +103,6 @@ export function addMember(teamName, { name, agentType = 'general', model = '', t
95
103
  // time blocked on the model, and a member that has never run is not working either.
96
104
  phase: 'starting',
97
105
  };
98
- if (isFirst) {
99
- team.leadName = memberName;
100
- team.leadAgentId = member.agentId;
101
- }
102
106
  team.members = [...others, member];
103
107
  writeTeamFile(teamName, team);
104
108
  return { ok: true, member };
@@ -166,6 +170,27 @@ export function findMember(teamName, name) {
166
170
  return team.members.find((m) => m.name === name) || null;
167
171
  }
168
172
 
173
+ /**
174
+ * Record a member's permission mode: `ask` | `auto` | `yolo`.
175
+ *
176
+ * Stored on the roster rather than broadcast as a message, because the mode is a property of the member
177
+ * that has to survive a restart — a respawn reads it here. A running member keeps the hook it was built
178
+ * with, so a change takes effect next time it starts; that is stated in the panel's flash message rather
179
+ * than left to surprise someone.
180
+ *
181
+ * `ask` is the default and the safe one: every tool call is a question. `auto` and `yolo` run without
182
+ * asking, which is what makes them worth an explicit, per-member switch.
183
+ */
184
+ export function setMemberMode(teamName, name, mode) {
185
+ const team = readTeamFile(teamName);
186
+ if (!team) return false;
187
+ const m = team.members.find((x) => x.name === name);
188
+ if (!m) return false;
189
+ m.permissionMode = String(mode || 'ask');
190
+ writeTeamFile(teamName, team);
191
+ return true;
192
+ }
193
+
169
194
  /** Everyone except `except`, used for broadcast. */
170
195
  export function otherMembers(teamName, except) {
171
196
  const team = readTeamFile(teamName);
@@ -234,7 +259,7 @@ export function readLeadInbox(teamName) {
234
259
  for (const m of readUnread(teamName, n)) {
235
260
  // Skip the USER'S OWN sends. Every message the user writes from the panel or `/team msg` is
236
261
  // stamped `from: TEAM_LEAD_NAME`, so it must not come back as mail from the team: typing
237
- // "你好" to the lead and being handed `team-lead: 你好` as unread team traffic is the model
262
+ // "hello" to the lead and being handed `team-lead: hello` as unread team traffic is the model
238
263
  // reading the person's own instruction as though an agent had sent it.
239
264
  //
240
265
  // The test is the SENDER being the alias — NOT the lead being the user. The lead is a
@@ -271,8 +296,18 @@ export function formatLeadInbox(teamName, { mark = false } = {}) {
271
296
  const unread = readLeadInbox(teamName);
272
297
  if (!unread.length) return '';
273
298
  if (mark) markLeadInboxRead(teamName, unread);
274
- const body = unread.map((m) => `${m.from}: ${String(m.text || '').trim()}`).join('\n');
275
- return `${unread.length} unread message(s) for the team lead. Answer with the SendMessage tool.\n\n${body}`;
299
+ // One block per message, delimited.
300
+ //
301
+ // Joined with nothing but a newline, two messages from the same sender read as one wall of text: a
302
+ // reply that happens to begin with a number was indistinguishable from a list index, which is exactly
303
+ // how "1: ..." twice in a row looked like broken numbering rather than two separate answers.
304
+ const body = unread
305
+ .map((m) => {
306
+ const who = String(m.from || 'unknown');
307
+ return `--- from ${who} ---\n${String(m.text || '').trim()}`;
308
+ })
309
+ .join('\n\n');
310
+ return `${unread.length} unread message(s) for the team lead. Answer each with the SendMessage tool.\n\n${body}`;
276
311
  }
277
312
 
278
313
  export function readRoster(teamName) {