@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.
- package/package.json +38 -38
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/config.js +10 -3
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/session-hub.js +37 -0
- package/src/settings-schema.js +2 -0
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +727 -577
- package/src/tools/fetch-url.js +118 -5
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +17 -3
- package/src/tools/read.js +13 -2
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +85 -34
- package/src/tui.js +2089 -714
- package/src/update.js +38 -3
- package/src/web.js +47 -1
package/src/team/runner.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
43
|
-
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
const
|
|
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.
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
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
|
-
// "
|
|
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
|
-
|
|
275
|
-
|
|
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) {
|