@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
@@ -0,0 +1,165 @@
1
+ import { refuse } from './util.mjs';
2
+ import { LABELS } from './modes.mjs';
3
+ import { BOT_COLORS } from './bots.mjs';
4
+
5
+ export const COMMANDS = Object.freeze([
6
+ ['new', 'Start a new session', '/new'],
7
+ ['schedule', 'Start a session on a schedule', '/schedule'],
8
+ ['sessions', 'Show every session across your projects', '/sessions'],
9
+ ['kanban', 'Show this project’s tasks by column', '/kanban'],
10
+ ['chats', 'Channels, direct messages and threads', '/chats'],
11
+ ['bots', 'Show your bots, keep one in this chat, or stop its schedule here', '/bots [add|remove|run] [name] · /bots schedule [on|off] [name] · /bots color [name] [color|random]'],
12
+ ['projects', 'Show your projects', '/projects'],
13
+ ['usage', 'Show your accounts and their plan usage', '/usage'],
14
+ ['model', 'Show or choose the model of this session', '/model [name|default <name>|add]'],
15
+ ['agent', 'Add a model to this session as an agent, or take one out', '/agent [add <model>|remove <name>]'],
16
+ ['agents', 'Show who is in this session', '/agents'],
17
+ ['permissions', 'What the model may do this session: ask, auto or full, and the folders it may read', '/permissions [ask|auto|full|default <mode>|folders add <path>]'],
18
+ ['budget', 'What this session has spent; set a spending limit or remove it', '/budget [dollars|off]'],
19
+ ['place', 'Where the new session runs: a new worktree, a branch, or here', '/place [worktree|branch <name>|here]'],
20
+ ['mcp', 'Your Claude Code MCP servers, on or off for this project', '/mcp [on|off] [server]'],
21
+ ['changes', 'Show what this session changed, in place of its chat', '/changes'],
22
+ ['chat', 'Go back to this session’s chat', '/chat'],
23
+ ['details', 'Checks, review, and log of this session', '/details'],
24
+ ['publish', 'Review the exact push and draft PR', '/publish'],
25
+ ['stop', 'Stop the running response; again, or with nothing running, cancel a waiting message', '/stop [id]'],
26
+ ['rename', 'Rename this session', '/rename [name]'],
27
+ ['archive', 'Archive this session; a new message brings it back', '/archive'],
28
+ ['archived', 'Show or hide archived sessions in the list', '/archived'],
29
+ ['slots', 'Set how many sessions run at once', '/slots [1-16]'],
30
+ ['peers', 'Show teammates and who is online', '/peers'],
31
+ ['invite', 'Add a teammate to this channel, or make a code for someone new', '/invite [@teammate|team]'],
32
+ ['join', 'Join a team; teammates ask about this project', '/join code'],
33
+ ['share', 'Share this project with your team', '/share [handle]'],
34
+ ['peer', 'Turn peer mode on or off', '/peer [on|off]'],
35
+ ['setup', 'Optional setup for scripted workflows', '/setup'],
36
+ ['help', 'Show commands and examples', '/help'],
37
+ ['quit', 'Close ELU Code', '/quit'],
38
+ ].map(([name, description, usage]) => Object.freeze({ name, description, usage })));
39
+
40
+ // A command needs an argument when its usage names one outside [brackets], such as /join code.
41
+ // A usage with several forms, set apart by " · ", needs one only when every form does.
42
+ export function needsArgument(command) {
43
+ return command.usage.split(' · ').every(form => form.replace(/\[[^\]]*\]/g, '').trim().split(/\s+/).length > 1);
44
+ }
45
+ export function commandMatches(text) {
46
+ if (!/^\/[^\s]*$/.test(text)) return [];
47
+ const prefix = text.slice(1).toLowerCase();
48
+ return COMMANDS.filter(command => command.name.startsWith(prefix));
49
+ }
50
+ // What a command's next word can be, so the command list keeps helping after the name:
51
+ // /model shows the models to choose, /mcp on the servers that are off. ctx holds what only
52
+ // the app knows, read lazily: models() [{name, vendor, note, current}], servers() {all, on},
53
+ // bots() [{name, about, here, scheduled, color}] where here means the bot is in the open
54
+ // chat, scheduled that it has a schedule, so it works by itself, and color is the one it shows in.
55
+ // invitees() {channel, people: [{name, online}]}: the teammates who can join the open
56
+ // channel, or null where no channel is open.
57
+ // A vendor's state in one line: what it does here, then why it cannot run yet, if anything.
58
+ // A vendor that needs its one-time check gets it by itself, on the owner's next message to it.
59
+ export const FIRST_CHECK = 'checked on your next message to it';
60
+ export const vendorText = row => [row.support, !row.available ? 'not installed' : row.support === 'not supported yet' ? '' : row.conformant === false && row.version === null ? FIRST_CHECK : !row.supported ? 'not supported' : !row.signedIn ? 'not signed in' : row.conformant === false ? FIRST_CHECK : 'ready'].filter(Boolean).join(' · ');
61
+ const MODE_TEXT = { ask: 'Asks before outside edits, escapes, and internet.', auto: 'A reviewer model, not you, decides escapes and internet.', full: 'Any file, any command, the internet.' };
62
+ function nextWords(command, done, ctx) {
63
+ const word = (value, description, more = false) => ({ word: value, description, more });
64
+ const models = () => { let rows = []; try { rows = ctx.models?.() ?? []; } catch { /* None listed. */ } return rows.map(row => word(row.name, [row.vendor, row.note, row.current ? 'in use' : ''].filter(Boolean).join(' · '))); };
65
+ const modes = () => Object.entries(MODE_TEXT).map(([name, text]) => word(name, `${LABELS[name]}: ${text}`));
66
+ if (command === 'model') {
67
+ if (!done.length) return [...models(), word('default', 'Set the model new sessions start with', true), word('add', 'Add a model by its name')];
68
+ if (done.length === 1 && done[0] === 'default') return models();
69
+ return [];
70
+ }
71
+ // /agent add offers the models /model offers; /agent remove the bots of this session.
72
+ if (command === 'agent' || command === 'agents') {
73
+ if (!done.length) return [word('add', 'Put a model in this session as an agent: add <model> [what it does] [--name NAME]', true), word('remove', 'Take an agent out of this session', true)];
74
+ if (done.length === 1 && done[0] === 'add') return models().map(row => ({ ...row, description: row.description.replace(/ · in use$|^in use$/, '') }));
75
+ if (done.length === 1 && done[0] === 'remove') return bots(ctx).filter(row => row.here).map(row => word(row.name, row.about));
76
+ return [];
77
+ }
78
+ if (command === 'permissions') {
79
+ if (!done.length) return [...modes(), word('default', 'Set the mode new sessions start with', true), word('folders', 'Let the model read another folder this session', true)];
80
+ if (done.length === 1 && done[0] === 'default') return modes();
81
+ if (done.length === 1 && done[0] === 'folders') return [word('add', 'Add a folder the model may read this session', true)];
82
+ return [];
83
+ }
84
+ if (command === 'place' && !done.length) return [word('worktree', 'A copy of this repo on a new branch'), word('branch', 'This repo on a branch you name', true), word('here', 'This checkout, as it is. Full access only')];
85
+ if (command === 'mcp') {
86
+ if (!done.length) return [word('on', 'Turn a server on for this project', true), word('off', 'Turn a server off for this project', true)];
87
+ if (done.length === 1 && ['on', 'off'].includes(done[0])) { let list = { all: [], on: [] }; try { list = ctx.servers?.() ?? list; } catch { /* None listed. */ } return list.all.filter(name => list.on.includes(name) === (done[0] === 'off')).map(name => word(name, done[0] === 'on' ? 'off now' : 'on now')); }
88
+ return [];
89
+ }
90
+ if (command === 'invite' && !done.length) {
91
+ let open = null; try { open = ctx.invitees?.() ?? null; } catch { /* None listed. */ }
92
+ return [...(open?.people ?? [])].sort((a, b) => Number(b.online) - Number(a.online)).map(row => word(`@${row.name}`, `add to #${open.channel} · ${row.online ? 'online' : 'offline'}`));
93
+ }
94
+ if (command === 'peer' && !done.length) return [word('on', 'Start peer mode'), word('off', 'Stop peer mode')];
95
+ if (command === 'bots') {
96
+ if (!done.length) return [word('add', 'Keep a bot in this chat', true), word('remove', 'Take a bot out of this chat', true), word('schedule', 'Turn a bot\'s schedule off or on in this session', true), word('run', 'Tell a scheduled bot to work now', true), word('color', 'Give a bot a color, or a new random one', true)];
97
+ if (done.length === 1 && ['add', 'remove'].includes(done[0])) return bots(ctx).filter(row => Boolean(row.here) === (done[0] === 'remove')).map(row => word(row.name, row.about));
98
+ if (done.length === 1 && done[0] === 'schedule') return [word('off', 'Stop it running by itself in this session', true), word('on', 'Let it run by itself here again', true)];
99
+ if (done.length === 1 && done[0] === 'color') return bots(ctx).map(row => word(row.name, row.color ? `${row.color} now` : row.about, true));
100
+ if (done.length === 2 && done[0] === 'color') return [word('random', 'A new one, picked for you'), ...BOT_COLORS.map(color => word(color, color === 'orange' ? 'the color of the session\'s model' : ''))];
101
+ // Only a bot of this chat that has a schedule can be run or switched.
102
+ if (done.length === 1 && done[0] === 'run' || done.length === 2 && done[0] === 'schedule' && ['on', 'off'].includes(done[1])) return bots(ctx).filter(row => row.here && row.scheduled).map(row => word(row.name, row.about));
103
+ return [];
104
+ }
105
+ return [];
106
+ }
107
+ // Teammates by @name, online first. teammates() gives [{name, online, path}] without you.
108
+ const people = ctx => { let rows = []; try { rows = ctx.teammates?.() ?? []; } catch { /* None listed. */ } return [...rows].sort((a, b) => Number(b.online) - Number(a.online)); };
109
+ function teammates(ctx) {
110
+ return people(ctx).map(row => ({ word: `@${row.name}`, description: row.online ? `online · ${row.path || 'direct'}` : 'offline', more: true }));
111
+ }
112
+ const bots = ctx => { try { return ctx.bots?.() ?? []; } catch { return []; } };
113
+ const startsWith = (word, partial) => word.startsWith(partial) || (word.startsWith('@') && word.slice(1).startsWith(partial));
114
+ export function argumentMatches(text, ctx = {}) {
115
+ const match = /^\/(\S+)\s+([^\n]*)$/.exec(text); if (!match) return [];
116
+ const command = match[1].toLowerCase(), words = match[2].split(/\s+/), partial = words.pop().toLowerCase(), done = words;
117
+ return nextWords(command, done, ctx).filter(row => startsWith(row.word.toLowerCase(), partial)).map(row => ({ ...row, value: `/${command} ${[...done, row.word].join(' ')}` }));
118
+ }
119
+ // Typing @ in Chats > Internal names a teammate: @dev offers @devbox.
120
+ export function mentionMatches(text, ctx = {}) {
121
+ const match = /^@([^\s@]*)$/.exec(text); if (!match) return [];
122
+ return teammates(ctx).filter(row => startsWith(row.word, `@${match[1].toLowerCase()}`)).map(row => ({ ...row, value: row.word }));
123
+ }
124
+ // Typing @ in a session names a saved bot: @se offers @sec.
125
+ export function botMatches(text, ctx = {}) {
126
+ const match = /^@([^\s@]*)$/.exec(text); if (!match) return [];
127
+ return bots(ctx).filter(row => row.name.startsWith(match[1].toLowerCase())).map(row => ({ word: `@${row.name}`, description: row.about, more: true, value: `@${row.name}` }));
128
+ }
129
+ // Typing @ in a session offers the saved bots, the session's model, then teammates; each row says which it is.
130
+ // @name is the bot's when a teammate has the same name, so that teammate is not offered
131
+ // here: Chats reaches them.
132
+ export function sessionMentions(text, ctx = {}) {
133
+ const match = /^@([^\s@]*)$/.exec(text); if (!match) return [];
134
+ const partial = match[1].toLowerCase(), taken = new Set(bots(ctx).map(row => row.name));
135
+ // @model is the session's own model: a message goes back to it after the owner wrote to a bot.
136
+ const model = ctx.model?.() ?? null, own = model && 'model'.startsWith(partial) ? [{ word: '@model', description: `${model} · the model of this session`, more: true, value: '@model' }] : [];
137
+ if (own.length) taken.add('model');
138
+ return [...botMatches(text, ctx), ...own, ...people(ctx).filter(row => !taken.has(row.name) && row.name.toLowerCase().startsWith(partial)).map(row => ({ word: `@${row.name}`, description: `teammate · ${row.online ? 'online' : 'offline'}`, more: true, value: `@${row.name}` }))];
139
+ }
140
+ export function commandWords(text) {
141
+ const words = []; let word = '', quote = null, escaped = false, started = false;
142
+ for (const ch of text) {
143
+ if (escaped) { word += ch; escaped = false; started = true; }
144
+ else if (ch === '\\' && quote !== "'") { escaped = true; started = true; }
145
+ else if (quote) { if (ch === quote) quote = null; else word += ch; }
146
+ else if (ch === '"' || ch === "'") { quote = ch; started = true; }
147
+ else if (/\s/.test(ch)) { if (started) { words.push(word); word = ''; started = false; } }
148
+ else { word += ch; started = true; }
149
+ }
150
+ if (quote || escaped) refuse('Close the quote or finish the escaped character.');
151
+ if (started) words.push(word); return words;
152
+ }
153
+ // Free text (what an agent is for) may hold apostrophes, so it is kept as typed.
154
+ const FREE = ['agent', 'agents'];
155
+ // Older names keep working.
156
+ const ALIASES = { '/tasks': '/sessions', '/close': '/archive' };
157
+ export function parseCommand(text) {
158
+ if (!text.trimStart().startsWith('/')) return null;
159
+ const raw = text.trim(), first = raw.split(/\s/, 1)[0], free = COMMANDS.find(command => FREE.includes(command.name) && `/${command.name}` === first.toLowerCase());
160
+ if (free) { const rest = raw.slice(first.length).trim(); return { ...free, args: rest ? rest.split(/\s+/) : [], text: rest }; }
161
+ const [name, ...args] = commandWords(raw);
162
+ const typed = name.toLowerCase(), command = COMMANDS.find(command => `/${command.name}` === (ALIASES[typed] ?? typed));
163
+ if (!command) refuse(`Unknown command: ${name}. Type / to see commands.`);
164
+ return { ...command, args };
165
+ }
@@ -0,0 +1,37 @@
1
+ import { ink, wrapText } from './terminal-visuals.mjs';
2
+
3
+ // How a session's Task (lib/plan.mjs) reads on screen: the card in its chat and the line
4
+ // in its status row. Every field is the model's text, so each goes through ink() as
5
+ // untrusted text: markup shows as typed.
6
+
7
+ const WAITS = 'waits for your approval';
8
+ const MARK = { todo: '[ ]', doing: '[~]', done: '[x]' };
9
+ const subtasksDone = plan => plan.subtasks.filter(row => row.state === 'done').length;
10
+ // approved · 2 of 5 subtasks done; or where the Task stands otherwise.
11
+ export function taskState(plan) {
12
+ if (plan.state === 'proposed') return WAITS;
13
+ if (plan.state === 'rejected') return 'rejected';
14
+ if (plan.state === 'done') return `done · ${plan.met.length} of ${plan.objectives.length} objectives reported met`;
15
+ const steps = plan.subtasks.length ? `${subtasksDone(plan)} of ${plan.subtasks.length} subtasks done` : plan.edit && plan.edit.started !== true ? 'starting' : 'no subtasks yet';
16
+ return `approved · ${steps}`;
17
+ }
18
+ const TONE = { proposed: 'orange', approved: 'blue', done: 'green', rejected: 'muted' };
19
+ // approval: an approval of an action waits in the same session; y and n answer that one first.
20
+ export function taskCard(plan, { width, colour = true, approval = false } = {}) {
21
+ const row = (text, tone, bold = false, indent = '') => wrapText(text, Math.max(8, width - indent.length)).map((line, index) => ink(`${index ? ' '.repeat(indent.length) : indent}${line}`, tone, colour, bold)).join('\n');
22
+ if (plan.state === 'rejected') return row(`Task rejected: ${plan.title}`, 'muted');
23
+ const who = [plan.by && plan.by !== 'lead' ? `proposed by ${plan.by}` : '', plan.startedBy === 'schedule' ? 'started by schedule' : plan.startedBy === 'handoff' ? 'started by a handoff' : ''].filter(Boolean);
24
+ const lines = [
25
+ row(`Task · ${taskState(plan)}${plan.state === 'proposed' && plan.revision > 1 ? ` · revision ${plan.revision}` : ''}`, TONE[plan.state], true),
26
+ row(plan.title, 'text', true),
27
+ row([`${plan.priority} priority`, `${plan.severity} severity`, ...who].join(' · '), 'muted'),
28
+ ];
29
+ if (plan.state === 'proposed') lines.push(...plan.description.split('\n').map(text => text ? row(text, 'text') : ''));
30
+ lines.push(row(`Goal: ${plan.goal}`, 'text'), ink('Objectives', 'muted', colour));
31
+ plan.objectives.forEach((text, index) => { const met = plan.met.includes(index + 1); lines.push(row(`${index + 1}. ${text}`, met ? 'green' : 'text', false, plan.state === 'proposed' ? ' ' : met ? ' ✓ ' : ' · ')); });
32
+ if (plan.subtasks.length) { lines.push(ink('Subtasks', 'muted', colour)); for (const step of plan.subtasks) lines.push(row(step.text, step.state === 'done' ? 'muted' : step.state === 'doing' ? 'blue' : 'text', false, ` ${MARK[step.state] ?? MARK.todo} `)); }
33
+ if (plan.state === 'proposed') lines.push(ink(approval ? 'y and n answer the approval below first. Then: Esc, y approve · n reject' : 'Esc, then y approve · n reject · or type what to change', 'muted', colour));
34
+ return lines.join('\n');
35
+ }
36
+ // The status line of a session whose Task waits.
37
+ export const taskNeedsYou = plan => `? Needs you · Esc, then y approve · n reject · Task: ${plan.title}`;
@@ -0,0 +1,268 @@
1
+ import blessed from 'blessed';
2
+ import { redact } from './publish.mjs';
3
+ import { sanitize } from './peer/peerdata.mjs';
4
+
5
+ // Explicit xterm indices avoid the library's hex matcher reducing accents to ANSI basics.
6
+ export const PALETTE = Object.freeze({ background: 233, panel: 234, raised: 235, highlight: 237, border: 238, dim: 241, text: 255, muted: 247, orange: 209, blue: 75, green: 79, red: 203, violet: 141, gold: 221, pink: 212, lime: 149, aqua: 80, rose: 174 });
7
+
8
+ // Escape terminal controls and Blessed markup before applying our own styles. trusted: text
9
+ // from a trusted teammate shows as sent; everything else is masked.
10
+ export const displayText = (value, { trusted = false } = {}) => (trusted ? String(value ?? '').slice(0, 512000) : redact(String(value ?? ''), 512000)).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/[{}]/g, ch => ch === '{' ? '{open}' : '{close}');
11
+ export function ink(value, tone, colour = true, bold = false, { trusted = false } = {}) {
12
+ const text = displayText(value, { trusted }), fg = colour && tone ? `{${PALETTE[tone]}-fg}` : '', end = fg ? '{/}' : '';
13
+ return `${fg}${bold ? '{bold}' : ''}${text}${bold ? '{/bold}' : ''}${end}`;
14
+ }
15
+ export function relativeTime(value, now = Date.now()) {
16
+ if (!Number.isFinite(Date.parse(value))) return 'not reported';
17
+ const minutes = Math.max(0, Math.floor((now - Date.parse(value)) / 60000));
18
+ return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes}m ago` : minutes < 1440 ? `${Math.floor(minutes / 60)}h ago` : `${Math.floor(minutes / 1440)}d ago`;
19
+ }
20
+ // Strips our tags to measure what the terminal will show.
21
+ export const visible = value => String(value ?? '').replace(/\{(open|close)\}/g, '\x00').replace(/\{\/?[a-z0-9-]*\}/g, '').replace(/\x00/g, '.');
22
+ // The key bar: each key in bold, its action muted; pairs that do not fit are dropped from the end.
23
+ export function keyHints(pairs, width, colour) {
24
+ const shown = [];
25
+ for (const [key, label] of pairs) {
26
+ const text = `${ink(key, 'text', colour, true)} ${ink(label, 'muted', colour)}`;
27
+ if (visible([...shown, text].join(' ')).length > width) break;
28
+ shown.push(text);
29
+ }
30
+ return shown.join(' ');
31
+ }
32
+
33
+ // Row 0 holds the brand and the six tabs, row 1 the view's buttons and title. The message
34
+ // box shows four lines of text above a one-row key bar, with a status row over it.
35
+ export function terminalLayout(width, height) {
36
+ const askHeight = 6, top = 2, keysTop = height - 1, askTop = keysTop - askHeight, statusTop = askTop - 1;
37
+ return { width, height, wide: width >= 120 && height >= 28, top, keysTop, askTop, askHeight, statusTop };
38
+ }
39
+
40
+ // Names and text from another machine: one line, no escapes, bidi or zero-width tricks.
41
+ // A trusted teammate's text shows as sent; anyone else's is masked too.
42
+ export const remote = (value, max = 400, { trusted = false } = {}) => (trusted ? sanitize(value, max) : redact(sanitize(value, max), max)).replace(/\s+/g, ' ').trim();
43
+ export const shortKey = key => String(key ?? '').replace(/[^0-9a-f]/gi, '').slice(0, 8);
44
+ export const peerName = (name, key) => `@${remote(name, 40).replace(/[\s@]+/g, '') || shortKey(key) || 'peer'}`;
45
+ // A person keeps one colour wherever Chats names them. You are blue, as in a session. A
46
+ // teammate's colour is their place among everyone on your teams, ordered by key, so two
47
+ // machines on one team show the same person in the same colour.
48
+ const PEOPLE = ['violet', 'gold', 'pink', 'lime', 'aqua', 'rose'];
49
+ export function peopleTones(status) {
50
+ const keys = [...new Set((Array.isArray(status?.teams) ? status.teams : []).flatMap(team => Array.isArray(team?.members) ? team.members : []).map(member => member?.key).filter(key => typeof key === 'string' && key))].sort();
51
+ return new Map(keys.map((key, at) => [key, key === status?.key ? 'blue' : PEOPLE[at % PEOPLE.length]]));
52
+ }
53
+ // Who a thread is with: a teammate, or the whole team for a team ask nobody took yet.
54
+ export const threadWho = item => item?.team && !item.peer ? 'your team' : peerName(item?.name, item?.peer);
55
+ // Cuts at a word boundary; only a single word longer than the width is cut inside.
56
+ export function fitWords(value, width) {
57
+ const text = String(value ?? '').replace(/\s/g, ' ').trimEnd();
58
+ if (text.length <= width) return text;
59
+ if (width < 2) return text.slice(0, Math.max(0, width));
60
+ const cut = text.slice(0, width - 1), space = cut.lastIndexOf(' ');
61
+ return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}…`;
62
+ }
63
+ export const clockTime = value => Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) : '--:--';
64
+ const rows = value => Array.isArray(value) ? value.filter(row => row && typeof row === 'object') : [];
65
+ const THREAD = { answering: ['←', 'asks you', 'text'], replied: ['→', 'replied', 'green'], failed: ['←', 'asks you', 'muted'], declined: ['←', 'asks you', 'muted'], incoming: ['←', 'asks you', 'orange'], held: ['→', 'reply ready', 'orange'], draft: ['→', 'draft to send', 'orange'], asked: ['→', 'asked', 'text'], answer: ['←', 'answered', 'green'], expired: ['→', 'expired', 'muted'], rejected: ['→', 'rejected', 'muted'] };
66
+ const NEEDS_OWNER = ['incoming', 'held', 'draft'];
67
+ // A bot's name, and the kinds of thread I started (the rest are a teammate's requests to me).
68
+ const BOT = /^[a-z][a-z0-9-]{0,29}$/, MINE = ['asked', 'answer', 'expired', 'rejected'];
69
+ // [message, reply]: each [text, tone]. A teammate's question shows my side under it; my
70
+ // question shows theirs. The question is item.question when item.text holds the reply.
71
+ function threadRows(item, who) {
72
+ // A bot is named beside the teammate whose machine runs it.
73
+ const bot = BOT.test(item.bot ?? '') ? `@${item.bot}` : null;
74
+ const trusted = item.trusted === true, text = remote(item.text, 400, { trusted }), question = remote(item.question, 400, { trusted }) || text, mine = !['incoming', 'answering', 'held', 'replied', 'failed'].includes(item.kind);
75
+ // Pictures come before the message's words, so a long message cannot push them off the
76
+ // row: how many on one I sent, how many and how large on one I got. The opened message
77
+ // lists each name and size.
78
+ const pictures = item.pictures?.list?.length ? item.pictures : null;
79
+ const sent = !pictures ? '' : pictures.unsent ? `, ${remote(pictures.count, 20)} not sent` : ` with ${remote(pictures.count, 20)}`, got = pictures ? ` with ${remote(pictures.count, 20)} (${remote(pictures.size, 12)})` : '';
80
+ const message = item.kind === 'draft' ? [`→ draft to send to ${who} · ${question}`, 'orange'] : mine ? [`→ you asked ${bot ? `${bot} (${who})` : who}${sent} · ${question}`, 'text']
81
+ : [`← ${who} ${item.request === 'handoff' ? 'sends you a task' : item.team ? 'asks the team' : bot ? `asks your bot ${bot}` : 'asks you'}${got} · ${question}`, item.kind === 'incoming' ? 'orange' : 'text'];
82
+ const reply = {
83
+ answering: ['your model is answering…', 'muted'], failed: ['your model could not answer', 'muted'], declined: ['you declined', 'muted'],
84
+ held: [`reply ready${item.blocked ? ', held: possible secret' : ''} · ${text}`, 'orange'], replied: [`you replied · ${text}`, 'green'],
85
+ asked: pictures?.unsent ? [`${remote(item.state, 30) || 'sent'} · ${remote(pictures.unsent, 300)}`, 'orange'] : [remote(item.state, 30) || 'sent', 'muted'], answer: [`${bot ?? who} answered · ${text}`, 'green'],
86
+ expired: ['expired', 'muted'], rejected: [`rejected · ${text}`, 'muted'],
87
+ }[item.kind] ?? null;
88
+ return [message, reply];
89
+ }
90
+ // Lines for the Chats panes: teammates, their live agents and threads. Each row keeps the value its keys act on.
91
+ // The Chats bar, like a chat app's: Channels (#general is everyone on a team, then the
92
+ // channels this machine is in), then Direct messages (each teammate). A row's target is
93
+ // what the message box writes to and which threads show beside it.
94
+ export const roomTarget = (team, channel) => `room\0${team}\0${channel ?? ''}`, dmTarget = key => `dm\0${key}`;
95
+ // A bot's row: mine (no peer), or a teammate's shared bot.
96
+ export const botTarget = (peer, name) => `bot\0${peer ?? ''}\0${name}`;
97
+ // The bar row a thread belongs to: its channel, #general for a team ask, else the teammate.
98
+ export function threadTarget(item, status) {
99
+ const teams = rows(status?.teams), holding = key => teams.find(team => rows(team.members).some(m => m.key === key)) ?? teams[0];
100
+ // What I asked a teammate's bot lists under that bot while they still share it. Their requests to my bots list under them.
101
+ if (item?.bot && item.peer && MINE.includes(item.kind)) {
102
+ const shared = teams.some(team => rows(team.members).some(m => m.key === item.peer && rows(m.bots).some(bot => bot.name === item.bot)));
103
+ return shared ? botTarget(item.peer, item.bot) : dmTarget(item.peer);
104
+ }
105
+ if (item?.room || item?.team) {
106
+ const team = teams.find(row => row.id === item.room?.team) ?? holding(item.peer);
107
+ if (!team) return null;
108
+ return roomTarget(team.id, item.room?.channel && rows(team.channels).some(row => row.id === item.room.channel) ? item.room.channel : null);
109
+ }
110
+ if (item?.peer) return dmTarget(item.peer);
111
+ // A draft names its teammate by name only.
112
+ const named = teams.flatMap(team => rows(team.members)).find(m => m.key !== status?.key && peerName(m.name, m.key) === peerName(item?.name));
113
+ return named ? dmTarget(named.key) : teams[0] ? roomTarget(teams[0].id, null) : null;
114
+ }
115
+ export function chatBar({ status, items, trusted = [], bots = [] }, width, colour) {
116
+ const line = (text, tone, bold) => ink(fitWords(text, width), tone, colour, bold);
117
+ const teams = rows(status?.teams), trust = new Set(Array.isArray(trusted) ? trusted : []), bar = [], people = new Map(), waiting = new Map(), toneOf = peopleTones(status);
118
+ if (!teams.length) return [{ line: line('No team yet. elu team create NAME', 'muted'), value: null }];
119
+ // Threads that wait for the owner are counted on their row.
120
+ for (const item of rows(items)) if (NEEDS_OWNER.includes(item.kind)) { const target = threadTarget(item, status); waiting.set(target, (waiting.get(target) ?? 0) + 1); }
121
+ // Who is in a channel, by name: me first, then whoever is online, then the rest. Only people still on the team count.
122
+ const cast = (team, keys) => {
123
+ const here = rows(team.members).filter(m => keys.includes(m.key)), mates = here.filter(m => m.key !== status.key);
124
+ return [...(mates.length < here.length ? [{ name: 'you', tone: 'blue' }] : []), ...[true, false].flatMap(on => mates.filter(m => Boolean(m.online) === on)).map(m => ({ name: peerName(m.name, m.key), tone: toneOf.get(m.key) ?? 'text' }))];
125
+ };
126
+ // A channel's row: its name, how many people are in it (muted), then the threads that wait for me. The name gives way on a narrow bar.
127
+ const room = (team, channel, name, members) => {
128
+ const target = roomTarget(team.id, channel), count = waiting.get(target), suffix = teams.length > 1 ? ` · ${remote(team.name, 30) || 'team'}` : '', who = cast(team, members), people = who.map(row => row.name), tones = who.map(row => row.tone);
129
+ const size = ` ${people.length}`, wait = count ? ` ${count}` : '', head = fitWords(`# ${name}${suffix}`, Math.max(0, width - size.length - wait.length));
130
+ bar.push({ line: `${ink(head, count ? 'orange' : 'text', colour, Boolean(count))}${ink(size, 'muted', colour)}${wait ? ink(wait, 'orange', colour, true) : ''}`, value: { type: 'room', target, team: team.id, channel, name, members, people, tones, waiting: count ?? 0 }, key: target });
131
+ };
132
+ bar.push({ line: line('Channels', 'muted', true), value: null });
133
+ for (const team of teams) {
134
+ room(team, null, 'general', rows(team.members).map(m => m.key));
135
+ for (const channel of rows(team.channels)) room(team, channel.id, remote(channel.name, 30), Array.isArray(channel.members) ? channel.members : []);
136
+ }
137
+ bar.push({ line: line('+ Add channel', 'muted'), value: { type: 'add', what: 'channel' }, key: 'add\0channel' }, { line: '', value: null }, { line: line('Direct messages', 'muted', true), value: null });
138
+ for (const team of teams) for (const member of rows(team.members)) if (member.key !== status.key && !people.has(member.key)) people.set(member.key, { member, team });
139
+ for (const { member, team } of people.values()) {
140
+ const target = dmTarget(member.key), count = waiting.get(target), state = member.online ? member.path ?? 'online' : 'offline', mark = trust.has(member.key) ? ' · trusted' : '';
141
+ // The name is in the teammate's own colour; the dot and the state say whether they are here.
142
+ const dot = `${member.online ? '●' : '○'} `, name = peerName(member.name, member.key).padEnd(14), tail = ` ${state}${mark}${count ? ` ${count}` : ''}`, tone = count ? 'orange' : member.online ? 'green' : 'muted';
143
+ const drawn = `${dot}${name}${tail}`.length <= width ? `${ink(dot, tone, colour, Boolean(count))}${ink(name, toneOf.get(member.key) ?? 'text', colour, Boolean(count))}${ink(tail, tone, colour, Boolean(count))}` : line(`${dot}${name}${tail}`, tone, Boolean(count));
144
+ bar.push({ line: drawn, value: { type: 'member', target, key: member.key, name: member.name, self: false, member, team, trusted: trust.has(member.key), waiting: count ?? 0 }, key: target });
145
+ }
146
+ // Bots: mine, then each teammate's shared ones with the name of the machine that runs them.
147
+ const bot = (peer, row, member) => {
148
+ if (!BOT.test(row.name ?? '')) return;
149
+ const target = botTarget(peer, row.name), from = member ? ` · ${remote(member.box, 32) || peerName(member.name, member.key)}` : '';
150
+ // My own bot shows in its colour (tone, set by the terminal); a teammate's is plain.
151
+ bar.push({ line: line(`◆ @${row.name}${from}`, !member ? PALETTE[row.tone] ? row.tone : 'text' : member.online ? 'text' : 'muted'), value: { type: 'bot', target, name: row.name, peer, owner: member?.name ?? null, box: member?.box ?? null, about: remote(row.about, 120), online: member ? member.online === true : true }, key: target });
152
+ };
153
+ for (const row of rows(bots)) bot(null, row, null);
154
+ for (const { member } of people.values()) for (const row of rows(member.bots).slice(0, 20)) bot(member.key, row, member);
155
+ bar.push({ line: line('+ Add teammate', 'muted'), value: { type: 'add', what: 'teammate' }, key: 'add\0teammate' });
156
+ return bar;
157
+ }
158
+ // The line over a channel's threads: who is in it, from its row in the bar, each name in
159
+ // that person's colour, then how someone gets in while there is room for it. Names that do
160
+ // not fit are counted as "+N more".
161
+ export function membersLine(room, width, colour = true) {
162
+ const names = Array.isArray(room?.people) ? room.people : [], tones = Array.isArray(room?.tones) ? room.tones : [];
163
+ const head = `In #${room?.name} · `, more = shown => shown < names.length ? `, +${names.length - shown} more` : '', text = shown => `${head}${names.slice(0, shown).join(', ') || 'nobody'}${more(shown)}`;
164
+ let shown = names.length;
165
+ while (shown > 1 && text(shown).length > width) shown--;
166
+ if (text(shown).length > width) return ink(fitWords(text(shown), width), 'muted', colour);
167
+ const how = room?.channel ? 'm changes who is in it' : '/invite adds a teammate', tail = text(shown).length + how.length + 3 <= width ? ` · ${how}` : '';
168
+ const listed = names.slice(0, shown).map((name, at) => ink(name, tones[at] ?? 'text', colour)).join(ink(', ', 'muted', colour)) || ink('nobody', 'muted', colour);
169
+ return `${ink(head, 'muted', colour)}${listed}${more(shown) || tail ? ink(`${more(shown)}${tail}`, 'muted', colour) : ''}`;
170
+ }
171
+ // trusted: the keys of teammates whose paths pass through. target: the bar
172
+ // row whose threads show (all of them without one); where: its name, for the empty pane.
173
+ export function teamPanes({ status, agents, items, trusted = [], bots = [] }, widths, colour, target = null, where = null) {
174
+ const line = (index, text, tone, bold, trusted = false) => ink(fitWords(text, widths[index]), tone, colour, bold, { trusted });
175
+ const team = chatBar({ status, items, trusted, bots }, widths[0], colour);
176
+ const work = [];
177
+ for (const mate of rows(agents)) {
178
+ const who = peerName(mate.name, mate.peer), live = rows(mate.rows);
179
+ if (!live.length) work.push({ line: line(1, `${mate.online ? '●' : '○'} ${who} no live sessions`, 'muted'), value: { type: 'agent', name: mate.name, peer: mate.peer, mate }, key: `${mate.peer}\0` });
180
+ for (const row of live) {
181
+ const overlap = Array.isArray(row.overlap) ? row.overlap.filter(file => typeof file === 'string') : [];
182
+ const parts = [`${overlap.length ? '⚠' : '·'} ${who}`, remote(row.handle, 40), remote(row.label, 30)].filter(Boolean).join(' ');
183
+ const text = [parts, overlap.length ? `overlap ${remote(overlap[0], 120)}${overlap.length > 1 ? ` +${overlap.length - 1}` : ''}` : '', remote(row.now, 80)].filter(Boolean).join(' · ');
184
+ work.push({ line: line(1, text, overlap.length ? 'orange' : 'text', Boolean(overlap.length)), value: { type: 'agent', name: mate.name, peer: mate.peer, mate, row }, key: `${mate.peer}\0${row.handle}\0${row.taskId}` });
185
+ }
186
+ }
187
+ if (!work.length) work.push({ line: line(1, 'No teammates’ sessions are live.', 'muted'), value: null });
188
+ const toneOf = peopleTones(status);
189
+ // A thread row: up to its first " · " it says who did what, and there a teammate's name is
190
+ // in their colour and "you" in yours. The rest keeps the row's own tone.
191
+ const said = (text, tone, bold, trusted, who, whoTone) => {
192
+ const fitted = fitWords(text, widths[2]), at = fitted.indexOf(' · '), start = at < 0 ? fitted : fitted.slice(0, at), rest = at < 0 ? '' : fitted.slice(at);
193
+ const names = [whoTone && who ? who.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : null, '\\byou\\b'].filter(Boolean).join('|');
194
+ return start.split(new RegExp(`(${names})`)).filter(Boolean).map(part => ink(part, whoTone && part === who ? whoTone : part === 'you' ? 'blue' : tone, colour, bold, { trusted })).join('') + (rest ? ink(rest, tone, colour, bold, { trusted }) : '');
195
+ };
196
+ // Like a chat: each message on its own row, the reply (or what it waits on) indented under it.
197
+ const threads = rows(items).filter(item => THREAD[item.kind] && (!target || threadTarget(item, status) === target)).sort((a, b) => (Date.parse(b.at) || 0) - (Date.parse(a.at) || 0)).flatMap(item => {
198
+ const value = { type: 'thread', ...item }, who = threadWho(item), [message, reply] = threadRows(item, who), trusted = item.trusted === true, whoTone = toneOf.get(item.peer) ?? null;
199
+ return [
200
+ { line: said(message[0], message[1], NEEDS_OWNER.includes(item.kind), trusted, who, whoTone), value, key: String(item.id) },
201
+ ...(reply ? [{ line: said(` ↳ ${reply[0]}`, reply[1], item.kind === 'held', trusted, who, whoTone), value, key: `${item.id}\0reply` }] : []),
202
+ ];
203
+ });
204
+ if (!threads.length) {
205
+ // An empty channel also says who is in it and how that changes. Sentences share a row while they fit.
206
+ const open = team.find(row => row.value?.target === target)?.value, room = open?.type === 'room' ? open : null;
207
+ const says = !where ? ['No threads yet. Type a question for your team, or @name for one teammate.'] : [`Nothing in ${where} yet.`, ...(
208
+ !room ? ['Type below to write to it.']
209
+ : room.channel ? ['Type below to write to it.', 'm changes who is in it.']
210
+ : room.people.length < 2 ? ['Nobody else is on the team yet.', '+ Add teammate invites someone.']
211
+ : ['Everyone on the team is in it.', 'Type below to write to it.'])];
212
+ const fitted = says.reduce((out, text) => out.length && `${out.at(-1)} ${text}`.length <= widths[2] ? [...out.slice(0, -1), `${out.at(-1)} ${text}`] : [...out, text], []);
213
+ threads.push(...fitted.map(text => ({ line: line(2, text, 'muted'), value: null })));
214
+ }
215
+ return [team, work, threads];
216
+ }
217
+ // "2 online" counts each teammate once across teams, never this machine.
218
+ export function peerSummary(on, status) {
219
+ if (!on) return 'peers off';
220
+ if (status && status.running === false) return 'peers stopped';
221
+ const online = new Set(rows(status?.teams).flatMap(team => rows(team.members)).filter(member => member.online && member.key !== status?.key).map(member => member.key));
222
+ return `${online.size} online`;
223
+ }
224
+
225
+ // Chat messages. Text is wrapped here, not by the box, so the speaker's bar runs down every row.
226
+ const columns = value => blessed.unicode.strWidth(value);
227
+ const escapeTags = value => value.replace(/[{}]/g, ch => ch === '{' ? '{open}' : '{close}');
228
+ // Word wrap to width columns. List items (-, *, 1.) keep their indent on the rows that follow.
229
+ export function wrapText(text, width) {
230
+ width = Math.max(8, width);
231
+ const out = [];
232
+ for (const source of String(text ?? '').split('\n')) {
233
+ const line = source.replace(/\s+$/, '');
234
+ if (!line) { out.push(''); continue; }
235
+ const marker = /^\s*(?:[-*•]|\d+[.)])\s+/.exec(line)?.[0] ?? /^\s*/.exec(line)[0];
236
+ const indent = ' '.repeat(Math.min(columns(marker), Math.floor(width / 2)));
237
+ let rest = line, first = true;
238
+ while (rest) {
239
+ const lead = first ? '' : indent, room = width - columns(lead);
240
+ if (columns(rest) <= room) { out.push(lead + rest); break; }
241
+ let piece = '', used = 0;
242
+ for (const ch of rest) { const size = columns(ch); if (used + size > room) break; piece += ch; used += size; }
243
+ if (!piece) piece = Array.from(rest)[0];
244
+ const space = piece.lastIndexOf(' ');
245
+ if (space > 0 && rest[piece.length] !== ' ') piece = piece.slice(0, space);
246
+ out.push(lead + piece.replace(/\s+$/, ''));
247
+ rest = rest.slice(piece.length).replace(/^\s+/, ''); first = false;
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+ // A header (speaker and time), then the text behind a bar in the speaker's colour. Shaded
253
+ // messages (yours) sit on a band the width of the pane.
254
+ export function chatBlock({ title, time = '', text, tone, shaded = false, width, colour = true }) {
255
+ const header = ink(title, tone, colour, true) + (time ? ` ${ink(time, 'muted', colour)}` : '');
256
+ const room = Math.max(10, width), rows = wrapText(text, room - 2);
257
+ const band = shaded && colour ? `{${PALETTE.raised}-bg}` : '', bar = colour ? `{${PALETTE[tone]}-fg}┃{/}` : '┃';
258
+ const body = rows.map(row => {
259
+ const fill = band ? ' '.repeat(Math.max(0, room - 2 - columns(row))) : '';
260
+ return `${band}${bar}${band} ${escapeTags(row)}${fill}${band ? '{/}' : ''}`;
261
+ });
262
+ return [header, ...body].join('\n');
263
+ }
264
+ export function messageTime(value, now = new Date()) {
265
+ const at = new Date(value); if (!Number.isFinite(at.getTime())) return '';
266
+ const clock = at.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
267
+ return at.toDateString() === now.toDateString() ? clock : `${at.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} ${clock}`;
268
+ }