@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.
- package/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +327 -2
- package/SECURITY.md +333 -0
- package/THIRD_PARTY_NOTICES.md +62 -0
- package/bin/elu-code.mjs +209 -0
- package/bin/elu.cjs +10 -0
- package/bin/start.cjs +6 -0
- package/docs/peer-network.md +257 -0
- package/docs/permissions.md +285 -0
- package/docs/vendors/antigravity.md +131 -0
- package/docs/vendors/claude.md +124 -0
- package/docs/vendors/codex.md +64 -0
- package/docs/vendors/grok.md +151 -0
- package/lib/acp-client.mjs +100 -0
- package/lib/adapters/agy.mjs +283 -0
- package/lib/adapters/claude.mjs +311 -0
- package/lib/adapters/codex.mjs +137 -0
- package/lib/adapters/grok.mjs +384 -0
- package/lib/adapters/index.mjs +13 -0
- package/lib/agents.mjs +77 -0
- package/lib/agy-hook.mjs +53 -0
- package/lib/approval-server.mjs +85 -0
- package/lib/approvals.mjs +128 -0
- package/lib/awake.mjs +45 -0
- package/lib/board.mjs +261 -0
- package/lib/bot-models.mjs +71 -0
- package/lib/bot-schedules.mjs +291 -0
- package/lib/bots.mjs +361 -0
- package/lib/changes.mjs +168 -0
- package/lib/checks.mjs +102 -0
- package/lib/conductor.mjs +337 -0
- package/lib/config.mjs +147 -0
- package/lib/conformance.mjs +109 -0
- package/lib/default-bots.mjs +150 -0
- package/lib/detect.mjs +108 -0
- package/lib/diff.mjs +217 -0
- package/lib/editor.mjs +165 -0
- package/lib/format.mjs +51 -0
- package/lib/git.mjs +173 -0
- package/lib/init.mjs +129 -0
- package/lib/install.mjs +130 -0
- package/lib/kanban.mjs +180 -0
- package/lib/mcp.mjs +79 -0
- package/lib/memory.mjs +35 -0
- package/lib/models.mjs +144 -0
- package/lib/modes.mjs +95 -0
- package/lib/never-list.mjs +110 -0
- package/lib/never-shims.mjs +311 -0
- package/lib/paste.mjs +56 -0
- package/lib/peer/a2a-http.mjs +166 -0
- package/lib/peer/a2a.mjs +357 -0
- package/lib/peer/asks.mjs +329 -0
- package/lib/peer/canonical.mjs +72 -0
- package/lib/peer/cli.mjs +154 -0
- package/lib/peer/daemon.mjs +525 -0
- package/lib/peer/identity.mjs +81 -0
- package/lib/peer/index.mjs +291 -0
- package/lib/peer/intake.mjs +207 -0
- package/lib/peer/invite.mjs +76 -0
- package/lib/peer/paths.mjs +7 -0
- package/lib/peer/peerdata.mjs +21 -0
- package/lib/peer/pictures.mjs +295 -0
- package/lib/peer/plans.mjs +182 -0
- package/lib/peer/relay.mjs +31 -0
- package/lib/peer/replies.mjs +128 -0
- package/lib/peer/roster.mjs +256 -0
- package/lib/peer/rpc.mjs +88 -0
- package/lib/peer/secrets.mjs +88 -0
- package/lib/peer/settings.mjs +45 -0
- package/lib/peer/shares.mjs +120 -0
- package/lib/peer/status.mjs +84 -0
- package/lib/peer/taskstore.mjs +108 -0
- package/lib/peer/transport.mjs +120 -0
- package/lib/pictures.mjs +104 -0
- package/lib/plan.mjs +238 -0
- package/lib/policy.mjs +98 -0
- package/lib/process.mjs +92 -0
- package/lib/project-settings.mjs +55 -0
- package/lib/project-trust.mjs +230 -0
- package/lib/projects.mjs +668 -0
- package/lib/publish.mjs +105 -0
- package/lib/runner.mjs +874 -0
- package/lib/sandbox.mjs +102 -0
- package/lib/schedules.mjs +270 -0
- package/lib/selection.mjs +202 -0
- package/lib/shell.mjs +101 -0
- package/lib/state.mjs +247 -0
- package/lib/terminal-bots.mjs +239 -0
- package/lib/terminal-commands.mjs +165 -0
- package/lib/terminal-plan.mjs +37 -0
- package/lib/terminal-visuals.mjs +268 -0
- package/lib/terminal.mjs +3159 -0
- package/lib/unit-file.mjs +17 -0
- package/lib/usage.mjs +249 -0
- package/lib/util.mjs +86 -0
- package/lib/vendor-check.mjs +96 -0
- package/lib/vendors.mjs +191 -0
- package/lib/workspace.mjs +168 -0
- package/lib/yaml.mjs +624 -0
- package/npm-shrinkwrap.json +827 -0
- package/package.json +70 -4
package/lib/shell.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { killGroup } from './process.mjs';
|
|
5
|
+
import { taskDir } from './state.mjs';
|
|
6
|
+
import { dataBlock, noSymlinks } from './util.mjs';
|
|
7
|
+
import { redact } from './publish.mjs';
|
|
8
|
+
|
|
9
|
+
// Commands the owner runs from the message box with ! (README, "Commands").
|
|
10
|
+
// A command typed in an open session is saved beside it, one JSON line per command:
|
|
11
|
+
// {at, command, cwd, output, code, signal, stopped, error}
|
|
12
|
+
// The chat shows these rows, and the lead gets them with the owner's next message.
|
|
13
|
+
// Possible secrets in the output are masked before a row is saved.
|
|
14
|
+
export const OUTPUT_MAX = 8000, TIME_LIMIT_MS = 10 * 60000;
|
|
15
|
+
const HEAD = 100000, TAIL = 300000, ROWS = 20, FILE_MAX = 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
// Output as plain text: no colour codes or cursor moves, and a line redrawn with a
|
|
18
|
+
// carriage return keeps its last state.
|
|
19
|
+
export function plainOutput(value) {
|
|
20
|
+
return String(value ?? '').replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\x1b[@-Z\\-_]/g, '')
|
|
21
|
+
.replace(/\r\n/g, '\n').split('\n').map(line => line.slice(line.lastIndexOf('\r') + 1)).join('\n').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
|
|
22
|
+
}
|
|
23
|
+
// A long output loses its middle, so the start and the end both stay.
|
|
24
|
+
export function fitOutput(text, max = OUTPUT_MAX) {
|
|
25
|
+
if (text.length <= max) return text;
|
|
26
|
+
const head = Math.floor(max * 0.4);
|
|
27
|
+
return `${text.slice(0, head)}\n[… ${text.length - max} characters cut …]\n${text.slice(head - max)}`;
|
|
28
|
+
}
|
|
29
|
+
// Runs one command in the user's shell, with no input and no terminal of its own.
|
|
30
|
+
// done resolves (never rejects) with {output, code, signal, stopped, error}; tail() is the
|
|
31
|
+
// end of the output so far; stop() ends the command and everything it started.
|
|
32
|
+
export function runShell(command, { cwd, env = process.env, onOutput = () => {}, limitMs = TIME_LIMIT_MS } = {}) {
|
|
33
|
+
let head = '', tail = '', dropped = 0, stopped = null, child = null, timer = null, settle = null;
|
|
34
|
+
const all = () => plainOutput(dropped ? `${head}\n[… ${dropped} characters cut …]\n${tail}` : head + tail);
|
|
35
|
+
const take = text => {
|
|
36
|
+
const room = HEAD - head.length;
|
|
37
|
+
head += text.slice(0, Math.max(0, room)); tail += text.slice(Math.max(0, room));
|
|
38
|
+
if (tail.length > TAIL) { dropped += tail.length - TAIL; tail = tail.slice(-TAIL); }
|
|
39
|
+
onOutput();
|
|
40
|
+
};
|
|
41
|
+
const stop = (reason = 'stopped') => {
|
|
42
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
43
|
+
stopped ??= reason;
|
|
44
|
+
const kill = signal => { if (process.platform === 'win32') child.kill(signal); else killGroup(child.pid, signal); };
|
|
45
|
+
kill('SIGTERM'); setTimeout(() => kill('SIGKILL'), 2000).unref();
|
|
46
|
+
};
|
|
47
|
+
const done = new Promise(resolve => {
|
|
48
|
+
let finished = false;
|
|
49
|
+
const finish = (code, signal, error = null) => {
|
|
50
|
+
if (finished) return; finished = true; clearTimeout(timer); clearTimeout(settle);
|
|
51
|
+
resolve({ output: redact(fitOutput(all().trimEnd()), OUTPUT_MAX + 200), code, signal, stopped, error });
|
|
52
|
+
};
|
|
53
|
+
try { child = spawn(command, { cwd, env, shell: process.env.SHELL || true, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', windowsHide: true }); }
|
|
54
|
+
catch (error) { finish(null, null, error.message); return; }
|
|
55
|
+
for (const stream of [child.stdout, child.stderr]) { stream.setEncoding('utf8'); stream.on('data', take); stream.on('error', () => {}); }
|
|
56
|
+
child.on('error', error => finish(null, null, error.message));
|
|
57
|
+
child.on('close', (code, signal) => finish(code, signal));
|
|
58
|
+
// A command that leaves something running in the background ends when its shell does.
|
|
59
|
+
child.on('exit', (code, signal) => { settle = setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); finish(code, signal); }, 500); });
|
|
60
|
+
timer = setTimeout(() => stop('time'), limitMs);
|
|
61
|
+
});
|
|
62
|
+
return { done, stop, tail: (max = 4000) => plainOutput((head + tail).slice(-max)) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const file = (repo, id) => path.join(taskDir(repo, id), 'shell.jsonl');
|
|
66
|
+
export const shellFile = file;
|
|
67
|
+
export function recordShell(repo, id, row) {
|
|
68
|
+
const target = file(repo, id); noSymlinks(target);
|
|
69
|
+
fs.appendFileSync(target, `${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
70
|
+
}
|
|
71
|
+
// The newest rows of a session, oldest first. A torn or foreign line is skipped.
|
|
72
|
+
export function shellRows(repo, id, limit = ROWS) {
|
|
73
|
+
let text;
|
|
74
|
+
try {
|
|
75
|
+
const target = file(repo, id); noSymlinks(target);
|
|
76
|
+
const size = fs.statSync(target).size, fd = fs.openSync(target, 'r');
|
|
77
|
+
try { const buffer = Buffer.alloc(Math.min(size, FILE_MAX)); fs.readSync(fd, buffer, 0, buffer.length, size - buffer.length); text = buffer.toString('utf8'); }
|
|
78
|
+
finally { fs.closeSync(fd); }
|
|
79
|
+
} catch { return []; }
|
|
80
|
+
const rows = [];
|
|
81
|
+
for (const line of text.split('\n')) {
|
|
82
|
+
try { const row = JSON.parse(line); if (row && typeof row.command === 'string' && typeof row.at === 'string') rows.push(row); } catch { /* Skipped. */ }
|
|
83
|
+
}
|
|
84
|
+
return rows.slice(-limit);
|
|
85
|
+
}
|
|
86
|
+
// How a finished command reads, in the chat and to the lead: the command, its output, and how it ended.
|
|
87
|
+
const shellEnd = row => row.error ? `could not start: ${row.error}` : row.stopped === 'time' ? 'stopped: it ran for 10 minutes' : row.stopped ? 'stopped' : row.signal ? `ended by ${row.signal}` : row.code ? `exit ${row.code}` : '';
|
|
88
|
+
export function shellText(row) {
|
|
89
|
+
return [`$ ${row.command}`, typeof row.output === 'string' ? row.output : '', shellEnd(row)].filter(Boolean).join('\n');
|
|
90
|
+
}
|
|
91
|
+
// A finished command as a model reads it. The command line is the owner's, and how it ended
|
|
92
|
+
// is the app's. What it printed is whatever the files and programs it touched put there, so
|
|
93
|
+
// it travels in a data block of its own and is never read as the owner's words.
|
|
94
|
+
export function shellForModel(row) {
|
|
95
|
+
const output = typeof row.output === 'string' ? row.output : '';
|
|
96
|
+
return { text: [`$ ${row.command}`, shellEnd(row)].filter(Boolean).join('\n'), ...(output ? { output: dataBlock('Output of this command', output) } : {}) };
|
|
97
|
+
}
|
|
98
|
+
// The lead's copy of a session's commands, as messages in time order with the owner's own.
|
|
99
|
+
export function shellMessages(repo, id) {
|
|
100
|
+
return shellRows(repo, id).map(row => { const { text, output } = shellForModel(row); return { at: row.at, shell: true, text: `The owner ran this in ${row.cwd ?? 'the project'}:\n${text}`, ...(output ? { output } : {}) }; });
|
|
101
|
+
}
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { atomic, mkdir, readJson, noSymlinks, refuse } from './util.mjs';
|
|
5
|
+
import { processIdentity } from './process.mjs';
|
|
6
|
+
import { checkPlace } from './modes.mjs';
|
|
7
|
+
|
|
8
|
+
export function event(repo, type, data = {}) {
|
|
9
|
+
mkdir(repo.state);
|
|
10
|
+
const file = path.join(repo.state, 'events.jsonl'); noSymlinks(file);
|
|
11
|
+
fs.appendFileSync(file, `${JSON.stringify({ id: crypto.randomUUID(), at: new Date().toISOString(), type, ...data })}\n`, { mode: 0o600 });
|
|
12
|
+
}
|
|
13
|
+
export function tasks(repo) {
|
|
14
|
+
const folder = path.join(repo.state, 'tasks'); noSymlinks(folder);
|
|
15
|
+
if (!fs.existsSync(folder)) return [];
|
|
16
|
+
return fs.readdirSync(folder).filter((id) => /^[1-9][0-9]*$/.test(id)).map((id) => readTask(repo, id)).sort((a, b) => Number(a.id) - Number(b.id));
|
|
17
|
+
}
|
|
18
|
+
export function taskDir(repo, id) {
|
|
19
|
+
if (!/^[1-9][0-9]*$/.test(String(id))) refuse('Task ID must be a positive integer.');
|
|
20
|
+
const dir = path.join(repo.state, 'tasks', String(id)); noSymlinks(dir); return dir;
|
|
21
|
+
}
|
|
22
|
+
// A session's records stay with its project. Its edit can run in another repository the
|
|
23
|
+
// lead found; there, its folder and branch carry a project mark, so two projects editing
|
|
24
|
+
// one repository never collide. A repo object for such an edit has `project` set.
|
|
25
|
+
const worktreesOf = root => path.join(path.dirname(root), `${path.basename(root)}.elu-code`);
|
|
26
|
+
export const projectMark = root => crypto.createHash('sha256').update(root).digest('hex').slice(0, 6);
|
|
27
|
+
// place: a new worktree (default), a worktree with a named branch checked
|
|
28
|
+
// out, or `here`, the real checkout itself, whose branch is whatever is checked out.
|
|
29
|
+
export function taskPlace(repo, id, repository = null, place = null) {
|
|
30
|
+
const project = repo.project ?? repo.root, kind = place?.kind ?? 'worktree';
|
|
31
|
+
if (kind === 'here') return { worktree: repository ?? repo.root, branch: null };
|
|
32
|
+
const own = !repository || repository === project, name = own ? String(id) : `${id}-${projectMark(project)}`;
|
|
33
|
+
const worktree = path.join(own ? project === repo.root ? repo.worktrees : worktreesOf(project) : worktreesOf(repository), name);
|
|
34
|
+
return { worktree, branch: kind === 'branch' ? place.branch : `feat/task-${name}` };
|
|
35
|
+
}
|
|
36
|
+
const BRANCH = /^[A-Za-z0-9._/-]{1,200}$/;
|
|
37
|
+
// What the owner sets for a session: its mode, spending limit, folders and bots. A turn
|
|
38
|
+
// holds the session for its whole length and writes its own copy of the record back at
|
|
39
|
+
// each step, so a setting changed meanwhile is kept in a small file beside the record.
|
|
40
|
+
// Every read lays that file over the record, and the next save takes it into the record
|
|
41
|
+
// and into the saver's own copy: the turn's, at the latest when it ends. null takes a
|
|
42
|
+
// value away.
|
|
43
|
+
const SETTINGS = ['permissions', 'permissionChanges', 'budgetUsd', 'folders', 'bots'];
|
|
44
|
+
const recordFile = (repo, id) => path.join(taskDir(repo, id), 'task.json');
|
|
45
|
+
export const settingsFile = (repo, id) => path.join(taskDir(repo, id), 'settings.json');
|
|
46
|
+
function waitingSettings(repo, id) {
|
|
47
|
+
const file = settingsFile(repo, id); noSymlinks(file);
|
|
48
|
+
if (!fs.existsSync(file)) return null;
|
|
49
|
+
try { const value = readJson(file); return value && typeof value === 'object' && !Array.isArray(value) ? value : null; } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
function laySettings(task, waiting) {
|
|
52
|
+
for (const key of SETTINGS) if (waiting && Object.hasOwn(waiting, key)) { if (waiting[key] === null) delete task[key]; else task[key] = waiting[key]; }
|
|
53
|
+
return task;
|
|
54
|
+
}
|
|
55
|
+
// Changes a session's settings whether or not a turn holds it; no caller waits for a turn.
|
|
56
|
+
// change gets the session as it reads now and alters its settings. Returns the session as
|
|
57
|
+
// it reads after.
|
|
58
|
+
export function changeSettings(repo, taskId, change) {
|
|
59
|
+
const release = waitLock(repo, `settings-${taskId}`);
|
|
60
|
+
try {
|
|
61
|
+
const task = readTask(repo, taskId), was = SETTINGS.map(key => JSON.stringify(task[key]));
|
|
62
|
+
change(task);
|
|
63
|
+
const changed = SETTINGS.filter((key, index) => JSON.stringify(task[key]) !== was[index]);
|
|
64
|
+
if (!changed.length) return task;
|
|
65
|
+
let turn = null;
|
|
66
|
+
try { turn = lock(repo, `task-${taskId}`); } catch (error) { if (!error.message.includes('holds this lock')) throw error; }
|
|
67
|
+
const file = settingsFile(repo, taskId); noSymlinks(file);
|
|
68
|
+
if (!turn) { atomic(file, { ...waitingSettings(repo, taskId), ...Object.fromEntries(changed.map(key => [key, task[key] ?? null])) }); return task; }
|
|
69
|
+
try { atomic(recordFile(repo, taskId), task); fs.rmSync(file, { force: true }); } finally { turn(); }
|
|
70
|
+
return task;
|
|
71
|
+
} finally { release(); }
|
|
72
|
+
}
|
|
73
|
+
// A record is read with what is kept beside it laid over it: the name, then the settings.
|
|
74
|
+
export function readTask(repo, id) { return laySettings(named(repo, record(repo, id)), waitingSettings(repo, id)); }
|
|
75
|
+
function record(repo, id) {
|
|
76
|
+
const task = readJson(path.join(taskDir(repo, id), 'task.json'));
|
|
77
|
+
if (task.kind === 'conversation') {
|
|
78
|
+
if (task.id !== String(id) || task.worktree !== (repo.project ?? repo.root) || task.base !== null || task.branch !== null) refuse('Invalid conversation record.');
|
|
79
|
+
return task;
|
|
80
|
+
}
|
|
81
|
+
if (task.repository !== undefined && (typeof task.repository !== 'string' || !path.isAbsolute(task.repository))) refuse('Invalid task record.');
|
|
82
|
+
let place = null;
|
|
83
|
+
if (task.place !== undefined) { try { place = checkPlace(task.place, { mode: 'full' }); } catch { refuse('Invalid task record.'); } }
|
|
84
|
+
const expected = taskPlace(repo, id, task.repository, place);
|
|
85
|
+
const branchOk = place?.kind === 'here' ? task.branch === null || typeof task.branch === 'string' && BRANCH.test(task.branch) : task.branch === expected.branch;
|
|
86
|
+
if (task.id !== String(id) || task.worktree !== expected.worktree || !branchOk || !/^[0-9a-f]{40,64}$/.test(task.base ?? '')) refuse('Invalid task record.');
|
|
87
|
+
return task;
|
|
88
|
+
}
|
|
89
|
+
export function saveTask(repo, task) {
|
|
90
|
+
const waiting = settingsFile(repo, task.id); noSymlinks(waiting);
|
|
91
|
+
if (!fs.existsSync(waiting)) { atomic(recordFile(repo, task.id), task); return; }
|
|
92
|
+
const release = waitLock(repo, `settings-${task.id}`);
|
|
93
|
+
try { laySettings(task, waitingSettings(repo, task.id)); atomic(recordFile(repo, task.id), task); fs.rmSync(waiting, { force: true }); } finally { release(); }
|
|
94
|
+
}
|
|
95
|
+
// A session's name in lists and titles: the one the owner gave it, else the title of its
|
|
96
|
+
// approved Task, else the short one the model chose in its first reply, else the first
|
|
97
|
+
// words of what was asked. The name is kept beside the record, in a file of its own, with
|
|
98
|
+
// who chose it. A running turn holds the record and its lock until it ends, and saves the
|
|
99
|
+
// record as it goes: a name in its own file is saved at once, and those saves never undo it.
|
|
100
|
+
export const TITLE_MAX = 60;
|
|
101
|
+
export const MODEL_TITLE_MAX = 40;
|
|
102
|
+
export const nameFile = (repo, id) => path.join(taskDir(repo, id), 'name.json');
|
|
103
|
+
// What the name file holds, or null when there is none. A file that cannot be read counts as none.
|
|
104
|
+
function savedName(repo, id) {
|
|
105
|
+
try { const value = readJson(nameFile(repo, id)); return value && typeof value === 'object' && !Array.isArray(value) ? value : null; } catch { return null; }
|
|
106
|
+
}
|
|
107
|
+
// A name that was saved: the owner's, read back. Older versions took up to 120 characters.
|
|
108
|
+
const kept = value => { const name = typeof value === 'string' ? oneLine(value) : ''; return name && name.length <= 120 && !/[\x00-\x1f\x7f]/.test(name) ? name : null; };
|
|
109
|
+
// Puts the saved name on a record that was just read: title, and titleBy for who chose it,
|
|
110
|
+
// owner, task or model. Without a name file the record keeps the name older versions saved
|
|
111
|
+
// in it, which the owner chose.
|
|
112
|
+
function named(repo, task) {
|
|
113
|
+
const name = savedName(repo, task.id);
|
|
114
|
+
if (!name) return task;
|
|
115
|
+
const owner = kept(name.owner), plan = owner ? null : planTitle(name.task), model = owner ?? plan ? null : modelTitle(name.model), title = owner ?? plan ?? model;
|
|
116
|
+
delete task.title; delete task.titleBy;
|
|
117
|
+
if (title) { task.title = title; task.titleBy = owner ? 'owner' : plan ? 'task' : 'model'; }
|
|
118
|
+
return task;
|
|
119
|
+
}
|
|
120
|
+
// What the name file holds, to change one part of it. A name older versions saved in the
|
|
121
|
+
// record is carried over as the owner's; a copy a turn saved there of a name that someone
|
|
122
|
+
// else chose is not.
|
|
123
|
+
function nameParts(repo, id) {
|
|
124
|
+
const saved = record(repo, id), before = saved.titleBy && saved.titleBy !== 'owner' ? null : kept(saved.title);
|
|
125
|
+
return savedName(repo, id) ?? (before ? { owner: before } : {});
|
|
126
|
+
}
|
|
127
|
+
const UNSEEN = /[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/;
|
|
128
|
+
const oneLine = value => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
129
|
+
// The first words of a request, in one row: cut at a word boundary, with … for the rest.
|
|
130
|
+
function firstWords(value, width) {
|
|
131
|
+
const text = oneLine(value);
|
|
132
|
+
if (text.length <= width) return text;
|
|
133
|
+
// A cut that falls right after a word keeps that word.
|
|
134
|
+
const cut = text.slice(0, width - 1), space = cut.lastIndexOf(' '), whole = /[\s,;:.!?]/.test(text[width - 1]);
|
|
135
|
+
return `${(whole || space <= 0 ? cut : cut.slice(0, space)).replace(/[\s,;:.!?-]+$/, '')}…`;
|
|
136
|
+
}
|
|
137
|
+
// A name the model gave is untrusted text. It is taken only as 2 to 6 plain words on one
|
|
138
|
+
// line, of up to MODEL_TITLE_MAX characters: no control or invisible characters, no markup,
|
|
139
|
+
// no address, no file path. A word with a slash is two plain words joined by it, as in
|
|
140
|
+
// read/write. Anything else is not a name: null, and the session keeps the one it shows.
|
|
141
|
+
export function modelTitle(value) {
|
|
142
|
+
if (typeof value !== 'string' || UNSEEN.test(value.trim())) return null;
|
|
143
|
+
const name = value.trim().replace(/^["'“”‘’]+|["'“”‘’.]+$/g, '').replace(/ +/g, ' ').trim(), words = name ? name.split(' ') : [];
|
|
144
|
+
if (name.length > MODEL_TITLE_MAX || words.length > 6 || !/[\p{L}\p{N}]/u.test(name)) return null;
|
|
145
|
+
// One word is a name only in a script that writes without spaces.
|
|
146
|
+
if (words.length < 2 && /^[\x20-\u024f]*$/.test(name)) return null;
|
|
147
|
+
if (/[<>{}[\]`*|\\~]|^#|:\/\//.test(name)) return null;
|
|
148
|
+
if (words.some(word => word.includes('/') && !/^\p{L}+\/\p{L}+$/u.test(word))) return null;
|
|
149
|
+
return name;
|
|
150
|
+
}
|
|
151
|
+
// true when the session has a name that someone chose: the owner, an approved Task or the model.
|
|
152
|
+
export function hasTitle(repo, taskId) { return Boolean(readTask(repo, taskId).title); }
|
|
153
|
+
// Saves the name the model chose, once. It never replaces the owner's name, which stays
|
|
154
|
+
// the one that shows, nor an earlier name of its own. false when it is not a name or the
|
|
155
|
+
// session has one from the model already.
|
|
156
|
+
export function suggestTitle(repo, taskId, value) {
|
|
157
|
+
const name = modelTitle(value);
|
|
158
|
+
if (!name) return false;
|
|
159
|
+
const release = waitLock(repo, `name-${taskId}`);
|
|
160
|
+
try {
|
|
161
|
+
const saved = nameParts(repo, taskId);
|
|
162
|
+
if (modelTitle(saved.model)) return false;
|
|
163
|
+
atomic(nameFile(repo, taskId), { ...saved, model: name }); return true;
|
|
164
|
+
} finally { release(); }
|
|
165
|
+
}
|
|
166
|
+
// A Task's title as a session name: one line, cut at a word to TITLE_MAX. null when it is
|
|
167
|
+
// empty or holds a control or invisible character.
|
|
168
|
+
function planTitle(value) { const name = typeof value === 'string' ? firstWords(value, TITLE_MAX) : ''; return name && !UNSEEN.test(name) ? name : null; }
|
|
169
|
+
// Names a session after its approved Task, whatever the session is doing. It replaces the
|
|
170
|
+
// title of an earlier Task and shows in place of the model's short name, and never in place
|
|
171
|
+
// of a name the owner set: that one stays until the owner takes it away. An empty title
|
|
172
|
+
// takes the Task's away again. false, and nothing changes, when the title is not plain text.
|
|
173
|
+
export function titleFromTask(repo, taskId, title) {
|
|
174
|
+
const name = planTitle(title);
|
|
175
|
+
if (!name && String(title ?? '').trim()) return false;
|
|
176
|
+
const release = waitLock(repo, `name-${taskId}`);
|
|
177
|
+
try {
|
|
178
|
+
const saved = nameParts(repo, taskId); delete saved.task;
|
|
179
|
+
atomic(nameFile(repo, taskId), name ? { ...saved, task: name } : saved); return true;
|
|
180
|
+
} finally { release(); }
|
|
181
|
+
}
|
|
182
|
+
// Why a typed name is not taken, or null.
|
|
183
|
+
export function nameProblem(value) {
|
|
184
|
+
const name = oneLine(value), rule = `A session name is one line of up to ${TITLE_MAX} characters.`;
|
|
185
|
+
return /[\x00-\x1f\x7f]/.test(name) ? rule : name.length > TITLE_MAX ? `${rule} This one is ${name.length}.` : null;
|
|
186
|
+
}
|
|
187
|
+
export function taskTitle(task) {
|
|
188
|
+
const name = typeof task?.title === 'string' ? task.title.trim() : '';
|
|
189
|
+
return name || firstWords(String(task?.goal ?? '').split('\n').find(line => line.trim()) ?? '', TITLE_MAX);
|
|
190
|
+
}
|
|
191
|
+
// Renames a session, whatever it is doing. An empty name takes the owner's name away again.
|
|
192
|
+
export function setTitle(repo, taskId, title) {
|
|
193
|
+
const name = oneLine(title), problem = nameProblem(name);
|
|
194
|
+
if (problem) refuse(problem);
|
|
195
|
+
record(repo, taskId);
|
|
196
|
+
const release = waitLock(repo, `name-${taskId}`);
|
|
197
|
+
try { atomic(nameFile(repo, taskId), { ...savedName(repo, taskId), owner: name || null }); } finally { release(); }
|
|
198
|
+
return readTask(repo, taskId);
|
|
199
|
+
}
|
|
200
|
+
// A lock is written whole under another name and then linked to its own, so a process that
|
|
201
|
+
// finds it never reads half of one, and the first to link wins.
|
|
202
|
+
const NO_LINKS = ['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS'], HALF_AGE_MS = 5000, GONE = Symbol('gone'), HALF = Symbol('half');
|
|
203
|
+
function claim(file, text) {
|
|
204
|
+
const draft = `${file}.${process.pid}.tmp`;
|
|
205
|
+
fs.rmSync(draft, { force: true });
|
|
206
|
+
fs.writeFileSync(draft, text, { flag: 'wx', mode: 0o600 });
|
|
207
|
+
try { fs.linkSync(draft, file); }
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (!NO_LINKS.includes(error.code)) throw error;
|
|
210
|
+
// A file system without hard links: written in place, as before.
|
|
211
|
+
fs.writeFileSync(file, text, { flag: 'wx', mode: 0o600 });
|
|
212
|
+
} finally { fs.rmSync(draft, { force: true }); }
|
|
213
|
+
}
|
|
214
|
+
// Who holds the lock that was found. It may be released between the two looks, and where
|
|
215
|
+
// there are no hard links it may be half written: by its holder now, or by one that died,
|
|
216
|
+
// whose half is removed once it is old.
|
|
217
|
+
function holder(file) {
|
|
218
|
+
try { return readJson(file); }
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error.code === 'ENOENT') return GONE;
|
|
221
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
222
|
+
try { if (Date.now() - fs.statSync(file).mtimeMs < HALF_AGE_MS) return HALF; } catch { return GONE; }
|
|
223
|
+
fs.rmSync(file, { force: true }); return GONE;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
export function lock(repo, name = 'allocate') {
|
|
227
|
+
mkdir(repo.state);
|
|
228
|
+
const file = path.join(repo.state, `${name}.lock`); noSymlinks(file);
|
|
229
|
+
const text = JSON.stringify({ pid: process.pid, identity: processIdentity(process.pid) });
|
|
230
|
+
let stale = 0;
|
|
231
|
+
// A lock that was released, or removed as half written, between the two looks is tried for again.
|
|
232
|
+
for (let look = 0; stale < 2 && look < 50; look++) {
|
|
233
|
+
try { claim(file, text); return () => { noSymlinks(file); fs.unlinkSync(file); }; }
|
|
234
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
235
|
+
const previous = holder(file);
|
|
236
|
+
if (previous === GONE) continue;
|
|
237
|
+
if (previous === HALF || !previous?.identity || processIdentity(previous.pid) === previous.identity) refuse('Another ELU Code command holds this lock.');
|
|
238
|
+
fs.rmSync(file, { force: true }); stale++;
|
|
239
|
+
}
|
|
240
|
+
refuse(stale ? 'Could not take the repository lock.' : 'Another ELU Code command holds this lock.');
|
|
241
|
+
}
|
|
242
|
+
export function waitLock(repo, name, attempts = 60) {
|
|
243
|
+
for (let attempt = 0; ; attempt++) {
|
|
244
|
+
try { return lock(repo, name); }
|
|
245
|
+
catch (error) { if (attempt >= attempts || !error.message.includes('holds this lock')) throw error; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// The Bots tab: its list rows and its dialogs, and the @name that sends a message to a bot.
|
|
2
|
+
// The terminal hands in its own dialogs, so nothing here touches the screen.
|
|
3
|
+
import { BOT_COLORS, botColor, botVendors, botEfforts, botNameProblem, botModelProblem, botServers, botServersProblem, botRoomProblem, botWorks, getBot, listBots, pickBotColor, saveBot, removeBot, botRuns, botShadows, SCHEDULE_MAX } from './bots.mjs';
|
|
4
|
+
import { botModels, matchModels, ownEffort } from './bot-models.mjs';
|
|
5
|
+
import { botScheduleProblem, botScheduleSpec } from './bot-schedules.mjs';
|
|
6
|
+
import { claudeServers } from './mcp.mjs';
|
|
7
|
+
import { VENDOR_NAMES } from './vendors.mjs';
|
|
8
|
+
import { ink, fitWords, wrapText } from './terminal-visuals.mjs';
|
|
9
|
+
|
|
10
|
+
export const NO_BOTS = 'No bots yet. A bot is a model with instructions you write once and use in any chat. Press n to make one.';
|
|
11
|
+
// Text in whole words; nothing when there is no room for a couple of them.
|
|
12
|
+
const words = (text, room) => room >= Math.min(text.length, Math.max(16, text.split(' ', 1)[0].length + 1)) ? fitWords(text, room) : '';
|
|
13
|
+
|
|
14
|
+
// When a bot runs by itself, as /schedule reads it: "weekdays 9:00". The saved text when it
|
|
15
|
+
// can no longer be read, and null when the bot has no schedule.
|
|
16
|
+
const scheduleText = bot => botScheduleSpec(bot)?.label ?? bot.schedule ?? null;
|
|
17
|
+
// "elu and github": names in a sentence.
|
|
18
|
+
const listed = names => names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names[0] ?? '';
|
|
19
|
+
// What is said of MCP servers a bot names that Claude Code does not have, or null for none.
|
|
20
|
+
const notSetUp = names => names.length ? `${listed(names)} ${names.length > 1 ? 'are' : 'is'} not set up in Claude Code yet.` : null;
|
|
21
|
+
const onlyClaude = vendor => `MCP servers reach Claude runs only. This bot runs on ${vendor}.`;
|
|
22
|
+
|
|
23
|
+
// Who picks a model or an effort the bot does not name: its vendor's own command line.
|
|
24
|
+
const own = vendor => `${VENDOR_NAMES[vendor] ?? vendor}'s own`;
|
|
25
|
+
// A bot's model and effort as its page names them, never the word default. named(bot) is
|
|
26
|
+
// the name a session shows that model by; for a bot that names no model it is the model
|
|
27
|
+
// the bot's latest run reported, or null before one did. An effort the bot does not name
|
|
28
|
+
// is the one the vendor's model list gives that model. Where a value was never read, the
|
|
29
|
+
// row says who picks it.
|
|
30
|
+
export const botModelText = (bot, named = () => null) => named(bot) ?? bot.model ?? own(bot.vendor);
|
|
31
|
+
export function botEffortText(bot) {
|
|
32
|
+
if (bot.effort) return bot.effort;
|
|
33
|
+
return botEfforts(bot.vendor).length ? ownEffort(bot.vendor, bot.model) ?? own(bot.vendor) : 'no setting';
|
|
34
|
+
}
|
|
35
|
+
// The model a bot's helpers run on, by the name a session shows that model by, or none.
|
|
36
|
+
export const botSubagentsText = (bot, named = () => null) => bot.subagents ? named({ vendor: 'claude', model: bot.subagents }) ?? bot.subagents : 'none';
|
|
37
|
+
// One row per bot: @name in its colour, what it runs on (vendor, model by name, effort when it names
|
|
38
|
+
// one), its helpers' model, its schedule or that it works when written to, its MCP servers
|
|
39
|
+
// (two names, then +N), whether it shadows, whether teammates can use it, then what it is for.
|
|
40
|
+
export function botLines(bots, width, colour, named = () => null) {
|
|
41
|
+
if (!bots.length) return wrapText(NO_BOTS, width - 1).map(line => ink(` ${line}`, 'muted', colour));
|
|
42
|
+
const column = Math.max(...bots.map(bot => bot.name.length)) + 3;
|
|
43
|
+
return bots.map(bot => {
|
|
44
|
+
const mcp = bot.mcp ?? [], servers = mcp.length ? `mcp ${mcp.slice(0, 2).join(' ')}${mcp.length > 2 ? ` +${mcp.length - 2}` : ''}` : null;
|
|
45
|
+
const on = [bot.vendor, named(bot) ?? bot.model, bot.effort ? `${bot.effort} effort` : null].filter(Boolean).join(' · ');
|
|
46
|
+
const helpers = bot.subagents ? `${botSubagentsText(bot, named)} helpers` : null, works = scheduleText(bot) ?? (bot.works ? 'works when written to' : null);
|
|
47
|
+
const runs = fitWords([on, helpers, works, servers, botShadows(bot) ? 'shadows' : null, bot.shared ? 'teammates can use it' : null].filter(Boolean).join(' · '), width - column), about = words(bot.about, width - column - runs.length - 2);
|
|
48
|
+
return ink(`@${bot.name}`.padEnd(column), botColor(bot), colour, true) + ink(runs, 'text', colour) + (about ? ink(` ${about}`, 'muted', colour) : '');
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// "@sec @docs +2": the bots in a chat, as many whole names as fit, for the message box's label.
|
|
52
|
+
export function botNames(names, room = 28) {
|
|
53
|
+
const shown = [];
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
const rest = names.length - shown.length - 1, text = [...shown, `@${name}`].join(' ') + (rest ? ` +${rest}` : '');
|
|
56
|
+
if (shown.length && text.length > room) break;
|
|
57
|
+
shown.push(`@${name}`);
|
|
58
|
+
}
|
|
59
|
+
return shown.join(' ') + (shown.length < names.length ? ` +${names.length - shown.length}` : '');
|
|
60
|
+
}
|
|
61
|
+
// A message that starts with @name, where name is a saved bot: the bot and the text for it.
|
|
62
|
+
// null when the first word is not a bot, so the message goes where it went before.
|
|
63
|
+
export function botMessage(text, bots) {
|
|
64
|
+
const match = /^@([A-Za-z][A-Za-z0-9-]{0,29})(?:\s+([\s\S]*))?$/.exec(String(text ?? '').trim());
|
|
65
|
+
const bot = match && bots.find(row => row.name === match[1].toLowerCase());
|
|
66
|
+
return bot ? { bot, text: (match[2] ?? '').trim() } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// What the page of a bot says under its rows: where its schedule runs, the MCP servers that
|
|
70
|
+
// will not reach it, and what it was saved to do that no row shows: its helpers, that it
|
|
71
|
+
// may merge pull requests, that it speaks after each turn. servers are the names Claude
|
|
72
|
+
// Code has.
|
|
73
|
+
export function botNotes(bot, servers) {
|
|
74
|
+
const missing = bot.mcp.filter(name => !servers.includes(name));
|
|
75
|
+
return [
|
|
76
|
+
bot.subagents ? botWorks(bot) ? "Its helpers work under the session's permissions, as the bot does." : 'Its helpers start only when the bot works. Give it a schedule to make it work.' : null,
|
|
77
|
+
botScheduleSpec(bot) ? `Runs by itself in each session it is in. /bots schedule off ${bot.name} stops it in one session.` : botScheduleProblem(bot.schedule),
|
|
78
|
+
bot.mcp.length && bot.vendor !== 'claude' ? onlyClaude(bot.vendor) : null,
|
|
79
|
+
missing.length ? `${notSetUp(missing)}${missing.includes('elu') ? ` npx @elu-labs/mcp-setup connects ${missing.length > 1 ? 'elu' : 'it'}.` : ''}` : null,
|
|
80
|
+
bot.merge ? 'It may merge pull requests. Ask for approval mode still asks you before each merge.' : null,
|
|
81
|
+
bot.shadow ? botShadows(bot) ? SHADOW_NOTE : 'This bot works in its sessions, so it does not speak after each turn.' : null,
|
|
82
|
+
].filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const SCHEDULE_HINT = 'When this bot runs by itself. Empty means it never does.';
|
|
86
|
+
// What a bot saved to speak after each turn does, as its page says it.
|
|
87
|
+
const SHADOW_NOTE = 'After a turn that changes no files it reads that turn and says what it would challenge. It reads only, and it still reviews each change.';
|
|
88
|
+
const SCHEDULES = [{ name: 'every 1h', note: 'each hour' }, { name: 'daily 9:00', note: 'every day at 9:00' }, { name: 'weekdays 9am', note: 'Monday to Friday at 9:00' }];
|
|
89
|
+
const serversHint = vendor => `The MCP servers this bot may use, by the names claude mcp list shows. Separate several with commas or spaces. Empty means none.${vendor === 'claude' ? '' : `\n${onlyClaude(vendor)}`}`;
|
|
90
|
+
|
|
91
|
+
// The dialogs, built on the terminal's own: prompt (one line), listDialog (rows), editSetting
|
|
92
|
+
// (the instructions editor), yes (a confirm) and setMessage (the status line). root gives
|
|
93
|
+
// the open project's folder, whose Claude Code MCP servers are offered with the owner's own.
|
|
94
|
+
// named: the name a bot's model is shown by (see botModelText).
|
|
95
|
+
export function botDialogs({ prompt, listDialog, editSetting, yes, setMessage, colour, root = () => null, named = () => null }) {
|
|
96
|
+
const tone = (value, name = 'text', bold = false) => ink(value, name, colour, bold);
|
|
97
|
+
const first = value => fitWords(String(value ?? '').split('\n').find(line => line.trim()) ?? '', 48) || 'none';
|
|
98
|
+
// The vendor list is skipped when there is one vendor, the effort list when the vendor has
|
|
99
|
+
// no such setting. Each gives undefined when it is closed with Esc.
|
|
100
|
+
async function pickVendor(current = null) {
|
|
101
|
+
const vendors = botVendors();
|
|
102
|
+
if (vendors.length === 1) return vendors[0];
|
|
103
|
+
const picked = await listDialog('Runs on', vendors.map(vendor => tone(vendor.padEnd(8), 'text', true) + tone(VENDOR_NAMES[vendor], 'muted')), { selected: Math.max(0, vendors.indexOf(current)) });
|
|
104
|
+
return picked ? vendors[picked.index] : undefined;
|
|
105
|
+
}
|
|
106
|
+
// The first choice names no effort: the vendor's own, with the level when it is known.
|
|
107
|
+
async function pickEffort(vendor, current = null, model = null) {
|
|
108
|
+
const efforts = botEfforts(vendor);
|
|
109
|
+
if (!efforts.length) return null;
|
|
110
|
+
const theirs = ownEffort(vendor, model);
|
|
111
|
+
const picked = await listDialog('Effort', [tone(theirs ? `${theirs} · ${own(vendor)}` : own(vendor)), ...efforts.map(effort => tone(effort))], { selected: efforts.indexOf(current) + 1 });
|
|
112
|
+
return picked ? picked.index ? efforts[picked.index - 1] : null : undefined;
|
|
113
|
+
}
|
|
114
|
+
// The colour a bot's name shows in. The first choice names none: one is picked at random.
|
|
115
|
+
async function pickColor(current = null) {
|
|
116
|
+
const picked = await listDialog('Color', [tone('random'.padEnd(8), 'text', true) + tone('one no other bot has, while there is one', 'muted'), ...BOT_COLORS.map(color => tone(color, color, true))], { selected: BOT_COLORS.indexOf(current) + 1 });
|
|
117
|
+
return picked ? picked.index ? BOT_COLORS[picked.index - 1] : null : undefined;
|
|
118
|
+
}
|
|
119
|
+
// picked: the model the vendor's own choice turned out to be, when a run has reported it.
|
|
120
|
+
const modelHint = (vendor, picked = null) => `A model ${vendor} knows by name. Empty uses ${own(vendor)}${picked ? `: ${picked}` : ''}.`;
|
|
121
|
+
// The Model step checks a name the way the store does, so a name that cannot be saved is
|
|
122
|
+
// refused here and never under the instructions. It lists the names the vendor's own
|
|
123
|
+
// command line reports, narrowed to what is typed. A name that is not listed is asked
|
|
124
|
+
// about once and taken on a second Enter: a list goes stale, and a full model name must
|
|
125
|
+
// stay possible. The saved name is never asked about. A vendor with no known names has
|
|
126
|
+
// no list.
|
|
127
|
+
function modelStep(vendor, saved = '') {
|
|
128
|
+
const models = botModels(vendor), check = text => botModelProblem(vendor, text.trim());
|
|
129
|
+
if (!models.length) return { check };
|
|
130
|
+
const fine = name => !name || name === saved || models.some(row => row.name === name);
|
|
131
|
+
return { check, suggest: text => matchModels(models, text), warn: text => fine(text.trim()) ? null : `${text.trim()} is not a model ${vendor} lists.` };
|
|
132
|
+
}
|
|
133
|
+
// The Schedule step refuses a text /schedule cannot read, one that does not repeat, and
|
|
134
|
+
// one too long for the store, each with the store's own line. It lists three examples.
|
|
135
|
+
function scheduleStep() {
|
|
136
|
+
const long = text => text.trim().replace(/\s+/g, ' ').length > SCHEDULE_MAX ? `Keep the schedule under ${SCHEDULE_MAX} characters, such as every 1h.` : null;
|
|
137
|
+
return { check: text => botScheduleProblem(text) ?? long(text), suggest: text => matchModels(SCHEDULES, text) };
|
|
138
|
+
}
|
|
139
|
+
// The MCP servers step lists the servers Claude Code has, narrowed to the name being
|
|
140
|
+
// typed. Taking one keeps the names before it, and a name already typed is not listed
|
|
141
|
+
// again. A badly formed name is refused here. A name Claude Code does not have is asked
|
|
142
|
+
// about once and taken on a second Enter: the server may be set up later. A saved name is
|
|
143
|
+
// never asked about.
|
|
144
|
+
function serversStep(saved = []) {
|
|
145
|
+
const known = Object.keys(claudeServers(root())).map(name => ({ name, note: '' }));
|
|
146
|
+
const names = text => { try { return botServers(text); } catch { return []; } };
|
|
147
|
+
const step = { check: text => botServersProblem(text), warn: text => notSetUp(names(text).filter(name => !saved.includes(name) && !known.some(row => row.name === name))) };
|
|
148
|
+
if (!known.length) return step;
|
|
149
|
+
return { ...step, suggest: text => {
|
|
150
|
+
const [, head = '', last] = /^(.*[\s,])?([^\s,]*)$/.exec(text), taken = names(head);
|
|
151
|
+
return matchModels(known.filter(row => !taken.includes(row.name)), last).map(row => ({ ...row, value: head + row.name }));
|
|
152
|
+
} };
|
|
153
|
+
}
|
|
154
|
+
// One line, asked again with the reason above it until the save is taken. Esc changes nothing.
|
|
155
|
+
async function line(title, body, value, save, options = {}) {
|
|
156
|
+
for (let note = ''; ;) {
|
|
157
|
+
const answer = await prompt(title, note + body, value, options);
|
|
158
|
+
if (answer === null) return;
|
|
159
|
+
try { save(answer.trim()); return; } catch (error) { note = `${error.message}\n\n`; value = answer; }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// n or + New bot: a name, where it runs, its model and effort, when it runs by itself, its
|
|
163
|
+
// MCP servers and its colour, then its instructions. A bot made here works when it is
|
|
164
|
+
// written to; what it does with the work is in its instructions.
|
|
165
|
+
async function create() {
|
|
166
|
+
// A full store is said before anything is asked, not after the instructions are written.
|
|
167
|
+
const full = botRoomProblem(); if (full) throw new Error(full);
|
|
168
|
+
const name = (await prompt('New bot', 'Lowercase letters, digits or -, starting with a letter.\nYou write @name to ask it.', ''))?.trim().replace(/^@/, '');
|
|
169
|
+
if (!name) return null;
|
|
170
|
+
// A name the store would refuse is refused here, before anything else is asked.
|
|
171
|
+
const problem = botNameProblem(name); if (problem) throw new Error(problem);
|
|
172
|
+
const vendor = await pickVendor(); if (vendor === undefined) return null;
|
|
173
|
+
const model = await prompt('Model', modelHint(vendor), '', modelStep(vendor)); if (model === null) return null;
|
|
174
|
+
const effort = await pickEffort(vendor, null, model.trim() || null); if (effort === undefined) return null;
|
|
175
|
+
const schedule = await prompt('Schedule', SCHEDULE_HINT, '', scheduleStep()); if (schedule === null) return null;
|
|
176
|
+
const servers = await prompt('MCP servers', serversHint(vendor), '', serversStep()); if (servers === null) return null;
|
|
177
|
+
// random, the first choice, saves no colour: the store picks one.
|
|
178
|
+
const color = await pickColor(); if (color === undefined) return null;
|
|
179
|
+
let saved = null;
|
|
180
|
+
// A refused save keeps the editor open with the reason, and the status line says it too.
|
|
181
|
+
await editSetting(`Instructions · @${name}`, '', text => { try { saved = saveBot({ name, vendor, model: model.trim() || null, effort, works: true, schedule: schedule.trim() || null, mcp: servers, color, instructions: text }); } catch (error) { setMessage(error.message); throw error; } });
|
|
182
|
+
if (saved) setMessage(`Made @${name}. Write @${name} in a session to ask it.`);
|
|
183
|
+
return saved;
|
|
184
|
+
}
|
|
185
|
+
async function remove(name) {
|
|
186
|
+
if (!await yes(`Remove @${name}`, 'Its name and instructions are deleted.', 'y/N')) return false;
|
|
187
|
+
removeBot(name); setMessage(`Removed @${name}.`); return true;
|
|
188
|
+
}
|
|
189
|
+
// Enter on a bot: each row changes one thing and saves it.
|
|
190
|
+
async function open(name) {
|
|
191
|
+
const row = (label, value) => tone(label.padEnd(14), 'muted') + tone(value);
|
|
192
|
+
for (let selected = 0; ;) {
|
|
193
|
+
const bot = getBot(name); if (!bot) return;
|
|
194
|
+
const save = (change, options) => saveBot({ ...bot, ...change }, options), runs = saved => setMessage(`@${saved.name} runs on ${botRuns(saved)}.`);
|
|
195
|
+
const rows = [tone('Name'.padEnd(14), 'muted') + tone(bot.name, botColor(bot), true), row('Runs on', bot.vendor), row('Model', first(botModelText(bot, named))), row('Effort', botEffortText(bot)), row('Schedule', first(scheduleText(bot))), row('MCP servers', first(bot.mcp.join(', '))), row('About', first(bot.about)), row('Instructions', first(bot.instructions)), tone('Teammates can use it ', 'muted') + tone(bot.shared ? 'yes' : 'no'), tone('Remove')];
|
|
196
|
+
// Color sits just above Remove, the last row.
|
|
197
|
+
const colorRow = rows.length - 1; rows.splice(colorRow, 0, tone('Color'.padEnd(14), 'muted') + tone(botColor(bot), botColor(bot), true));
|
|
198
|
+
const picked = await listDialog(`Bot @${bot.name}`, rows, { selected, notes: botNotes(bot, Object.keys(claudeServers(root()))) });
|
|
199
|
+
if (!picked) return;
|
|
200
|
+
selected = picked.index;
|
|
201
|
+
// A change the store refuses is said in the status line, and the bot stays open as it was.
|
|
202
|
+
try {
|
|
203
|
+
if (selected === colorRow) {
|
|
204
|
+
const color = await pickColor(botColor(bot));
|
|
205
|
+
if (color !== undefined) { const saved = save({ color: color ?? pickBotColor(listBots().filter(row => row.name !== bot.name), { not: botColor(bot) }) }); setMessage(`@${saved.name} is ${saved.color} now.`); }
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (selected === 0) await line(`Rename @${bot.name}`, 'Lowercase letters, digits or -, starting with a letter.', bot.name, value => {
|
|
209
|
+
const next = value.replace(/^@/, ''); if (next === bot.name) return;
|
|
210
|
+
const problem = botNameProblem(next, { previous: bot.name }); if (problem) throw new Error(problem);
|
|
211
|
+
save({ name: next }, { previous: bot.name }); name = next; setMessage(`@${bot.name} is now @${next}.`);
|
|
212
|
+
});
|
|
213
|
+
else if (selected === 1) {
|
|
214
|
+
// A model and an effort belong to their vendor, so a new vendor starts with its own.
|
|
215
|
+
// Helpers start on claude only, so a saved helpers model goes with the old vendor.
|
|
216
|
+
const vendor = await pickVendor(bot.vendor);
|
|
217
|
+
if (vendor && vendor !== bot.vendor) runs(save({ vendor, model: null, effort: null, subagents: null }));
|
|
218
|
+
} else if (selected === 2) await line('Model', modelHint(bot.vendor, bot.model ? null : named(bot)), bot.model ?? '', value => runs(save({ model: value || null })), modelStep(bot.vendor, bot.model ?? ''));
|
|
219
|
+
else if (selected === 3) {
|
|
220
|
+
if (!botEfforts(bot.vendor).length) { setMessage(`${bot.vendor} has no effort setting.`); continue; }
|
|
221
|
+
const effort = await pickEffort(bot.vendor, bot.effort, bot.model);
|
|
222
|
+
if (effort !== undefined) runs(save({ effort }));
|
|
223
|
+
} else if (selected === 4) await line('Schedule', SCHEDULE_HINT, bot.schedule ?? '', value => {
|
|
224
|
+
const saved = save({ schedule: value || null });
|
|
225
|
+
setMessage(saved.schedule ? `@${saved.name} runs by itself ${scheduleText(saved)}.` : `@${saved.name} does not run by itself.`);
|
|
226
|
+
}, scheduleStep());
|
|
227
|
+
else if (selected === 5) await line('MCP servers', serversHint(bot.vendor), bot.mcp.join(', '), value => {
|
|
228
|
+
const saved = save({ mcp: value });
|
|
229
|
+
setMessage(saved.mcp.length ? `@${saved.name}'s MCP servers: ${saved.mcp.join(', ')}.` : `@${saved.name} has no MCP servers.`);
|
|
230
|
+
}, serversStep(bot.mcp));
|
|
231
|
+
else if (selected === 6) await line('About', `One line on what @${bot.name} is for. Empty uses the first line of its instructions.`, bot.about, value => { save({ about: value }); setMessage('Saved.'); });
|
|
232
|
+
else if (selected === 7) await editSetting(`Instructions · @${bot.name}`, bot.instructions, text => save({ instructions: text }));
|
|
233
|
+
else if (selected === 8) { const saved = save({ shared: !bot.shared }); setMessage(saved.shared ? `Teammates can use @${bot.name}.` : `Only you can use @${bot.name}.`); }
|
|
234
|
+
else if (await remove(bot.name)) return;
|
|
235
|
+
} catch (error) { setMessage(error.message); }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { create, open, remove };
|
|
239
|
+
}
|