@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/projects.mjs
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { git, diff, head, baseFile, prepareGit, currentBranch, located, findRepository } from './git.mjs';
|
|
5
|
+
import { tasks, readTask, taskDir, lock, saveTask, nameFile, settingsFile } from './state.mjs';
|
|
6
|
+
import { atomic, noSymlinks, readJson, refuse, hash, within } from './util.mjs';
|
|
7
|
+
import { parseConfig, validateConfig, defaultConfig } from './config.mjs';
|
|
8
|
+
import { stringifyYaml } from './yaml.mjs';
|
|
9
|
+
import { detect } from './detect.mjs';
|
|
10
|
+
import { initPlan, applyInit, inferChecks } from './init.mjs';
|
|
11
|
+
import { beginApproved, runPlan, runTask, continueTask, createConversation, runConversation, runBot, runShadows, isPeerRun, setPermissions as savePermissions, addFolder as saveFolder, setBots as saveBots, setBudget as saveBudget } from './runner.mjs';
|
|
12
|
+
import { preparePublish, publish, redact } from './publish.mjs';
|
|
13
|
+
import { shellFile, shellRows, shellText, shellForModel } from './shell.mjs';
|
|
14
|
+
import { ADAPTERS } from './adapters/index.mjs';
|
|
15
|
+
import { personalDirectory, usageSnapshot, refreshCodexUsage } from './usage.mjs';
|
|
16
|
+
import { pinnedConfig, runChecks, evidenceCurrent } from './checks.mjs';
|
|
17
|
+
import { approveSaved, ensureTrustStore, trustedText } from './project-trust.mjs';
|
|
18
|
+
import { claudeServers, checkServerName } from './mcp.mjs';
|
|
19
|
+
import { readBoard, sessionClosed } from './kanban.mjs';
|
|
20
|
+
import { projectFolder, pendingRequests, saveRequest, removeRequest, historyPlan, createHistory } from './workspace.mjs';
|
|
21
|
+
import { botKey, botOfKey, botWorks, needBot, sharedBot } from './bots.mjs';
|
|
22
|
+
import { buildChanges, LIMITS } from './diff.mjs';
|
|
23
|
+
import { approveTask, currentTask, openTask, planFile, saysYes, sessionTasks, taskToStart, waitingTask } from './plan.mjs';
|
|
24
|
+
import { configModel, defaultModel, ownsPolicy, setDefaultModel, setSessionModel, vendorShort, withModel } from './models.mjs';
|
|
25
|
+
|
|
26
|
+
export { personalDirectory } from './usage.mjs';
|
|
27
|
+
export function registerProject(repo) {
|
|
28
|
+
// What the owner approved is recorded before the list grows, so a project added now is
|
|
29
|
+
// never taken for one he already had.
|
|
30
|
+
ensureTrustStore();
|
|
31
|
+
const dir = personalDirectory();
|
|
32
|
+
const release = lock({ state: dir }, 'projects');
|
|
33
|
+
try {
|
|
34
|
+
const file = path.join(dir, 'projects.json'); noSymlinks(file);
|
|
35
|
+
const rows = fs.existsSync(file) ? readJson(file) : [];
|
|
36
|
+
if (!Array.isArray(rows) || rows.some(row => typeof row?.root !== 'string')) refuse('The project registry is invalid.');
|
|
37
|
+
atomic(file, [...rows.filter(row => row.root !== repo.root && !(repo.common && row.common === repo.common)), { root: repo.root, common: repo.common }]);
|
|
38
|
+
} finally { release(); }
|
|
39
|
+
}
|
|
40
|
+
// Archives a project: it leaves the registry (and the Projects tab). Its folder and the
|
|
41
|
+
// sessions saved in it stay as they are, and opening the folder again brings both back.
|
|
42
|
+
export function archiveProject(root) {
|
|
43
|
+
const dir = personalDirectory();
|
|
44
|
+
const release = lock({ state: dir }, 'projects');
|
|
45
|
+
try {
|
|
46
|
+
const file = path.join(dir, 'projects.json'); noSymlinks(file);
|
|
47
|
+
const rows = fs.existsSync(file) ? readJson(file) : [];
|
|
48
|
+
if (!Array.isArray(rows) || rows.some(row => typeof row?.root !== 'string')) refuse('The project registry is invalid.');
|
|
49
|
+
if (!rows.some(row => row.root === root)) return false;
|
|
50
|
+
atomic(file, rows.filter(row => row.root !== root)); folders.delete(root); return true;
|
|
51
|
+
} finally { release(); }
|
|
52
|
+
}
|
|
53
|
+
export function claimView(repo) {
|
|
54
|
+
const releases = []; let common;
|
|
55
|
+
const attach = value => { if (value.common && value.common !== common) { releases.push(lock(value, 'view')); common = value.common; } };
|
|
56
|
+
const release = () => { for (const close of releases.splice(0).reverse()) close(); };
|
|
57
|
+
try { releases.push(lock({ state: personalDirectory() }, `view-${hash(repo.root)}`)); attach(repo); return { readOnly: false, release, attach }; }
|
|
58
|
+
catch (error) {
|
|
59
|
+
release();
|
|
60
|
+
if (!error.message.includes('holds this lock')) throw error;
|
|
61
|
+
return { readOnly: true, release() {} };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function readTail(file, limit = 64000) {
|
|
65
|
+
noSymlinks(file); if (!fs.existsSync(file)) return '';
|
|
66
|
+
const fd = fs.openSync(file, 'r');
|
|
67
|
+
try { const size = fs.fstatSync(fd).size; const bytes = Buffer.alloc(Math.min(size, limit)); fs.readSync(fd, bytes, 0, bytes.length, Math.max(0, size - limit)); return bytes.toString('utf8'); }
|
|
68
|
+
finally { fs.closeSync(fd); }
|
|
69
|
+
}
|
|
70
|
+
// Views redraw several times a second. Each file is parsed again only after it changes;
|
|
71
|
+
// values are frozen because every redraw shares them.
|
|
72
|
+
const parsed = new Map();
|
|
73
|
+
function freeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); for (const item of Object.values(value)) freeze(item); } return value; }
|
|
74
|
+
// Callers check the folders above the file; the file itself is checked here.
|
|
75
|
+
function signature(file) {
|
|
76
|
+
let stat;
|
|
77
|
+
try { stat = fs.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
78
|
+
if (stat.isSymbolicLink()) refuse(`Refusing a symbolic link: ${file}`);
|
|
79
|
+
return `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
80
|
+
}
|
|
81
|
+
// A redraw checks the state folder once, then only the parts below it.
|
|
82
|
+
function stateDir(repo, id) {
|
|
83
|
+
if (!/^[1-9][0-9]*$/.test(String(id))) refuse('Task ID must be a positive integer.');
|
|
84
|
+
let current = repo.state;
|
|
85
|
+
for (const part of ['tasks', String(id)]) {
|
|
86
|
+
current = path.join(current, part);
|
|
87
|
+
try { if (fs.lstatSync(current).isSymbolicLink()) refuse(`Refusing a symbolic link: ${current}`); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
88
|
+
}
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
91
|
+
// beside is one more file, or several, the value is read from too: a change to any reads it again.
|
|
92
|
+
function memo(file, key, read, beside = null) {
|
|
93
|
+
const also = other => { try { return signature(other); } catch { return 'refused'; } };
|
|
94
|
+
const first = signature(file), sig = first === null || !beside ? first : [first, ...[beside].flat().map(also)].join('|'), id = `${key}\0${file}`;
|
|
95
|
+
if (sig === null) { parsed.delete(id); return undefined; }
|
|
96
|
+
const hit = parsed.get(id);
|
|
97
|
+
if (hit?.sig === sig) return hit.value;
|
|
98
|
+
const value = freeze(read()); parsed.delete(id); parsed.set(id, { sig, value });
|
|
99
|
+
if (parsed.size > 4000) parsed.delete(parsed.keys().next().value);
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
// A bot runs as bot:<name>; the activity names it the way the owner writes to it, and the
|
|
103
|
+
// session's own run as the model.
|
|
104
|
+
const shown = role => botOfKey(role) ? `@${botOfKey(role)}` : role === 'lead' ? 'model' : role;
|
|
105
|
+
function eventRows(file) {
|
|
106
|
+
return memo(file, 'events', () => readTail(file).split('\n').flatMap(line => {
|
|
107
|
+
try {
|
|
108
|
+
const row = JSON.parse(line);
|
|
109
|
+
// A row says who did what, never which session by number: taskId carries that, and step
|
|
110
|
+
// is what the session's row in the list shows while it runs.
|
|
111
|
+
const check = row.type === 'check-started' ? `running ${row.name}` : row.type === 'check-finished' ? `${row.name} ${row.status}` : null;
|
|
112
|
+
const what = `${typeof row.note === 'string' ? row.note : row.type.replace(/^role-/, '').replace(/^task-/, 'session ').replaceAll('-', ' ')}${row.state ? ` (${row.state})` : ''}`;
|
|
113
|
+
const text = check !== null ? `checks: ${check}` : row.role ? `${shown(row.role)}: ${what}` : what;
|
|
114
|
+
return [{ at: row.at, text: redact(text), taskId: row.taskId ?? null, ...(check !== null ? { step: redact(check) } : {}) }];
|
|
115
|
+
} catch { return []; /* Partial append. */ }
|
|
116
|
+
})) ?? [];
|
|
117
|
+
}
|
|
118
|
+
// vendor: the run's vendor; its adapter reads a log line when it exports logRow,
|
|
119
|
+
// else the Claude and Codex shapes below are tried.
|
|
120
|
+
function logRows(file, role, at, id, vendor = null) {
|
|
121
|
+
return memo(file, `log\0${role}\0${at ?? ''}\0${id}\0${vendor ?? ''}`, () => {
|
|
122
|
+
const rows = []; const time = at ?? fs.statSync(file).mtime.toISOString(), prefix = `${shown(role)}: `;
|
|
123
|
+
const add = text => rows.push({ at: time, text: redact(prefix + text), taskId: id, step: redact(text) });
|
|
124
|
+
const logRow = ADAPTERS[vendor]?.logRow;
|
|
125
|
+
for (const line of readTail(file).split('\n')) {
|
|
126
|
+
try {
|
|
127
|
+
const row = JSON.parse(line);
|
|
128
|
+
if (typeof logRow === 'function') { const read = logRow(row); if (read?.text) add(String(read.text)); continue; }
|
|
129
|
+
for (const item of row.message?.content ?? []) {
|
|
130
|
+
if (item.type === 'text') add(item.text);
|
|
131
|
+
if (item.type === 'tool_use') add(`${item.name} ${item.input?.file_path ?? item.input?.command ?? ''}`);
|
|
132
|
+
}
|
|
133
|
+
if (row.type === 'result') add(row.structured_output?.summary ?? row.result ?? '');
|
|
134
|
+
if (row.type === 'item.completed' && row.item?.text) { let text = row.item.text; try { text = JSON.parse(text).summary ?? text; } catch {} add(text); }
|
|
135
|
+
} catch { /* An unfinished line is not an event. */ }
|
|
136
|
+
}
|
|
137
|
+
return rows;
|
|
138
|
+
}) ?? [];
|
|
139
|
+
}
|
|
140
|
+
export function activity(repo, values = tasks(repo), limit = 100) {
|
|
141
|
+
// The newest rows in time order. Every row of one log shares its run time, so whole logs
|
|
142
|
+
// are ordered as blocks instead of sorting each row on every redraw.
|
|
143
|
+
const blocks = [], time = value => Number.isFinite(Date.parse(value)) ? Date.parse(value) : 0;
|
|
144
|
+
noSymlinks(repo.state);
|
|
145
|
+
for (const row of eventRows(path.join(repo.state, 'events.jsonl')).slice(-limit)) blocks.push([time(row.at), blocks.length, [row]]);
|
|
146
|
+
for (const task of values) {
|
|
147
|
+
const files = [...task.runs.map(run => run.logFile), task.activeLog].filter(Boolean), dir = stateDir(repo, task.id);
|
|
148
|
+
for (const file of new Set(files)) {
|
|
149
|
+
if (path.dirname(file) !== dir) continue;
|
|
150
|
+
const run = task.runs.find(run => run.logFile === file);
|
|
151
|
+
const rows = logRows(file, run?.role ?? task.activeRole ?? 'lead', run?.at ?? task.activeSince, task.id, run?.vendor ?? null);
|
|
152
|
+
if (rows.length) blocks.push([time(rows[0].at), blocks.length, rows]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
blocks.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
156
|
+
const newest = []; let count = 0;
|
|
157
|
+
for (let index = blocks.length - 1; index >= 0 && count < limit; index--) { const rows = blocks[index][2].slice(-(limit - count)); newest.push(rows); count += rows.length; }
|
|
158
|
+
return newest.reverse().flat();
|
|
159
|
+
}
|
|
160
|
+
function viewTasks(repo) {
|
|
161
|
+
const folder = path.join(repo.state, 'tasks'); noSymlinks(folder);
|
|
162
|
+
if (!fs.existsSync(folder)) return [];
|
|
163
|
+
return fs.readdirSync(folder).filter(id => /^[1-9][0-9]*$/.test(id)).map(id => memo(path.join(stateDir(repo, id), 'task.json'), 'task', () => withPlan(repo, readTask(repo, id)), [nameFile(repo, id), settingsFile(repo, id), planFile(repo, id)]))
|
|
164
|
+
.filter(Boolean).sort((a, b) => Number(a.id) - Number(b.id));
|
|
165
|
+
}
|
|
166
|
+
// A session in a snapshot carries its current Task (lib/plan.mjs) as plan, when it has one.
|
|
167
|
+
function withPlan(repo, task) {
|
|
168
|
+
const plan = currentTask(repo, task.id);
|
|
169
|
+
return plan ? { ...task, plan } : task;
|
|
170
|
+
}
|
|
171
|
+
// The project's own settings, when the owner approved them on this machine; null when it
|
|
172
|
+
// has none or they are not approved. Parsed once for each text.
|
|
173
|
+
const approvedConfigs = new Map();
|
|
174
|
+
function approvedConfig(root) {
|
|
175
|
+
const text = trustedText(root, 'config.yaml');
|
|
176
|
+
if (text == null) { approvedConfigs.delete(root); return null; }
|
|
177
|
+
const hit = approvedConfigs.get(root); if (hit?.text === text) return hit.config;
|
|
178
|
+
const config = freeze(parseConfig(text)); approvedConfigs.set(root, { text, config }); return config;
|
|
179
|
+
}
|
|
180
|
+
// The settings a new session starts from: the project's own as the owner approved them
|
|
181
|
+
// (the file as it stands, or the version he approved last while it differs), else the ones
|
|
182
|
+
// a project with no settings file gets. The checks found in the folder run after an edit,
|
|
183
|
+
// inside the check sandbox. They are never handed to the lead as shell commands: only a
|
|
184
|
+
// settings file the owner wrote, or approved, names those.
|
|
185
|
+
function startConfig(root) {
|
|
186
|
+
const text = trustedText(root, 'config.yaml'); if (text != null) return parseConfig(text);
|
|
187
|
+
const config = defaultConfig(); config.checks = inferChecks(root);
|
|
188
|
+
return config;
|
|
189
|
+
}
|
|
190
|
+
export function projectSnapshot(repo) {
|
|
191
|
+
const config = approvedConfig(repo.root);
|
|
192
|
+
const values = viewTasks(repo);
|
|
193
|
+
const initialized = Boolean(config && repo.state && fs.existsSync(path.join(repo.state, 'manifest.json')));
|
|
194
|
+
const saved = pendingRequests(repo.root);
|
|
195
|
+
// Closed sessions leave Sessions. The board keeps the ones moved to Done there, and shows no archived one.
|
|
196
|
+
let closed = []; try { const board = readBoard(repo); closed = values.filter(task => sessionClosed(board, task)).map(task => task.id); } catch { /* An unreadable board closes nothing. */ }
|
|
197
|
+
return { name: savedProjectName(repo.root) ?? path.basename(repo.root), branch: repo.common ? currentBranch(repo.root) : 'new project', root: repo.root, initialized, roles: config?.roles ?? defaultConfig().roles, tasks: values, closed,
|
|
198
|
+
requests: saved.filter(row => !QUEUE_ONLY.includes(row.state) && !values.some(task => task.id === row.taskId && task.goal === row.goal || task.id === row.continuationTaskId && task.state === 'running')),
|
|
199
|
+
queue: saved.filter(row => row.state !== 'done'), activity: activity(repo, values), usage: usageSnapshot() };
|
|
200
|
+
}
|
|
201
|
+
// Requests the conductor is handling or has finished; History lists only saved ones.
|
|
202
|
+
const QUEUE_ONLY = ['queued', 'starting', 'running', 'done'];
|
|
203
|
+
// The registry already records each Git root and common directory. While both still exist,
|
|
204
|
+
// reuse them instead of asking Git on every redraw. Anything else is resolved again.
|
|
205
|
+
const folders = new Map();
|
|
206
|
+
function registeredProject(entry) {
|
|
207
|
+
if (entry.common && typeof entry.common === 'string' && fs.existsSync(path.join(entry.root, '.git')) && fs.existsSync(entry.common)) return located(entry.root, entry.common);
|
|
208
|
+
const cached = folders.get(entry.root);
|
|
209
|
+
if (cached && fs.existsSync(cached.root) && !fs.existsSync(path.join(cached.root, '.git'))) return cached;
|
|
210
|
+
const repo = projectFolder(entry.root); if (entry.common && repo.common !== entry.common) refuse('Repository location changed.');
|
|
211
|
+
if (!repo.common) folders.set(entry.root, repo); else folders.delete(entry.root);
|
|
212
|
+
return repo;
|
|
213
|
+
}
|
|
214
|
+
// A project's name and marker live in its folder, next to its settings.
|
|
215
|
+
const projectFile = root => path.join(root, '.elu-code', 'project.json');
|
|
216
|
+
// One rule for a name the owner types and a name a copied folder ships: 1 to 60 characters,
|
|
217
|
+
// none of them a control character. Braces are allowed; the screen draws a name as plain text.
|
|
218
|
+
function projectNameOf(value) {
|
|
219
|
+
const name = typeof value === 'string' ? value.trim() : '';
|
|
220
|
+
return name && name.length <= 60 && !/[\u0000-\u001f\u007f-\u009f]/.test(name) ? name : null;
|
|
221
|
+
}
|
|
222
|
+
export function savedProjectName(root) {
|
|
223
|
+
try {
|
|
224
|
+
const file = projectFile(root); noSymlinks(file); if (!fs.existsSync(file)) return null;
|
|
225
|
+
return projectNameOf(readJson(file)?.name);
|
|
226
|
+
} catch { return null; }
|
|
227
|
+
}
|
|
228
|
+
// A folder is a project once it has ELU Code settings or is in the registry.
|
|
229
|
+
export function knownProject(location) {
|
|
230
|
+
if (['project.json', 'config.yaml'].some(name => fs.existsSync(path.join(location.root, '.elu-code', name)))) return true;
|
|
231
|
+
try {
|
|
232
|
+
const file = path.join(personalDirectory(), 'projects.json'); noSymlinks(file);
|
|
233
|
+
const rows = fs.existsSync(file) ? readJson(file) : [];
|
|
234
|
+
return Array.isArray(rows) && rows.some(row => row?.root === location.root || Boolean(location.common) && row?.common === location.common);
|
|
235
|
+
} catch { return false; }
|
|
236
|
+
}
|
|
237
|
+
export function nameProject(location, name) {
|
|
238
|
+
const value = projectNameOf(String(name ?? ''));
|
|
239
|
+
if (!value) refuse('Use a project name of 1 to 60 characters.');
|
|
240
|
+
const folder = path.join(location.root, '.elu-code'); noSymlinks(folder); fs.mkdirSync(folder, { recursive: true });
|
|
241
|
+
atomic(projectFile(location.root), { name: value, createdAt: new Date().toISOString() });
|
|
242
|
+
registerProject(location); return value;
|
|
243
|
+
}
|
|
244
|
+
export function registeredProjects() {
|
|
245
|
+
const file = path.join(personalDirectory(), 'projects.json'); noSymlinks(file);
|
|
246
|
+
if (!fs.existsSync(file)) return [];
|
|
247
|
+
const entries = readJson(file); if (!Array.isArray(entries)) refuse('The project registry is invalid.');
|
|
248
|
+
return entries.flatMap(entry => { try { return [registeredProject(entry)]; } catch { return []; } });
|
|
249
|
+
}
|
|
250
|
+
export function dashboardSnapshot() {
|
|
251
|
+
const file = path.join(personalDirectory(), 'projects.json'); noSymlinks(file);
|
|
252
|
+
if (!fs.existsSync(file)) return { projects: [], usage: usageSnapshot() };
|
|
253
|
+
const entries = readJson(file); if (!Array.isArray(entries)) refuse('The project registry is invalid.');
|
|
254
|
+
return { usage: usageSnapshot(), projects: entries.map(entry => {
|
|
255
|
+
try { return projectSnapshot(registeredProject(entry)); }
|
|
256
|
+
catch (error) { return { root: entry.root, name: savedProjectName(entry.root) ?? path.basename(entry.root), unavailable: error.message, tasks: [], requests: [], queue: [], roles: {}, activity: [] }; }
|
|
257
|
+
}) };
|
|
258
|
+
}
|
|
259
|
+
// Turns one of your Claude Code MCP servers on or off for this project's lead. All are on
|
|
260
|
+
// until turned off, as in Claude Code.
|
|
261
|
+
export function setServer(repo, name, on, proposedConfig) {
|
|
262
|
+
checkServerName(name);
|
|
263
|
+
if (!claudeServers(repo.root)[name]) refuse(`No MCP server named ${name}. Run claude mcp list to see yours.`);
|
|
264
|
+
// The change starts from settings the owner approved, never from a file he has not.
|
|
265
|
+
const file = path.join(repo.root, '.elu-code/config.yaml'), approved = trustedText(repo.root, 'config.yaml');
|
|
266
|
+
const config = approved != null ? parseConfig(approved) : structuredClone(proposedConfig);
|
|
267
|
+
const off = new Set(config.mcpOff ?? []); if (on) off.delete(name); else off.add(name);
|
|
268
|
+
delete config.mcp; if (off.size) config.mcpOff = [...off]; else delete config.mcpOff;
|
|
269
|
+
validateConfig(config); const text = stringifyYaml(config);
|
|
270
|
+
fs.mkdirSync(path.dirname(file), { recursive: true }); approveSaved(repo.root, 'config.yaml', text); atomic(file, text); return config;
|
|
271
|
+
}
|
|
272
|
+
export function taskDetails(repo, id) {
|
|
273
|
+
const task = readTask(repo, id);
|
|
274
|
+
if (task.kind === 'conversation') return { task, diff: '', checks: [], reviews: [], logs: task.runs.filter(run => path.dirname(run.logFile) === taskDir(repo, id)).map(run => ({ role: run.role, text: readTail(run.logFile) })) };
|
|
275
|
+
const untracked = git(task.worktree, ['ls-files', '--others', '--exclude-standard', '-z']).stdout.split('\0').filter(Boolean);
|
|
276
|
+
const additions = untracked.map(file => {
|
|
277
|
+
const full = path.join(task.worktree, file); noSymlinks(full);
|
|
278
|
+
if (!fs.statSync(full).isFile()) return `New path: ${file} (not a regular file)`;
|
|
279
|
+
const text = readTail(full);
|
|
280
|
+
return `New file: ${file}\n${text.includes('\0') ? '(binary content)' : text}${fs.statSync(full).size > 64000 ? '\n[File preview truncated.]' : ''}`;
|
|
281
|
+
});
|
|
282
|
+
return { task, diff: [diff(task.worktree, task.base), ...additions].join('\n'), checks: task.evidence?.results ?? [], reviews: task.reviews, logs: task.runs.filter(run => path.dirname(run.logFile) === taskDir(repo, id)).map(run => ({ role: run.role, text: readTail(run.logFile) })) };
|
|
283
|
+
}
|
|
284
|
+
// The start of a new file for the Changes view: a regular file only, never through a link,
|
|
285
|
+
// never a wait on a pipe. All of it when it is at most `most` bytes, else `preview` bytes.
|
|
286
|
+
function readStart(file, most, preview) {
|
|
287
|
+
try { noSymlinks(file); } catch (error) { return { note: /symbolic link/.test(error.message) ? 'symbolic link, not opened' : 'could not be read' }; }
|
|
288
|
+
let fd;
|
|
289
|
+
try {
|
|
290
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
291
|
+
const stat = fs.fstatSync(fd);
|
|
292
|
+
if (!stat.isFile()) return { note: 'not a regular file' };
|
|
293
|
+
const bytes = Buffer.alloc(stat.size <= most ? stat.size : Math.min(stat.size, preview));
|
|
294
|
+
return { bytes: bytes.subarray(0, fs.readSync(fd, bytes, 0, bytes.length, 0)), size: stat.size };
|
|
295
|
+
} catch (error) { return error.code === 'ENOENT' ? null : { note: error.code === 'ELOOP' ? 'symbolic link, not opened' : 'could not be read' }; }
|
|
296
|
+
finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
297
|
+
}
|
|
298
|
+
// What taskDetails shows as text, as files, hunks and lines (the shape is in diff.mjs). The
|
|
299
|
+
// same base and the same safe Git flags, with renames paired. Each call runs Git, so a view
|
|
300
|
+
// keeps the answer and asks again only to refresh.
|
|
301
|
+
export function taskPatch(repo, id) {
|
|
302
|
+
const task = readTask(repo, id);
|
|
303
|
+
if (task.kind === 'conversation') return buildChanges();
|
|
304
|
+
const args = ['diff', '--no-ext-diff', '--no-textconv', '--no-color', '--find-renames', '--src-prefix=a/', '--dst-prefix=b/'];
|
|
305
|
+
const run = git(task.worktree, [...args, task.base, '--'], { allowFailure: true });
|
|
306
|
+
if (run.status !== 0 && run.error?.code !== 'ENOBUFS') refuse('git could not finish.', { exitCode: run.status, reason: (run.error?.message ?? run.stderr).trim().slice(0, 1000) });
|
|
307
|
+
// A patch too large to hold arrives cut short. The totals then come from Git's own count.
|
|
308
|
+
const counts = run.status === 0 ? null : git(task.worktree, [...args, '--raw', '--numstat', '-z', task.base, '--']).stdout;
|
|
309
|
+
const untracked = git(task.worktree, ['ls-files', '--others', '--exclude-standard', '-z']).stdout.split('\0').filter(Boolean);
|
|
310
|
+
// New files are read whole while they are small, 16 MB in all, so their lines can be counted.
|
|
311
|
+
let budget = 16 * 1024 * 1024;
|
|
312
|
+
const read = file => {
|
|
313
|
+
const got = readStart(path.join(task.worktree, file), Math.min(budget, 4 * 1024 * 1024), LIMITS.previewBytes);
|
|
314
|
+
budget -= got?.bytes?.length ?? 0;
|
|
315
|
+
return got;
|
|
316
|
+
};
|
|
317
|
+
return buildChanges({ base: task.base, branch: task.branch ?? (currentBranch(task.worktree) || null), patch: run.stdout, counts, untracked, read });
|
|
318
|
+
}
|
|
319
|
+
export function conversationEntries(repo, task) {
|
|
320
|
+
const messages = task.messages ?? [];
|
|
321
|
+
// A message the owner wrote to a bot reads You → @name; the bot's reply is an entry of its own.
|
|
322
|
+
// A turn the bot's schedule started reads Schedule → @name, so it is never taken for the owner's.
|
|
323
|
+
// shadow: a shadow bot said it after the agent's turn, unasked; quiet: a note about its turn and not its answer, such as why it did not speak.
|
|
324
|
+
const written = row => row.bot ? { role: `@${row.bot}`, bot: row.bot, at: row.at, text: row.text, ...(row.failed ? { failed: true } : {}), ...(row.shadow ? { shadow: true } : {}), ...(row.quiet ? { quiet: true } : {}) }
|
|
325
|
+
: row.to && !row.from ? { role: `${row.scheduled ? 'Schedule' : 'You'} → ${row.to}`, to: row.to.replace(/^@/, ''), at: row.at, text: row.text, ...(row.scheduled ? { scheduled: true } : {}) } : { role: row.from ?? 'You', at: row.at, text: row.text };
|
|
326
|
+
const entries = [...(!messages.some(row => row.text === task.goal) ? [{ role: 'You', at: task.createdAt, text: task.goal }] : []), ...messages.map(written)];
|
|
327
|
+
// A shared bot that answers a teammate speaks where the lead would, under its own name.
|
|
328
|
+
const peerBot = task.origin?.bot ? botKey(task.origin.bot) : null, asBot = (at, text) => ({ role: `@${task.origin.bot}`, bot: task.origin.bot, at, text });
|
|
329
|
+
// Commands the owner ran with ! sit in the conversation where they happened.
|
|
330
|
+
for (const row of memo(shellFile(repo, task.id), 'shell', () => shellRows(repo, task.id)) ?? []) entries.push({ role: row.cwd && row.cwd !== repo.root ? 'Shell · editing workspace' : 'Shell', shell: true, at: row.at, text: shellText(row), forModel: shellForModel(row) });
|
|
331
|
+
for (const run of task.runs.filter(run => run.role === 'lead' || run.role === peerBot)) {
|
|
332
|
+
if (path.dirname(run.logFile) !== taskDir(repo, task.id)) continue;
|
|
333
|
+
const reply = memo(run.logFile, 'reply', () => {
|
|
334
|
+
let summary, model;
|
|
335
|
+
for (const line of readTail(run.logFile).split('\n')) {
|
|
336
|
+
try {
|
|
337
|
+
const row = JSON.parse(line);
|
|
338
|
+
if (row.type === 'system' && row.subtype === 'init' && typeof row.model === 'string') model = row.model;
|
|
339
|
+
if (row.type === 'result') {
|
|
340
|
+
summary = row.structured_output?.summary;
|
|
341
|
+
if (!summary && typeof row.result === 'string') { try { summary = JSON.parse(row.result).summary; } catch { summary = row.result; } }
|
|
342
|
+
}
|
|
343
|
+
if (row.type === 'item.completed' && row.item?.type === 'agent_message') { try { summary = JSON.parse(row.item.text).summary; } catch { summary = row.item.text; } }
|
|
344
|
+
} catch { /* A partial stream remains visible through the running status. */ }
|
|
345
|
+
}
|
|
346
|
+
return { summary: summary ?? null, model: model ?? null };
|
|
347
|
+
});
|
|
348
|
+
if (reply?.summary) entries.push(run.role === 'lead' ? { role: speaker(run, run.model ?? reply.model), lead: true, at: run.at, text: reply.summary } : asBot(run.at, reply.summary));
|
|
349
|
+
}
|
|
350
|
+
const last = task.runs.findLast(run => run.role === 'lead' || run.role === peerBot), text = (task.reply ?? task.result)?.summary, at = task.runs.at(-1)?.at ?? task.createdAt;
|
|
351
|
+
if (!entries.some(row => row.lead || peerBot && row.bot) && text && !(task.reply ?? task.result).bot) entries.push(peerBot && last?.role === peerBot ? asBot(at, text) : { role: speaker(last ?? { role: 'lead' }, last?.model), lead: true, at, text });
|
|
352
|
+
return entries.sort((a, b) => Date.parse(a.at ?? 0) - Date.parse(b.at ?? 0)).slice(-80);
|
|
353
|
+
}
|
|
354
|
+
// The chat as a bot reads it when the owner writes to it: who wrote each entry, oldest
|
|
355
|
+
// first. A long chat keeps its newest part.
|
|
356
|
+
function botChat(repo, task) {
|
|
357
|
+
// The line about files the owner has not approved is for him, not for a bot.
|
|
358
|
+
const rows = conversationEntries(repo, { ...task, messages: (task.messages ?? []).filter(row => !row.unapproved) }).map(entry => ({ at: entry.at, from: entry.lead ? 'agent' : entry.shell ? 'owner (shell command)' : entry.scheduled ? `schedule of @${entry.to}` : entry.shadow ? `${entry.role} (shadow: its comment on the turn before, not a request)` : entry.role.replace(/^You/, 'owner'), ...(entry.shell ? entry.forModel : { text: String(entry.text ?? '').slice(0, 16000) }) }));
|
|
359
|
+
// A command's output counts toward the size with its text.
|
|
360
|
+
const length = row => row.text.length + (row.output?.length ?? 0);
|
|
361
|
+
let size = 0, start = rows.length;
|
|
362
|
+
while (start > 0 && size + length(rows[start - 1]) <= 60000) size += length(rows[--start]);
|
|
363
|
+
return rows.slice(start);
|
|
364
|
+
}
|
|
365
|
+
// claude-fable-5-1 reads Fable 5.1, claude-3-5-sonnet-20241022 reads Sonnet 3.5.
|
|
366
|
+
export function modelName(id) {
|
|
367
|
+
if (typeof id !== 'string' || !id.trim()) return null;
|
|
368
|
+
const parts = id.trim().replace(/\[.*\]$/, '').replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-');
|
|
369
|
+
const words = parts.filter(part => /^[a-z]+$/i.test(part)), numbers = parts.filter(part => /^\d+$/.test(part));
|
|
370
|
+
if (!words.length || words.length + numbers.length !== parts.length) return id.trim().slice(0, 40);
|
|
371
|
+
return [words.map(word => word[0].toUpperCase() + word.slice(1)).join(' '), numbers.join('.')].filter(Boolean).join(' ');
|
|
372
|
+
}
|
|
373
|
+
// A chat speaker: the model that wrote the reply, such as Fable 5.1. A run that named no
|
|
374
|
+
// model reads as its vendor.
|
|
375
|
+
export function speaker(run, model) {
|
|
376
|
+
return modelName(model) ?? (run?.vendor ? vendorShort(run.vendor) : 'Model');
|
|
377
|
+
}
|
|
378
|
+
// The name a model is shown by before it answers, as its chat titles will name it: the full
|
|
379
|
+
// name a run reported for it when one is known (opus reads Opus 5.5), else the name it was
|
|
380
|
+
// chosen by, else its vendor. known: [{ vendor, model }] from earlier runs, oldest first.
|
|
381
|
+
export function modelLabel(choice, known = []) {
|
|
382
|
+
const family = id => (modelName(id) ?? '').split(' ')[0].toLowerCase();
|
|
383
|
+
const seen = known.findLast(row => row.vendor === choice.vendor && row.model && (!choice.model || row.model === choice.model || family(row.model) === family(choice.model)));
|
|
384
|
+
return modelName(seen?.model) ?? modelName(choice.model) ?? vendorShort(choice.vendor);
|
|
385
|
+
}
|
|
386
|
+
// The model a session's next message goes to, { vendor, model }: the one in its own saved
|
|
387
|
+
// policy. A session pinned to a commit has none of its own and reads as its last run.
|
|
388
|
+
export function sessionModel(repo, task) {
|
|
389
|
+
if (ownsPolicy(task)) {
|
|
390
|
+
try { const file = path.join(stateDir(repo, task.id), 'policy.yaml'), saved = memo(file, 'model', () => configModel(parseConfig(fs.readFileSync(file, 'utf8')))); if (saved) return saved; }
|
|
391
|
+
catch { /* An unreadable policy shows the last run instead. */ }
|
|
392
|
+
}
|
|
393
|
+
const last = (task.runs ?? []).findLast(run => run.role === 'lead');
|
|
394
|
+
return last ? { vendor: last.vendor, model: null } : null;
|
|
395
|
+
}
|
|
396
|
+
// The models the runs of these sessions reported, oldest first, for modelLabel.
|
|
397
|
+
export function reportedModels(tasks) {
|
|
398
|
+
return tasks.flatMap(task => (task.runs ?? []).filter(run => run.role === 'lead' && run.model)).sort((a, b) => String(a.at).localeCompare(String(b.at))).map(run => ({ vendor: run.vendor, model: run.model }));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function setupCommitPlan(repo) {
|
|
402
|
+
const manifest = readJson(path.join(repo.state, 'manifest.json'));
|
|
403
|
+
const allowed = ['.elu-code/config.yaml', ...(manifest.files ?? []).map(file => file.path).filter(file => ['AGENTS.md', 'CLAUDE.md'].includes(file))];
|
|
404
|
+
const staged = git(repo.root, ['diff', '--cached', '--name-only', '-z']).stdout.split('\0').filter(Boolean);
|
|
405
|
+
if (staged.some(file => !allowed.includes(file))) refuse('Other files are staged. Save that work before committing the project setup.');
|
|
406
|
+
const base = head(repo.root);
|
|
407
|
+
const files = allowed.flatMap(file => {
|
|
408
|
+
const full = path.join(repo.root, file); noSymlinks(full);
|
|
409
|
+
const before = baseFile(repo.root, base, file, { optional: true });
|
|
410
|
+
const content = fs.existsSync(full) ? fs.readFileSync(full, 'utf8') : null;
|
|
411
|
+
return before === content ? [] : [{ file, content }];
|
|
412
|
+
});
|
|
413
|
+
parseConfig(fs.readFileSync(path.join(repo.root, '.elu-code/config.yaml'), 'utf8'));
|
|
414
|
+
return { base, files, digest: hash(JSON.stringify(files)), branch: git(repo.root, ['branch', '--show-current']).stdout.trim(), diff: files.map(({ file, content }) => `${file}\n${content ?? '(deleted)'}`).join('\n\n') };
|
|
415
|
+
}
|
|
416
|
+
export function commitSetup(repo, approved) {
|
|
417
|
+
const current = setupCommitPlan(repo);
|
|
418
|
+
if (current.base !== approved.base || current.digest !== approved.digest) refuse('The setup changed after the preview. Review it again.');
|
|
419
|
+
if (!current.files.length) return 'The project setup is already committed.';
|
|
420
|
+
const release = lock(repo);
|
|
421
|
+
try {
|
|
422
|
+
prepareGit(repo, repo.root, ['add', '--', ...current.files.map(file => file.file)]);
|
|
423
|
+
git(repo.root, ['-c', `core.hooksPath=${path.join(repo.state, 'empty-hooks')}`, 'commit', '-m', 'Configure ELU Code']);
|
|
424
|
+
return 'Project setup committed. Start a session to begin a task.';
|
|
425
|
+
} finally { release(); }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// This controller owns only the tasks started by this view. Dashboard has no controller.
|
|
429
|
+
// Checking and publishing need the session to themselves. A turn holds it while it runs, and
|
|
430
|
+
// then the answer is to wait for the reply, in place of the lock's own line.
|
|
431
|
+
function publishLock(repo, id) {
|
|
432
|
+
try { return lock(repo, `task-${id}`); }
|
|
433
|
+
catch (error) {
|
|
434
|
+
if (!error.message.includes('holds this lock')) throw error;
|
|
435
|
+
let state = null; try { state = readTask(repo, id).state; } catch { /* The lock's own line stands. */ }
|
|
436
|
+
if (['running', 'preparing'].includes(state)) refuse('Wait for the reply, or /stop, then publish.');
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
export class ProjectController {
|
|
441
|
+
constructor(repo, { readOnly = false, onRepository } = {}) {
|
|
442
|
+
this.repo = repo; this.readOnly = readOnly; this.onRepository = onRepository; this.running = new Map(); this.conversations = new Map(); this.usageAbort = new AbortController();
|
|
443
|
+
this.draftConfig = startConfig(repo.root); this.fromApproved = trustedText(repo.root, 'config.yaml') != null;
|
|
444
|
+
}
|
|
445
|
+
snapshot() { const value = projectSnapshot(this.repo); return value.initialized ? value : { ...value, roles: this.draftConfig.roles }; }
|
|
446
|
+
// A long-lived owner picks up agent changes saved by any terminal. A settings file that
|
|
447
|
+
// changed is not used until the owner approves it: the settings he approved stay. Once he
|
|
448
|
+
// goes without them, the draft starts over from the defaults. With no file and none
|
|
449
|
+
// approved the draft stays.
|
|
450
|
+
reloadConfig() {
|
|
451
|
+
const approved = trustedText(this.repo.root, 'config.yaml') != null;
|
|
452
|
+
if (approved || this.fromApproved || fs.existsSync(path.join(this.repo.root, '.elu-code/config.yaml'))) this.draftConfig = startConfig(this.repo.root);
|
|
453
|
+
this.fromApproved = approved;
|
|
454
|
+
}
|
|
455
|
+
writable() { if (this.readOnly) refuse('This view is read-only. Use the first view to make changes.'); }
|
|
456
|
+
// A session's edit runs in the repository the lead chose; its records stay here.
|
|
457
|
+
taskRepo(task) {
|
|
458
|
+
if (!task?.repository || task.repository === this.repo.root) return this.repo;
|
|
459
|
+
const found = findRepository(task.repository);
|
|
460
|
+
if (!found || found.root !== task.repository) refuse('The repository this session edits has moved or is gone.');
|
|
461
|
+
return { ...found, state: this.repo.state, project: this.repo.root };
|
|
462
|
+
}
|
|
463
|
+
// The repository the lead named, from the project folder or your home. null means the
|
|
464
|
+
// project folder itself, which starts its own history.
|
|
465
|
+
// folders: the session's extra folders; a repository in one may be edited too.
|
|
466
|
+
editTarget(named, folders = []) {
|
|
467
|
+
const root = this.repo.root, home = fs.realpathSync(os.homedir()), value = typeof named === 'string' && named.trim() ? named.trim() : '.';
|
|
468
|
+
const folder = path.resolve(root, value === '~' ? home : value.startsWith('~/') ? path.join(home, value.slice(2)) : value);
|
|
469
|
+
const real = fs.existsSync(folder) && fs.statSync(folder).isDirectory() ? fs.realpathSync(folder) : null;
|
|
470
|
+
if (!real || !within(home, real) && !within(root, real) && !folders.some(dir => within(dir, real))) refuse(`The model chose ${value}, which is not a folder in your home. Tell it which repository to change.`);
|
|
471
|
+
const found = findRepository(real);
|
|
472
|
+
if (found) return found.root === root ? this.repo : { ...found, state: this.repo.state, project: root };
|
|
473
|
+
if (real !== root) refuse(`${value} is not a Git repository. Tell the model which repository to change.`);
|
|
474
|
+
if (root === home) refuse('Tell the model which repository to change. Your home folder never becomes a repository.');
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
historyPlan() { this.writable(); return historyPlan(this.repo); }
|
|
478
|
+
createHistory(plan) { this.writable(); const next = createHistory(plan); this.adoptRepository(next); return next; }
|
|
479
|
+
setChecks(command) { this.writable(); const next = structuredClone(this.draftConfig); next.checks = [{ name: 'tests', command }]; validateConfig(next); this.draftConfig = next; }
|
|
480
|
+
setupPlan() { this.writable(); return initPlan(this.repo, this.draftConfig); }
|
|
481
|
+
setup(plan) { this.writable(); const result = applyInit(this.repo, plan); registerProject(this.repo); return result; }
|
|
482
|
+
vendors() { this.writable(); return detect(this.repo.root); }
|
|
483
|
+
servers() { const all = Object.keys(claudeServers(this.repo.root)), off = this.draftConfig.mcpOff ?? []; return { all, on: all.filter(name => !off.includes(name)) }; }
|
|
484
|
+
setServer(name, on) {
|
|
485
|
+
this.writable(); this.draftConfig = setServer(this.repo, name, on, this.draftConfig);
|
|
486
|
+
return on ? `${name} is on. The model can use its tools from your next message.` : `${name} is off for this project. /mcp on ${name} turns it back on.`;
|
|
487
|
+
}
|
|
488
|
+
queue(goal, id, update) { this.writable(); return saveRequest(this.repo.root, goal, id, update); }
|
|
489
|
+
cancelRequest(id) { this.writable(); removeRequest(this.repo.root, id); }
|
|
490
|
+
// A teammate's ask or review never escalates to editing and may continue only its own
|
|
491
|
+
// conversation; an accepted handoff becomes the owner's normal task. permissions, place
|
|
492
|
+
// and folders are the owner's session choices; a follow-up without them
|
|
493
|
+
// inherits the task's, and one with a mode switches the task from this turn.
|
|
494
|
+
// bots: the bots in the chat, set like the other session choices. bot: the bot this message
|
|
495
|
+
// is written to; it answers in one read-only turn and the lead does not run. A teammate's
|
|
496
|
+
// request names a shared bot in origin.bot instead, and that bot answers it.
|
|
497
|
+
// model: the model the session runs on, { vendor, model }. A new session without one
|
|
498
|
+
// starts with the project's default; a follow-up with one switches from this turn.
|
|
499
|
+
// A bot that has a schedule works instead: its turn may end in an edit, which then takes
|
|
500
|
+
// the path the model's edit takes, with the bot doing the work. scheduled: the bot's own
|
|
501
|
+
// schedule wrote the message. botChat: a new session becomes that bot's own chat.
|
|
502
|
+
// timed: a /schedule job wrote the message, so the owner is not at this turn.
|
|
503
|
+
// A turn that changes no files is followed by the session's shadow bots (runShadows), once
|
|
504
|
+
// each, when the owner wrote the message to the model or to a bot that works. Their
|
|
505
|
+
// turns are part of this message: the next one waits for them as it waits for a review.
|
|
506
|
+
// approved: the owner approved the session's Task (lib/plan.mjs), with the key or by typing
|
|
507
|
+
// yes. Its editing turn starts at once, by the model or the bot that proposed it, with no
|
|
508
|
+
// second planning turn. An editing turn starts in no other way than for an approved Task.
|
|
509
|
+
ask(text, id, { readOnly = false, origin = null, context = null, permissions, place, folders, bots, bot = null, model, scheduled = false, botChat: ownChat = false, timed = false, approved = false } = {}) {
|
|
510
|
+
this.writable();
|
|
511
|
+
let saved = id ? readTask(this.repo, id) : null;
|
|
512
|
+
const own = Boolean(saved) && !origin && !readOnly && !scheduled && !timed;
|
|
513
|
+
if (approved && !own) refuse('Only the owner approves a Task, in its own session.');
|
|
514
|
+
if (own && !approved && saysYes(text) && waitingTask(sessionTasks(this.repo, saved.id))) { approveTask(this.repo, saved.id); approved = true; }
|
|
515
|
+
const agreed = approved ? taskToStart(this.repo, saved.id) ?? refuse('That Task is no longer waiting to start.') : null;
|
|
516
|
+
// A Task that needs no edit is worked on in an ordinary turn, which is told it is approved.
|
|
517
|
+
const direct = agreed?.edit.intent === 'edit' ? agreed : null;
|
|
518
|
+
if (direct) bot = direct.edit.bot ?? null;
|
|
519
|
+
if (saved && ['ask', 'review', 'handoff'].includes(origin?.kind) && (saved.kind !== 'conversation' || saved.origin?.peer !== origin.peer)) refuse('A teammate can continue only their own conversation.');
|
|
520
|
+
if (origin && (bot !== null || bots !== undefined)) refuse("Bots join your own sessions, not a teammate's question.");
|
|
521
|
+
if (scheduled && (bot === null || !saved)) refuse("A bot's scheduled turn names the bot and its session.");
|
|
522
|
+
if (origin?.bot && !isPeerRun(origin)) refuse('A peer request names an invalid bot.');
|
|
523
|
+
if (saved && isPeerRun(origin) && origin.bot && origin.bot !== saved.origin?.bot) refuse('A teammate can continue only their own conversation.');
|
|
524
|
+
if (isPeerRun(origin) && (origin.bot ?? saved?.origin?.bot)) sharedBot(origin.bot ?? saved.origin.bot);
|
|
525
|
+
else if (isPeerRun(origin) && (saved ? pinnedConfig(this.repo, null, { task: saved }).config : this.draftConfig).roles.lead.vendor !== 'claude') refuse('A teammate\'s question needs Claude as the model.');
|
|
526
|
+
if (saved && isPeerRun(origin) && (permissions !== undefined || place !== undefined || folders !== undefined)) refuse('Teammate questions stay read-only.');
|
|
527
|
+
if (origin && model !== undefined) refuse("A teammate's question runs on your default model.");
|
|
528
|
+
const addressed = bot === null ? null : needBot(bot);
|
|
529
|
+
if (addressed && isPeerRun(saved?.origin)) refuse("Bots join your own sessions, not a teammate's question.");
|
|
530
|
+
if (saved && permissions !== undefined) saved = savePermissions(this.repo, saved.id, permissions);
|
|
531
|
+
for (const dir of saved ? folders ?? [] : []) saved = saveFolder(this.repo, saved.id, dir);
|
|
532
|
+
if (saved && bots !== undefined) saved = saveBots(this.repo, saved.id, bots);
|
|
533
|
+
if (saved && model !== undefined) saved = setSessionModel(this.repo, saved.id, model);
|
|
534
|
+
const task = saved ?? createConversation(this.repo, withModel(this.draftConfig, model ?? defaultModel(this.draftConfig)), text, { origin, permissions, place, folders, bots, ...(ownChat && addressed ? { botChat: addressed.name } : {}) });
|
|
535
|
+
const location = saved && saved.kind !== 'conversation' ? this.taskRepo(saved) : this.repo;
|
|
536
|
+
const shadowed = !origin && !readOnly && !scheduled && !timed && (!addressed || botWorks(addressed));
|
|
537
|
+
const finished = this.own(task.id, async signal => {
|
|
538
|
+
let value;
|
|
539
|
+
const chat = current => botChat(this.repo, current);
|
|
540
|
+
// owner: the owner wrote this message, just now. Only such a turn may start a vendor's one-time check.
|
|
541
|
+
const owner = !origin && !scheduled && !timed;
|
|
542
|
+
const conversation = direct ? Promise.resolve().then(() => beginApproved(this.repo, task.id, text, direct)) : addressed ? runBot(location, task, text, { signal, name: addressed.name, chat, scheduled, owner, unattended: timed ? 'schedule' : false }) : runConversation(location, task, text, { signal, origin, context, readOnly, owner, unattended: scheduled || timed ? 'schedule' : origin?.kind === 'handoff' ? 'handoff' : false }); this.conversations.set(task.id, conversation);
|
|
543
|
+
try { value = await conversation; } finally { this.conversations.delete(task.id); }
|
|
544
|
+
// A shadow turn never fails the message it follows.
|
|
545
|
+
if (value.state !== 'preparing') return shadowed ? runShadows(location, value, { signal, chat, by: addressed ? `@${addressed.name}` : 'lead' }).catch(() => value) : value;
|
|
546
|
+
// The bot this message was written to makes the edit it asked for, where the model would.
|
|
547
|
+
const worker = addressed ? { bot: addressed.name, scheduled, chat } : null;
|
|
548
|
+
try {
|
|
549
|
+
// The gate: whatever a reply said, the app starts an edit only for a Task the owner
|
|
550
|
+
// approved, or one a schedule recorded for a run nobody watches.
|
|
551
|
+
if (!openTask(sessionTasks(this.repo, value.id))) refuse('This session has no approved Task, so no edit was started.');
|
|
552
|
+
if (value.kind !== 'conversation') return await continueTask(this.taskRepo(value), value.id, text, { signal, recordAnswer: false, worker });
|
|
553
|
+
let target = this.editTarget(worker ? value.botEdit?.repository : direct?.edit.repository ?? value.result?.repository);
|
|
554
|
+
if (!target || target === this.repo) { await this.editingRepository(signal); signal.throwIfAborted(); target = this.repo; }
|
|
555
|
+
value = readTask(this.repo, task.id);
|
|
556
|
+
const { config } = pinnedConfig(this.repo, null, { task: value });
|
|
557
|
+
const plan = await runPlan(target, value.goal, { automatic: true, config, id: value.id });
|
|
558
|
+
return await runTask(target, plan, { signal, conversation: value, worker });
|
|
559
|
+
} catch (error) {
|
|
560
|
+
const current = readTask(this.repo, task.id);
|
|
561
|
+
if (current.kind === 'conversation' || current.state === 'preparing') { current.state = signal.aborted ? 'stopped' : 'blocked'; current.error = { message: error.message }; saveTask(this.repo, current); }
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
return { id: task.id, finished };
|
|
566
|
+
}
|
|
567
|
+
async editingRepository(signal) {
|
|
568
|
+
if (this.repo.common && git(this.repo.root, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0) return;
|
|
569
|
+
if (this.preparation) return this.preparation;
|
|
570
|
+
this.preparation = (async () => {
|
|
571
|
+
while (this.conversations.size) await Promise.allSettled([...this.conversations.values()]);
|
|
572
|
+
signal?.throwIfAborted();
|
|
573
|
+
const plan = historyPlan(this.repo);
|
|
574
|
+
this.adoptRepository(plan ? createHistory(plan) : projectFolder(this.repo.root));
|
|
575
|
+
})().finally(() => { this.preparation = null; });
|
|
576
|
+
return this.preparation;
|
|
577
|
+
}
|
|
578
|
+
adoptRepository(next) {
|
|
579
|
+
const previous = this.repo;
|
|
580
|
+
if (previous.state !== next.state) {
|
|
581
|
+
for (const task of tasks(previous)) {
|
|
582
|
+
const from = taskDir(previous, task.id), to = taskDir(next, task.id);
|
|
583
|
+
if (fs.existsSync(to)) refuse('Task history already exists in the new repository. Reopen the project before continuing.');
|
|
584
|
+
const copy = (source, destination) => {
|
|
585
|
+
noSymlinks(source); noSymlinks(destination); const stat = fs.statSync(source);
|
|
586
|
+
if (stat.isDirectory()) { fs.mkdirSync(destination, { recursive: true, mode: 0o700 }); for (const name of fs.readdirSync(source)) copy(path.join(source, name), path.join(destination, name)); }
|
|
587
|
+
else { noSymlinks(destination); if (!fs.existsSync(destination)) fs.copyFileSync(source, destination); }
|
|
588
|
+
};
|
|
589
|
+
copy(from, to);
|
|
590
|
+
task.runs = task.runs.map(run => ({ ...run, logFile: path.join(to, path.basename(run.logFile)) }));
|
|
591
|
+
task.activeLog = null; saveTask(next, task);
|
|
592
|
+
}
|
|
593
|
+
const events = path.join(previous.state, 'events.jsonl'); noSymlinks(events);
|
|
594
|
+
if (fs.existsSync(events) && !fs.existsSync(path.join(next.state, 'events.jsonl'))) fs.copyFileSync(events, path.join(next.state, 'events.jsonl'));
|
|
595
|
+
}
|
|
596
|
+
this.repo = next; this.onRepository?.(next); registerProject(next);
|
|
597
|
+
}
|
|
598
|
+
async verify(id, command) {
|
|
599
|
+
this.writable(); const release = publishLock(this.repo, id);
|
|
600
|
+
try {
|
|
601
|
+
const task = readTask(this.repo, id);
|
|
602
|
+
if (task.kind === 'conversation' || task.state === 'running') refuse('Select a finished editing task to verify.');
|
|
603
|
+
const repo = this.taskRepo(task), { config } = pinnedConfig(repo, task.base, { current: false, task });
|
|
604
|
+
if (command) {
|
|
605
|
+
if (task.mode !== 'automatic') refuse('Update and commit checks through project settings for this task.');
|
|
606
|
+
config.checks = [{ name: 'tests', command }]; validateConfig(config);
|
|
607
|
+
const text = stringifyYaml(config); atomic(path.join(taskDir(this.repo, id), 'policy.yaml'), text); task.configHash = hash(text); saveTask(this.repo, task);
|
|
608
|
+
}
|
|
609
|
+
const result = await runChecks(repo, task);
|
|
610
|
+
const reviewed = task.reviewStatus === 'skipped' || task.reviewStatus === 'passed' && task.reviews.at(-1)?.digest === result.digest;
|
|
611
|
+
task.state = reviewed && evidenceCurrent(task) ? 'ready' : 'unverified';
|
|
612
|
+
saveTask(this.repo, task); return result;
|
|
613
|
+
} finally { release(); }
|
|
614
|
+
}
|
|
615
|
+
setupCommitPlan() { this.writable(); return setupCommitPlan(this.repo); }
|
|
616
|
+
commitSetup(plan) { this.writable(); return commitSetup(this.repo, plan); }
|
|
617
|
+
plan(goal) { this.writable(); return runPlan(this.repo, goal); }
|
|
618
|
+
start(plan, request) {
|
|
619
|
+
this.writable(); if (request) this.queue(request.goal, request.id, { taskId: plan.id, state: 'starting', reason: 'Starting task.' });
|
|
620
|
+
return this.own(plan.id, signal => runTask(this.repo, plan, { signal })).then(task => { if (request) removeRequest(this.repo.root, request.id); return task; }, error => {
|
|
621
|
+
if (request) { if (tasks(this.repo).some(task => task.id === plan.id && task.goal === request.goal)) removeRequest(this.repo.root, request.id); else this.queue(request.goal, request.id, { state: 'blocked', taskId: null, reason: error.message }); } throw error;
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
answer(id, text) { this.writable(); const repo = this.taskRepo(readTask(this.repo, id)); return this.own(String(id), signal => continueTask(repo, id, text, { signal })); }
|
|
625
|
+
// The session's mode and folders: a switch applies from the next turn.
|
|
626
|
+
setPermissions(id, mode) { this.writable(); return savePermissions(this.repo, String(id), mode); }
|
|
627
|
+
// The bots in the session's chat: saved bots only. Returns the task.
|
|
628
|
+
setBots(id, names) { this.writable(); return saveBots(this.repo, String(id), names); }
|
|
629
|
+
setBudget(id, dollars) { this.writable(); return saveBudget(this.repo, String(id), dollars); }
|
|
630
|
+
// What the model has spent in a session and the most it may: the limit the owner set for
|
|
631
|
+
// the session, or null when he set none.
|
|
632
|
+
budget(id) {
|
|
633
|
+
const task = readTask(this.repo, String(id));
|
|
634
|
+
const spent = (task.runs ?? []).filter(run => run.role === 'lead').reduce((total, run) => total + (run.costUsd ?? 0), 0), own = Number.isFinite(task.budgetUsd);
|
|
635
|
+
return { spent, limit: own ? task.budgetUsd : null, own };
|
|
636
|
+
}
|
|
637
|
+
// The session's model, from its next message. Returns the task.
|
|
638
|
+
setModel(id, choice) { this.writable(); return setSessionModel(this.repo, String(id), choice); }
|
|
639
|
+
// The model new sessions of this project start with, saved in its config file.
|
|
640
|
+
setDefaultModel(choice) { this.writable(); this.draftConfig = setDefaultModel(this.repo, choice, this.draftConfig); return defaultModel(this.draftConfig); }
|
|
641
|
+
defaultModel() { return defaultModel(this.draftConfig); }
|
|
642
|
+
addFolder(id, dir) { this.writable(); return saveFolder(this.repo, String(id), dir); }
|
|
643
|
+
own(id, run) {
|
|
644
|
+
if (this.running.has(id)) refuse('This task is already running.');
|
|
645
|
+
const controller = new AbortController();
|
|
646
|
+
const promise = Promise.resolve().then(() => run(controller.signal)).finally(() => this.running.delete(id));
|
|
647
|
+
this.running.set(id, { controller, promise }); return promise;
|
|
648
|
+
}
|
|
649
|
+
async stop(id, reason = 'Stopped by the owner.') {
|
|
650
|
+
this.writable(); const run = this.running.get(String(id));
|
|
651
|
+
if (!run) refuse('This task is not owned by this view.');
|
|
652
|
+
run.controller.abort(new Error(reason));
|
|
653
|
+
await run.promise.catch(() => {});
|
|
654
|
+
}
|
|
655
|
+
refreshUsage() {
|
|
656
|
+
this.writable();
|
|
657
|
+
return this.usageRefresh ??= refreshCodexUsage({ signal: this.usageAbort.signal }).finally(() => { this.usageRefresh = null; });
|
|
658
|
+
}
|
|
659
|
+
async close() { this.usageAbort.abort(); await Promise.all([this.usageRefresh, ...[...this.running.keys()].map(id => this.stop(id))]); }
|
|
660
|
+
async pushPlan(id) {
|
|
661
|
+
this.writable(); const release = publishLock(this.repo, id);
|
|
662
|
+
try { const task = readTask(this.repo, id); if (task.kind === 'conversation' || task.state === 'answered') refuse('This response has no code changes to publish.'); return await preparePublish(this.taskRepo(task), task); } finally { release(); }
|
|
663
|
+
}
|
|
664
|
+
async push(id, confirmationId) {
|
|
665
|
+
this.writable(); const release = publishLock(this.repo, id);
|
|
666
|
+
try { const task = readTask(this.repo, id); return await publish(this.taskRepo(task), task, confirmationId); } finally { release(); }
|
|
667
|
+
}
|
|
668
|
+
}
|