@elu-labs/elu 0.0.0-stage → 0.1.1
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 +340 -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 +270 -0
- package/lib/terminal.mjs +3181 -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/agy-hook.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
// The PreToolUse hook every ELU Code launch of Antigravity CLI installs. agy's
|
|
5
|
+
// own settings deny or ask per permission action; this hook covers what they cannot: web
|
|
6
|
+
// search has no permission action, and the never list is matched on command text. It reads
|
|
7
|
+
// the tool call on stdin and prints {"decision":"deny"|"ask","reason"}; no output means no
|
|
8
|
+
// opinion, so agy's own permission flow decides. A deny also exits 2 with the reason on
|
|
9
|
+
// stderr, the blocking convention hooks share. Anything unreadable denies. No imports from
|
|
10
|
+
// lib/: agy starts this script for every matched tool call, so it stays small and fast.
|
|
11
|
+
export const PROFILES = Object.freeze(['locked', 'ask', 'auto', 'full']);
|
|
12
|
+
export const TOOLS = Object.freeze(['search_web', 'run_command', 'write_to_file', 'replace_file_content', 'multi_replace_file_content', 'generate_image', 'schedule', 'invoke_subagent', 'define_subagent']);
|
|
13
|
+
// Which tool calls reach the hook in each profile; the hook decides from there.
|
|
14
|
+
export const MATCHERS = Object.freeze({ locked: TOOLS.join('|'), ask: 'search_web|run_command', auto: TOOLS.join('|'), full: 'run_command' });
|
|
15
|
+
// Push, tag, merge, deploy and publish by command text: a tripwire, not a boundary.
|
|
16
|
+
// A command word ends where the word does: git merge-base and git tag-list are other
|
|
17
|
+
// commands, as they are to the other vendors' rules. Anything else after the word still counts.
|
|
18
|
+
const NEVER = [
|
|
19
|
+
String.raw`^git(?:\s+(?:-[cC]\s+\S+|--?\S+))*\s+(?:push|tag|merge)`, String.raw`^gh\s+pr\s+merge`, String.raw`^gh\s+release`,
|
|
20
|
+
String.raw`^(?:npm|pnpm|yarn|cargo)\s+publish`, String.raw`^(?:firebase|vercel|fly)\s+deploy`, String.raw`^gcloud\s+(?:run|app)\s+deploy`,
|
|
21
|
+
String.raw`^kubectl\s+apply`, String.raw`^terraform\s+apply`,
|
|
22
|
+
].map(source => new RegExp(`${source}(?![\\w-])`));
|
|
23
|
+
const deny = reason => ({ decision: 'deny', reason });
|
|
24
|
+
const ask = reason => ({ decision: 'ask', reason });
|
|
25
|
+
export function commandText(input) {
|
|
26
|
+
for (const key of ['CommandLine', 'command', 'cmd']) if (typeof input?.[key] === 'string') return input[key];
|
|
27
|
+
return input == null ? '' : typeof input === 'string' ? input : JSON.stringify(input);
|
|
28
|
+
}
|
|
29
|
+
export function neverListed(text) {
|
|
30
|
+
return String(text).split(/\s*(?:\|\||&&|;|\||\n)\s*/).some(part => { const segment = part.trim().replace(/^(?:sudo|env)\s+/, ''); return NEVER.some(rule => rule.test(segment)); });
|
|
31
|
+
}
|
|
32
|
+
// The decision for one tool call, or null for no opinion.
|
|
33
|
+
export function decide(profile, event) {
|
|
34
|
+
const tool = typeof event?.tool_name === 'string' ? event.tool_name : null;
|
|
35
|
+
if (!PROFILES.includes(profile) || !tool) return deny('ELU Code could not read this tool call.');
|
|
36
|
+
if (profile === 'locked') return TOOLS.includes(tool) ? deny(`${tool} is not available in a read-only run.`) : null;
|
|
37
|
+
if (tool === 'run_command' && neverListed(commandText(event.tool_input))) return deny('Push, tag, merge, deploy and publish are never run by the model.');
|
|
38
|
+
if (tool === 'search_web') return profile === 'ask' ? ask('Web search needs your approval.') : profile === 'auto' ? deny('Web search is off in Approve for me.') : null;
|
|
39
|
+
if (profile === 'auto' && TOOLS.includes(tool)) return ask(`${tool} goes to the reviewer.`);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function main() {
|
|
43
|
+
const at = process.argv.indexOf('--profile'), profile = at > 0 ? process.argv[at + 1] : null;
|
|
44
|
+
let event = null;
|
|
45
|
+
try { event = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { /* Unreadable: denied below. */ }
|
|
46
|
+
const verdict = decide(profile, event);
|
|
47
|
+
if (!verdict) return;
|
|
48
|
+
process.stdout.write(`${JSON.stringify(verdict)}\n`);
|
|
49
|
+
if (verdict.decision === 'deny') { process.stderr.write(`${verdict.reason}\n`); process.exitCode = 2; }
|
|
50
|
+
}
|
|
51
|
+
let direct = false;
|
|
52
|
+
try { direct = Boolean(process.argv[1]) && pathToFileURL(fs.realpathSync(process.argv[1])).href === import.meta.url; } catch { /* Imported, or started another way. */ }
|
|
53
|
+
if (direct) main();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { approvalSummary, awaitDecision, requestApproval } from './approvals.mjs';
|
|
7
|
+
import { APPROVAL_WAIT_SECONDS } from './modes.mjs';
|
|
8
|
+
|
|
9
|
+
// The live approval relay for Claude Code: a stdio MCP server with one tool,
|
|
10
|
+
// approve, named by --permission-prompt-tool. Claude calls it for every action its permission
|
|
11
|
+
// mode would prompt for; the call becomes a request file the terminal answers, and the reply
|
|
12
|
+
// is the one text block Claude expects. Newline-delimited JSON-RPC 2.0, no dependencies.
|
|
13
|
+
// Every message is answered without blocking the read loop, so a ping arriving while an
|
|
14
|
+
// approval waits is still answered, and an unknown method never hangs the run.
|
|
15
|
+
export const SERVER_NAME = 'elu-code-approval', TOOL_NAME = 'approve';
|
|
16
|
+
const DENIED = 'The owner declined this. Do not retry it another way. Finish without it and say what you could not do.';
|
|
17
|
+
const ID_CHARS = /[^A-Za-z0-9_-]/g, PLAIN = /^[A-Za-z0-9_-]{1,80}$/;
|
|
18
|
+
|
|
19
|
+
function options(argv) {
|
|
20
|
+
const out = { session: null, root: null, task: null, role: null, vendor: null, log: null, wait: APPROVAL_WAIT_SECONDS, progress: 60 };
|
|
21
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
22
|
+
const name = argv[i].replace(/^--/, ''), value = argv[i + 1];
|
|
23
|
+
if (!Object.hasOwn(out, name) || value === undefined) throw new Error(`Unknown or incomplete option: ${argv[i]}`);
|
|
24
|
+
out[name] = ['wait', 'progress'].includes(name) ? Number(value) : value;
|
|
25
|
+
}
|
|
26
|
+
if (!PLAIN.test(out.session ?? '')) throw new Error('The approval server needs a plain --session id.');
|
|
27
|
+
if (!(out.wait > 0) || !(out.progress > 0)) throw new Error('--wait and --progress are positive seconds.');
|
|
28
|
+
if (out.log && !path.isAbsolute(out.log)) throw new Error('--log must be an absolute path.');
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
const timeoutText = wait => `No answer from the owner within ${Math.max(1, Math.round(wait / 60))} minutes. Do not retry it another way; say what you need.`;
|
|
32
|
+
const TOOL = { name: TOOL_NAME, description: 'Asks the owner of this session to allow or deny a tool call.', inputSchema: { type: 'object', properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } }, required: ['tool_name', 'input'] } };
|
|
33
|
+
|
|
34
|
+
export async function serve({ argv = process.argv.slice(2), input = process.stdin, output = process.stdout } = {}) {
|
|
35
|
+
const opts = options(argv);
|
|
36
|
+
const send = value => { try { output.write(`${JSON.stringify({ jsonrpc: '2.0', ...value })}\n`); } catch { /* Claude closed the pipe; the run is over. */ } };
|
|
37
|
+
const reply = (id, result) => send({ id, result }), fail = (id, code, message) => send({ id, error: { code, message } });
|
|
38
|
+
let calls = 0;
|
|
39
|
+
const record = row => { if (opts.log) { try { fs.appendFileSync(opts.log, `${JSON.stringify(row)}\n`, { mode: 0o600 }); } catch { /* The decision still reaches Claude. */ } } };
|
|
40
|
+
async function approve(id, params) {
|
|
41
|
+
const args = params?.arguments ?? {};
|
|
42
|
+
const tool = typeof args.tool_name === 'string' && args.tool_name.trim() ? args.tool_name.slice(0, 120) : 'unknown';
|
|
43
|
+
const use = String(args.tool_use_id ?? '').replace(ID_CHARS, '').slice(0, 78) || `call-${++calls}`;
|
|
44
|
+
const approvalId = `${opts.session}-${use}`;
|
|
45
|
+
let summary;
|
|
46
|
+
try { ({ summary } = requestApproval({ id: approvalId, root: opts.root, taskId: opts.task, role: opts.role, vendor: opts.vendor, tool, input: args.input, waitSeconds: opts.wait })); }
|
|
47
|
+
catch (error) {
|
|
48
|
+
// The same call asked twice waits on the first request; anything else is answered as a denial.
|
|
49
|
+
if (!/already requested/.test(error.message)) { record({ id: approvalId, tool, summary: approvalSummary(tool, args.input), decision: 'deny', at: new Date().toISOString() }); return reply(id, text({ behavior: 'deny', message: DENIED })); }
|
|
50
|
+
summary = approvalSummary(tool, args.input);
|
|
51
|
+
}
|
|
52
|
+
const token = params?._meta?.progressToken; let ticks = 0;
|
|
53
|
+
const timer = setInterval(() => { if (token !== undefined) send({ method: 'notifications/progress', params: { progressToken: token, progress: ++ticks, message: `Waiting for the owner: ${summary}` } }); }, opts.progress * 1000);
|
|
54
|
+
let decision;
|
|
55
|
+
try { decision = await awaitDecision(approvalId, { waitSeconds: opts.wait }); } catch { decision = 'deny'; }
|
|
56
|
+
finally { clearInterval(timer); }
|
|
57
|
+
record({ id: approvalId, tool, summary, decision, at: new Date().toISOString() });
|
|
58
|
+
reply(id, text(decision === 'allow' ? { behavior: 'allow' } : { behavior: 'deny', message: decision === 'timeout' ? timeoutText(opts.wait) : DENIED }));
|
|
59
|
+
}
|
|
60
|
+
const text = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
|
|
61
|
+
function handle(row) {
|
|
62
|
+
const { id, method, params } = row;
|
|
63
|
+
if (method === 'initialize') return reply(id, { protocolVersion: params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: '1' } });
|
|
64
|
+
if (method === 'ping') return reply(id, {});
|
|
65
|
+
if (method === 'tools/list') return reply(id, { tools: [TOOL] });
|
|
66
|
+
if (method === 'tools/call') return params?.name === TOOL_NAME ? approve(id, params) : fail(id, -32602, `Unknown tool: ${String(params?.name ?? '').slice(0, 60)}`);
|
|
67
|
+
return fail(id, -32601, 'Method not found');
|
|
68
|
+
}
|
|
69
|
+
const lines = readline.createInterface({ input });
|
|
70
|
+
lines.on('line', line => {
|
|
71
|
+
if (!line.trim()) return;
|
|
72
|
+
let row; try { row = JSON.parse(line); } catch { return fail(null, -32700, 'Parse error'); }
|
|
73
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return fail(null, -32600, 'Invalid request');
|
|
74
|
+
if (row.method === undefined) return; // A response to a notification of ours: nothing to do.
|
|
75
|
+
if (row.id === undefined || row.id === null) return; // Notifications (initialized, cancelled) need no reply.
|
|
76
|
+
handle(row);
|
|
77
|
+
});
|
|
78
|
+
await new Promise(resolve => { lines.once('close', resolve); input.once('end', resolve); });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
82
|
+
process.stdout.on('error', () => process.exit(0));
|
|
83
|
+
try { await serve(); process.exit(0); }
|
|
84
|
+
catch (error) { process.stderr.write(`${error.message}\n`); process.exit(2); }
|
|
85
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { mkdir, noSymlinks, personalDirectory, readJson, refuse } from './util.mjs';
|
|
6
|
+
import { APPROVAL_WAIT_SECONDS } from './modes.mjs';
|
|
7
|
+
|
|
8
|
+
// The approval file protocol. A vendor relay (the Claude MCP server, an ACP client) writes
|
|
9
|
+
// a request under <personalDirectory()>/approvals and waits; the terminal answers it. Both
|
|
10
|
+
// files are written once, so the first writer wins and nothing is overwritten.
|
|
11
|
+
const ID = /^[A-Za-z0-9_-]{1,160}$/;
|
|
12
|
+
const INPUT_LIMIT = 4096, POLL_MS = 300;
|
|
13
|
+
// A summary of this length was cut: what it asks cannot be read whole.
|
|
14
|
+
export const SUMMARY_LIMIT = 200;
|
|
15
|
+
const control = new RegExp(`[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f${String.fromCodePoint(0x200b)}-${String.fromCodePoint(0x200f)}${String.fromCodePoint(0x2028)}-${String.fromCodePoint(0x202e)}${String.fromCodePoint(0x2060)}-${String.fromCodePoint(0x206f)}${String.fromCodePoint(0xfeff)}]`, 'g');
|
|
16
|
+
const clean = value => String(value ?? '').replace(control, '');
|
|
17
|
+
const checkId = id => { if (typeof id !== 'string' || !ID.test(id)) refuse('Invalid approval ID.'); return id; };
|
|
18
|
+
|
|
19
|
+
export function approvalsDir() {
|
|
20
|
+
const dir = path.join(personalDirectory(), 'approvals'); mkdir(dir);
|
|
21
|
+
if ((fs.statSync(dir).mode & 0o777) !== 0o700) fs.chmodSync(dir, 0o700);
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
const requestFile = id => path.join(approvalsDir(), `${id}.json`);
|
|
25
|
+
const answerFile = id => path.join(approvalsDir(), `${id}.answer.json`);
|
|
26
|
+
// A file is written whole under another name and then linked to its own, so a reader in
|
|
27
|
+
// another process never finds half of one, and the first writer still wins. The list
|
|
28
|
+
// skips the other name (it does not end in .json) and removes one a dead writer left.
|
|
29
|
+
const DRAFT = /^\.[A-Za-z0-9_.-]{1,180}\.[0-9a-f]{12}\.tmp$/, DRAFT_AGE_MS = 60000, NO_LINKS = ['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS'];
|
|
30
|
+
function writeOnce(file, value) {
|
|
31
|
+
noSymlinks(file);
|
|
32
|
+
const text = `${JSON.stringify(value, null, 2)}\n`, draft = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
33
|
+
fs.writeFileSync(draft, text, { flag: 'wx', mode: 0o600 });
|
|
34
|
+
try { fs.linkSync(draft, file); return true; }
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === 'EEXIST') return false;
|
|
37
|
+
if (!NO_LINKS.includes(error.code)) throw error;
|
|
38
|
+
// A file system without hard links: written in place, as before.
|
|
39
|
+
try { fs.writeFileSync(file, text, { flag: 'wx', mode: 0o600 }); return true; } catch (again) { if (again.code === 'EEXIST') return false; throw again; }
|
|
40
|
+
} finally { remove(draft); }
|
|
41
|
+
}
|
|
42
|
+
// Model output is clipped before it reaches disk or a screen: 4 KB of input, 200 characters of summary.
|
|
43
|
+
function clip(input) {
|
|
44
|
+
if (input === undefined) return null;
|
|
45
|
+
const text = typeof input === 'string' ? input : JSON.stringify(input) ?? 'null';
|
|
46
|
+
return text.length <= INPUT_LIMIT ? input : { clipped: true, text: text.slice(0, INPUT_LIMIT) };
|
|
47
|
+
}
|
|
48
|
+
const relativeToHome = (value, home) => { const root = home ?? os.homedir(); const text = String(value); return text === root ? '~' : text.startsWith(`${root}/`) ? `~/${text.slice(root.length + 1)}` : text; };
|
|
49
|
+
export function approvalSummary(tool, input, { home, title } = {}) {
|
|
50
|
+
const field = name => typeof input?.[name] === 'string' ? input[name] : '';
|
|
51
|
+
let text;
|
|
52
|
+
if (title) text = String(title);
|
|
53
|
+
else if (tool === 'Bash') text = `Run: ${field('command')}${input?.dangerouslyDisableSandbox === true ? ' outside the sandbox' : ''}`;
|
|
54
|
+
else if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool)) text = `Edit: ${relativeToHome(field('file_path') || field('notebook_path'), home)}`;
|
|
55
|
+
else if (tool === 'WebFetch') text = `Open: ${field('url')}`;
|
|
56
|
+
else if (tool === 'WebSearch') text = `Search: ${field('query')}`;
|
|
57
|
+
else text = `Use ${clean(tool) || 'a tool'}`;
|
|
58
|
+
return clean(text).replace(/\s+/g, ' ').trim().slice(0, SUMMARY_LIMIT);
|
|
59
|
+
}
|
|
60
|
+
export function requestApproval({ id, root, taskId, role, vendor, tool, input, title = null, waitSeconds = APPROVAL_WAIT_SECONDS, pid = process.pid }) {
|
|
61
|
+
checkId(id);
|
|
62
|
+
if (typeof tool !== 'string' || !tool.trim() || tool.length > 120) refuse('An approval names its tool.');
|
|
63
|
+
if (!Number.isFinite(waitSeconds) || waitSeconds <= 0) refuse('An approval needs a positive wait.');
|
|
64
|
+
const at = Date.now(), summary = approvalSummary(tool, input, { title });
|
|
65
|
+
const record = { version: 1, id, root: root ?? null, taskId: taskId == null ? null : String(taskId), role: role ?? null, vendor: vendor ?? null, tool: clean(tool).slice(0, 120), input: clip(input), title: title == null ? null : clean(title).slice(0, SUMMARY_LIMIT), summary, pid, at: new Date(at).toISOString(), expiresAt: new Date(at + waitSeconds * 1000).toISOString() };
|
|
66
|
+
if (!writeOnce(requestFile(id), record)) refuse('This approval was already requested.');
|
|
67
|
+
return { id, summary };
|
|
68
|
+
}
|
|
69
|
+
// true when the answer was recorded; false when it was already answered or the request is gone.
|
|
70
|
+
export function answerApproval(id, decision) {
|
|
71
|
+
checkId(id);
|
|
72
|
+
if (!['allow', 'deny'].includes(decision)) refuse('Answer allow or deny.');
|
|
73
|
+
const request = requestFile(id); noSymlinks(request);
|
|
74
|
+
if (!fs.existsSync(request)) return false;
|
|
75
|
+
return writeOnce(answerFile(id), { id, decision, at: new Date().toISOString() });
|
|
76
|
+
}
|
|
77
|
+
function readAnswer(id) {
|
|
78
|
+
const file = answerFile(id); noSymlinks(file);
|
|
79
|
+
if (!fs.existsSync(file)) return null;
|
|
80
|
+
try { const value = readJson(file); return ['allow', 'deny'].includes(value?.decision) ? value.decision : 'deny'; } catch { return 'deny'; }
|
|
81
|
+
}
|
|
82
|
+
const remove = file => { try { fs.rmSync(file, { force: true }); } catch { /* Pruned already. */ } };
|
|
83
|
+
// Polls for the owner's answer, then removes both files, the request first: with the answer
|
|
84
|
+
// gone and the request still there, a list made in between would show it as waiting again.
|
|
85
|
+
// An abort stops the wait.
|
|
86
|
+
export async function awaitDecision(id, { waitSeconds = APPROVAL_WAIT_SECONDS, signal } = {}) {
|
|
87
|
+
checkId(id);
|
|
88
|
+
const until = Date.now() + waitSeconds * 1000;
|
|
89
|
+
try {
|
|
90
|
+
for (;;) {
|
|
91
|
+
signal?.throwIfAborted();
|
|
92
|
+
const decision = readAnswer(id);
|
|
93
|
+
if (decision) return decision;
|
|
94
|
+
if (Date.now() >= until) return 'timeout';
|
|
95
|
+
await new Promise(resolve => { const timer = setTimeout(resolve, Math.min(POLL_MS, Math.max(1, until - Date.now()))); signal?.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); });
|
|
96
|
+
}
|
|
97
|
+
} finally { remove(requestFile(id)); remove(answerFile(id)); }
|
|
98
|
+
}
|
|
99
|
+
const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
100
|
+
// Requests still waiting for the owner, oldest first. Entries whose relay died or whose
|
|
101
|
+
// wait has passed are removed, so a crashed run leaves nothing to answer.
|
|
102
|
+
export function pendingApprovals({ root, taskId } = {}) {
|
|
103
|
+
const dir = approvalsDir(), now = Date.now(), rows = [], names = fs.readdirSync(dir);
|
|
104
|
+
for (const name of names.filter(name => DRAFT.test(name))) {
|
|
105
|
+
try { if (now - fs.lstatSync(path.join(dir, name)).mtimeMs > DRAFT_AGE_MS) remove(path.join(dir, name)); } catch { /* Linked and removed by its writer. */ }
|
|
106
|
+
}
|
|
107
|
+
for (const name of names.filter(name => name.endsWith('.json') && !name.endsWith('.answer.json'))) {
|
|
108
|
+
const id = name.slice(0, -5); if (!ID.test(id)) continue;
|
|
109
|
+
let record; try { record = readJson(path.join(dir, name)); } catch { remove(path.join(dir, name)); continue; }
|
|
110
|
+
if (!Number.isInteger(record?.pid) || !alive(record.pid) || !(Date.parse(record.expiresAt) > now)) { remove(path.join(dir, name)); remove(answerFile(id)); continue; }
|
|
111
|
+
// Decided in the meantime: its request was removed after it was read above.
|
|
112
|
+
if (fs.existsSync(answerFile(id)) || !fs.existsSync(path.join(dir, name))) continue;
|
|
113
|
+
if (root !== undefined && record.root !== root) continue;
|
|
114
|
+
if (taskId !== undefined && record.taskId !== String(taskId)) continue;
|
|
115
|
+
rows.push({ id, root: record.root, taskId: record.taskId, role: record.role, vendor: record.vendor, tool: record.tool, summary: clean(record.summary).slice(0, SUMMARY_LIMIT), at: record.at, expiresAt: record.expiresAt });
|
|
116
|
+
}
|
|
117
|
+
return rows.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
118
|
+
}
|
|
119
|
+
// A run's approval log: one JSON row per line, { id, tool, summary, decision, at }. Bad lines are skipped.
|
|
120
|
+
export function readApprovalLog(file) {
|
|
121
|
+
noSymlinks(file); if (!fs.existsSync(file)) return [];
|
|
122
|
+
const rows = [];
|
|
123
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n').slice(0, 1000)) {
|
|
124
|
+
if (!line.trim()) continue;
|
|
125
|
+
try { const row = JSON.parse(line); if (row && typeof row === 'object' && !Array.isArray(row)) rows.push({ id: clean(row.id).slice(0, 160), tool: clean(row.tool).slice(0, 120), summary: clean(row.summary).slice(0, SUMMARY_LIMIT), decision: ['allow', 'deny', 'timeout'].includes(row.decision) ? row.decision : 'deny', at: typeof row.at === 'string' ? row.at : null }); } catch { /* Skipped. */ }
|
|
126
|
+
}
|
|
127
|
+
return rows;
|
|
128
|
+
}
|
package/lib/awake.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { atomic, mkdir, noSymlinks, readJson } from './util.mjs';
|
|
5
|
+
import { waitLock } from './state.mjs';
|
|
6
|
+
import { personalDirectory } from './usage.mjs';
|
|
7
|
+
import { processIdentity } from './process.mjs';
|
|
8
|
+
import { listSchedules } from './schedules.mjs';
|
|
9
|
+
import { conductorStatus } from './conductor.mjs';
|
|
10
|
+
|
|
11
|
+
// While an agent works or a schedule is on, caffeinate -i keeps the Mac from sleeping when
|
|
12
|
+
// idle. The display still sleeps and locks as set. One caffeinate serves every terminal,
|
|
13
|
+
// the conductor and the scheduler. Its process is recorded, so whoever sees nothing left
|
|
14
|
+
// to do stops it.
|
|
15
|
+
const BIN = '/usr/bin/caffeinate';
|
|
16
|
+
const record = () => path.join(personalDirectory(), 'caffeinate.json');
|
|
17
|
+
|
|
18
|
+
export function keepAwake(wanted, { platform = process.platform, bin = BIN, start = spawn, stop = pid => process.kill(pid, 'SIGTERM') } = {}) {
|
|
19
|
+
if (platform !== 'darwin' || !fs.existsSync(bin)) return false;
|
|
20
|
+
// Tests use their own start and stop, and never keep the real computer awake.
|
|
21
|
+
if (process.env.NODE_TEST_CONTEXT && start === spawn) return false;
|
|
22
|
+
mkdir(personalDirectory());
|
|
23
|
+
const release = waitLock({ state: personalDirectory() }, 'caffeinate', 100);
|
|
24
|
+
try {
|
|
25
|
+
const file = record(); noSymlinks(file);
|
|
26
|
+
let saved = null; try { saved = fs.existsSync(file) ? readJson(file) : null; } catch { saved = null; }
|
|
27
|
+
const running = Boolean(saved?.identity) && processIdentity(saved.pid) === saved.identity;
|
|
28
|
+
if (wanted && !running) {
|
|
29
|
+
const child = start(bin, ['-i'], { detached: true, stdio: 'ignore' }); child.unref?.();
|
|
30
|
+
atomic(file, { pid: child.pid, identity: processIdentity(child.pid), startedAt: new Date().toISOString() });
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (!wanted && running) { try { stop(saved.pid); } catch { /* It already ended. */ } }
|
|
34
|
+
if (!wanted && saved) fs.rmSync(file, { force: true });
|
|
35
|
+
return wanted;
|
|
36
|
+
} finally { release(); }
|
|
37
|
+
}
|
|
38
|
+
// Busy means the conductor has work, or a schedule is on. busy overrides the first part
|
|
39
|
+
// for the conductor itself.
|
|
40
|
+
export function refreshAwake({ busy, ...options } = {}) {
|
|
41
|
+
let working = busy;
|
|
42
|
+
if (working === undefined) { try { working = conductorStatus().runs.length > 0; } catch { working = false; } }
|
|
43
|
+
let scheduled = false; try { scheduled = listSchedules().some(row => row.enabled); } catch { /* Unreadable schedules keep nothing awake. */ }
|
|
44
|
+
try { return keepAwake(working || scheduled, options); } catch { return false; }
|
|
45
|
+
}
|
package/lib/board.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { relativeTime, remote, peerName, threadWho, fitWords } from './terminal-visuals.mjs';
|
|
2
|
+
import { untilText } from './schedules.mjs';
|
|
3
|
+
import { botWork } from './bots.mjs';
|
|
4
|
+
import { taskTitle } from './state.mjs';
|
|
5
|
+
|
|
6
|
+
// One row per agent run, waiting question, queued request, or recent result, across
|
|
7
|
+
// every project. Rows needing the owner come first, then live work, then the queue.
|
|
8
|
+
const FINISHED = ['answered', 'ready', 'unverified', 'blocked', 'stopped'];
|
|
9
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
10
|
+
const time = value => { const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; };
|
|
11
|
+
const clock = ms => { const seconds = Math.max(0, Math.floor(ms / 1000)); return seconds < 3600 ? `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}` : `${Math.floor(seconds / 3600)}h${String(Math.floor(seconds % 3600 / 60)).padStart(2, '0')}`; };
|
|
12
|
+
const firstLine = value => String(value ?? '').split('\n').find(line => line.trim())?.trim() ?? '';
|
|
13
|
+
function cost(task) {
|
|
14
|
+
const known = (task.runs ?? []).filter(run => Number.isFinite(run.costUsd));
|
|
15
|
+
return known.length ? `${known.length < task.runs.length ? '≥' : ''}$${known.reduce((sum, run) => sum + run.costUsd, 0).toFixed(2)}` : '-';
|
|
16
|
+
}
|
|
17
|
+
// The newest thing a session did: its latest activity row that carries a step.
|
|
18
|
+
function latestStep(project, id) {
|
|
19
|
+
for (let index = project.activity.length - 1; index >= 0; index--) {
|
|
20
|
+
const row = project.activity[index];
|
|
21
|
+
if (typeof row.step === 'string' && String(row.taskId) === String(id)) return firstLine(row.step);
|
|
22
|
+
}
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
function working(task) {
|
|
26
|
+
if (task.state === 'preparing') return { agent: 'lead', label: 'preparing' };
|
|
27
|
+
if (!task.activeRole) return { agent: 'checks', label: 'checks' };
|
|
28
|
+
if (task.activeRole === 'lead') return { agent: 'lead', label: task.kind === 'conversation' ? 'answering' : 'editing' };
|
|
29
|
+
return botWork(task) ?? { agent: task.activeRole, label: 'reviewing' };
|
|
30
|
+
}
|
|
31
|
+
function outcome(task) {
|
|
32
|
+
const checks = task.evidence?.results, passed = checks?.filter(row => row.status === 'passed').length;
|
|
33
|
+
if (task.state === 'ready') return `checks ${checks ? `${passed}/${checks.length}` : '-'} · review ${task.reviewStatus ?? '-'}`;
|
|
34
|
+
if (task.state === 'unverified') return 'edits not verified · /publish runs the checks first';
|
|
35
|
+
if (task.state === 'answered') return firstLine((task.reply ?? task.result)?.summary) || firstLine(task.goal);
|
|
36
|
+
return firstLine(task.error?.message) || firstLine(task.goal);
|
|
37
|
+
}
|
|
38
|
+
// Peer work that needs the owner sits with the questions in group 0. An incoming message
|
|
39
|
+
// (question, review or task) waits in the peer daemon's files and arrives through
|
|
40
|
+
// peerItems(); an older one may still be a saved request (state waiting, with an origin).
|
|
41
|
+
// Held replies and agent drafts live only with the daemon.
|
|
42
|
+
// Asks and reviews from a teammate; a handoff becomes a session of its own.
|
|
43
|
+
export const peerQuestion = origin => ['ask', 'review'].includes(origin?.kind);
|
|
44
|
+
const ORIGIN = { ask: ['ASK', 'Question'], review: ['REVIEW', 'Review their diff'], handoff: ['TASK', 'Task'] };
|
|
45
|
+
function incoming(project, request, item, now) {
|
|
46
|
+
const who = peerName(request.origin.name, request.origin.peer), [label, lead] = ORIGIN[request.origin.kind] ?? ORIGIN.ask;
|
|
47
|
+
const text = request.origin.kind === 'review' ? '' : remote(request.context ?? item?.text, 400, { trusted: item?.trusted === true });
|
|
48
|
+
return { kind: 'request', root: project.root, project: project.name, id: '', request, item, agent: who, label: `${label} ${who}`, group: 0, order: time(request.createdAt), now: text ? `${lead} · ${text}` : lead, text: text || lead, time: relativeTime(request.createdAt, now), cost: '-' };
|
|
49
|
+
}
|
|
50
|
+
function peerRow(item, online, now) {
|
|
51
|
+
const who = threadWho(item), text = remote(item.text, 400, { trusted: item.trusted === true }), at = time(item.at);
|
|
52
|
+
const base = { kind: 'peer', item, root: typeof item.root === 'string' ? item.root : '', project: remote(item.project, 40) || '-', id: '', agent: who, cost: '-', time: relativeTime(item.at, now) };
|
|
53
|
+
if (item.kind === 'incoming') { const [label, lead] = ORIGIN[item.request ?? item.origin?.kind] ?? ORIGIN.ask; return { ...base, label: `${label} ${who}`, group: 0, order: at, now: text ? `${lead} · ${text}` : lead, text: text || lead }; }
|
|
54
|
+
if (item.kind === 'held') return { ...base, label: `REPLY ${who}`, group: 0, order: at, now: `Reply ready to send · ${text}`, text };
|
|
55
|
+
if (item.kind === 'draft') return { ...base, label: `SEND ${who}?`, group: 0, order: at, now: `Your model wants to ask · ${text}`, text };
|
|
56
|
+
if (item.kind === 'asked') return { ...base, label: 'asked', group: 2, order: 3e13 + at, now: item.team || online.has(item.peer) ? text : `${who} offline · will retry` };
|
|
57
|
+
if (['answer', 'expired', 'rejected'].includes(item.kind) && now - at < DAY) return { ...base, label: item.kind, group: 3, order: -at, now: item.kind === 'answer' ? `Answer · ${text}` : text || `Ask ${item.kind}`, text: text || `Ask ${item.kind}` };
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
export const peerDecision = row => row && (row.kind === 'peer' ? ['incoming', 'held', 'draft'].includes(row.item.kind) : row.kind === 'request' && Boolean(row.request.origin) && row.request.state === 'waiting');
|
|
61
|
+
// An incoming message from a teammate: y accepts it, n declines.
|
|
62
|
+
export const peerIncoming = row => peerDecision(row) && (row.kind === 'request' || row.item.kind === 'incoming');
|
|
63
|
+
// A live approval the lead waits on: its summary reads "Run: npm install".
|
|
64
|
+
// The row says "wants to run: npm install".
|
|
65
|
+
const wants = summary => String(summary ?? '').replace(/^(Run|Edit|Open|Search|Use)\b/, verb => verb.toLowerCase());
|
|
66
|
+
function approvalRow(approval, projects, now) {
|
|
67
|
+
const root = typeof approval.root === 'string' ? approval.root : '', name = projects.find(project => project.root === root)?.name ?? (root ? root.split('/').filter(Boolean).at(-1) : null) ?? '-';
|
|
68
|
+
const text = wants(approval.summary) || 'use a tool';
|
|
69
|
+
return { kind: 'approval', root, project: name, id: approval.taskId ?? '', approval, agent: approval.role ?? 'lead', label: 'WANTS', group: 0, order: time(approval.at), now: text, text, time: relativeTime(approval.at, now), cost: '-' };
|
|
70
|
+
}
|
|
71
|
+
// Finished work stays listed for `recent` ms (a day by default); peer threads for a day.
|
|
72
|
+
// approvals: pendingApprovals() rows, each waiting for y or n.
|
|
73
|
+
// archived: also list the sessions the owner archived, last, under their own heading.
|
|
74
|
+
export function boardRows(projects, now = Date.now(), { items = [], online = new Set(), recent: window = DAY, schedules = [], approvals = [], archived = false, timed = {} } = {}) {
|
|
75
|
+
const rows = [], matched = new Set();
|
|
76
|
+
items = Array.isArray(items) ? items.filter(item => item && typeof item === 'object' && typeof item.id === 'string') : [];
|
|
77
|
+
// A lead that waits for an allow or deny is not working. Its session's own row moves to
|
|
78
|
+
// Needs you and says what it wants: the session is listed once, with no spinner, and
|
|
79
|
+
// keeps its place under the cursor. An approval with no session row gets a row of its own.
|
|
80
|
+
const pending = (Array.isArray(approvals) ? approvals : []).filter(approval => approval && typeof approval.id === 'string'), held = new Set();
|
|
81
|
+
for (const project of projects) {
|
|
82
|
+
if (project.unavailable) continue;
|
|
83
|
+
for (const task of project.tasks ?? []) {
|
|
84
|
+
// A teammate's question is answered in its Chats thread, not as a session.
|
|
85
|
+
if (peerQuestion(task.origin)) continue;
|
|
86
|
+
if (project.closed?.includes(task.id)) {
|
|
87
|
+
if (!archived) continue;
|
|
88
|
+
const at = Math.max(time(task.createdAt), ...(task.runs ?? []).map(run => time(run.at)), ...(task.messages ?? []).map(row => time(row.at)));
|
|
89
|
+
rows.push({ kind: 'task', root: project.root, project: project.name, id: task.id, task, cost: cost(task), agent: '-', label: 'archived', archived: true, group: 5, order: -at, now: outcome(task), time: relativeTime(new Date(at).toISOString(), now) });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// timed: the bots of this session that run on a schedule (botSchedulesShown), when it has any.
|
|
93
|
+
const base = { kind: 'task', root: project.root, project: project.name, id: task.id, task, cost: cost(task), ...(task.origin ? { agent: peerName(task.origin.name, task.origin.peer) } : {}), ...(timed[project.root]?.[task.id] ? { timed: timed[project.root][task.id] } : {}) };
|
|
94
|
+
const last = Math.max(time(task.createdAt), ...(task.runs ?? []).map(run => time(run.at)), ...(task.messages ?? []).map(row => time(row.at)));
|
|
95
|
+
const asked = ['running', 'preparing'].includes(task.state) ? pending.filter(approval => String(approval.taskId) === String(task.id) && (!approval.root || approval.root === project.root)) : [];
|
|
96
|
+
if (asked.length) {
|
|
97
|
+
for (const approval of asked) held.add(approval.id);
|
|
98
|
+
const [approval] = asked, text = wants(approval.summary) || 'use a tool';
|
|
99
|
+
// The age is how long the approval has waited, not how long the session has run.
|
|
100
|
+
rows.push({ ...base, agent: approval.role ?? 'lead', label: 'WANTS', approval, group: 0, order: time(approval.at), now: text, text, time: relativeTime(approval.at, now) });
|
|
101
|
+
} else if (['running', 'preparing'].includes(task.state)) {
|
|
102
|
+
const since = time(task.activeSince) || time(task.createdAt);
|
|
103
|
+
rows.push({ ...working(task), ...base, group: 1, order: since, now: latestStep(project, task.id) || firstLine(task.goal), time: clock(now - since) });
|
|
104
|
+
} else if (task.plan?.state === 'proposed') {
|
|
105
|
+
// A Task that waits for the owner's yes (lib/plan.mjs) needs them like a question does.
|
|
106
|
+
rows.push({ agent: 'lead', ...base, label: 'TASK', group: 0, order: last, now: `Task to approve: ${task.plan.title}`, time: relativeTime(new Date(last).toISOString(), now) });
|
|
107
|
+
} else if (task.state === 'needs_input') {
|
|
108
|
+
const response = task.reply?.outcome === 'needs_input' ? task.reply : task.result;
|
|
109
|
+
rows.push({ agent: 'lead', ...base, label: 'WAITING YOU', group: 0, order: last, now: firstLine(response?.question) || firstLine(task.goal), time: relativeTime(new Date(last).toISOString(), now) });
|
|
110
|
+
} else if (FINISHED.includes(task.state) && now - last < window) {
|
|
111
|
+
rows.push({ agent: '-', ...base, label: task.state, group: 3, order: -last, now: outcome(task), time: relativeTime(new Date(last).toISOString(), now) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const request of project.queue ?? []) {
|
|
115
|
+
if (!['queued', 'starting', 'blocked', 'waiting'].includes(request.state)) continue;
|
|
116
|
+
if (request.origin) { matched.add(request.id); if (request.state === 'waiting') { rows.push(incoming(project, request, items.find(item => item.requestId === request.id), now)); continue; } if (peerQuestion(request.origin)) continue; }
|
|
117
|
+
const delay = time(request.notBefore) - now;
|
|
118
|
+
const label = request.state === 'queued' ? delay > 0 ? `retry ${Math.ceil(delay / 1000)}s` : 'queued' : request.state === 'waiting' ? 'saved' : request.state;
|
|
119
|
+
const text = request.state === 'blocked' ? `${firstLine(request.goal)} — ${firstLine(request.reason)}` : firstLine(request.goal);
|
|
120
|
+
// A blocked or saved request waits for the owner, like a question.
|
|
121
|
+
const owner = ['blocked', 'waiting'].includes(request.state);
|
|
122
|
+
rows.push({ kind: 'request', root: project.root, project: project.name, id: request.continuationTaskId ?? '', request, agent: request.origin ? peerName(request.origin.name, request.origin.peer) : '-', label, group: owner ? 0 : 2, order: owner ? time(request.createdAt) : (request.priority ?? 1) * 1e13 + time(request.queuedAt ?? request.createdAt), now: text, time: relativeTime(request.queuedAt ?? request.createdAt, now), cost: '-' });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const approval of pending) if (!held.has(approval.id)) rows.push(approvalRow(approval, projects, now));
|
|
126
|
+
for (const item of items) if (!(item.kind === 'incoming' && matched.has(item.requestId))) { const row = peerRow(item, online, now); if (row) rows.push(row); }
|
|
127
|
+
// Schedules come last: they wait for their time, not for the owner.
|
|
128
|
+
for (const schedule of schedules) {
|
|
129
|
+
const name = projects.find(project => project.root === schedule.root)?.name ?? schedule.project ?? '-';
|
|
130
|
+
const next = schedule.enabled ? time(schedule.nextRunAt) : 0;
|
|
131
|
+
rows.push({ kind: 'schedule', root: schedule.root, project: name, id: '', schedule, agent: 'lead', label: schedule.when.label, group: 4, order: schedule.enabled ? next : 9e15 + time(schedule.createdAt),
|
|
132
|
+
now: firstLine(schedule.goal), text: schedule.note ? `${firstLine(schedule.goal)} — ${firstLine(schedule.note)}` : firstLine(schedule.goal), time: schedule.enabled ? untilText(schedule.nextRunAt, now) : schedule.nextRunAt || schedule.when.kind !== 'once' ? 'paused' : 'done' });
|
|
133
|
+
}
|
|
134
|
+
const recent = rows.filter(row => row.group === 3).sort((a, b) => a.order - b.order).slice(0, 30);
|
|
135
|
+
// A live approval blocks its lead right now, so it leads Needs you.
|
|
136
|
+
return [...rows.filter(row => row.group !== 3), ...recent].sort((a, b) => a.group - b.group || Number(Boolean(b.approval)) - Number(Boolean(a.approval)) || a.order - b.order)
|
|
137
|
+
.map(row => ({ ...row, key: `${row.root}\0${row.kind}\0${row.kind === 'task' ? row.id : row.kind === 'peer' ? row.item.id : row.kind === 'schedule' ? row.schedule.id : row.kind === 'approval' ? row.approval.id : row.request.id}` }));
|
|
138
|
+
}
|
|
139
|
+
const fit = (value, width) => { const text = String(value ?? ''); return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text.padEnd(width); };
|
|
140
|
+
const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
141
|
+
// An incoming row reads "@name question", "@name review" or "@name task".
|
|
142
|
+
const PEER_VERB = { ask: ['?', 'question'], review: ['◇', 'review'], handoff: ['⇄', 'task'] };
|
|
143
|
+
// What a row shows first: an icon and tone for its state, and who does what. A session is
|
|
144
|
+
// known by its title; its number is never shown.
|
|
145
|
+
export function rowBadge(row, now = Date.now()) {
|
|
146
|
+
// A row is short in the list: the session's model is not named, a bot or a reviewer is.
|
|
147
|
+
const who = row.agent === 'lead' ? '' : `${row.agent} `;
|
|
148
|
+
if (row.approval) return { icon: '?', tone: 'orange', title: `${who}wants to` };
|
|
149
|
+
if (row.kind === 'task') {
|
|
150
|
+
const state = row.task.state;
|
|
151
|
+
if (row.archived) return { icon: '▫', tone: 'muted', title: 'archived' };
|
|
152
|
+
if (row.group === 1) return { icon: SPINNER[Math.floor(now / 100) % SPINNER.length], tone: 'blue', title: `${who}${row.label}` };
|
|
153
|
+
if (row.group === 0 && row.task.plan?.state === 'proposed') return { icon: '?', tone: 'orange', title: 'Task to approve' };
|
|
154
|
+
if (state === 'needs_input') return { icon: '?', tone: 'orange', title: 'asks you' };
|
|
155
|
+
if (state === 'ready') return { icon: '✓', tone: 'green', title: 'ready to publish' };
|
|
156
|
+
if (state === 'answered') return { icon: '✓', tone: 'green', title: 'answered' };
|
|
157
|
+
if (state === 'unverified') return { icon: '!', tone: 'orange', title: 'not verified' };
|
|
158
|
+
if (state === 'blocked') return { icon: '✗', tone: 'red', title: 'blocked' };
|
|
159
|
+
return { icon: '■', tone: 'muted', title: state };
|
|
160
|
+
}
|
|
161
|
+
if (row.kind === 'schedule') return { icon: '◷', tone: row.schedule.enabled ? 'blue' : 'muted', title: row.label };
|
|
162
|
+
if (row.kind === 'request') {
|
|
163
|
+
if (row.group === 0 && row.request.origin && row.request.state === 'waiting') { const [icon, verb] = PEER_VERB[row.request.origin.kind] ?? PEER_VERB.ask; return { icon, tone: 'orange', title: `${row.agent} ${verb}` }; }
|
|
164
|
+
const state = row.request.state, peer = row.agent !== '-' ? ` · ${row.agent}` : '';
|
|
165
|
+
if (state === 'blocked') return { icon: '✗', tone: 'red', title: `blocked${peer}` };
|
|
166
|
+
if (state === 'waiting') return { icon: '!', tone: 'orange', title: `saved${peer}` };
|
|
167
|
+
if (state === 'starting') return { icon: '›', tone: 'blue', title: `starting${peer}` };
|
|
168
|
+
return { icon: '·', tone: 'muted', title: `${row.label}${peer}` };
|
|
169
|
+
}
|
|
170
|
+
const kind = row.item.kind;
|
|
171
|
+
if (kind === 'incoming') { const [icon, verb] = PEER_VERB[row.item.request ?? row.item.origin?.kind] ?? PEER_VERB.ask; return { icon, tone: 'orange', title: `${row.agent} ${verb}` }; }
|
|
172
|
+
if (kind === 'held') return { icon: '↩', tone: 'orange', title: `reply to ${row.agent}` };
|
|
173
|
+
if (kind === 'draft') return { icon: '✎', tone: 'orange', title: `ask ${row.agent}?` };
|
|
174
|
+
if (kind === 'asked') return { icon: '→', tone: 'blue', title: `asked ${row.agent}` };
|
|
175
|
+
if (kind === 'answer') return { icon: '←', tone: 'green', title: `${row.agent} answered` };
|
|
176
|
+
return { icon: '·', tone: 'muted', title: `ask ${kind} · ${row.agent}` };
|
|
177
|
+
}
|
|
178
|
+
// The Sessions bar, beside the open session. A session reads "Trim coupon codes"; any
|
|
179
|
+
// other row reads as its badge and its text.
|
|
180
|
+
// A session at rest with a bot on a schedule shows when that bot runs next, in place of its age.
|
|
181
|
+
const nextTimed = row => row.group === 3 ? (row.timed ?? []).filter(value => value.on && value.nextRunAt).map(value => value.nextRunAt).sort()[0] ?? null : null;
|
|
182
|
+
const ageText = (row, now = Date.now()) => nextTimed(row) ? untilText(nextTimed(row), now) : String(row.time ?? '').replace('just now', 'now').replace(/ ago$/, '');
|
|
183
|
+
// Cut at a word, as fitWords does. The bar is narrow, so a word that ends exactly at the edge
|
|
184
|
+
// stays, and a cut never ends on the dot between two parts.
|
|
185
|
+
const fitBar = (value, width) => {
|
|
186
|
+
const text = String(value ?? '').replace(/\s/g, ' ').trimEnd();
|
|
187
|
+
return (width > 1 && text.length > width && text[width - 1] === ' ' ? `${text.slice(0, width - 1).trimEnd()}…` : fitWords(text, width)).replace(/ ·…$/, '…');
|
|
188
|
+
};
|
|
189
|
+
// room: how wide the title may be, when the row is drawn.
|
|
190
|
+
export function rowTitle(row, now = Date.now(), room = Infinity) {
|
|
191
|
+
if (row.kind === 'task' && !row.approval) return taskTitle(row.task);
|
|
192
|
+
// A request of yours is the message you wrote; one that waits to retry says so first.
|
|
193
|
+
if (row.kind === 'request' && !peerIncoming(row)) return `${row.label.startsWith('retry') ? `${row.label} · ` : ''}${firstLine(row.request.goal)}`;
|
|
194
|
+
const title = rowBadge(row, now).title, text = row.kind === 'schedule' ? row.now : row.text ?? row.now;
|
|
195
|
+
// A session that waits on an approval keeps its name, then what is wanted. When the row
|
|
196
|
+
// is too narrow for the name and the start of the approval, the name is cut first, at a
|
|
197
|
+
// word; a name that would be left with one word keeps the row instead.
|
|
198
|
+
if (row.kind === 'task') {
|
|
199
|
+
const want = row.agent === 'lead' ? text : `${title} ${text}`, name = taskTitle(row.task), short = Number.isFinite(room) && name.length + 3 + Math.min(want.length, 9) > room ? fitBar(name, Math.max(8, room - 12)) : name;
|
|
200
|
+
return `${short.includes(' ') ? short : name} · ${want}`;
|
|
201
|
+
}
|
|
202
|
+
return row.approval ? `${title} ${text}` : [title, text].filter(Boolean).join(' · ');
|
|
203
|
+
}
|
|
204
|
+
// The bar's project column, when its rows come from more than one project: as wide as the
|
|
205
|
+
// longest name as it will show, cut at a word to about a quarter of the row. The ages then
|
|
206
|
+
// share a width too, so the names line up.
|
|
207
|
+
export function barColumns(rows, width) {
|
|
208
|
+
const roots = new Set(rows.map(row => row.root).filter(Boolean)), names = rows.map(row => row.project).filter(name => name && name !== '-'), most = Math.max(7, Math.floor(width * 0.28));
|
|
209
|
+
if (roots.size < 2 || !names.length) return { project: 0, clock: 0 };
|
|
210
|
+
return { project: Math.max(...names.map(name => fitBar(name, most).length)), clock: Math.min(6, Math.max(...rows.map(row => ageText(row).length))) };
|
|
211
|
+
}
|
|
212
|
+
// One row as coloured cells that add up to exactly `width` characters: the state's icon,
|
|
213
|
+
// the title cut at a word, the project when asked for, and the age at the right edge.
|
|
214
|
+
// A row too narrow for both keeps the title and leaves the project out.
|
|
215
|
+
export function barCells(row, width, now = Date.now(), { project = 0, clock = 0 } = {}) {
|
|
216
|
+
const badge = rowBadge(row, now), quiet = row.group === 3 || badge.tone === 'muted', age = fit(ageText(row, now), Math.min(ageText(row, now).length, 6)).padStart(clock);
|
|
217
|
+
const named = project && width - 2 - (project + 1) - (age.length + 1) >= 12 ? project : 0, room = Math.max(1, width - 2 - (named ? named + 1 : 0) - (age.length + 1));
|
|
218
|
+
return [
|
|
219
|
+
{ text: `${badge.icon} `, tone: badge.tone, bold: true },
|
|
220
|
+
{ text: fitBar(rowTitle(row, now, room), room).padEnd(room), tone: quiet ? 'muted' : 'text', bold: row.group === 0 },
|
|
221
|
+
...(named ? [{ text: ` ${fitBar(row.project === '-' ? '' : row.project, named).padEnd(named)}`, tone: 'muted' }] : []),
|
|
222
|
+
{ text: ` ${age}`, tone: nextTimed(row) ? 'blue' : 'muted' },
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
// What a row that is not a session shows beside the list: its badge and project, its whole
|
|
226
|
+
// text, and the keys that act on it.
|
|
227
|
+
export function rowCard(row, now = Date.now()) {
|
|
228
|
+
const badge = rowBadge(row, now), keys = rowActions(row).map(([key, label]) => `${key} ${label}`).join(' · ');
|
|
229
|
+
const text = row.kind === 'schedule' ? [row.text, row.schedule.enabled ? `Next run ${row.time}.` : row.time === 'paused' ? 'Paused.' : 'It has run.'].join('\n') : row.kind === 'approval' ? `${row.agent === 'lead' ? 'The model' : row.agent} wants to ${row.text}` : row.now;
|
|
230
|
+
return { title: [`${badge.icon} ${badge.title}`, row.project === '-' ? '' : row.project].filter(Boolean).join(' · '), tone: badge.tone === 'muted' ? 'text' : badge.tone, text, keys };
|
|
231
|
+
}
|
|
232
|
+
export const BOARD_GROUPS = [['Needs you', 'orange'], ['Running', 'blue'], ['Queued', 'text'], ['Recent', 'green'], ['Scheduled', 'blue'], ['Archived', 'muted']];
|
|
233
|
+
// Board lines: a heading per non-empty group, a blank line between groups, then the rows.
|
|
234
|
+
export function boardSections(rows) {
|
|
235
|
+
const lines = [];
|
|
236
|
+
BOARD_GROUPS.forEach(([name, tone], group) => {
|
|
237
|
+
const part = rows.filter(row => row.group === group);
|
|
238
|
+
if (!part.length) return;
|
|
239
|
+
if (lines.length) lines.push({ spacer: true });
|
|
240
|
+
lines.push({ header: name, tone, count: part.length });
|
|
241
|
+
for (const row of part) lines.push({ row });
|
|
242
|
+
});
|
|
243
|
+
return lines;
|
|
244
|
+
}
|
|
245
|
+
// The keys that act on a row, most useful first; the key bar shows the first three.
|
|
246
|
+
export function rowActions(row) {
|
|
247
|
+
if (!row) return [];
|
|
248
|
+
if (row.approval) return [['y', 'allow'], ['n', 'deny'], ['enter', 'open']];
|
|
249
|
+
if (peerDecision(row)) return row.kind === 'peer' && ['held', 'draft'].includes(row.item.kind) ? [['y', 'send'], ['n', 'discard'], ['enter', 'read']] : [['y', 'accept'], ['n', 'decline'], ['enter', row.kind === 'peer' ? 'read' : 'open']];
|
|
250
|
+
if (row.kind === 'peer') return [['enter', 'read'], ['n', row.item.kind === 'asked' ? 'cancel' : 'dismiss']];
|
|
251
|
+
if (row.kind === 'schedule') return [['r', 'run now'], ['p', row.schedule.enabled ? 'pause' : 'resume'], ['x', 'delete'], ['enter', 'last run']];
|
|
252
|
+
if (row.kind === 'request') return ['blocked', 'waiting'].includes(row.request.state) ? [['enter', 'open'], ['r', 'retry'], ['s', 'cancel']] : [['enter', 'open'], ['s', 'cancel']];
|
|
253
|
+
if (row.archived) return [['enter', 'open'], ['u', 'unarchive'], ['e', 'rename']];
|
|
254
|
+
if (row.group === 1) return [['enter', 'open'], ['s', 'stop'], ['d', 'changes'], ['e', 'rename']];
|
|
255
|
+
if (row.group === 0 && row.task.plan?.state === 'proposed') return [['y', 'approve'], ['n', 'reject'], ['enter', 'open'], ['e', 'rename'], ['c', 'archive']];
|
|
256
|
+
const state = row.task.state;
|
|
257
|
+
if (state === 'needs_input') return [['enter', 'open'], ['a', 'answer'], ['d', 'changes'], ['e', 'rename'], ['c', 'archive']];
|
|
258
|
+
if (state === 'ready') return [['enter', 'open'], ['p', 'publish'], ['d', 'changes'], ['e', 'rename'], ['c', 'archive']];
|
|
259
|
+
if (state === 'answered') return [['enter', 'open'], ['a', 'follow up'], ['e', 'rename'], ['c', 'archive']];
|
|
260
|
+
return [['enter', 'open'], ['r', 'retry'], ['d', 'changes'], ['e', 'rename'], ['c', 'archive']];
|
|
261
|
+
}
|