@elu-labs/elu 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/README.md +327 -2
  4. package/SECURITY.md +333 -0
  5. package/THIRD_PARTY_NOTICES.md +62 -0
  6. package/bin/elu-code.mjs +209 -0
  7. package/bin/elu.cjs +10 -0
  8. package/bin/start.cjs +6 -0
  9. package/docs/peer-network.md +257 -0
  10. package/docs/permissions.md +285 -0
  11. package/docs/vendors/antigravity.md +131 -0
  12. package/docs/vendors/claude.md +124 -0
  13. package/docs/vendors/codex.md +64 -0
  14. package/docs/vendors/grok.md +151 -0
  15. package/lib/acp-client.mjs +100 -0
  16. package/lib/adapters/agy.mjs +283 -0
  17. package/lib/adapters/claude.mjs +311 -0
  18. package/lib/adapters/codex.mjs +137 -0
  19. package/lib/adapters/grok.mjs +384 -0
  20. package/lib/adapters/index.mjs +13 -0
  21. package/lib/agents.mjs +77 -0
  22. package/lib/agy-hook.mjs +53 -0
  23. package/lib/approval-server.mjs +85 -0
  24. package/lib/approvals.mjs +128 -0
  25. package/lib/awake.mjs +45 -0
  26. package/lib/board.mjs +261 -0
  27. package/lib/bot-models.mjs +71 -0
  28. package/lib/bot-schedules.mjs +291 -0
  29. package/lib/bots.mjs +361 -0
  30. package/lib/changes.mjs +168 -0
  31. package/lib/checks.mjs +102 -0
  32. package/lib/conductor.mjs +337 -0
  33. package/lib/config.mjs +147 -0
  34. package/lib/conformance.mjs +109 -0
  35. package/lib/default-bots.mjs +150 -0
  36. package/lib/detect.mjs +108 -0
  37. package/lib/diff.mjs +217 -0
  38. package/lib/editor.mjs +165 -0
  39. package/lib/format.mjs +51 -0
  40. package/lib/git.mjs +173 -0
  41. package/lib/init.mjs +129 -0
  42. package/lib/install.mjs +130 -0
  43. package/lib/kanban.mjs +180 -0
  44. package/lib/mcp.mjs +79 -0
  45. package/lib/memory.mjs +35 -0
  46. package/lib/models.mjs +144 -0
  47. package/lib/modes.mjs +95 -0
  48. package/lib/never-list.mjs +110 -0
  49. package/lib/never-shims.mjs +311 -0
  50. package/lib/paste.mjs +56 -0
  51. package/lib/peer/a2a-http.mjs +166 -0
  52. package/lib/peer/a2a.mjs +357 -0
  53. package/lib/peer/asks.mjs +329 -0
  54. package/lib/peer/canonical.mjs +72 -0
  55. package/lib/peer/cli.mjs +154 -0
  56. package/lib/peer/daemon.mjs +525 -0
  57. package/lib/peer/identity.mjs +81 -0
  58. package/lib/peer/index.mjs +291 -0
  59. package/lib/peer/intake.mjs +207 -0
  60. package/lib/peer/invite.mjs +76 -0
  61. package/lib/peer/paths.mjs +7 -0
  62. package/lib/peer/peerdata.mjs +21 -0
  63. package/lib/peer/pictures.mjs +295 -0
  64. package/lib/peer/plans.mjs +182 -0
  65. package/lib/peer/relay.mjs +31 -0
  66. package/lib/peer/replies.mjs +128 -0
  67. package/lib/peer/roster.mjs +256 -0
  68. package/lib/peer/rpc.mjs +88 -0
  69. package/lib/peer/secrets.mjs +88 -0
  70. package/lib/peer/settings.mjs +45 -0
  71. package/lib/peer/shares.mjs +120 -0
  72. package/lib/peer/status.mjs +84 -0
  73. package/lib/peer/taskstore.mjs +108 -0
  74. package/lib/peer/transport.mjs +120 -0
  75. package/lib/pictures.mjs +104 -0
  76. package/lib/plan.mjs +238 -0
  77. package/lib/policy.mjs +98 -0
  78. package/lib/process.mjs +92 -0
  79. package/lib/project-settings.mjs +55 -0
  80. package/lib/project-trust.mjs +230 -0
  81. package/lib/projects.mjs +668 -0
  82. package/lib/publish.mjs +105 -0
  83. package/lib/runner.mjs +874 -0
  84. package/lib/sandbox.mjs +102 -0
  85. package/lib/schedules.mjs +270 -0
  86. package/lib/selection.mjs +202 -0
  87. package/lib/shell.mjs +101 -0
  88. package/lib/state.mjs +247 -0
  89. package/lib/terminal-bots.mjs +239 -0
  90. package/lib/terminal-commands.mjs +165 -0
  91. package/lib/terminal-plan.mjs +37 -0
  92. package/lib/terminal-visuals.mjs +268 -0
  93. package/lib/terminal.mjs +3159 -0
  94. package/lib/unit-file.mjs +17 -0
  95. package/lib/usage.mjs +249 -0
  96. package/lib/util.mjs +86 -0
  97. package/lib/vendor-check.mjs +96 -0
  98. package/lib/vendors.mjs +191 -0
  99. package/lib/workspace.mjs +168 -0
  100. package/lib/yaml.mjs +624 -0
  101. package/npm-shrinkwrap.json +827 -0
  102. package/package.json +70 -4
package/lib/bots.mjs ADDED
@@ -0,0 +1,361 @@
1
+ // Bots: a bot is a name, a vendor, an optional model and effort, and instructions. The
2
+ // owner uses it in any chat, in any project. It reads and answers; it never edits. Bots
3
+ // live in the personal folder (bots.json), not in a project. In a chat a bot answers a
4
+ // message written to it and reviews the session's edits; a shared bot also answers
5
+ // teammates. Five bots come with the app (BUILT_IN_BOTS); the owner makes the rest.
6
+ // A bot may also run by itself: schedule says when, in the words /schedule takes, and mcp
7
+ // names the owner's Claude Code MCP servers it uses (lib/bot-schedules.mjs runs it). Such
8
+ // a bot works: on its schedule and when written to it may edit files and run commands,
9
+ // within the session's permissions, and it does not review. works says the same of a bot
10
+ // with no schedule: it works when written to and never by itself. subagents names the
11
+ // model of the helpers such a bot hands work to (Claude Code only); a working bot that
12
+ // names none may start helpers on its own model (botHelpers). color is the colour its
13
+ // name and the bar beside its replies show in, on this machine only.
14
+ import { createHash } from 'node:crypto';
15
+ import path from 'node:path';
16
+ import { DEFAULT_BOTS, LAST_BOT, RETIRED_BOTS, REVISED_BOTS } from './default-bots.mjs';
17
+ import { atomic, personalDirectory, readJson } from './util.mjs';
18
+ import { CAPS, EFFORTS, OWN_MODEL, VENDORS, VENDOR_EFFORTS } from './vendors.mjs';
19
+
20
+ export const BOT_NAME = /^[a-z][a-z0-9-]{0,29}$/;
21
+ // Effort levels a bot may ask for. Not every vendor takes each one: see botEfforts.
22
+ export { EFFORTS };
23
+ // Instructions hold a whole skill file. Long ones run to about 100,000 characters.
24
+ export const BOTS_MAX = 30, ABOUT_MAX = 120, INSTRUCTIONS_MAX = 200000;
25
+ // How many bots one chat holds.
26
+ export const CHAT_BOTS_MAX = 5;
27
+ // The colours a bot can show in: tones of the terminal's theme (PALETTE in
28
+ // lib/terminal-visuals.mjs). You are blue in every chat, so blue is not one of them, and
29
+ // orange is the session's model's unless the owner gives it to a bot.
30
+ export const BOT_COLORS = Object.freeze(['violet', 'gold', 'pink', 'lime', 'aqua', 'rose', 'orange']);
31
+ // The ones a bot is given when the owner names none.
32
+ const PICKED = BOT_COLORS.filter(color => color !== 'orange');
33
+ // What saveBot would say about a colour on its own, or null. None is fine: one is picked.
34
+ export function botColorProblem(value) {
35
+ if (value == null || value === '') return null;
36
+ return BOT_COLORS.includes(String(value).trim().toLowerCase()) ? null : `A bot's color is one of: ${BOT_COLORS.join(', ')}.`;
37
+ }
38
+ // Names that already mean something after an @ or in a chat.
39
+ const RESERVED = ['team', 'general', 'you', 'me', 'all', 'everyone', 'here', 'channel', 'model'];
40
+ const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/[\]-]{0,79}$/;
41
+ // What saveBot would say about a model on its own, or null when it is fine. No model is the
42
+ // vendor's default. The Model step asks this before it goes on, so the line never shows
43
+ // under another field.
44
+ export function botModelProblem(vendor, value) {
45
+ if (value == null || value === '') return null;
46
+ if (!MODEL.test(String(value).trim())) return 'A model name has no spaces: up to 80 letters, digits, dots, colons, slashes or dashes.';
47
+ return CAPS[vendor]?.model === 'none' ? `${vendor} does not take a model.` : null;
48
+ }
49
+
50
+ // What saveBot would say about a bot's subagents on their own, or null. The value is the
51
+ // model its helpers run on, checked like a bot's own model; none means no helpers. Helpers
52
+ // start on Claude Code only.
53
+ export function botSubagentsProblem(vendor, value) {
54
+ if (value == null || value === '') return null;
55
+ return vendor === 'claude' ? botModelProblem(vendor, value) : `Subagents start on claude only. This bot runs on ${vendor}.`;
56
+ }
57
+
58
+ const fail = message => { throw new Error(message); };
59
+ // The bots the app comes with (lib/default-bots.mjs), in the order they are listed. Each is
60
+ // saved into bots.json once and is an ordinary bot from then on: the owner can edit or
61
+ // remove it, and a removed one does not come back.
62
+ export const BUILT_IN_BOTS = Object.freeze(DEFAULT_BOTS.map(bot => Object.freeze({ vendor: 'claude', model: null, effort: null, shared: false, schedule: null, mcp: [], merge: false, ...bot })));
63
+ export const botsFile = () => path.join(personalDirectory(), 'bots.json');
64
+ // Vendors a bot can run on: the ones that may answer read-only.
65
+ export const botVendors = () => VENDORS.filter(vendor => CAPS[vendor]?.reviewer);
66
+ export const botEfforts = vendor => VENDOR_EFFORTS[vendor] ?? [];
67
+ const oneLine = value => String(value ?? '').replace(/\s+/g, ' ').trim();
68
+ // The MCP servers a bot names, each once: a list, or names split by commas or spaces. They
69
+ // are the names claude mcp list shows.
70
+ export const BOT_MCP_MAX = 8, SCHEDULE_MAX = 60;
71
+ const SERVER = /^[A-Za-z0-9_.-]{1,64}$/;
72
+ export function botServers(value) {
73
+ const names = [...new Set((Array.isArray(value) ? value : String(value ?? '').split(/[\s,]+/)).map(name => String(name ?? '').trim()).filter(Boolean))];
74
+ const bad = names.find(name => !SERVER.test(name));
75
+ if (bad) fail(`${bad.slice(0, 40)} is not an MCP server name. Use the names claude mcp list shows.`);
76
+ if (names.length > BOT_MCP_MAX) fail(`A bot uses up to ${BOT_MCP_MAX} MCP servers.`);
77
+ return names;
78
+ }
79
+ // What saveBot would say about a bot's MCP servers, or null when they are fine.
80
+ export function botServersProblem(value) { try { botServers(value); return null; } catch (error) { return error.message; } }
81
+
82
+ const plainName = value => String(value ?? '').trim().replace(/^@/, '');
83
+ // What is wrong with a name on its own, or null.
84
+ function nameProblem(name) {
85
+ if (!BOT_NAME.test(name)) return 'Name a bot with 1 to 30 lowercase letters, digits or -, starting with a letter.';
86
+ return RESERVED.includes(name) ? `${name} is taken. Choose another name.` : null;
87
+ }
88
+ // Returns the bot in its saved shape, or throws with a line the owner can act on.
89
+ function clean(input) {
90
+ if (!input || typeof input !== 'object' || Array.isArray(input)) fail('A bot needs a name and instructions.');
91
+ const name = plainName(input.name), problem = nameProblem(name);
92
+ if (problem) fail(problem);
93
+ const vendor = String(input.vendor ?? 'claude');
94
+ if (!botVendors().includes(vendor)) fail(`A bot runs on one of: ${botVendors().join(', ')}.`);
95
+ const model = input.model == null || input.model === '' ? null : String(input.model).trim();
96
+ const modelProblem = botModelProblem(vendor, input.model); if (modelProblem) fail(modelProblem);
97
+ const effort = input.effort == null || input.effort === '' ? null : String(input.effort).trim().toLowerCase();
98
+ if (effort !== null && !botEfforts(vendor).includes(effort)) fail(botEfforts(vendor).length ? `Effort on ${vendor} is one of: ${botEfforts(vendor).join(', ')}.` : `${vendor} has no effort setting.`);
99
+ const instructions = String(input.instructions ?? '').trim();
100
+ if (!instructions) fail('Write what the bot does in its instructions.');
101
+ if (instructions.length > INSTRUCTIONS_MAX) fail(`The instructions are ${instructions.length.toLocaleString('en-US')} characters. Keep them under ${INSTRUCTIONS_MAX.toLocaleString('en-US')}.`);
102
+ const about = oneLine(input.about || instructions.split('\n').find(line => line.trim()) || '').slice(0, ABOUT_MAX);
103
+ // When it runs by itself, as typed (botScheduleProblem in lib/bot-schedules.mjs reads it), or null.
104
+ const schedule = oneLine(input.schedule) || null;
105
+ if (schedule && schedule.length > SCHEDULE_MAX) fail(`Keep the schedule under ${SCHEDULE_MAX} characters, such as every 1h.`);
106
+ const subagents = input.subagents == null || input.subagents === '' ? null : String(input.subagents).trim();
107
+ const helpersProblem = botSubagentsProblem(vendor, subagents); if (helpersProblem) fail(helpersProblem);
108
+ // A saved colour this build does not know reads as none, so the bot stays listed; saveBot
109
+ // refuses one the owner types.
110
+ const given = String(input.color ?? '').trim().toLowerCase(), color = BOT_COLORS.includes(given) ? given : null;
111
+ return { name, vendor, model, effort, about, instructions, shared: input.shared === true, schedule, mcp: botServers(input.mcp), merge: input.merge === true, works: input.works === true, subagents,
112
+ shadow: input.shadow === true, color };
113
+ }
114
+ const usable = bot => { try { clean(bot); return true; } catch { return false; } };
115
+
116
+ // builtIn in the file names the built-in bots this store was already offered, so each is
117
+ // added once and a removed one stays removed.
118
+ // defaults numbers the set of built-in bots the store was last brought up to.
119
+ const DEFAULTS = 2;
120
+ const write = (bots, offered) => atomic(botsFile(), { version: 1, defaults: DEFAULTS, builtIn: offered, bots });
121
+ const fingerprint = text => createHash('sha256').update(String(text ?? '').trim()).digest('hex').slice(0, 16);
122
+ // A store from before this set, brought up to it: a bot an earlier build came with and the
123
+ // owner never edited is removed when this build no longer has it, and gets this build's
124
+ // instructions when it does.
125
+ function upgraded(rows, now) {
126
+ return rows.filter(row => !(RETIRED_BOTS[row?.name] && RETIRED_BOTS[row.name] === fingerprint(row.instructions))).map(row => {
127
+ const old = REVISED_BOTS[row?.name], next = old?.text === fingerprint(row?.instructions) ? BUILT_IN_BOTS.find(bot => bot.name === row.name) : null;
128
+ return next ? { ...row, instructions: next.instructions, about: row.about === old.about ? next.about : row.about, model: row.model ?? next.model, updatedAt: now } : row;
129
+ });
130
+ }
131
+ // The order bots are listed in: the ones the app comes with as BUILT_IN_BOTS has them,
132
+ // then the owner's own by name, and LAST_BOT after all of them.
133
+ const place = name => { const at = BUILT_IN_BOTS.findIndex(bot => bot.name === name); return name === LAST_BOT ? BUILT_IN_BOTS.length + 1 : at < 0 ? BUILT_IN_BOTS.length : at; };
134
+ const inOrder = (a, b) => place(a.name) - place(b.name) || a.name.localeCompare(b.name);
135
+ // The store as saved: its rows as they are, and the built-in names it was offered. A
136
+ // built-in bot the store was never offered is added on this read, unless a saved row has
137
+ // its name or there is no room for it; either way it is not offered again.
138
+ function store() {
139
+ let value = null;
140
+ try { value = readJson(botsFile()); }
141
+ catch (error) { if (error.code !== 'ENOENT') fail(`${botsFile()} could not be read. Fix it or remove it.`); }
142
+ const saved = Array.isArray(value?.bots) ? value.bots : [], offered = Array.isArray(value?.builtIn) ? value.builtIn.filter(name => typeof name === 'string') : [];
143
+ const now = new Date().toISOString(), current = value?.defaults === DEFAULTS, rows = current ? saved : upgraded(saved, now);
144
+ const fresh = BUILT_IN_BOTS.filter(bot => !offered.includes(bot.name));
145
+ if (current && !fresh.length) return { rows, offered };
146
+ const room = Math.max(0, BOTS_MAX - rows.filter(usable).length);
147
+ const added = fresh.filter(bot => !rows.some(row => row?.name === bot.name)).slice(0, room).map(bot => ({ ...bot, createdAt: now, updatedAt: now }));
148
+ const next = { rows: [...rows, ...added], offered: [...offered, ...fresh.map(bot => bot.name)] };
149
+ // A folder that cannot be written still lists them, and the next read tries again.
150
+ try { write(next.rows, next.offered); } catch { /* Listing never fails on a save. */ }
151
+ return next;
152
+ }
153
+ const listed = rows => rows.filter(usable).map(bot => ({ ...clean(bot), createdAt: bot.createdAt ?? null, updatedAt: bot.updatedAt ?? null })).sort(inOrder);
154
+ // Every saved bot, in the order above. A row that is no longer valid is left out, never fixed up.
155
+ export function listBots() { return listed(store().rows); }
156
+ // The colour a bot shows in. One saved before bots had colours shows in the colour the
157
+ // built-in of its name comes with, or in a pick from its name that never changes.
158
+ export function botColor(bot) {
159
+ if (BOT_COLORS.includes(bot?.color)) return bot.color;
160
+ const name = String(bot?.name ?? '');
161
+ return BUILT_IN_BOTS.find(row => row.name === name)?.color ?? PICKED[parseInt(fingerprint(name).slice(0, 8), 16) % PICKED.length];
162
+ }
163
+ // A colour for a bot the owner gave none: at random, from the ones the fewest other bots
164
+ // show in, so one no other bot has while there is one. not: a colour to leave out, so
165
+ // asking again gives another.
166
+ export function pickBotColor(bots = listBots(), { not = null } = {}) {
167
+ const open = PICKED.filter(color => color !== not), count = color => bots.filter(bot => botColor(bot) === color).length;
168
+ const least = Math.min(...open.map(count)), pool = open.filter(color => count(color) === least);
169
+ return pool[Math.floor(Math.random() * pool.length)];
170
+ }
171
+ export function getBot(name) { const wanted = String(name ?? '').replace(/^@/, ''); return listBots().find(bot => bot.name === wanted) ?? null; }
172
+ // The saved bot, or the line the owner sees when a message or a chat names one that is not saved.
173
+ export function needBot(name) { return getBot(name) ?? fail(`There is no bot named ${String(name ?? '').replace(/^@/, '').slice(0, 40)}. Open the Bots tab to make one.`); }
174
+ // The bots of one chat: saved names, each once, at most five.
175
+ export function chatBots(names) {
176
+ if (!Array.isArray(names) || names.some(name => typeof name !== 'string')) fail('Bots are a list of names.');
177
+ const bots = [...new Set(names.map(name => needBot(name).name))];
178
+ if (bots.length > CHAT_BOTS_MAX) fail(`A chat holds up to ${CHAT_BOTS_MAX} bots.`);
179
+ return bots;
180
+ }
181
+ // Teammates' questions run on a vendor that can keep them inside the shared files (CAPS.peer).
182
+ export const canShare = vendor => CAPS[vendor]?.peer === true;
183
+ // The shared bot a teammate asked for. One that is gone, not shared, or on a vendor that
184
+ // cannot answer teammates gets the same line, so a teammate learns nothing more about it.
185
+ export function sharedBot(name) {
186
+ let bot = null; try { bot = getBot(name); } catch { /* An unreadable file shares nothing. */ }
187
+ if (!bot?.shared || !canShare(bot.vendor)) fail(`The bot ${String(name ?? '').replace(/^@/, '').slice(0, 40)} is no longer shared with teammates.`);
188
+ return bot;
189
+ }
190
+ // What saveBot would say about a name, or null when it is fine: a name that is not allowed,
191
+ // is reserved, or belongs to another bot. previous is the bot's saved name when it is being
192
+ // renamed. With no previous the name is for a new bot, so any saved bot's name is taken.
193
+ export function botNameProblem(value, { previous = null } = {}) {
194
+ const name = plainName(value);
195
+ return nameProblem(name) ?? (name !== plainName(previous) && listBots().some(bot => bot.name === name) ? `A bot named ${name} exists.` : null);
196
+ }
197
+ // What saveBot would say when there is no room for one more bot, or null. A new bot asks
198
+ // this before its first question.
199
+ export const botRoomProblem = (bots = listBots()) => bots.length >= BOTS_MAX ? `You can keep up to ${BOTS_MAX} bots. Remove one first.` : null;
200
+
201
+ // Creates a bot, or changes one. previous is the saved name when it is being renamed.
202
+ export function saveBot(input, { previous = null } = {}) {
203
+ const unknown = botColorProblem(input?.color); if (unknown) fail(unknown);
204
+ const bot = clean(input), { rows, offered } = store(), bots = listed(rows), old = previous ?? bot.name, current = bots.find(row => row.name === old);
205
+ if (bot.shared && !canShare(bot.vendor)) fail(`A bot on ${bot.vendor} cannot be shared: teammates' questions run on ${botVendors().filter(canShare).join(', ')} only.`);
206
+ if (previous !== null && !current) fail(`There is no bot named ${previous}.`);
207
+ const taken = botNameProblem(bot.name, { previous: old });
208
+ if (taken) fail(taken);
209
+ const full = current ? null : botRoomProblem(bots); if (full) fail(full);
210
+ // No colour given: a saved bot keeps the one it shows in, and a new bot gets one picked.
211
+ const color = bot.color ?? (current ? botColor(current) : pickBotColor(bots));
212
+ const now = new Date().toISOString(), saved = { ...bot, color, createdAt: current?.createdAt ?? now, updatedAt: now };
213
+ write([...bots.filter(row => row.name !== old), saved].sort(inOrder), offered);
214
+ return saved;
215
+ }
216
+ export function removeBot(name) {
217
+ const { rows, offered } = store(), bots = listed(rows), wanted = String(name ?? '').replace(/^@/, '');
218
+ if (!bots.some(bot => bot.name === wanted)) return false;
219
+ write(bots.filter(bot => bot.name !== wanted), offered); return true;
220
+ }
221
+ // "claude · opus · high effort": what a bot runs on, for a list row.
222
+ export function botRuns(bot) { return [bot.vendor, bot.model, bot.effort ? `${bot.effort} effort` : null].filter(Boolean).join(' · '); }
223
+ // The bots teammates may use, as they are told about them: a name and what it is for.
224
+ export function sharedBots() { return listBots().filter(bot => bot.shared && canShare(bot.vendor)).map(bot => ({ name: bot.name, about: bot.about })); }
225
+
226
+ // The bots of a session. A list is the owner's choice, and an empty one means no bots. A
227
+ // session saved without the field (one from before every review was a bot's, or one made
228
+ // by a path that passes none) has @reviewer while a bot of that name is saved.
229
+ export function sessionBots(task) {
230
+ if (Array.isArray(task?.bots)) return task.bots;
231
+ return getBot('reviewer') ? ['reviewer'] : [];
232
+ }
233
+ // The key a bot runs under in a session (task.runs[].role, task.sessions, task.activeRole),
234
+ // so @reviewer is never taken for the reviewer that older sessions saved under that name.
235
+ export const botKey = name => `bot:${name}`;
236
+ export const botOfKey = key => typeof key === 'string' && key.startsWith('bot:') ? key.slice(4) : null;
237
+ // What a bot is doing in a running session, for a status row: it answers the message the
238
+ // owner just wrote to it, it works when its schedule started it or when it goes on from its
239
+ // own reply to edit, and otherwise it reviews. A shadow bot's turn after the agent's is
240
+ // shadowing. null when no bot is at work.
241
+ export function botWork(task) {
242
+ const bot = botOfKey(task?.activeRole), last = task?.messages?.at(-1);
243
+ if (!bot) return null;
244
+ if (task.shadowing?.bot === bot) return { agent: `@${bot}`, label: 'shadowing' };
245
+ return { agent: `@${bot}`, label: last?.bot === bot || last?.to === `@${bot}` && last.scheduled ? 'working' : last?.to === `@${bot}` ? 'answering' : 'reviewing' };
246
+ }
247
+ // What a bot runs with: it reads and answers, with a five minute limit for each turn and
248
+ // no dollar budget of its own, plus the model and effort the owner chose.
249
+ export function botRole(bot) {
250
+ return { vendor: bot.vendor, access: 'read', network: 'off', commands: [], writablePaths: [], timeoutSeconds: 300, maxCostUsd: null, ...(bot.model ? { model: bot.model } : {}), ...(bot.effort ? { effort: bot.effort } : {}) };
251
+ }
252
+ // A bot with a schedule works by itself and when written to; one with works on and no
253
+ // schedule works when written to, and never by itself. Neither is one of the session's
254
+ // reviewers: the other bots review what it changes.
255
+ export const botScheduled = bot => Boolean(bot?.schedule);
256
+ export const botWorks = bot => bot?.works === true || botScheduled(bot);
257
+ export const botReviews = bot => Boolean(bot) && !botWorks(bot);
258
+ // A shadow bot is a second pair of eyes on the agent that works in a session: it reviews
259
+ // each change as any reviewing bot does, and after a turn that changed no files it reads
260
+ // that turn and says what it would challenge. It reads only. A bot that works does its own
261
+ // work instead, so it never shadows.
262
+ export const botShadows = bot => bot?.shadow === true && !botWorks(bot);
263
+ // What a working bot runs with: the limits the project gives its own model (time, dollars,
264
+ // commands, paths), on the bot's vendor, model and effort. A vendor that cannot enforce a
265
+ // command list or a dollar budget gets none, as a model on that vendor would.
266
+ export function botWorkRole(bot, lead, access = 'edit') {
267
+ const { model, ...limits } = lead, caps = CAPS[bot.vendor] ?? {}, edit = access === 'edit';
268
+ return { ...limits, vendor: bot.vendor, access, commands: edit && caps.commands ? lead.commands : [], writablePaths: edit ? lead.writablePaths : [], maxCostUsd: caps.budget ? lead.maxCostUsd : null, ...(bot.model ? { model: bot.model } : {}), ...(bot.effort ? { effort: bot.effort } : {}) };
269
+ }
270
+ // The model of the helpers a working bot may start in a session with these permissions, or
271
+ // null: helpers start on Claude Code, and never in a read-only (locked) session. A bot
272
+ // saved with a helpers model leads with helpers on that model. Any other working bot may
273
+ // start them on its own model (OWN_MODEL), so its instructions decide whether it does. One
274
+ // that may merge pull requests and names no helpers model starts none: a helper runs under
275
+ // the deny rules of its bot's turn, and that turn's rules allow a merge.
276
+ export const botHelpers = (bot, mode) => bot?.vendor !== 'claude' || mode === 'locked' ? null : bot.subagents || (botWorks(bot) && bot.merge !== true ? OWN_MODEL : null);
277
+ // What a bot that has helpers is told in each of its working turns. edit: files of the
278
+ // repository may change in this turn. A bot whose helpers run on its own model is told
279
+ // only that it can start one, and to do so when it is asked to.
280
+ function helpersNote(model, { edit }) {
281
+ if (model === OWN_MODEL) return [
282
+ "You can hand a piece of this work to a helper, and only when the owner's instructions for you, or the message you are answering, ask you to use helpers; otherwise do the work yourself. The Agent tool starts one: always with subagent_type executor, the only kind there is here. An executor runs on the model you run on, starts with nothing but the task you write for it, and reports back to you alone. Check what it returns before you build on it: you answer for the result.",
283
+ `Executors work under this session's permissions, as you do, and they cannot start helpers of their own.${edit ? '' : ' The rule of this turn holds for them too: they read and run commands, and change no file of a repository.'}`,
284
+ ].join(' ');
285
+ return [
286
+ `You lead this work, and you do not have to do all of it yourself. The Agent tool starts a helper: always with subagent_type executor, the only kind there is here. Each executor runs on ${model}, starts with nothing but the task you write for it, and reports back to you alone.`,
287
+ 'Plan the work first. Hand each piece that can be carried out on its own to an executor, with everything it needs: the goal, the files and folders, what done looks like, and what it must leave alone. Check what each one returns before you build on it: read what it changed and run what proves it. You answer for the result; what an executor did is your work.',
288
+ `Executors work under this session's permissions, as you do, and they cannot start helpers of their own.${edit ? '' : ' The rule of this turn holds for them too: they read and run commands, and change no file of a repository.'}`,
289
+ ].join(' ');
290
+ }
291
+ // The owner's instructions for a bot, as every one of its turns carries them.
292
+ const brief = bot => `Instructions from the owner for @${bot.name}:\n${bot.instructions}`;
293
+ // Which of its MCP servers a working bot has in this run. missing are the ones its
294
+ // instructions may count on and it starts without.
295
+ function serversNote(bot, { on = [], missing = [] } = {}) {
296
+ return [
297
+ on.length ? `MCP servers on for you (tools named mcp__NAME__*): ${on.join(', ')}.` : '',
298
+ missing.length ? bot.vendor === 'claude' ? `These MCP servers are named for you but are not set up in Claude Code on this computer, so you start without them: ${missing.join(', ')}. Say so when your work needs one.` : `MCP servers reach a bot that runs on claude only, so you have none of: ${missing.join(', ')}. Say so when your work needs one.` : '',
299
+ ].filter(Boolean).join(' ');
300
+ }
301
+ // A working bot's first turn: its schedule started it, or the owner wrote to it. It may
302
+ // read, run commands and use its MCP servers as the session's permissions allow; files of
303
+ // a repository change in a second turn, in the session's editing workspace. edit is false
304
+ // where the session can no longer be edited. ask is true when the owner wrote the message:
305
+ // only then may the bot turn its own schedule off or on for this session. helpers: the model
306
+ // its executor helpers run on in this turn (botHelpers), or null when it has none.
307
+ export function botWorkInstructions(bot, { scheduled = false, label = null, servers = {}, edit = true, ask = false, helpers = null } = {}) {
308
+ return [
309
+ `You are @${bot.name}, a bot the owner keeps in this session to do work. ${scheduled ? `Nobody wrote the last message of the chat: your schedule${label ? ` (${label})` : ''} started this turn, and the owner may not be here. Do what your instructions say for a run.` : 'The owner wrote the last message of the chat to you. Do what it asks, the way your instructions say.'} The chat so far, and the session's edits when it has any, are in the context below. You never speak for the agent working in this session.`,
310
+ `You may read files, run commands and use your MCP tools as far as this session's permissions allow. ${edit ? 'Do not edit files of a repository in this turn. When files must change, say what you will change and set intent to edit: ELU Code then prepares the editing workspace of this session and starts you there, and the session\'s other bots review your change. With intent edit, set repository to the Git repository to change, as an absolute path, a path relative to your working directory, or . for the working directory; the Repositories list in the context names the ones in the owner\'s home folder. Otherwise set intent to answer and repository to an empty string.' : 'The edits of this session are published, so nothing more can be changed here: set intent to answer, and say what you would change in a new session.'} Reply in plain text for the owner to read later.`,
311
+ helpers ? helpersNote(helpers, { edit: false }) : '',
312
+ serversNote(bot, servers),
313
+ ask ? `The owner may tell you to stop, or to start again, running on your schedule in this session. Only then set schedule to off or on. Otherwise set it to keep.` : '',
314
+ brief(bot),
315
+ ].filter(Boolean).join('\n\n');
316
+ }
317
+ // The turn that follows intent edit: the bot makes its change where the agent working in
318
+ // this session would.
319
+ export function botEditInstructions(bot, { servers = {}, helpers = null } = {}) {
320
+ return [`You are @${bot.name}, a bot the owner keeps in this session to do work. You are now in the editing workspace of this session. Make the change you described in your last message of the chat, and nothing else; the session's goal and the chat are context. Return your result, with an empty comments array and any review responses. The session's other bots review your change before it can be published.`, helpers ? helpersNote(helpers, { edit: true }) : '', serversNote(bot, servers), brief(bot)].filter(Boolean).join('\n\n');
321
+ }
322
+ // After a review of the bot's own change.
323
+ export function botFixInstructions(bot, { helpers = null } = {}) {
324
+ return [`You are @${bot.name}. Address the review and the failed checks of the change you made in this session. Return one response per review finding with its ID, fixed or disputed, and the reason.`, helpers ? helpersNote(helpers, { edit: true }) : '', brief(bot)].filter(Boolean).join('\n\n');
325
+ }
326
+ // A message the owner wrote to a bot in a session.
327
+ export function botChatInstructions(bot) {
328
+ return `You are @${bot.name}, a bot in a chat between the owner and the agent that works in this session, whose entries in the chat are from agent. The owner wrote the last message of the chat to you; the chat so far, and the session's edits when it has any, are in the context below. Answer the owner in plain text and set intent to answer. You read and answer; you never edit files, and you never speak for that agent.\n\n${brief(bot)}`;
329
+ }
330
+ // A shadow bot's turn. It follows a turn of the agent that works in the session, one that
331
+ // changed no files. by names that agent as the chat does: lead for the session's model,
332
+ // @name for a bot that works.
333
+ export function botShadowInstructions(bot, { by = 'lead' } = {}) {
334
+ const agent = by === 'lead' ? "the session's model, whose entries in the chat are from agent" : `${by}, a bot that works in this session`;
335
+ return [
336
+ `You are @${bot.name}, a bot the owner keeps in this session to shadow the agent that works in it: a second engineer who reads each turn and pushes back. That agent is ${agent}. It just finished a turn that changed no files, and nobody wrote to you. The chat so far, and the session's edits when it has any, are in the context below; the agent's turn is its last entry in the chat.`,
337
+ 'Check that turn: read the files of the project it relies on when that helps. Then do one of three things. Challenge what is wrong or risky in it, and say why. Offer a better approach, and say what it gains. Or, when you would do the same, say in one line that you have nothing to add.',
338
+ 'Be brief: the most important point first, a few lines in all, no summary of the turn and no praise. The owner and the agent both read your reply in the chat, and the agent reads it on its next turn. Nobody answers you now, so ask no questions. You read and never edit files or run commands, and you never speak for the agent or for the owner. Reply in plain text and set intent to answer.',
339
+ brief(bot),
340
+ ].join('\n\n');
341
+ }
342
+ // A bot's review of the session's edits.
343
+ export function botReviewInstructions(bot) {
344
+ return `Review as @${bot.name}, a bot the owner added to this chat. Review the supplied full changed-file contents and diff. Put actionable findings in comments. Use must_fix for a blocking defect. Do not execute tools or edit files. Acknowledge whether prior fixes resolved earlier findings.\n\n${brief(bot)}`;
345
+ }
346
+ // What follows the peer safety instructions when a shared bot answers a teammate.
347
+ export function botPeerInstructions(bot) { return `You answer as @${bot.name}, a bot the owner shares with teammates.\n\n${brief(bot)}`; }
348
+ // What the agent that works in a session is told about the bots that shadow it, or null.
349
+ export function shadowBotsNote(names = []) {
350
+ const bots = names.map(name => getBot(name)).filter(botShadows);
351
+ if (!bots.length) return null;
352
+ return `${bots.map(bot => `@${bot.name}`).join(', ')} ${bots.length > 1 ? 'shadow' : 'shadows'} you in this chat: after a turn of yours that changes no files, ${bots.length > 1 ? 'each' : 'it'} may add one comment to the chat, marked shadow. Nobody asked for it and it is not an instruction. On your next turn weigh it, use what is right in it, and say so briefly when it changes what you do. Do not answer it otherwise.`;
353
+ }
354
+ // What the lead is told about the bots in its chat. A bot that is no longer saved is left out.
355
+ export function chatBotsNote(names = []) {
356
+ const bots = names.map(name => getBot(name)).filter(Boolean);
357
+ if (!bots.length) return null;
358
+ // A bot that works does its own work in the session and reviews nothing.
359
+ const working = bots.some(botWorks) ? ' A bot marked as working does its own work in this session, when the owner writes to it and on its schedule when it has one; it does not review.' : '';
360
+ return [`Bots in this chat. The owner writes to one with @name; such a message and its reply are in the chat for you to read, not to answer. Each reviews your edits before they can be published.${working}`, ...bots.map(bot => `@${bot.name} — ${bot.about}${botWorks(bot) ? ` (working: ${bot.schedule ?? 'when the owner writes to it'})` : ''}`)].join('\n');
361
+ }
@@ -0,0 +1,168 @@
1
+ import blessed from 'blessed';
2
+ import { redact } from './publish.mjs';
3
+ import { ink, wrapText } from './terminal-visuals.mjs';
4
+
5
+ // The Changes view of a session: what taskPatch found, as rows to draw. Each row is one
6
+ // screen line that already fits the width, with the same tags the other views use.
7
+
8
+ // Plain ASCII is one column a character. Only other text needs the library's width table.
9
+ const ASCII = /^[\x20-\x7e]*$/;
10
+ const columns = value => ASCII.test(value) ? value.length : blessed.unicode.strWidth(value);
11
+ const count = value => Number(value ?? 0).toLocaleString('en-US');
12
+ const plural = (value, word) => `${count(value)} ${word}${value === 1 ? '' : 's'}`;
13
+
14
+ // File content and paths on screen. A control character becomes a visible sign, and the
15
+ // characters that reorder text become their code, so nothing reaches the terminal as a
16
+ // control. Known secrets are masked like everywhere else.
17
+ const CONTROL = /[\x00-\x08\x0a-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g;
18
+ const sign = ch => { const code = ch.charCodeAt(0); return code < 0x20 ? String.fromCharCode(0x2400 + code) : code === 0x7f ? '\u2421' : `<U+${code.toString(16).toUpperCase().padStart(4, '0')}>`; };
19
+ const safe = value => redact(String(value ?? '').replace(CONTROL, sign), Infinity);
20
+ // Text that went through safe() is painted as it is. Masking a piece of a line a second
21
+ // time could change its width after it was measured.
22
+ const paint = (text, tone, colour, bold = false) => ink(text, tone, colour, bold, { trusted: true });
23
+ // redact() masks a private key inside one text. A diff shows the key line by line, so the
24
+ // lines between its first and its last are masked here.
25
+ const KEY_START = /-----BEGIN [^-]*PRIVATE KEY-----/, KEY_END = /-----END [^-]*PRIVATE KEY-----/;
26
+ function masker() {
27
+ let inside = false;
28
+ return text => {
29
+ if (inside) { if (KEY_END.test(text)) inside = false; return '[redacted]'; }
30
+ const at = text.search(KEY_START);
31
+ if (at >= 0 && !KEY_END.test(text.slice(at))) inside = true;
32
+ return safe(text);
33
+ };
34
+ }
35
+ // Tabs become spaces up to the next multiple of 4, counted from the start of the text.
36
+ function expandTabs(text) {
37
+ if (!text.includes('\t')) return text;
38
+ let out = '', used = 0;
39
+ for (const part of text.split(/(\t)/)) {
40
+ if (part === '\t') { const pad = 4 - used % 4; out += ' '.repeat(pad); used += pad; }
41
+ else { out += part; used += columns(part); }
42
+ }
43
+ return out;
44
+ }
45
+ // Cuts a line of code into rows of at most `room` columns. Every character is kept, so the
46
+ // cut is by column and never by word. A mark that sits on a character stays with it.
47
+ function chop(text, room) {
48
+ const out = [];
49
+ if (ASCII.test(text)) {
50
+ for (let at = 0; at < text.length; at += room) out.push(text.slice(at, at + room));
51
+ return out.length ? out : [''];
52
+ }
53
+ let piece = '', used = 0;
54
+ for (const ch of text) {
55
+ const size = columns(ch);
56
+ if (used + size > room && piece) { out.push(piece); piece = ''; used = 0; }
57
+ piece += ch; used += size;
58
+ }
59
+ out.push(piece);
60
+ return out;
61
+ }
62
+ function head(text, room) {
63
+ let out = '', used = 0;
64
+ for (const ch of text) { const size = columns(ch); if (used + size > room) break; out += ch; used += size; }
65
+ return out;
66
+ }
67
+ function tail(text, room) {
68
+ const chars = Array.from(text); let out = '', used = 0;
69
+ for (let index = chars.length - 1; index >= 0; index--) { const size = columns(chars[index]); if (used + size > room) break; out = chars[index] + out; used += size; }
70
+ return out;
71
+ }
72
+ const clip = (text, room) => columns(text) <= room ? text : `${head(text, Math.max(0, room - 1))}…`;
73
+ // A path that does not fit loses its middle and keeps the file name.
74
+ function clipPath(value, room) {
75
+ if (columns(value) <= room) return value;
76
+ const slash = value.lastIndexOf('/'), name = slash < 0 ? value : value.slice(slash), keep = room - 1 - columns(name);
77
+ return slash > 0 && keep >= 1 ? `${head(value.slice(0, slash), keep)}…${name}` : `…${tail(value, Math.max(0, room - 1))}`;
78
+ }
79
+ // A rename reads `old → new`. When both do not fit, the shorter side keeps its length.
80
+ function renamed(from, to, room) {
81
+ if (columns(from) + 3 + columns(to) <= room) return `${from} → ${to}`;
82
+ let left = Math.floor((room - 3) / 2), right = room - 3 - left;
83
+ if (columns(from) < left) { right += left - columns(from); left = columns(from); }
84
+ else if (columns(to) < right) { left += right - columns(to); right = columns(to); }
85
+ return `${clipPath(from, left)} → ${clipPath(to, right)}`;
86
+ }
87
+ const row = (text, kind, file = null) => ({ text, kind, file });
88
+ const notes = (text, width, colour, file = null, indent = '') => wrapText(safe(text), Math.max(8, width - columns(indent))).map(line => row(indent + paint(line, 'muted', colour), 'note', file));
89
+
90
+ // `BASE...BRANCH · 24 files changed · +2,468 −177`, then what a limit left out.
91
+ function summaryRows(changes, width, colour) {
92
+ const total = (changes.files?.length ?? 0) + (changes.hiddenFiles ?? 0);
93
+ if (!total) return [row(paint('No changes yet.', 'muted', colour), 'summary')];
94
+ const base = /^[0-9a-f]{40,64}$/.test(changes.base ?? '') ? changes.base.slice(0, 8) : changes.base;
95
+ const where = safe(base && changes.branch ? `${base}...${changes.branch}` : changes.branch ?? base ?? '');
96
+ const files = `${plural(total, 'file')} changed`, plus = `+${count(changes.added)}`, minus = `−${count(changes.removed)}`;
97
+ const room = width - columns(`${files} · ${plus} ${minus}`) - 3;
98
+ const label = where && room >= 8 ? (columns(where) <= room ? where : `${head(where, Math.ceil((room - 1) / 2))}…${tail(where, Math.floor((room - 1) / 2))}`) : '';
99
+ const fits = columns(`${files} · ${plus} ${minus}`) <= width;
100
+ const rows = fits
101
+ ? [row(`${label ? `${paint(label, 'muted', colour)}${paint(' · ', 'dim', colour)}` : ''}${paint(files, 'text', colour, true)}${paint(' · ', 'dim', colour)}${paint(plus, 'green', colour)} ${paint(minus, 'red', colour)}`, 'summary')]
102
+ : wrapText(`${files} · ${plus} ${minus}`, width).map(line => row(paint(line, 'text', colour), 'summary'));
103
+ if (changes.cut) {
104
+ const left = [changes.hiddenFiles ? plural(changes.hiddenFiles, 'file') : '', changes.hiddenLines ? plural(changes.hiddenLines, 'changed line') : ''].filter(Boolean);
105
+ const text = left.length ? `Not shown: ${left.join(' · ')}.` : 'Parts of some files are not shown. Each file says what is missing.';
106
+ rows.push(...notes(`${text}${changes.uncounted ? ' The totals leave out new files that were not read in full.' : ''}`, width, colour));
107
+ }
108
+ return rows;
109
+ }
110
+ // `▾ src/coupon.mjs +82 −1`: the fold marker, the path, the counts at the right edge.
111
+ function fileRow(file, index, width, colour, unfolded) {
112
+ const word = file.status === 'added' ? 'new' : file.status === 'deleted' ? 'deleted' : '';
113
+ const tally = file.binary ? 'binary' : `+${count(file.added)} −${count(file.removed)}`;
114
+ // When the path has little room, the status word is the first thing to go.
115
+ const status = word && width - 4 - columns(`${word} ${tally}`) >= 16 ? word : '';
116
+ const room = Math.max(1, width - 4 - columns(tally) - (status ? columns(status) + 2 : 0));
117
+ const name = file.status === 'renamed' && file.from ? renamed(safe(file.from), safe(file.path), room) : clipPath(safe(file.path), room);
118
+ const gap = ' '.repeat(Math.max(2, width - 2 - columns(name) - columns(tally) - (status ? columns(status) + 2 : 0)));
119
+ const right = file.binary ? paint(tally, 'muted', colour) : `${paint(`+${count(file.added)}`, 'green', colour)} ${paint(`−${count(file.removed)}`, 'red', colour)}`;
120
+ return row(`${paint(unfolded ? '▾' : '▸', 'muted', colour)} ${paint(name, 'text', colour, true)}${gap}${status ? `${paint(status, 'muted', colour)} ` : ''}${right}`, 'file', index);
121
+ }
122
+ // Old number, new number, then +, - or a space, then the text. A long line goes on under
123
+ // its own text column, so nothing is cut.
124
+ function lineRows(file, index, width, colour) {
125
+ const lines = file.hunks.flatMap(hunk => hunk.lines), mask = masker(), rows = [];
126
+ const digits = String(lines.reduce((most, line) => Math.max(most, line.old ?? 0, line.now ?? 0), 0)).length;
127
+ const indent = ' '.repeat(2 * digits + 4), room = Math.max(1, width - columns(indent));
128
+ // A file with Windows line ends shows no mark for them. A file that mixes both shows each one.
129
+ const windows = lines.some(line => line.text.endsWith('\r')) && lines.every(line => line.more || line.text.endsWith('\r'));
130
+ for (const hunk of file.hunks) {
131
+ rows.push(row(paint(clip(safe(hunk.header), width), 'muted', colour), 'hunk', index));
132
+ for (const line of hunk.lines) {
133
+ const tone = line.kind === 'add' ? 'green' : line.kind === 'del' ? 'red' : null, mark = line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' ';
134
+ const pieces = chop(expandTabs(mask(windows && line.text.endsWith('\r') ? line.text.slice(0, -1) : line.text)), room);
135
+ rows.push(row(`${paint(`${String(line.old ?? '').padStart(digits)} ${String(line.now ?? '').padStart(digits)}`, 'muted', colour)} ${paint(`${mark} ${pieces[0]}`, tone, colour)}`, line.kind, index));
136
+ for (const piece of pieces.slice(1)) rows.push(row(indent + paint(piece, tone, colour), line.kind, index));
137
+ if (line.more) rows.push(...notes(`… ${plural(line.more, 'more character')} not shown`, width, colour, index, indent));
138
+ if (line.eol === false) rows.push(...notes('\', width, colour, index, indent));
139
+ }
140
+ }
141
+ return rows;
142
+ }
143
+ // The rows of the view, top to bottom: [{ text, kind, file }]. kind is summary, file, hunk,
144
+ // context, add, del, note or blank. file is the index of the row's file, null above the
145
+ // first one and on blank rows. open: a Set of the file indexes that are unfolded. Without
146
+ // it every file is open. A folded file is its header row alone.
147
+ export function changeRows(changes, width, colour = true, { open } = {}) {
148
+ width = Math.max(1, Math.floor(width));
149
+ const rows = summaryRows(changes ?? {}, width, colour);
150
+ let spaced = true;
151
+ for (const [index, file] of (changes?.files ?? []).entries()) {
152
+ const unfolded = !open || open.has(index);
153
+ if (spaced) rows.push(row('', 'blank'));
154
+ rows.push(fileRow(file, index, width, colour, unfolded));
155
+ spaced = unfolded;
156
+ if (!unfolded) continue;
157
+ // The header of a binary file already says so.
158
+ const note = file.binary ? String(file.note ?? '').split(' · ').filter(part => part !== 'binary file').join(' · ') : file.note;
159
+ if (note) rows.push(...notes(note, width, colour, index, ' '));
160
+ rows.push(...lineRows(file, index, width, colour));
161
+ }
162
+ return rows;
163
+ }
164
+ // The label of the view's tab: `Changes 24`, or `Changes` when there are none.
165
+ export function changeSummary(changes) {
166
+ const total = (changes?.files?.length ?? 0) + (changes?.hiddenFiles ?? 0);
167
+ return total ? `Changes ${count(total)}` : 'Changes';
168
+ }