@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.
@@ -0,0 +1,311 @@
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
+ // 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;
77
+
78
+ const color = pickColor(memberName, others.map((m) => m.color));
79
+ const member = {
80
+ agentId: formatAgentId(memberName, teamName),
81
+ name: memberName,
82
+ 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
+ model,
88
+ color,
89
+ joinedAt: Date.now(),
90
+ cwd: cwd || process.cwd(),
91
+ taskId,
92
+ prompt,
93
+ isActive: true,
94
+ // What the member is DOING. `isActive` alone could not say it: a round spends most of its
95
+ // time blocked on the model, and a member that has never run is not working either.
96
+ phase: 'starting',
97
+ };
98
+ if (isFirst) {
99
+ team.leadName = memberName;
100
+ team.leadAgentId = member.agentId;
101
+ }
102
+ team.members = [...others, member];
103
+ writeTeamFile(teamName, team);
104
+ return { ok: true, member };
105
+ }
106
+
107
+ /** Mark a member idle or active. Used by the roster view and by the lead's status line. */
108
+ /**
109
+ * Record what a member is doing.
110
+ *
111
+ * `phase` is the real answer and `isActive` is derived from it, so a caller that only knows
112
+ * one thing still gets a truthful roster.
113
+ */
114
+ export function setMemberActive(teamName, memberName, isActive, phase) {
115
+ const p = phase || (isActive === false ? 'idle' : 'starting');
116
+ return updateMember(teamName, memberName, { isActive, phase: p });
117
+ }
118
+
119
+ /**
120
+ * Take a member off the roster.
121
+ *
122
+ * Their inbox goes with them: it is addressed by name, so leaving the file behind means the next
123
+ * member to take that name inherits a stranger's mail.
124
+ */
125
+ export function removeTeammate(teamName, memberName) {
126
+ const lead = (readTeamFile(teamName) || {}).leadName || TEAM_LEAD_NAME;
127
+ if (memberName === lead) return { ok: false, error: 'the lead cannot be removed' };
128
+ const team = readTeamFile(teamName);
129
+ if (!team) return { ok: false, error: `no team "${teamName}"` };
130
+ if (!team.members.some((m) => m.name === memberName)) {
131
+ return { ok: false, error: `"${memberName}" is not on team "${teamName}"` };
132
+ }
133
+ removeMember(teamName, memberName);
134
+ clearMailbox(teamName, memberName);
135
+ return { ok: true };
136
+ }
137
+
138
+ /**
139
+ * Dissolve a team.
140
+ *
141
+ * Members are told first, so a running teammate learns why its team vanished rather than finding
142
+ * an empty roster on its next poll.
143
+ */
144
+ export function disbandTeam(teamName, { reason = 'the lead ended the team' } = {}) {
145
+ const team = readTeamFile(teamName);
146
+ if (!team) return { ok: false, error: `no team "${teamName}"` };
147
+ // By ROLE: a team whose leader is named something else still has exactly one.
148
+ const members = team.members.filter((m) => m.role !== 'lead');
149
+ for (const m of members) {
150
+ try {
151
+ writeToMailbox(teamName, m.name, {
152
+ from: TEAM_LEAD_NAME,
153
+ text: `[team ending] ${reason}`,
154
+ summary: 'team ending',
155
+ });
156
+ } catch { /* the directory is about to go anyway */ }
157
+ }
158
+ deleteTeam(teamName);
159
+ return { ok: true, told: members.length };
160
+ }
161
+
162
+ /** The roster entry for a name, or null. */
163
+ export function findMember(teamName, name) {
164
+ const team = readTeamFile(teamName);
165
+ if (!team) return null;
166
+ return team.members.find((m) => m.name === name) || null;
167
+ }
168
+
169
+ /** Everyone except `except`, used for broadcast. */
170
+ export function otherMembers(teamName, except) {
171
+ const team = readTeamFile(teamName);
172
+ if (!team) return [];
173
+ return team.members.filter((m) => m.name !== except);
174
+ }
175
+
176
+ /**
177
+ * The roster as a model should read it.
178
+ *
179
+ * One line per member with the state that affects what the reader should do: who is idle (can be
180
+ * given work), who is busy (do not interrupt), what each owns.
181
+ */
182
+ /**
183
+ * The roster as a user or a model should read it, by NAME.
184
+ *
185
+ * `formatRoster` takes the team object; a caller usually holds only the name, and reading the
186
+ * file is what the rest of this module does anyway.
187
+ */
188
+ /**
189
+ * The member list, by name. Empty when there is no such team.
190
+ *
191
+ * `readTeamFile` is store.js; this is the accessor the rest of the program should use, so a
192
+ * caller never has to know which module owns the file.
193
+ */
194
+ export function rosterOf(teamName) {
195
+ const team = readTeamFile(teamName);
196
+ return (team && Array.isArray(team.members)) ? team.members : [];
197
+ }
198
+
199
+ /**
200
+ * The name of the member who leads this team.
201
+ *
202
+ * Falls back to the default name for a team written before `leadName` existed — nothing
203
+ * about such a team can be improved by guessing a different leader.
204
+ */
205
+ export function leadOf(teamName) {
206
+ const team = readTeamFile(teamName);
207
+ if (!team) return TEAM_LEAD_NAME;
208
+ if (team.leadName) return team.leadName;
209
+ const byRole = (team.members || []).find((m) => m.role === 'lead');
210
+ return (byRole && byRole.name) || TEAM_LEAD_NAME;
211
+ }
212
+
213
+ /**
214
+ * Mail for the team's lead that it has not read yet.
215
+ *
216
+ * "THE LEAD" IS A ROSTER ROLE HELD BY A REAL AGENT. `addMember` gives the `lead` role to the
217
+ * FIRST member added, and that member has a full agent identity like anyone else — `agentId`,
218
+ * `agentType`, `cwd`, a phase, its own inbox. The user is never on the roster, so a message
219
+ * addressed to the lead is addressed to an agent, and sending the lead an instruction is the
220
+ * normal way to command the team.
221
+ *
222
+ * `TEAM_LEAD_NAME` is a separate thing: the fixed alias the USER'S OWN sends carry (`/team msg`,
223
+ * the panel composer) and that harness notices use as their `from` (`disbandTeam`). A teammate
224
+ * cannot be named it — `addMember` refuses — so mail under that alias is always the user's own
225
+ * words, which is why the reader skips it. Both names are otherwise collected, because a member
226
+ * may be addressed under the lead's roster name or the alias.
227
+ */
228
+ export function readLeadInbox(teamName) {
229
+ const team = readTeamFile(teamName);
230
+ if (!team) return [];
231
+ const leadNames = new Set([leadOf(teamName), TEAM_LEAD_NAME]);
232
+ const out = [];
233
+ for (const n of leadNames) {
234
+ for (const m of readUnread(teamName, n)) {
235
+ // Skip the USER'S OWN sends. Every message the user writes from the panel or `/team msg` is
236
+ // 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
238
+ // reading the person's own instruction as though an agent had sent it.
239
+ //
240
+ // The test is the SENDER being the alias — NOT the lead being the user. The lead is a
241
+ // rostered agent; a message FROM it is real mail and is kept.
242
+ if (m.from === TEAM_LEAD_NAME) continue;
243
+ out.push({ ...m, _to: n });
244
+ }
245
+ }
246
+ return out.sort((a, b) => String(a.timestamp || '').localeCompare(String(b.timestamp || '')));
247
+ }
248
+
249
+ /** Mark the messages `readLeadInbox` returned as read. */
250
+ export function markLeadInboxRead(teamName, messages) {
251
+ const byName = new Map();
252
+ for (const m of messages || []) {
253
+ const to = m._to || leadOf(teamName);
254
+ if (!byName.has(to)) byName.set(to, []);
255
+ byName.get(to).push(m);
256
+ }
257
+ let n = 0;
258
+ for (const [to, batch] of byName) n += markAllRead(teamName, to, batch);
259
+ return n;
260
+ }
261
+
262
+
263
+ /**
264
+ * The lead's unread mail as one block of text, or '' when there is none.
265
+ *
266
+ * `mark` decides whether reading it counts as seeing it. The caller that FEEDS it to the model
267
+ * passes true — the model has now seen it — while a reader that only counts messages passes
268
+ * false, or the count would empty the box before anyone read it.
269
+ */
270
+ export function formatLeadInbox(teamName, { mark = false } = {}) {
271
+ const unread = readLeadInbox(teamName);
272
+ if (!unread.length) return '';
273
+ 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}`;
276
+ }
277
+
278
+ export function readRoster(teamName) {
279
+ return formatRoster(readTeamFile(teamName));
280
+ }
281
+
282
+ export function formatRoster(team) {
283
+ if (!team) return 'No team.';
284
+ const lines = [`Team "${team.name}"${team.description ? ` — ${team.description}` : ''}`];
285
+ for (const m of team.members) {
286
+ const isLead = m.role === 'lead';
287
+ // `phase` when it is there; `isActive` for a member written by an older build.
288
+ const phase = m.phase || (m.isActive === false ? 'idle' : 'starting');
289
+ const state = isLead ? 'lead' : phase;
290
+ // Unread mail from SOMEONE ELSE. The lead's count is reported rather than hidden — a
291
+ // teammate waiting for an answer is what the roster should show — but the user's own sends
292
+ // are excluded, or `readRoster` reports a message the user just typed as mail they received.
293
+ const unread = readUnread(team.name, m.name).filter((x) => x.from !== TEAM_LEAD_NAME).length;
294
+ const bits = [state];
295
+ if (m.taskId) bits.push(`task ${m.taskId}`);
296
+ if (unread) bits.push(`${unread} unread`);
297
+ lines.push(` ${m.name.padEnd(16)} ${bits.join(', ')}`);
298
+ }
299
+ return lines.join('\n');
300
+ }
301
+
302
+ /** Teams that exist on disk, for /team with no argument. */
303
+ export function knownTeams() {
304
+ return listTeams().map((t) => ({
305
+ name: t.name,
306
+ description: t.description,
307
+ members: t.members.length,
308
+ createdAt: t.createdAt,
309
+ leadSessionId: t.leadSessionId,
310
+ }));
311
+ }
@@ -0,0 +1,226 @@
1
+ // The team panel: who is on the team, what each is doing, and a composer to message them.
2
+ //
3
+ // WHY A PANEL AND NOT ONLY THE COMMAND
4
+ // ------------------------------------
5
+ // `/team msg alice ...` works, but a team is a thing you WATCH. The question a user has is "is
6
+ // anyone stuck, and who do I need to nudge" — and answering it from the command line means running
7
+ // `/team list`, reading four lines, and remembering them. The panel keeps the roster and each
8
+ // member's recent traffic on screen while you type.
9
+ //
10
+ // SHAPE
11
+ // -----
12
+ // Left: the roster, one line per member — name, live state, unread count. Below it a one-line
13
+ // composer. Enter sends to the selected member; Tab moves between members; Esc leaves.
14
+ //
15
+ // It follows tasks-browser.js: a pure renderer plus a key handler, so both are testable without a
16
+ // terminal.
17
+ import fs from 'node:fs';
18
+ import { C } from './colors.js';
19
+ import { visualWidth, fit } from './term.js';
20
+ import { readTeamFile } from './team/store.js';
21
+ import { readMailbox, writeToMailbox } from './team/mailbox.js';
22
+ import { readTasks, formatTasks } from './team/tasks.js';
23
+ import { TEAM_LEAD_NAME } from './team/constants.js';
24
+
25
+
26
+ /** The state a panel needs, created once when it opens. */
27
+ export function makeTeamPanel(teamName) {
28
+ return {
29
+ teamName,
30
+ selectedIndex: 0,
31
+ /** 'roster' or 'compose', so a keystroke means one thing at a time. */
32
+ mode: 'roster',
33
+ composing: '',
34
+ caret: 0,
35
+ flash: '',
36
+ /** How much of each member's recent mail to show. */
37
+ mailLines: 4,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * The members as rows, in roster order.
43
+ *
44
+ * The LEAD is included and is selectable: the user is the boss, and talking to the lead is how
45
+ * they give the team its work.
46
+ */
47
+ export function teamRows(panel) {
48
+ const team = readTeamFile(panel.teamName);
49
+ if (!team || !Array.isArray(team.members)) return [];
50
+ return team.members.map((m) => {
51
+ // Unread mail TO this member from someone ELSE. The user's own sends are stamped
52
+ // `from: TEAM_LEAD_NAME` and sit in the recipient's box until it reads them, so counting
53
+ // them made the roster show `1 new` for a message the user had just typed themselves —
54
+ // a badge nothing could ever clear and nothing ever delivered.
55
+ const unread = readMailbox(panel.teamName, m.name)
56
+ .filter((x) => !x.read && x.from !== TEAM_LEAD_NAME).length;
57
+ return {
58
+ name: m.name,
59
+ model: m.model || '',
60
+ color: m.color || '',
61
+ // By ROLE: a team that names its own leader still shows it as the lead.
62
+ isLead: m.role === 'lead',
63
+ unread,
64
+ // The member's own PHASE — what it is doing, not whether a flag is set. `starting` covers
65
+ // one written before the phase existed; `working` means a tool is actually running, and
66
+ // `thinking` means it is blocked on the model, which is most of a round.
67
+ //
68
+ // `lead` is decided by ROLE, not by the name matching the wire alias. The alias test never
69
+ // matched a real lead (`addMember` gives the role to the first member added, whose name is
70
+ // its own — `NiceHello_01`, not `team-lead`), so the branch was dead and every lead fell
71
+ // through to its phase.
72
+ state: (m.role === 'lead') ? 'lead'
73
+ : (m.phase || (m.isActive === false ? 'idle' : 'starting')),
74
+ };
75
+ });
76
+ }
77
+
78
+ /** The traffic for one member: what they said, and what was said to them, newest last. */
79
+ export function memberTraffic(teamName, name, limit = 4) {
80
+ const box = readMailbox(teamName, name);
81
+ return box.slice(-limit).map((m) => ({
82
+ from: m.from,
83
+ text: String(m.text || '').split('\n')[0].slice(0, 100),
84
+ at: m.timestamp,
85
+ }));
86
+ }
87
+
88
+ /** Draw the panel. Exactly `rows` lines, each `cols` wide. */
89
+ export function renderTeamPanel(panel, cols, rows) {
90
+ const out = [];
91
+ const push = (s) => { out.push(fit(s, cols)); };
92
+
93
+ const team = readTeamFile(panel.teamName);
94
+ const memberCount = (team && team.members) ? team.members.length : 0;
95
+ const title = team
96
+ ? ` Team "${panel.teamName}" — ${memberCount} member(s)`
97
+ : ` Team "${panel.teamName}" — the roster is gone`;
98
+
99
+ push(C.border + '─'.repeat(cols) + C.reset);
100
+ push(C.cyan + C.bold + title + C.reset
101
+ + (panel.flash ? ` ${C.yellow}${panel.flash}${C.reset}` : ''));
102
+
103
+ const roster = teamRows(panel);
104
+ if (!roster.length) {
105
+ push(' ' + C.gray + 'No members. Add one with /team add <name>.' + C.reset);
106
+ }
107
+
108
+ // The roster, then the selected member's traffic, then the composer.
109
+ const composerRows = 3;
110
+ const trafficRoom = Math.max(0, Math.min(panel.mailLines + 2, rows - roster.length - composerRows - 6));
111
+
112
+ for (let i = 0; i < roster.length && out.length < rows - composerRows - trafficRoom - 2; i++) {
113
+ const m = roster[i];
114
+ const sel = i === panel.selectedIndex;
115
+ const ptr = sel ? `${C.cyan}❯${C.reset} ` : ' ';
116
+ const name = (m.isLead ? `${m.name} (lead)` : m.name).padEnd(20);
117
+ // The states a member can be in, each with its own colour: green is a tool actually running,
118
+ // cyan is blocked on the model, grey is waiting for work, yellow is an agent that has not
119
+ // begun. `starting` used to fall through to `not started`, so a lead mid-launch claimed the
120
+ // one thing that was certainly false about it — this is the row the user read as "my lead
121
+ // never came up", and the correct reading was "it is on its way".
122
+ const state = m.state === 'lead' ? `${C.gray}lead${C.reset}`
123
+ : m.state === 'working' ? `${C.green}working${C.reset}`
124
+ : m.state === 'thinking' ? `${C.cyan}thinking${C.reset}`
125
+ : m.state === 'idle' ? `${C.gray}idle${C.reset}`
126
+ : m.state === 'starting' ? `${C.yellow}starting${C.reset}`
127
+ : `${C.yellow}not started${C.reset}`;
128
+ const mail = m.unread ? `${C.yellow} ${m.unread} new${C.reset}` : '';
129
+ const model = m.model ? `${C.gray} ${m.model}${C.reset}` : '';
130
+ push(sel
131
+ ? `${ptr}${C.cyan}${name}${C.reset}${state}${mail}${model}`
132
+ : `${ptr}${name}${state}${mail}${model}`);
133
+ }
134
+
135
+ // What the selected member has been saying.
136
+ const sel = roster[panel.selectedIndex];
137
+ if (sel && trafficRoom > 0) {
138
+ push('');
139
+ push(` ${C.gray}── ${sel.name} ──${C.reset}`);
140
+ const traffic = memberTraffic(panel.teamName, sel.name, trafficRoom);
141
+ if (!traffic.length) push(' ' + C.gray + '(no messages yet)' + C.reset);
142
+ for (const t of traffic) {
143
+ // `team-lead` is the alias the USER'S own sends carry, so this line reads "you wrote this".
144
+ // It is NOT a statement that the lead is the user — the lead is a rostered agent under its
145
+ // own name. That misreading once removed this very alias check from the inbox reader, which
146
+ // made a message you sent to your lead come back to you as though an agent had sent it.
147
+ const who = t.from === TEAM_LEAD_NAME ? 'you' : t.from;
148
+ push(` ${C.gray}${who}:${C.reset} ${t.text}`);
149
+ }
150
+ }
151
+
152
+ // The shared task list, one line, so "is the team moving" needs no second screen.
153
+ const tasks = readTasks(panel.teamName);
154
+ if (tasks.length && out.length < rows - composerRows - 1) {
155
+ const open = tasks.filter((t) => t.status !== 'completed' && t.status !== 'cancelled').length;
156
+ push(' ' + C.gray + `${tasks.length - open}/${tasks.length} tasks done${C.reset}`);
157
+ }
158
+
159
+ while (out.length < rows - composerRows) out.push(' '.repeat(cols));
160
+
161
+ // The composer. One line, always visible, and it SAYS who it will go to — the failure mode of a
162
+ // chat box is sending to the wrong person and only finding out afterwards.
163
+ push(C.border + '─'.repeat(cols) + C.reset);
164
+ if (panel.mode === 'compose') {
165
+ push(` ${C.cyan}→ ${sel ? sel.name : '?'}${C.reset} ${panel.composing}${C.cyan}█${C.reset}`);
166
+ push(' ' + C.gray + 'Enter send · Esc cancel' + C.reset);
167
+ } else {
168
+ push(' ' + C.gray + 'Enter write · Tab next · ↑↓ select · Esc close' + C.reset);
169
+ push('');
170
+ }
171
+
172
+ return out.slice(0, rows);
173
+ }
174
+
175
+ /**
176
+ * Keys for the panel.
177
+ *
178
+ * Returns an action for the caller to perform, rather than performing it: the panel does not own
179
+ * the frame, the agent or the clipboard, and a renderer that acts is a renderer that cannot be
180
+ * tested.
181
+ */
182
+ export function handleTeamPanelKey(panel, t) {
183
+ if (!t) return { handled: false };
184
+
185
+ if (panel.mode === 'compose') {
186
+ if (t.key === 'escape') { panel.mode = 'roster'; panel.composing = ''; panel.caret = 0; return { handled: true }; }
187
+ if (t.key === 'enter') {
188
+ const text = panel.composing.trim();
189
+ const roster = teamRows(panel);
190
+ const to = roster[panel.selectedIndex];
191
+ panel.composing = '';
192
+ panel.caret = 0;
193
+ panel.mode = 'roster';
194
+ if (!text || !to) return { handled: true };
195
+ return { handled: true, action: 'send', to: to.name, text };
196
+ }
197
+ if (t.key === 'backspace') {
198
+ panel.composing = panel.composing.slice(0, -1);
199
+ panel.caret = panel.composing.length;
200
+ return { handled: true };
201
+ }
202
+ if (t.ch && !t.key?.startsWith('c-')) { panel.composing += t.ch; panel.caret = panel.composing.length; return { handled: true }; }
203
+ return { handled: true };
204
+ }
205
+
206
+ const roster = teamRows(panel);
207
+ const n = roster.length;
208
+
209
+ if (t.key === 'escape' || t.ch === 'q') return { handled: true, action: 'close' };
210
+ if (t.key === 'enter') { if (n) { panel.mode = 'compose'; panel.composing = ''; } return { handled: true }; }
211
+ if (t.key === 'tab' || t.key === 'down') { if (n) panel.selectedIndex = (panel.selectedIndex + 1) % n; return { handled: true }; }
212
+ if (t.key === 's-tab' || t.key === 'up') { if (n) panel.selectedIndex = (panel.selectedIndex - 1 + n) % n; return { handled: true }; }
213
+ if (t.key === 'home') { panel.selectedIndex = 0; return { handled: true }; }
214
+ if (t.key === 'end') { if (n) panel.selectedIndex = n - 1; return { handled: true }; }
215
+ // `r` marks everything read for the selected member — the way to clear a "3 new" badge you have
216
+ // already looked at in the traffic pane.
217
+ if (t.ch === 'r' && n) return { handled: true, action: 'markRead', name: roster[panel.selectedIndex].name };
218
+ return { handled: false };
219
+ }
220
+
221
+ /** Deliver a message from the panel. Separated so the key handler stays pure. */
222
+ export function sendFromPanel(teamName, to, text) {
223
+ if (!to || !text) return false;
224
+ writeToMailbox(teamName, to, { from: TEAM_LEAD_NAME, text: String(text) });
225
+ return true;
226
+ }
package/src/term.js CHANGED
@@ -379,11 +379,38 @@ const tokCache = new Map();
379
379
  const TOK_CACHE_MAX = 20000;
380
380
  const TOK_CACHE_MAX_LEN = 4096; // only memoise strings this short
381
381
 
382
+ /**
383
+ * The same estimate, UNROUNDED — for summing many small chunks.
384
+ *
385
+ * `estimateTokens` rounds up, which is right for measuring one finished body: a 5-character
386
+ * message should not read as 1.25 tokens.
387
+ *
388
+ * It is wrong for a STREAM. Deltas arrive a few characters at a time, and each one took
389
+ * `ceil(ascii / 4)` — `ceil(3/4)` is 1, so three characters were billed as a whole token. Over
390
+ * hundreds of chunks that inflated the running figure badly: measured at +33% on ordinary
391
+ * English, and the visible symptom was the output counter DROPPING (3000 -> 2300) the moment the
392
+ * provider's exact `usage` arrived and replaced the estimate. Nothing was lost; the estimate had
393
+ * been lying.
394
+ *
395
+ * So the running counter accumulates this unrounded value, and rounds once when it displays.
396
+ * Callers that measure a complete string keep using `estimateTokens`.
397
+ */
398
+ export function estimateTokensFloat(text) {
399
+ const s = String(text ?? '');
400
+ let ascii = 0, nonAscii = 0;
401
+ for (const ch of s) {
402
+ if (ch.codePointAt(0) <= 127) ascii++;
403
+ else nonAscii++;
404
+ }
405
+ return ascii / 4 + nonAscii;
406
+ }
407
+
382
408
  export function estimateTokens(text) {
383
409
  const s = String(text ?? '');
384
410
  if (s.length <= TOK_CACHE_MAX_LEN) {
385
411
  const hit = tokCache.get(s);
386
412
  if (hit !== undefined) return hit;
413
+ if (hit !== undefined) return hit;
387
414
  let ascii = 0, nonAscii = 0;
388
415
  for (const ch of s) {
389
416
  if (ch.codePointAt(0) <= 127) ascii++;
@@ -192,22 +192,37 @@ export function planTrim(messages, keepRatio, mustKeep) {
192
192
  // A budget of 0 means "elide everything eligible"; a budget of 1 means "keep it
193
193
  // all", in which case we still elide the already-marked ones so a repeated call
194
194
  // is a no-op rather than a growing patch.
195
- const budget = totalBytes * ratio;
195
+ // The share of the ELIGIBLE text to keep, not of `totalBytes`. `totalBytes` counts the
196
+ // pointers left by earlier passes, so after a few passes it mostly describes text that can no
197
+ // longer be elided — and a budget drawn from it stops constraining the loop.
198
+ const eligibleBytes = candidates.reduce((n, i) => n + messages[i].content.length, 0);
199
+ const budget = eligibleBytes * ratio;
196
200
  const keep = new Set();
197
201
  let keepBytes = 0;
198
202
  for (let k = candidates.length - 1; k >= 0; k--) {
199
203
  const i = candidates[k];
200
204
  if (i === mustKeep) { keep.add(i); keepBytes += messages[i].content.length; continue; }
201
205
  const size = messages[i].content.length;
202
- // Keep this chunk if we are still under budget, but ALWAYS keep the single
203
- // newest result: a trim that elides the output the model is currently reading
204
- // is how a working turn turns into a confused one.
205
- if (keepBytes + size <= budget || keep.size === 0) {
206
+ // Under budget: keep. Otherwise elide — except for the result the model is reading right
207
+ // now, which the `mustKeep` branch above has already kept.
208
+ //
209
+ // There used to be a `|| keep.size === 0` here, meaning "always keep at least the newest".
210
+ // `mustKeep` is what actually protects the live result, and the override made a single large
211
+ // result impossible to trim: it was the only candidate, so it was always "the newest", so
212
+ // the budget never applied and the trim elided nothing.
213
+ if (keepBytes + size <= budget) {
206
214
  keep.add(i);
207
215
  keepBytes += size;
208
216
  }
209
217
  }
210
218
 
219
+ // THE FLOOR: never elide everything.
220
+ //
221
+ // The budget can be smaller than every candidate — one 200k result against a 60k budget is the
222
+ // ordinary case, because a long turn leaves the history with only the newest result still
223
+ // un-elided. Without this the trim would replace that result with a pointer, which is worse
224
+ // than not trimming: the model loses the output it just asked for.
225
+ if (!keep.size && candidates.length) keep.add(candidates[candidates.length - 1]);
211
226
  const elide = [];
212
227
  let elideBytes = 0;
213
228
  for (const i of candidates) {