@hncode/hncode 0.5.1 → 0.5.2
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 +1 -1
- package/src/agent-task.js +12 -1
- package/src/agent.js +111 -33
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +42 -6
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/llm.js +6 -0
- package/src/permissions.js +29 -7
- package/src/registry-browser.js +21 -6
- 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 +95 -6
- 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 +139 -0
- package/src/team/runner.js +347 -0
- package/src/team/spawn.js +247 -0
- package/src/team/store.js +145 -0
- package/src/team/tasks.js +164 -0
- package/src/team/team.js +311 -0
- package/src/team-panel.js +226 -0
- package/src/term.js +27 -0
- package/src/tool-result.js +20 -5
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +53 -3
- package/src/tools/edit.js +591 -436
- package/src/tools/git.js +15 -3
- package/src/tools/index.js +2 -0
- package/src/tools/team.js +398 -0
- package/src/tools/web-search.js +88 -75
- package/src/tui.js +5805 -4862
- package/src/update.js +285 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/src/tasks-browser.js
CHANGED
|
@@ -200,11 +200,14 @@ export function renderTasksBrowser(state, cols, rows) {
|
|
|
200
200
|
// hitbox to aim at — those are recorded in state._taskHits below.
|
|
201
201
|
const hot = state.hoverIndex === i;
|
|
202
202
|
const ptr = selected ? C.cyan + '\u276f ' + C.reset : (hot ? C.hover + '\u276f ' + C.reset : ' ');
|
|
203
|
+
// A sub-task is drawn under its parent, indented, so a swarm reads as one unit of work with
|
|
204
|
+
// visible parts rather than as N unrelated rows. `parentTaskId` is set by AgentSwarm.
|
|
205
|
+
const lead = t.parentTaskId ? C.gray + ' \u2514 ' + C.reset : '';
|
|
203
206
|
const idCol = t.kind === 'agent' ? C.green : t.kind === 'question' ? C.orange : C.blue;
|
|
204
207
|
const idText = selected ? idCol + C.bold + t.taskId + C.reset : idCol + t.taskId + C.reset;
|
|
205
208
|
const pad = ' '.repeat(Math.max(0, 20 - visWidth(t.taskId)));
|
|
206
209
|
const badge = statusColorToken(t.status) + (STATUS_LABEL[t.status] || t.status) + C.reset;
|
|
207
|
-
const prefix = ptr + idText + pad + ' ' + badge;
|
|
210
|
+
const prefix = lead + ptr + idText + pad + ' ' + badge;
|
|
208
211
|
const budget = Math.max(0, listInnerW - visWidth(prefix) - 1);
|
|
209
212
|
if (budget < 4) return fitExactly(prefix, listInnerW);
|
|
210
213
|
const desc = singleLine(t.description) || singleLine(t.command) || '(no description)';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The name every team's leader is known by. Not a member name a user can pick. */
|
|
2
|
+
export const TEAM_LEAD_NAME = 'team-lead';
|
|
3
|
+
|
|
4
|
+
/** Broadcast address in SendMessage: everyone on the team except the sender. */
|
|
5
|
+
export const BROADCAST = '*';
|
|
6
|
+
|
|
7
|
+
/** How many teammates one team may hold. A team that grows past this cannot be reasoned about. */
|
|
8
|
+
export const MAX_TEAMMATES = 8;
|
|
9
|
+
|
|
10
|
+
/** How many tool steps a teammate may take in one run before it is stopped. */
|
|
11
|
+
export const TEAMMATE_MAX_STEPS = 40;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How long a teammate waits for a message before ending its run.
|
|
15
|
+
*
|
|
16
|
+
* 0 = indefinitely, which is the default: the lead is a person typing, and a member that
|
|
17
|
+
* quits after a quiet spell cannot be given work later. A caller that genuinely wants a
|
|
18
|
+
* bounded run passes a positive value (`spawnTeammate({ idleTimeoutMs: 60_000 })`).
|
|
19
|
+
*/
|
|
20
|
+
export const TEAMMATE_IDLE_TIMEOUT_MS = 0;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Who is running right now.
|
|
2
|
+
//
|
|
3
|
+
// A teammate runs in the SAME process as its lead, so "am I a teammate" cannot live on a module
|
|
4
|
+
// variable — every member would overwrite it and the last one to start would answer for all of
|
|
5
|
+
// them. `AsyncLocalStorage` gives each async execution chain its own view, which is the shape
|
|
6
|
+
// needed: a teammate's whole run happens inside one `runAs` call, so every function it reaches
|
|
7
|
+
// sees ITS identity while the lead's sees none.
|
|
8
|
+
//
|
|
9
|
+
// WHY NOT A STACK
|
|
10
|
+
// ---------------
|
|
11
|
+
// A stack would be popped by any interleaving, and teammate runs interleave freely — they await
|
|
12
|
+
// tool calls, network, timers. AsyncLocalStorage follows the async context rather than the call
|
|
13
|
+
// stack, so it survives those awaits.
|
|
14
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
15
|
+
import { TEAM_LEAD_NAME } from './constants.js';
|
|
16
|
+
|
|
17
|
+
const storage = new AsyncLocalStorage();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The identity of the member whose async context we are inside, or undefined for the lead.
|
|
21
|
+
*
|
|
22
|
+
* `undefined` IS the lead: the person at the keyboard is not a teammate. Code that needs "am I a
|
|
23
|
+
* teammate" asks `currentAgent()`, rather than a separate flag that could disagree with this.
|
|
24
|
+
*/
|
|
25
|
+
export function currentAgent() {
|
|
26
|
+
return storage.getStore() || undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isTeammate() {
|
|
30
|
+
return !!storage.getStore();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The calling member's name, or the lead's when there is none. */
|
|
34
|
+
export function currentAgentName() {
|
|
35
|
+
const ctx = storage.getStore();
|
|
36
|
+
return ctx ? ctx.name : TEAM_LEAD_NAME;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The team the calling member belongs to, or undefined for a lead with no team. */
|
|
40
|
+
export function currentTeamName() {
|
|
41
|
+
const ctx = storage.getStore();
|
|
42
|
+
return ctx ? ctx.teamName : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run `fn` as `identity`.
|
|
47
|
+
*
|
|
48
|
+
* Every await inside `fn` sees the same identity — that is the whole point, and why this must
|
|
49
|
+
* WRAP the teammate's run rather than be set before it.
|
|
50
|
+
*/
|
|
51
|
+
export function runAs(identity, fn) {
|
|
52
|
+
return storage.run(identity, fn);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build the identity record for a member. */
|
|
56
|
+
export function makeIdentity({ name, teamName, color, agentType, model, taskId = null }) {
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
teamName,
|
|
60
|
+
color,
|
|
61
|
+
agentType: agentType || 'general',
|
|
62
|
+
model: model || '',
|
|
63
|
+
// Set when this teammate was spawned to work on a specific task, so it can report back to
|
|
64
|
+
// that task rather than to the team at large.
|
|
65
|
+
taskId,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Distinct enough to tell apart in a log line.
|
|
70
|
+
export const MEMBER_COLORS = ['cyan', 'green', 'yellow', 'magenta', 'blue', 'red', 'orange'];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A colour for a member: stable per name, avoiding what the team already uses.
|
|
74
|
+
*
|
|
75
|
+
* Stable because a colour that changed between messages would make the transcript unreadable —
|
|
76
|
+
* the eye tracks a member BY its colour.
|
|
77
|
+
*/
|
|
78
|
+
export function pickColor(name, taken = []) {
|
|
79
|
+
const used = new Set(taken);
|
|
80
|
+
const n = MEMBER_COLORS.length;
|
|
81
|
+
const start = [...String(name)].reduce((a, c) => a + c.charCodeAt(0), 0) % n;
|
|
82
|
+
for (let i = 0; i < n; i++) {
|
|
83
|
+
const c = MEMBER_COLORS[(start + i) % n];
|
|
84
|
+
if (!used.has(c)) return c;
|
|
85
|
+
}
|
|
86
|
+
// Every colour taken: share one rather than returning undefined, which would leave the member
|
|
87
|
+
// uncoloured and indistinguishable.
|
|
88
|
+
return MEMBER_COLORS[start];
|
|
89
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The mailbox: one inbox file per member, addressed by name.
|
|
2
|
+
//
|
|
3
|
+
// ~/.hncode/teams/<team>/inboxes/<name>.json -> TeammateMessage[]
|
|
4
|
+
//
|
|
5
|
+
// WHY A FILE PER RECIPIENT
|
|
6
|
+
// ------------------------
|
|
7
|
+
// A single shared log would need every reader to track its own watermark, and a member that
|
|
8
|
+
// restarts would re-read everything it had already seen. Per-recipient files make "what is
|
|
9
|
+
// waiting for me" a plain read, and marking read is a write to your own file only — no member
|
|
10
|
+
// contends with another over the same one.
|
|
11
|
+
//
|
|
12
|
+
// WRITES ARE ATOMIC
|
|
13
|
+
// -----------------
|
|
14
|
+
// Write to a temp file, then rename. `rename` is atomic on every platform we run on, so a
|
|
15
|
+
// reader either sees the whole previous inbox or the whole new one — never a half-written JSON
|
|
16
|
+
// that parses as nothing and silently drops the mail. This also covers the cross-process case
|
|
17
|
+
// (two hncode sessions in the same team), where an in-process mutex would not.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { inboxDir, inboxPath } from './store.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A message as it sits in an inbox.
|
|
24
|
+
*
|
|
25
|
+
* `read` lives on the message rather than in a separate watermark file: it travels with the
|
|
26
|
+
* message when the inbox is rewritten, and a member reading its own inbox is the only writer.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_INBOX = 500;
|
|
29
|
+
|
|
30
|
+
function writeAtomic(file, text) {
|
|
31
|
+
const dir = path.dirname(file);
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
34
|
+
fs.writeFileSync(tmp, text, 'utf8');
|
|
35
|
+
try {
|
|
36
|
+
fs.renameSync(tmp, file);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
try { fs.unlinkSync(tmp); } catch { /* the tmp is worthless now */ }
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every message in a member's inbox, oldest first. Never throws. */
|
|
44
|
+
export function readMailbox(teamName, agentName) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(fs.readFileSync(inboxPath(teamName, agentName), 'utf8'));
|
|
47
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Only what the recipient has not seen. */
|
|
54
|
+
export function readUnread(teamName, agentName) {
|
|
55
|
+
return readMailbox(teamName, agentName).filter((m) => !m.read);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Deliver a message.
|
|
60
|
+
*
|
|
61
|
+
* The sender's name and colour are stamped here rather than passed by the call site, so every
|
|
62
|
+
* message carries them whether it came from the lead, a teammate, or a future caller.
|
|
63
|
+
*/
|
|
64
|
+
export function writeToMailbox(teamName, recipientName, { from, text, summary, color }) {
|
|
65
|
+
const file = inboxPath(teamName, recipientName);
|
|
66
|
+
const box = readMailbox(teamName, recipientName);
|
|
67
|
+
const msg = {
|
|
68
|
+
from: String(from || 'unknown'),
|
|
69
|
+
text: String(text == null ? '' : text),
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
read: false,
|
|
72
|
+
};
|
|
73
|
+
if (summary) msg.summary = String(summary);
|
|
74
|
+
if (color) msg.color = String(color);
|
|
75
|
+
box.push(msg);
|
|
76
|
+
// Bounded: an inbox nobody drains must not grow without limit and turn every read into a
|
|
77
|
+
// parse of the whole session's chatter.
|
|
78
|
+
const kept = box.length > MAX_INBOX ? box.slice(box.length - MAX_INBOX) : box;
|
|
79
|
+
writeAtomic(file, JSON.stringify(kept, null, 2));
|
|
80
|
+
return msg;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Mark the given messages read. Returns how many changed.
|
|
85
|
+
*
|
|
86
|
+
* `ids` are timestamps, not array positions: the box is re-read here, and a message
|
|
87
|
+
* delivered in between would shift every later index — marking by position would then
|
|
88
|
+
* silence the wrong message, or one that was never delivered at all.
|
|
89
|
+
*
|
|
90
|
+
* When called with no `ids` every message is marked, which is what a caller that
|
|
91
|
+
* genuinely drained the whole box wants. A caller that PROCESSED a specific batch must
|
|
92
|
+
* pass it: blanket-marking re-reads the box and marks whatever arrived since the
|
|
93
|
+
* batch was taken, and those messages are then never delivered.
|
|
94
|
+
*/
|
|
95
|
+
export function markAllRead(teamName, agentName, ids = null) {
|
|
96
|
+
const box = readMailbox(teamName, agentName);
|
|
97
|
+
const want = Array.isArray(ids) ? new Set(ids.map((m) => (m && m.timestamp) || m)) : null;
|
|
98
|
+
let n = 0;
|
|
99
|
+
for (const m of box) {
|
|
100
|
+
if (m.read) continue;
|
|
101
|
+
if (want && !want.has(m.timestamp)) continue;
|
|
102
|
+
m.read = true;
|
|
103
|
+
n++;
|
|
104
|
+
}
|
|
105
|
+
if (n) writeAtomic(inboxPath(teamName, agentName), JSON.stringify(box, null, 2));
|
|
106
|
+
return n;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Empty a member's inbox. Used when a teammate is removed from the team. */
|
|
110
|
+
export function clearMailbox(teamName, agentName) {
|
|
111
|
+
try { fs.unlinkSync(inboxPath(teamName, agentName)); return true; } catch { return false; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Render unread mail the way a member should see it: as an addressable block, not as prose.
|
|
116
|
+
*
|
|
117
|
+
* The `from` line matters — a teammate's next action is often "reply to whoever asked", and a
|
|
118
|
+
* block of text with no name attached leaves it guessing.
|
|
119
|
+
*/
|
|
120
|
+
export function formatInbox(messages) {
|
|
121
|
+
if (!messages.length) return '';
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const m of messages) {
|
|
124
|
+
out.push(`[message from ${m.from}]`);
|
|
125
|
+
if (m.summary) out.push(`(${m.summary})`);
|
|
126
|
+
out.push(m.text);
|
|
127
|
+
out.push('');
|
|
128
|
+
}
|
|
129
|
+
return out.join('\n').trimEnd();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Every inbox file for a team, so the rota can be cleaned with the team. */
|
|
133
|
+
export function listInboxes(teamName) {
|
|
134
|
+
try {
|
|
135
|
+
return fs.readdirSync(inboxDir(teamName)).filter((f) => f.endsWith('.json'));
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
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
|
+
for (;;) {
|
|
190
|
+
if (shouldStop && shouldStop()) return null;
|
|
191
|
+
const unread = readUnread(teamName, agentName);
|
|
192
|
+
if (unread.length) return unread;
|
|
193
|
+
if (Date.now() >= deadline) return null;
|
|
194
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Run one teammate to completion.
|
|
200
|
+
*
|
|
201
|
+
* `deps` carries what the runner cannot import: the built agent config, the tool set, and the
|
|
202
|
+
* callbacks that reach the TUI. Passing them in keeps this module free of the 17k-line TUI it
|
|
203
|
+
* would otherwise have to import, and makes the loop testable with a stub agent.
|
|
204
|
+
*/
|
|
205
|
+
export async function runTeammate({
|
|
206
|
+
teamName,
|
|
207
|
+
memberName,
|
|
208
|
+
cfg,
|
|
209
|
+
makeState,
|
|
210
|
+
toolNote = () => {},
|
|
211
|
+
shouldStop = () => false,
|
|
212
|
+
// 0 = wait forever. See `waitForMail`: a member that quits after a quiet spell cannot be
|
|
213
|
+
// given work later, and the lead is a person typing rather than a service on a deadline.
|
|
214
|
+
idleTimeoutMs = 0,
|
|
215
|
+
makeAgent,
|
|
216
|
+
onStart,
|
|
217
|
+
onFinish,
|
|
218
|
+
}) {
|
|
219
|
+
const identity = currentAgent();
|
|
220
|
+
if (!identity || identity.name !== memberName) {
|
|
221
|
+
return { ok: false, error: `runTeammate must be called inside runAs() for "${memberName}"` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const team = readTeamFile(teamName);
|
|
225
|
+
if (!team) return { ok: false, error: `no team "${teamName}"` };
|
|
226
|
+
const member = team.members.find((m) => m.name === memberName);
|
|
227
|
+
if (!member) return { ok: false, error: `"${memberName}" is not on team "${teamName}"` };
|
|
228
|
+
|
|
229
|
+
// A task may have been assigned before the teammate started, or be waiting to be claimed.
|
|
230
|
+
let task = null;
|
|
231
|
+
if (member.taskId) {
|
|
232
|
+
task = readTasks(teamName).find((t) => t.id === member.taskId) || null;
|
|
233
|
+
}
|
|
234
|
+
if (!task) {
|
|
235
|
+
const free = unclaimedTasks(teamName);
|
|
236
|
+
if (free.length) {
|
|
237
|
+
const claimed = claimTask(teamName, free[0].id, memberName);
|
|
238
|
+
if (claimed.ok) task = claimed.task;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const taskList = readTasks(teamName);
|
|
243
|
+
// The system entry goes FIRST and the task text after it. The Agent has no `systemPrompt`
|
|
244
|
+
// option — the prompt is an ordinary system message the caller supplies, which is how the
|
|
245
|
+
// TUI builds one per turn.
|
|
246
|
+
const messages = [
|
|
247
|
+
{ 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
|
+
},
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
onStart && onStart({ member, task });
|
|
261
|
+
|
|
262
|
+
const result = { ok: true, reports: [], task: task && task.id };
|
|
263
|
+
let idle = false;
|
|
264
|
+
|
|
265
|
+
for (;;) {
|
|
266
|
+
if (shouldStop()) break;
|
|
267
|
+
|
|
268
|
+
updateMember(teamName, memberName, { isActive: true, phase: 'thinking' });
|
|
269
|
+
|
|
270
|
+
// Run the agent for this round. `messages` is the running transcript OF THIS TEAMMATE,
|
|
271
|
+
// so it remembers what it already did across the rounds of one assignment.
|
|
272
|
+
// One round of the teammate's work. `makeAgent` is injectable so the loop can be tested
|
|
273
|
+
// without a model; production passes nothing and gets a real Agent.
|
|
274
|
+
const build = makeAgent || ((opts) => new Agent(opts));
|
|
275
|
+
const agent = build({
|
|
276
|
+
cfg,
|
|
277
|
+
messages,
|
|
278
|
+
maxSteps: TEAMMATE_MAX_STEPS,
|
|
279
|
+
onEvent: (e) => {
|
|
280
|
+
if (e.type === 'tool_start') toolNote(memberName, e.name, e.args);
|
|
281
|
+
// The one moment a member is unambiguously working, as opposed to blocked on the
|
|
282
|
+
// model — which is what most of a round is.
|
|
283
|
+
updateMember(teamName, memberName, { isActive: true, phase: 'working' });
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
await agent.run();
|
|
287
|
+
|
|
288
|
+
// The Agent kept the SAME array and appended to it, so the transcript already carries
|
|
289
|
+
// this round. Nothing to copy — `messages` IS `agent.messages`.
|
|
290
|
+
|
|
291
|
+
// A tool named SendMessage already delivered its message (see the tool's own handler), so
|
|
292
|
+
// here we only report completion to the lead if the agent did not.
|
|
293
|
+
if (task) {
|
|
294
|
+
const t = readTasks(teamName).find((x) => x.id === task.id);
|
|
295
|
+
if (t && t.status === 'in_progress') {
|
|
296
|
+
updateTask(teamName, task.id, {
|
|
297
|
+
status: 'completed',
|
|
298
|
+
result: lastAssistantText(agent) || '',
|
|
299
|
+
});
|
|
300
|
+
// The team's OWN lead: a member reports to whoever leads it, which is not
|
|
301
|
+
// necessarily the default name.
|
|
302
|
+
writeToMailbox(teamName, leadOf(teamName), {
|
|
303
|
+
from: memberName,
|
|
304
|
+
text: `Done with ${task.id}: ${task.subject}\n\n${lastAssistantText(agent) || '(no summary)'}`,
|
|
305
|
+
summary: `done ${task.id}`,
|
|
306
|
+
color: member.color,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
updateMember(teamName, memberName, { isActive: false, phase: 'idle' });
|
|
312
|
+
onFinish && onFinish({ member, task });
|
|
313
|
+
|
|
314
|
+
const mail = await waitForMail(teamName, memberName, { timeoutMs: idleTimeoutMs, shouldStop });
|
|
315
|
+
if (!mail) { idle = true; break; }
|
|
316
|
+
|
|
317
|
+
// Only the batch that was just read is marked. Marking the whole box would also
|
|
318
|
+
// mark anything delivered since `waitForMail` returned, and that message would
|
|
319
|
+
// then never be seen — it is unread, so it is not in `mail`, and it is now read,
|
|
320
|
+
// so the next `readUnread` skips it.
|
|
321
|
+
markAllRead(teamName, memberName, mail);
|
|
322
|
+
|
|
323
|
+
// A shutdown or a team-ending notice stops the loop before the agent sees it as a request.
|
|
324
|
+
const stop = mail.find((m) => /\[(shutdown|team ending)\]/i.test(m.text));
|
|
325
|
+
if (stop) { result.stoppedBy = stop.from; break; }
|
|
326
|
+
|
|
327
|
+
messages.push({ role: 'user', content: formatInbox(mail) });
|
|
328
|
+
result.reports.push(...mail.map((m) => ({ from: m.from, summary: m.summary })));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
updateMember(teamName, memberName, { isActive: false });
|
|
332
|
+
return { ...result, idle };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The teammate's last assistant turn, as plain text. */
|
|
336
|
+
function lastAssistantText(agent) {
|
|
337
|
+
const msgs = agent.messages || [];
|
|
338
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
339
|
+
const m = msgs[i];
|
|
340
|
+
if (m.role !== 'assistant') continue;
|
|
341
|
+
const text = typeof m.content === 'string'
|
|
342
|
+
? m.content
|
|
343
|
+
: (Array.isArray(m.content) ? m.content.filter((b) => b.type === 'text').map((b) => b.text).join('') : '');
|
|
344
|
+
if (text.trim()) return text.trim();
|
|
345
|
+
}
|
|
346
|
+
return '';
|
|
347
|
+
}
|