@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
package/src/team/team.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Creating, joining and leaving a team.
|
|
2
|
+
//
|
|
3
|
+
// A team is a roster on disk plus a task list. Nothing here runs an agent — that is the runner's
|
|
4
|
+
// job, and keeping the two apart means the bookkeeping can be reasoned about (and tested)
|
|
5
|
+
// without spawning anything.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import {
|
|
8
|
+
readTeamFile, writeTeamFile, deleteTeam, removeMember, updateMember,
|
|
9
|
+
formatAgentId, sanitizeName, teamDir, listTeams, inboxPath,
|
|
10
|
+
} from './store.js';
|
|
11
|
+
import { clearMailbox, writeToMailbox, readMailbox, readUnread, markAllRead } from './mailbox.js';
|
|
12
|
+
import { pickColor } from './context.js';
|
|
13
|
+
import { TEAM_LEAD_NAME, MAX_TEAMMATES } from './constants.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a team with `leadName` as its lead.
|
|
17
|
+
*
|
|
18
|
+
* A name already in use is REFUSED rather than silently replaced: the existing roster and task
|
|
19
|
+
* history belong to a session that may still be running, and overwriting it would strand those
|
|
20
|
+
* members with no lead.
|
|
21
|
+
*/
|
|
22
|
+
export function createTeam({ teamName, description = '', leadName = TEAM_LEAD_NAME, leadSessionId, cwd }) {
|
|
23
|
+
const name = String(teamName || '').trim();
|
|
24
|
+
if (!name) return { ok: false, error: 'a team name is required' };
|
|
25
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/.test(name)) {
|
|
26
|
+
return { ok: false, error: `"${name}" is not usable as a team name — letters, digits, spaces, - and _ only` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const existing = readTeamFile(name);
|
|
30
|
+
if (existing) {
|
|
31
|
+
return { ok: false, error: `team "${name}" already exists (${existing.members.length} member(s)). Pick another name, or delete it first.` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// NO members yet. The first one added becomes the lead — see addMember — so a team is not
|
|
35
|
+
// born with a placeholder on it, and "the first team member" is not quietly the second.
|
|
36
|
+
const team = {
|
|
37
|
+
name,
|
|
38
|
+
description: String(description || ''),
|
|
39
|
+
createdAt: Date.now(),
|
|
40
|
+
// Filled in when the first member joins. Until then the team has no leader, which is a
|
|
41
|
+
// true statement about it rather than a placeholder.
|
|
42
|
+
leadAgentId: '',
|
|
43
|
+
leadName,
|
|
44
|
+
leadSessionId: leadSessionId || '',
|
|
45
|
+
members: [],
|
|
46
|
+
};
|
|
47
|
+
writeTeamFile(name, team);
|
|
48
|
+
return { ok: true, team };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Put a member on the roster.
|
|
53
|
+
*
|
|
54
|
+
* Returns the member record. Re-adding an existing name updates it instead of duplicating —
|
|
55
|
+
* which is what re-spawning a teammate that died should do, and a duplicate would make every
|
|
56
|
+
* name-addressed message ambiguous.
|
|
57
|
+
*/
|
|
58
|
+
export function addMember(teamName, { name, agentType = 'general', model = '', taskId = null, cwd, prompt = '' }) {
|
|
59
|
+
const team = readTeamFile(teamName);
|
|
60
|
+
if (!team) return { ok: false, error: `no team "${teamName}"` };
|
|
61
|
+
|
|
62
|
+
const memberName = String(name || '').trim();
|
|
63
|
+
if (!memberName) return { ok: false, error: 'a teammate name is required' };
|
|
64
|
+
const teamLead = (readTeamFile(teamName) || {}).leadName || TEAM_LEAD_NAME;
|
|
65
|
+
if (memberName === teamLead) return { ok: false, error: `"${teamLead}" is the lead of this team` };
|
|
66
|
+
|
|
67
|
+
const others = team.members.filter((m) => m.name !== memberName);
|
|
68
|
+
if (others.length >= MAX_TEAMMATES) {
|
|
69
|
+
return { ok: false, error: `team "${teamName}" already has ${others.length} members (max ${MAX_TEAMMATES})` };
|
|
70
|
+
}
|
|
71
|
+
|
|
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
|
+
}
|
|
81
|
+
|
|
82
|
+
const color = pickColor(memberName, others.map((m) => m.color));
|
|
83
|
+
const member = {
|
|
84
|
+
agentId: formatAgentId(memberName, teamName),
|
|
85
|
+
name: memberName,
|
|
86
|
+
agentType,
|
|
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',
|
|
95
|
+
model,
|
|
96
|
+
color,
|
|
97
|
+
joinedAt: Date.now(),
|
|
98
|
+
cwd: cwd || process.cwd(),
|
|
99
|
+
taskId,
|
|
100
|
+
prompt,
|
|
101
|
+
isActive: true,
|
|
102
|
+
// What the member is DOING. `isActive` alone could not say it: a round spends most of its
|
|
103
|
+
// time blocked on the model, and a member that has never run is not working either.
|
|
104
|
+
phase: 'starting',
|
|
105
|
+
};
|
|
106
|
+
team.members = [...others, member];
|
|
107
|
+
writeTeamFile(teamName, team);
|
|
108
|
+
return { ok: true, member };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Mark a member idle or active. Used by the roster view and by the lead's status line. */
|
|
112
|
+
/**
|
|
113
|
+
* Record what a member is doing.
|
|
114
|
+
*
|
|
115
|
+
* `phase` is the real answer and `isActive` is derived from it, so a caller that only knows
|
|
116
|
+
* one thing still gets a truthful roster.
|
|
117
|
+
*/
|
|
118
|
+
export function setMemberActive(teamName, memberName, isActive, phase) {
|
|
119
|
+
const p = phase || (isActive === false ? 'idle' : 'starting');
|
|
120
|
+
return updateMember(teamName, memberName, { isActive, phase: p });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Take a member off the roster.
|
|
125
|
+
*
|
|
126
|
+
* Their inbox goes with them: it is addressed by name, so leaving the file behind means the next
|
|
127
|
+
* member to take that name inherits a stranger's mail.
|
|
128
|
+
*/
|
|
129
|
+
export function removeTeammate(teamName, memberName) {
|
|
130
|
+
const lead = (readTeamFile(teamName) || {}).leadName || TEAM_LEAD_NAME;
|
|
131
|
+
if (memberName === lead) return { ok: false, error: 'the lead cannot be removed' };
|
|
132
|
+
const team = readTeamFile(teamName);
|
|
133
|
+
if (!team) return { ok: false, error: `no team "${teamName}"` };
|
|
134
|
+
if (!team.members.some((m) => m.name === memberName)) {
|
|
135
|
+
return { ok: false, error: `"${memberName}" is not on team "${teamName}"` };
|
|
136
|
+
}
|
|
137
|
+
removeMember(teamName, memberName);
|
|
138
|
+
clearMailbox(teamName, memberName);
|
|
139
|
+
return { ok: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Dissolve a team.
|
|
144
|
+
*
|
|
145
|
+
* Members are told first, so a running teammate learns why its team vanished rather than finding
|
|
146
|
+
* an empty roster on its next poll.
|
|
147
|
+
*/
|
|
148
|
+
export function disbandTeam(teamName, { reason = 'the lead ended the team' } = {}) {
|
|
149
|
+
const team = readTeamFile(teamName);
|
|
150
|
+
if (!team) return { ok: false, error: `no team "${teamName}"` };
|
|
151
|
+
// By ROLE: a team whose leader is named something else still has exactly one.
|
|
152
|
+
const members = team.members.filter((m) => m.role !== 'lead');
|
|
153
|
+
for (const m of members) {
|
|
154
|
+
try {
|
|
155
|
+
writeToMailbox(teamName, m.name, {
|
|
156
|
+
from: TEAM_LEAD_NAME,
|
|
157
|
+
text: `[team ending] ${reason}`,
|
|
158
|
+
summary: 'team ending',
|
|
159
|
+
});
|
|
160
|
+
} catch { /* the directory is about to go anyway */ }
|
|
161
|
+
}
|
|
162
|
+
deleteTeam(teamName);
|
|
163
|
+
return { ok: true, told: members.length };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The roster entry for a name, or null. */
|
|
167
|
+
export function findMember(teamName, name) {
|
|
168
|
+
const team = readTeamFile(teamName);
|
|
169
|
+
if (!team) return null;
|
|
170
|
+
return team.members.find((m) => m.name === name) || null;
|
|
171
|
+
}
|
|
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
|
+
|
|
194
|
+
/** Everyone except `except`, used for broadcast. */
|
|
195
|
+
export function otherMembers(teamName, except) {
|
|
196
|
+
const team = readTeamFile(teamName);
|
|
197
|
+
if (!team) return [];
|
|
198
|
+
return team.members.filter((m) => m.name !== except);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The roster as a model should read it.
|
|
203
|
+
*
|
|
204
|
+
* One line per member with the state that affects what the reader should do: who is idle (can be
|
|
205
|
+
* given work), who is busy (do not interrupt), what each owns.
|
|
206
|
+
*/
|
|
207
|
+
/**
|
|
208
|
+
* The roster as a user or a model should read it, by NAME.
|
|
209
|
+
*
|
|
210
|
+
* `formatRoster` takes the team object; a caller usually holds only the name, and reading the
|
|
211
|
+
* file is what the rest of this module does anyway.
|
|
212
|
+
*/
|
|
213
|
+
/**
|
|
214
|
+
* The member list, by name. Empty when there is no such team.
|
|
215
|
+
*
|
|
216
|
+
* `readTeamFile` is store.js; this is the accessor the rest of the program should use, so a
|
|
217
|
+
* caller never has to know which module owns the file.
|
|
218
|
+
*/
|
|
219
|
+
export function rosterOf(teamName) {
|
|
220
|
+
const team = readTeamFile(teamName);
|
|
221
|
+
return (team && Array.isArray(team.members)) ? team.members : [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The name of the member who leads this team.
|
|
226
|
+
*
|
|
227
|
+
* Falls back to the default name for a team written before `leadName` existed — nothing
|
|
228
|
+
* about such a team can be improved by guessing a different leader.
|
|
229
|
+
*/
|
|
230
|
+
export function leadOf(teamName) {
|
|
231
|
+
const team = readTeamFile(teamName);
|
|
232
|
+
if (!team) return TEAM_LEAD_NAME;
|
|
233
|
+
if (team.leadName) return team.leadName;
|
|
234
|
+
const byRole = (team.members || []).find((m) => m.role === 'lead');
|
|
235
|
+
return (byRole && byRole.name) || TEAM_LEAD_NAME;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Mail for the team's lead that it has not read yet.
|
|
240
|
+
*
|
|
241
|
+
* "THE LEAD" IS A ROSTER ROLE HELD BY A REAL AGENT. `addMember` gives the `lead` role to the
|
|
242
|
+
* FIRST member added, and that member has a full agent identity like anyone else — `agentId`,
|
|
243
|
+
* `agentType`, `cwd`, a phase, its own inbox. The user is never on the roster, so a message
|
|
244
|
+
* addressed to the lead is addressed to an agent, and sending the lead an instruction is the
|
|
245
|
+
* normal way to command the team.
|
|
246
|
+
*
|
|
247
|
+
* `TEAM_LEAD_NAME` is a separate thing: the fixed alias the USER'S OWN sends carry (`/team msg`,
|
|
248
|
+
* the panel composer) and that harness notices use as their `from` (`disbandTeam`). A teammate
|
|
249
|
+
* cannot be named it — `addMember` refuses — so mail under that alias is always the user's own
|
|
250
|
+
* words, which is why the reader skips it. Both names are otherwise collected, because a member
|
|
251
|
+
* may be addressed under the lead's roster name or the alias.
|
|
252
|
+
*/
|
|
253
|
+
export function readLeadInbox(teamName) {
|
|
254
|
+
const team = readTeamFile(teamName);
|
|
255
|
+
if (!team) return [];
|
|
256
|
+
const leadNames = new Set([leadOf(teamName), TEAM_LEAD_NAME]);
|
|
257
|
+
const out = [];
|
|
258
|
+
for (const n of leadNames) {
|
|
259
|
+
for (const m of readUnread(teamName, n)) {
|
|
260
|
+
// Skip the USER'S OWN sends. Every message the user writes from the panel or `/team msg` is
|
|
261
|
+
// stamped `from: TEAM_LEAD_NAME`, so it must not come back as mail from the team: typing
|
|
262
|
+
// "hello" to the lead and being handed `team-lead: hello` as unread team traffic is the model
|
|
263
|
+
// reading the person's own instruction as though an agent had sent it.
|
|
264
|
+
//
|
|
265
|
+
// The test is the SENDER being the alias — NOT the lead being the user. The lead is a
|
|
266
|
+
// rostered agent; a message FROM it is real mail and is kept.
|
|
267
|
+
if (m.from === TEAM_LEAD_NAME) continue;
|
|
268
|
+
out.push({ ...m, _to: n });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return out.sort((a, b) => String(a.timestamp || '').localeCompare(String(b.timestamp || '')));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Mark the messages `readLeadInbox` returned as read. */
|
|
275
|
+
export function markLeadInboxRead(teamName, messages) {
|
|
276
|
+
const byName = new Map();
|
|
277
|
+
for (const m of messages || []) {
|
|
278
|
+
const to = m._to || leadOf(teamName);
|
|
279
|
+
if (!byName.has(to)) byName.set(to, []);
|
|
280
|
+
byName.get(to).push(m);
|
|
281
|
+
}
|
|
282
|
+
let n = 0;
|
|
283
|
+
for (const [to, batch] of byName) n += markAllRead(teamName, to, batch);
|
|
284
|
+
return n;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The lead's unread mail as one block of text, or '' when there is none.
|
|
290
|
+
*
|
|
291
|
+
* `mark` decides whether reading it counts as seeing it. The caller that FEEDS it to the model
|
|
292
|
+
* passes true — the model has now seen it — while a reader that only counts messages passes
|
|
293
|
+
* false, or the count would empty the box before anyone read it.
|
|
294
|
+
*/
|
|
295
|
+
export function formatLeadInbox(teamName, { mark = false } = {}) {
|
|
296
|
+
const unread = readLeadInbox(teamName);
|
|
297
|
+
if (!unread.length) return '';
|
|
298
|
+
if (mark) markLeadInboxRead(teamName, unread);
|
|
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}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function readRoster(teamName) {
|
|
314
|
+
return formatRoster(readTeamFile(teamName));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function formatRoster(team) {
|
|
318
|
+
if (!team) return 'No team.';
|
|
319
|
+
const lines = [`Team "${team.name}"${team.description ? ` — ${team.description}` : ''}`];
|
|
320
|
+
for (const m of team.members) {
|
|
321
|
+
const isLead = m.role === 'lead';
|
|
322
|
+
// `phase` when it is there; `isActive` for a member written by an older build.
|
|
323
|
+
const phase = m.phase || (m.isActive === false ? 'idle' : 'starting');
|
|
324
|
+
const state = isLead ? 'lead' : phase;
|
|
325
|
+
// Unread mail from SOMEONE ELSE. The lead's count is reported rather than hidden — a
|
|
326
|
+
// teammate waiting for an answer is what the roster should show — but the user's own sends
|
|
327
|
+
// are excluded, or `readRoster` reports a message the user just typed as mail they received.
|
|
328
|
+
const unread = readUnread(team.name, m.name).filter((x) => x.from !== TEAM_LEAD_NAME).length;
|
|
329
|
+
const bits = [state];
|
|
330
|
+
if (m.taskId) bits.push(`task ${m.taskId}`);
|
|
331
|
+
if (unread) bits.push(`${unread} unread`);
|
|
332
|
+
lines.push(` ${m.name.padEnd(16)} ${bits.join(', ')}`);
|
|
333
|
+
}
|
|
334
|
+
return lines.join('\n');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Teams that exist on disk, for /team with no argument. */
|
|
338
|
+
export function knownTeams() {
|
|
339
|
+
return listTeams().map((t) => ({
|
|
340
|
+
name: t.name,
|
|
341
|
+
description: t.description,
|
|
342
|
+
members: t.members.length,
|
|
343
|
+
createdAt: t.createdAt,
|
|
344
|
+
leadSessionId: t.leadSessionId,
|
|
345
|
+
}));
|
|
346
|
+
}
|