@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/sandbox.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { findBin, refuse, mkdir, noSymlinks } from './util.mjs';
|
|
5
|
+
import { assertNoCodexProjectConfig } from './policy.mjs';
|
|
6
|
+
import { agentEnv, runProcess } from './process.mjs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
// Whether Claude Code's shell sandbox can start on this machine. On Linux it is bubblewrap: it
|
|
12
|
+
// needs bwrap and socat on PATH, and bwrap must be allowed to make its namespaces, which
|
|
13
|
+
// Ubuntu 24.04 stops until an AppArmor profile for it is loaded (docs/permissions.md, Linux).
|
|
14
|
+
// 'missing': a tool is not installed. 'blocked': both are, and bwrap cannot start. 'ok'
|
|
15
|
+
// otherwise, and on every other system, where nothing is started. bwrap is really started,
|
|
16
|
+
// once, around a command that does nothing, so ask once per app start and keep the answer.
|
|
17
|
+
export function shellSandbox({ platform = process.platform, env = process.env } = {}) {
|
|
18
|
+
if (platform !== 'linux') return 'ok';
|
|
19
|
+
const bwrap = findBin('bwrap', env);
|
|
20
|
+
if (!bwrap || !findBin('socat', env)) return 'missing';
|
|
21
|
+
const started = spawnSync(bwrap, ['--unshare-net', '--ro-bind', '/', '/', '/bin/sh', '-c', ':'], { env, stdio: 'ignore', timeout: 5000 });
|
|
22
|
+
return started.status === 0 ? 'ok' : 'blocked';
|
|
23
|
+
}
|
|
24
|
+
// What the Permissions picker says about it: nothing where the sandbox works.
|
|
25
|
+
export function shellSandboxLine(state = shellSandbox()) {
|
|
26
|
+
if (state === 'missing') return ['No OS sandbox here: commands ask you (Ask) or are reviewed (Approve for me).'];
|
|
27
|
+
if (state === 'blocked') return ['The OS sandbox cannot start here, so commands ask you (Ask) or are reviewed (Approve for me). See https://github.com/ELU-Labs/elu/blob/main/docs/permissions.md#linux'];
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The helper is the one program started outside the sandbox, so only the owner's own
|
|
32
|
+
// environment names it. A project's settings file comes with the repository and never does.
|
|
33
|
+
export function findSandboxHelper(config={},env=process.env){
|
|
34
|
+
const configured=env.ELU_CODE_SANDBOX_HELPER;
|
|
35
|
+
if(configured){
|
|
36
|
+
if(!path.isAbsolute(configured))refuse('The sandbox helper path must be absolute.');
|
|
37
|
+
try{fs.accessSync(configured,fs.constants.X_OK);if(fs.statSync(configured).isFile())return fs.realpathSync(configured);}catch{}
|
|
38
|
+
refuse('The configured sandbox helper is missing or not executable.');
|
|
39
|
+
}
|
|
40
|
+
const bin=findBin('codex',env);if(!bin)refuse('Install the Codex sandbox helper, or set ELU_CODE_SANDBOX_HELPER to its absolute path.');return bin;
|
|
41
|
+
}
|
|
42
|
+
export async function probeSandbox(config={}){
|
|
43
|
+
const helper=findSandboxHelper(config);
|
|
44
|
+
const root=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'elu-code-sandbox-proof-')));
|
|
45
|
+
const cwd=path.join(root,'worktree');mkdir(cwd);const home=path.join(root,'home');mkdir(home);
|
|
46
|
+
mkdir(path.join(home,'.codex'));mkdir(path.join(home,'.config'));
|
|
47
|
+
const outside=path.join(root,'outside');const server=net.createServer(socket=>socket.end('probe'));
|
|
48
|
+
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
|
49
|
+
const script=`const fs=require('fs'),net=require('net');fs.writeFileSync('allowed','yes');let writeDenied=false;try{fs.writeFileSync(${JSON.stringify(outside)},'bad')}catch{writeDenied=true}const socket=net.connect(${server.address().port},'127.0.0.1');const done=(networkDenied)=>{socket.destroy();console.log(JSON.stringify({writeDenied,networkDenied}));};socket.once('error',()=>done(true));socket.once('connect',()=>done(false));socket.setTimeout(1500,()=>done(false));`;
|
|
50
|
+
try{
|
|
51
|
+
const run=await sandboxRun({cwd,helper,program:process.execPath,args:['-e',script,'elu-code-sandbox-probe'],logFile:path.join(root,'probe.log'),timeoutSeconds:10,env:{HOME:home,CODEX_HOME:path.join(home,'.codex'),XDG_CONFIG_HOME:path.join(home,'.config')}});
|
|
52
|
+
let proof;try{proof=JSON.parse(fs.readFileSync(path.join(root,'probe.log'),'utf8').trim());}catch{}
|
|
53
|
+
if(run.code!==0||run.timedOut||typeof proof?.writeDenied!=='boolean'||!proof?.networkDenied||fs.existsSync(outside))refuse('The sandbox helper failed its file-write or network test. Nothing was started.',{exitCode:run.code,proof:proof??null,diagnostic:fs.readFileSync(run.stderrFile,'utf8').slice(-1500)});
|
|
54
|
+
return {available:true,working:true,writeDenied:proof.writeDenied,outsideWriteContained:true,networkDenied:true,requiresSignIn:false};
|
|
55
|
+
}finally{await new Promise(resolve=>server.close(resolve));fs.rmSync(root,{recursive:true,force:true});}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// This uses the vendor's sandbox helper. It never supplies a sandbox implementation.
|
|
59
|
+
// network: 'off' (every other check) or 'on' (checks of a Full access session).
|
|
60
|
+
export function sandboxArgs({ cwd, protectedPaths = [], writablePaths = ['.'], scratch, program, args = [], readPaths = [], deniedPaths=[], helper=findBin('codex'), network = 'off' }) {
|
|
61
|
+
if (!['off', 'on'].includes(network)) refuse('Network is off or on.');
|
|
62
|
+
const name = `elu_code_${crypto.randomBytes(8).toString('hex')}`;
|
|
63
|
+
const filesystem = { ':minimal': 'read', [cwd]: 'read' };
|
|
64
|
+
for (const folder of ['/tmp','/private/tmp','/var/tmp','/private/var/tmp']) filesystem[folder]='deny';
|
|
65
|
+
for (const file of writablePaths) filesystem[path.resolve(cwd, file)] = 'write';
|
|
66
|
+
for (const file of protectedPaths) filesystem[path.resolve(cwd, file)] = 'read';
|
|
67
|
+
if(fs.existsSync(path.join(cwd,'.git')))filesystem[path.join(cwd, '.git')] = 'deny';
|
|
68
|
+
for (const file of readPaths) filesystem[file] = 'read';
|
|
69
|
+
for (const file of ['.env', '.env.*', '**/.env', '**/.env.*', '**/.npmrc', '**/auth.json', '**/credentials.json', '**/*.pem']) filesystem[path.join(cwd, file)] = 'deny';
|
|
70
|
+
if (scratch) filesystem[scratch] = 'write';
|
|
71
|
+
const runtime = path.dirname(fs.realpathSync(process.execPath));
|
|
72
|
+
filesystem[runtime] = 'read';
|
|
73
|
+
const executable = path.isAbsolute(program) ? program : findBin(program);
|
|
74
|
+
if (!executable) refuse(`Check program is not installed: ${program}`);
|
|
75
|
+
for(const file of [executable,helper].filter(Boolean)){
|
|
76
|
+
let executableRoot = path.dirname(fs.realpathSync(file));
|
|
77
|
+
const modulePart = executableRoot.indexOf(`${path.sep}node_modules${path.sep}`);
|
|
78
|
+
if (modulePart >= 0) {
|
|
79
|
+
const prefix = executableRoot.slice(0, modulePart + '/node_modules/'.length);
|
|
80
|
+
const pieces = executableRoot.slice(prefix.length).split(path.sep);
|
|
81
|
+
executableRoot = path.join(prefix, ...pieces.slice(0, pieces[0].startsWith('@') ? 2 : 1));
|
|
82
|
+
}
|
|
83
|
+
filesystem[executableRoot] = 'read';
|
|
84
|
+
}
|
|
85
|
+
for(const file of deniedPaths)filesystem[file]='deny';
|
|
86
|
+
const entries = Object.entries(filesystem).map(([key, access]) => `${JSON.stringify(key)}=${JSON.stringify(access)}`).join(',');
|
|
87
|
+
const profile = `{filesystem={${entries}},network={enabled=${network === 'on'}}}`;
|
|
88
|
+
return ['sandbox', '-P', name, '-c', `permissions.${name}=${profile}`, '-c', 'features.hooks=false', '-c', 'shell_environment_policy.inherit="all"', '-c', 'shell_environment_policy.set.OPENSSL_CONF="/dev/null"', '--', program, ...args];
|
|
89
|
+
}
|
|
90
|
+
export async function sandboxRun({ cwd, protectedPaths, writablePaths, scratch, program, args, logFile, processFile, timeoutSeconds = 120, env: extraEnv = {}, readPaths, helper, config, signal, network = 'off' }) {
|
|
91
|
+
const bin = helper??findSandboxHelper(config);
|
|
92
|
+
assertNoCodexProjectConfig(cwd);
|
|
93
|
+
const ownedScratch=!scratch;
|
|
94
|
+
scratch ??= path.join(cwd,'.elu-code','tmp',crypto.randomUUID());
|
|
95
|
+
noSymlinks(scratch); mkdir(scratch);
|
|
96
|
+
const helperHome=path.join(scratch,'codex-home');mkdir(helperHome);
|
|
97
|
+
const temporary=path.join(scratch,'tmp');mkdir(temporary);
|
|
98
|
+
const globalConfig = path.join(scratch ?? cwd, 'global.npmrc');
|
|
99
|
+
const env = { ...agentEnv(), ...extraEnv, CODEX_HOME:helperHome, OPENSSL_CONF: '/dev/null', npm_config_userconfig: '/dev/null', npm_config_globalconfig: globalConfig, npm_config_script_shell: '/bin/sh', npm_config_update_notifier: 'false', npm_config_audit: 'false', npm_config_fund: 'false', TMPDIR:temporary, npm_config_cache: path.join(scratch, 'npm-cache') };
|
|
100
|
+
try { return await runProcess({ bin, args: sandboxArgs({ cwd, protectedPaths, writablePaths, scratch, program, args, readPaths, helper:bin, network }), cwd, env, logFile, processFile, timeoutSeconds, signal }); }
|
|
101
|
+
finally { if(ownedScratch)fs.rmSync(scratch,{recursive:true,force:true}); }
|
|
102
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { atomic, cleanPath, mkdir, noSymlinks, readJson, refuse } from './util.mjs';
|
|
8
|
+
import { isInstalled, BY_HAND } from './install.mjs';
|
|
9
|
+
import { waitLock } from './state.mjs';
|
|
10
|
+
import { personalDirectory } from './usage.mjs';
|
|
11
|
+
import { enqueue, startConductor } from './conductor.mjs';
|
|
12
|
+
import { pendingRequests } from './workspace.mjs';
|
|
13
|
+
import { MODES, checkMode } from './modes.mjs';
|
|
14
|
+
import { checkModel } from './models.mjs';
|
|
15
|
+
import { unitArgument, unitEnvironment } from './unit-file.mjs';
|
|
16
|
+
|
|
17
|
+
// Schedules send a message to a project's lead at set times. Each run is a queued
|
|
18
|
+
// request, like a message typed in Tasks, so it ends at "ready" and nothing is pushed.
|
|
19
|
+
// A login item checks every minute, so runs happen while the computer is awake even
|
|
20
|
+
// with ELU Code closed. Times are the computer's local time.
|
|
21
|
+
|
|
22
|
+
const FULL_DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
23
|
+
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
24
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
25
|
+
const MIN_EVERY = 15, MAX_EVERY = 7 * 24 * 60;
|
|
26
|
+
export const WHEN_HELP = 'Examples: daily 9:00 weekdays 9am mon,thu 14:30 every 2h tomorrow 15:00 2026-12-01 9:00';
|
|
27
|
+
|
|
28
|
+
const file = () => path.join(personalDirectory(), 'schedules.json');
|
|
29
|
+
const pad = value => String(value).padStart(2, '0');
|
|
30
|
+
export const clockText = (hour, minute) => `${hour}:${pad(minute)}`;
|
|
31
|
+
|
|
32
|
+
function parseTime(word) {
|
|
33
|
+
const match = /^(\d{1,2})(?::(\d{2}))?(am|pm)?$/.exec(word ?? '');
|
|
34
|
+
if (!match) return null;
|
|
35
|
+
let hour = Number(match[1]); const minute = match[2] ? Number(match[2]) : 0, half = match[3];
|
|
36
|
+
if (minute > 59) return null;
|
|
37
|
+
if (half) { if (hour < 1 || hour > 12) return null; hour = hour % 12 + (half === 'pm' ? 12 : 0); }
|
|
38
|
+
else if (hour > 23) return null;
|
|
39
|
+
return { hour, minute };
|
|
40
|
+
}
|
|
41
|
+
function parseDays(word) {
|
|
42
|
+
const days = new Set();
|
|
43
|
+
for (const part of word.split(',').filter(Boolean)) {
|
|
44
|
+
const range = part.split('-');
|
|
45
|
+
const index = name => name.length >= 3 ? FULL_DAYS.findIndex(day => day.startsWith(name)) : -1;
|
|
46
|
+
if (range.length === 2) {
|
|
47
|
+
const from = index(range[0]), to = index(range[1]); if (from < 0 || to < 0) return null;
|
|
48
|
+
for (let day = from; ; day = (day + 1) % 7) { days.add(day); if (day === to) break; }
|
|
49
|
+
} else { const day = index(part); if (day < 0) return null; days.add(day); }
|
|
50
|
+
}
|
|
51
|
+
return days.size ? [...days].sort((a, b) => a - b) : null;
|
|
52
|
+
}
|
|
53
|
+
function duration(count, unit) {
|
|
54
|
+
const value = Number(count);
|
|
55
|
+
if (/^(m|min|mins|minute|minutes)$/.test(unit)) return value;
|
|
56
|
+
if (/^(h|hr|hrs|hour|hours)$/.test(unit)) return value * 60;
|
|
57
|
+
if (/^(d|day|days)$/.test(unit)) return value * 24 * 60;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
// Reads "daily 9:00", "weekdays 9am", "mon,thu 14:30", "every 2h", "in 30m",
|
|
61
|
+
// "tomorrow 15:00" or "2026-12-01 9:00". "now" or nothing means not scheduled.
|
|
62
|
+
export function parseWhen(text, now = new Date()) {
|
|
63
|
+
const value = String(text ?? '').trim().toLowerCase().replace(/\s+(am|pm)\b/g, '$1').replace(/\b(at|on)\b/g, ' ').replace(/,\s+/g, ',').replace(/\s+/g, ' ').trim();
|
|
64
|
+
if (!value || value === 'now') return null;
|
|
65
|
+
const fail = () => refuse(`Could not read "${String(text).trim()}" as a time. ${WHEN_HELP}`);
|
|
66
|
+
if (value === 'hourly') return describe({ kind: 'every', minutes: 60 });
|
|
67
|
+
let match = /^every (\d+) ?([a-z]+)$/.exec(value);
|
|
68
|
+
if (match) {
|
|
69
|
+
const minutes = duration(match[1], match[2]); if (minutes === null) fail();
|
|
70
|
+
if (minutes < MIN_EVERY || minutes > MAX_EVERY) refuse(`Choose every 15m to every 7d.`);
|
|
71
|
+
return describe({ kind: 'every', minutes });
|
|
72
|
+
}
|
|
73
|
+
match = /^in (\d+) ?([a-z]+)$/.exec(value);
|
|
74
|
+
if (match) {
|
|
75
|
+
const minutes = duration(match[1], match[2]); if (minutes === null || minutes < 1) fail();
|
|
76
|
+
return describe({ kind: 'once', at: new Date(now.getTime() + minutes * 60000).toISOString() });
|
|
77
|
+
}
|
|
78
|
+
const words = value.replace(/^(every|each) /, '').split(' ');
|
|
79
|
+
const time = parseTime(words.at(-1)), head = words.slice(0, -1).join(' ');
|
|
80
|
+
if (!time) { if (words.length === 1 && (parseDays(words[0]) || ['daily', 'day', 'weekdays', 'weekday', 'weekends', 'today', 'tomorrow'].includes(words[0]))) refuse(`Add a time, for example ${words[0]} 9:00.`); fail(); }
|
|
81
|
+
if (!head || ['daily', 'day', 'everyday'].includes(head)) return describe({ kind: 'days', days: [0, 1, 2, 3, 4, 5, 6], ...time });
|
|
82
|
+
if (['weekdays', 'weekday'].includes(head)) return describe({ kind: 'days', days: [1, 2, 3, 4, 5], ...time });
|
|
83
|
+
if (['weekends', 'weekend'].includes(head)) return describe({ kind: 'days', days: [0, 6], ...time });
|
|
84
|
+
const once = date => {
|
|
85
|
+
if (date <= now) refuse('That time has passed. Choose a later one.');
|
|
86
|
+
return describe({ kind: 'once', at: date.toISOString() });
|
|
87
|
+
};
|
|
88
|
+
if (head === 'today' || head === 'tomorrow') return once(new Date(now.getFullYear(), now.getMonth(), now.getDate() + (head === 'tomorrow' ? 1 : 0), time.hour, time.minute));
|
|
89
|
+
match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(head);
|
|
90
|
+
if (match) {
|
|
91
|
+
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), time.hour, time.minute);
|
|
92
|
+
if (date.getMonth() !== Number(match[2]) - 1) fail();
|
|
93
|
+
return once(date);
|
|
94
|
+
}
|
|
95
|
+
const days = parseDays(head.replace(/ /g, ',')); if (!days) fail();
|
|
96
|
+
return describe({ kind: 'days', days, ...time });
|
|
97
|
+
}
|
|
98
|
+
function describe(spec) {
|
|
99
|
+
let label;
|
|
100
|
+
if (spec.kind === 'every') label = spec.minutes % 1440 === 0 ? `every ${spec.minutes / 1440}d` : spec.minutes % 60 === 0 ? `every ${spec.minutes / 60}h` : `every ${spec.minutes}m`;
|
|
101
|
+
else if (spec.kind === 'once') label = `once ${dateText(new Date(spec.at))}`;
|
|
102
|
+
else {
|
|
103
|
+
const key = spec.days.join(), at = clockText(spec.hour, spec.minute);
|
|
104
|
+
label = key === '0,1,2,3,4,5,6' ? `daily ${at}` : key === '1,2,3,4,5' ? `weekdays ${at}` : key === '0,6' ? `weekends ${at}` : `every ${spec.days.map(day => DAY_NAMES[day]).join(', ')} ${at}`;
|
|
105
|
+
}
|
|
106
|
+
return { ...spec, label };
|
|
107
|
+
}
|
|
108
|
+
export function dateText(date) { return `${DAY_NAMES[date.getDay()]} ${MONTHS[date.getMonth()]} ${date.getDate()} ${clockText(date.getHours(), date.getMinutes())}`; }
|
|
109
|
+
// The first run strictly after `after`, or null when a one-time run has passed.
|
|
110
|
+
export function nextRun(spec, after = new Date()) {
|
|
111
|
+
if (spec.kind === 'every') return new Date(after.getTime() + spec.minutes * 60000);
|
|
112
|
+
if (spec.kind === 'once') { const at = new Date(spec.at); return at > after ? at : null; }
|
|
113
|
+
for (let offset = 0; offset <= 8; offset++) {
|
|
114
|
+
const date = new Date(after.getFullYear(), after.getMonth(), after.getDate() + offset, spec.hour, spec.minute);
|
|
115
|
+
if (date > after && spec.days.includes(date.getDay())) return date;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
// "in 25m", "in 3h", "Tue 9:00": short enough for the Tasks clock column where possible.
|
|
120
|
+
export function untilText(value, now = Date.now()) {
|
|
121
|
+
const at = Date.parse(value); if (!Number.isFinite(at)) return '-';
|
|
122
|
+
const minutes = Math.max(0, Math.round((at - now) / 60000));
|
|
123
|
+
if (minutes < 60) return `in ${minutes}m`;
|
|
124
|
+
if (minutes < 24 * 60) return `in ${Math.round(minutes / 60)}h`;
|
|
125
|
+
return `in ${Math.round(minutes / 1440)}d`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validWhen(spec) {
|
|
129
|
+
if (spec?.kind === 'every') return Number.isInteger(spec.minutes) && spec.minutes >= MIN_EVERY && spec.minutes <= MAX_EVERY;
|
|
130
|
+
if (spec?.kind === 'once') return Number.isFinite(Date.parse(spec.at));
|
|
131
|
+
return spec?.kind === 'days' && Array.isArray(spec.days) && spec.days.length && spec.days.every(day => Number.isInteger(day) && day >= 0 && day <= 6) && Number.isInteger(spec.hour) && spec.hour >= 0 && spec.hour <= 23 && Number.isInteger(spec.minute) && spec.minute >= 0 && spec.minute <= 59;
|
|
132
|
+
}
|
|
133
|
+
function readAll() {
|
|
134
|
+
const target = file(); noSymlinks(target);
|
|
135
|
+
if (!fs.existsSync(target)) return [];
|
|
136
|
+
const value = readJson(target);
|
|
137
|
+
if (!Array.isArray(value?.schedules)) refuse('The schedule file could not be read.');
|
|
138
|
+
return value.schedules.filter(row => typeof row?.id === 'string' && typeof row.root === 'string' && typeof row.goal === 'string' && validWhen(row.when));
|
|
139
|
+
}
|
|
140
|
+
let cache = { stamp: null, rows: [] };
|
|
141
|
+
// Read on every Tasks redraw, so a file that has not changed is not parsed again.
|
|
142
|
+
export function listSchedules() {
|
|
143
|
+
let stamp = null; try { const stat = fs.statSync(file()); stamp = `${stat.mtimeMs}:${stat.size}`; } catch { return []; }
|
|
144
|
+
if (stamp !== cache.stamp) { try { cache = { stamp, rows: readAll() }; } catch { cache = { stamp, rows: [] }; } }
|
|
145
|
+
return cache.rows;
|
|
146
|
+
}
|
|
147
|
+
function change(edit) {
|
|
148
|
+
mkdir(personalDirectory());
|
|
149
|
+
const release = waitLock({ state: personalDirectory() }, 'schedules', 200);
|
|
150
|
+
try { const rows = readAll(), result = edit(rows); atomic(file(), { version: 1, schedules: rows }); return result; }
|
|
151
|
+
finally { release(); }
|
|
152
|
+
}
|
|
153
|
+
// permissions: the mode each run gets, ask or auto; a schedule never runs with Full access.
|
|
154
|
+
// model: the model each run starts on, { vendor, model }; without one, the project's default at that time.
|
|
155
|
+
export function saveSchedule({ root, project, goal, when, permissions, model }, now = new Date()) {
|
|
156
|
+
if (typeof goal !== 'string' || !goal.trim() || goal.length > 16000) refuse('Enter a task of 1 to 16000 characters.');
|
|
157
|
+
if (!validWhen(when)) refuse(`Choose when it runs. ${WHEN_HELP}`);
|
|
158
|
+
const mode = permissions === undefined || permissions === null ? null : checkMode(permissions, { allowFull: false });
|
|
159
|
+
const next = nextRun(when, now); if (!next) refuse('That time has passed. Choose a later one.');
|
|
160
|
+
const row = { id: `schedule-${crypto.randomUUID().slice(0, 8)}`, root, project: project ?? path.basename(root), goal, when: describe(when), enabled: true, createdAt: now.toISOString(), nextRunAt: next.toISOString(), lastRunAt: null, lastRequestId: null, lastTaskId: null, runs: 0, note: null, permissions: mode, ...(model ? { model: checkModel(model) } : {}) };
|
|
161
|
+
return change(rows => { if (rows.length >= 50) refuse('Keep at most 50 schedules.'); rows.push(row); return row; });
|
|
162
|
+
}
|
|
163
|
+
export function removeSchedule(id) {
|
|
164
|
+
return change(rows => { const index = rows.findIndex(row => row.id === id); if (index < 0) refuse('That schedule no longer exists.'); rows.splice(index, 1); return rows.length; });
|
|
165
|
+
}
|
|
166
|
+
export function pauseSchedule(id, paused, now = new Date()) {
|
|
167
|
+
return change(rows => {
|
|
168
|
+
const row = rows.find(value => value.id === id); if (!row) refuse('That schedule no longer exists.');
|
|
169
|
+
if (!paused) { const next = nextRun(row.when, now); if (!next) refuse('That one-time run has passed. Delete it and schedule a new one.'); row.nextRunAt = next.toISOString(); }
|
|
170
|
+
row.enabled = !paused; row.note = null; return row;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// The run's task number, once the conductor has started it. Requests are deleted two
|
|
174
|
+
// minutes after they finish, so the schedule keeps the number itself.
|
|
175
|
+
function settle(row) {
|
|
176
|
+
if (!row.lastRequestId || row.lastTaskId) return;
|
|
177
|
+
try { const request = pendingRequests(row.root).find(value => value.id === row.lastRequestId); if (request?.taskId) row.lastTaskId = String(request.taskId); } catch { /* Read next time. */ }
|
|
178
|
+
}
|
|
179
|
+
function fire(row, at) {
|
|
180
|
+
if (!fs.existsSync(row.root)) { row.note = 'The project folder is missing.'; return false; }
|
|
181
|
+
let pending = null; try { pending = pendingRequests(row.root).find(value => value.scheduleId === row.id && value.state !== 'done'); } catch { /* Treated as none. */ }
|
|
182
|
+
if (pending) { row.note = `Skipped ${dateText(at)}: the last run is ${pending.state}.`; return false; }
|
|
183
|
+
const request = enqueue(row.root, row.goal, { priority: 1, scheduleId: row.id, ...(MODES.includes(row.permissions) && row.permissions !== 'full' ? { permissions: row.permissions } : {}), ...(row.model ? { model: row.model } : {}) });
|
|
184
|
+
Object.assign(row, { lastRequestId: request.id, lastTaskId: null, lastRunAt: at.toISOString(), runs: row.runs + 1, note: null });
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
// Queues every schedule that is due. A computer that slept through several runs gets
|
|
188
|
+
// one, then the next time is counted from now.
|
|
189
|
+
export function runDue(now = new Date(), { start = startConductor } = {}) {
|
|
190
|
+
if (!fs.existsSync(file())) return 0;
|
|
191
|
+
const fired = change(rows => {
|
|
192
|
+
let count = 0;
|
|
193
|
+
for (const row of rows) {
|
|
194
|
+
settle(row);
|
|
195
|
+
if (!row.enabled || !(Date.parse(row.nextRunAt) <= now.getTime())) continue;
|
|
196
|
+
try { if (fire(row, now)) count++; } catch (error) { row.note = error.message; }
|
|
197
|
+
const next = nextRun(row.when, now);
|
|
198
|
+
if (row.when.kind === 'once' || !next) Object.assign(row, { enabled: false, nextRunAt: null }); else row.nextRunAt = next.toISOString();
|
|
199
|
+
}
|
|
200
|
+
return count;
|
|
201
|
+
});
|
|
202
|
+
if (fired) start();
|
|
203
|
+
return fired;
|
|
204
|
+
}
|
|
205
|
+
export function runScheduleNow(id, now = new Date(), { start = startConductor } = {}) {
|
|
206
|
+
const ok = change(rows => { const row = rows.find(value => value.id === id); if (!row) refuse('That schedule no longer exists.'); settle(row); if (!fire(row, now)) refuse(row.note); return true; });
|
|
207
|
+
if (ok) start();
|
|
208
|
+
return ok;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Login item: runs `elu schedule tick` every minute. run(bin, args) loads it;
|
|
212
|
+
// tests pass their own, and the real one refuses inside the test runner.
|
|
213
|
+
const LABEL = 'dev.elu-code.scheduler';
|
|
214
|
+
const xml = value => String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
215
|
+
const runDefault = (bin, args, { allowFailure = false } = {}) => {
|
|
216
|
+
const result = spawnSync(bin, args, { encoding: 'utf8', timeout: 15000 });
|
|
217
|
+
if (result.status !== 0 && !allowFailure) refuse(`${bin} could not load the scheduler: ${(result.stderr || result.error?.message || '').trim().slice(0, 200)}`);
|
|
218
|
+
};
|
|
219
|
+
// Session folders (such as a terminal's own shims under the temp folder) vanish later.
|
|
220
|
+
function keptPath(value = '') {
|
|
221
|
+
const temp = [os.tmpdir(), fs.realpathSync.native?.(os.tmpdir()) ?? os.tmpdir()];
|
|
222
|
+
return cleanPath(value).split(path.delimiter).filter(entry => entry && path.isAbsolute(entry) && !temp.some(root => entry.startsWith(root))).join(path.delimiter);
|
|
223
|
+
}
|
|
224
|
+
export function schedulerFiles({ platform = process.platform, home = os.homedir(), env = process.env } = {}) {
|
|
225
|
+
if (platform === 'darwin') return [path.join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`)];
|
|
226
|
+
if (platform === 'linux') { const dir = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'systemd', 'user'); return [path.join(dir, 'elu-code-scheduler.service'), path.join(dir, 'elu-code-scheduler.timer')]; }
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
const guard = run => { if (run === runDefault && process.env.NODE_TEST_CONTEXT) refuse('Tests must not install login items.'); };
|
|
230
|
+
export function installScheduler({ platform = process.platform, home = os.homedir(), env = process.env, run = runDefault, lasting = isInstalled({ env, home }) } = {}) {
|
|
231
|
+
guard(run);
|
|
232
|
+
const files = schedulerFiles({ platform, home, env });
|
|
233
|
+
if (!files.length) return { installed: false, reason: 'Schedules run only while ELU Code is open on this system.' };
|
|
234
|
+
// A login item names this copy's files, so it is written only by the installed command. A
|
|
235
|
+
// copy a package runner fetched (npx and the like) sits in a cache that gets cleared.
|
|
236
|
+
if (!lasting) return { installed: false, reason: `Schedules run only while ELU Code is open until it is installed: ${BY_HAND}` };
|
|
237
|
+
const args = [process.execPath, fileURLToPath(new URL('../bin/elu-code.mjs', import.meta.url)), 'schedule', 'tick'];
|
|
238
|
+
const keep = Object.fromEntries([['PATH', keptPath(env.PATH)], ...['HOME', 'XDG_CONFIG_HOME', 'LANG', 'USER', 'LOGNAME', 'SHELL'].map(name => [name, env[name]])].filter(([, value]) => value));
|
|
239
|
+
let contents;
|
|
240
|
+
if (platform === 'darwin') {
|
|
241
|
+
const log = path.join(personalDirectory(), 'scheduler.log');
|
|
242
|
+
contents = [`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${LABEL}</string>\n <key>ProgramArguments</key><array>${args.map(arg => `<string>${xml(arg)}</string>`).join('')}</array>\n <key>StartInterval</key><integer>60</integer>\n <key>RunAtLoad</key><true/>\n <key>ProcessType</key><string>Background</string>\n <key>EnvironmentVariables</key><dict>${Object.entries(keep).map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join('')}</dict>\n <key>StandardOutPath</key><string>${xml(log)}</string>\n <key>StandardErrorPath</key><string>${xml(log)}</string>\n</dict>\n</plist>\n`];
|
|
243
|
+
} else {
|
|
244
|
+
contents = [`[Unit]\nDescription=ELU Code schedules\n\n[Service]\nType=oneshot\nExecStart=${args.map(unitArgument).join(' ')}\n${Object.entries(keep).map(([key, value]) => unitEnvironment(key, value)).join('\n')}\n`,
|
|
245
|
+
`[Unit]\nDescription=Check ELU Code schedules every minute\n\n[Timer]\nOnCalendar=*-*-* *:*:00\nAccuracySec=10s\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`];
|
|
246
|
+
}
|
|
247
|
+
const same = files.every((target, index) => { try { noSymlinks(target); return fs.readFileSync(target, 'utf8') === contents[index]; } catch { return false; } });
|
|
248
|
+
if (same) return { installed: true, changed: false, files };
|
|
249
|
+
for (const [index, target] of files.entries()) { mkdir(path.dirname(target)); atomic(target, contents[index]); }
|
|
250
|
+
if (platform === 'darwin') {
|
|
251
|
+
const domain = `gui/${process.getuid()}`;
|
|
252
|
+
run('launchctl', ['bootout', domain, files[0]], { allowFailure: true });
|
|
253
|
+
run('launchctl', ['bootstrap', domain, files[0]]);
|
|
254
|
+
} else {
|
|
255
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
256
|
+
run('systemctl', ['--user', 'enable', '--now', 'elu-code-scheduler.timer']);
|
|
257
|
+
}
|
|
258
|
+
return { installed: true, changed: true, files };
|
|
259
|
+
}
|
|
260
|
+
export function uninstallScheduler({ platform = process.platform, home = os.homedir(), env = process.env, run = runDefault } = {}) {
|
|
261
|
+
guard(run);
|
|
262
|
+
const files = schedulerFiles({ platform, home, env }).filter(target => { noSymlinks(target); return fs.existsSync(target); });
|
|
263
|
+
if (!files.length) return { removed: false };
|
|
264
|
+
try {
|
|
265
|
+
if (platform === 'darwin') run('launchctl', ['bootout', `gui/${process.getuid()}`, files[0]], { allowFailure: true });
|
|
266
|
+
else run('systemctl', ['--user', 'disable', '--now', 'elu-code-scheduler.timer'], { allowFailure: true });
|
|
267
|
+
} catch { /* It may not be loaded; the files still go. */ }
|
|
268
|
+
for (const target of files) fs.rmSync(target, { force: true });
|
|
269
|
+
return { removed: true, files };
|
|
270
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// Drag to select and copy, the way a terminal does, kept inside the pane you started in.
|
|
4
|
+
// A double click takes a word, a triple click the row. The copy happens when you let go.
|
|
5
|
+
|
|
6
|
+
const INVERSE = 8 << 18;
|
|
7
|
+
const ESCAPES = /\x1b\[[\d;]*m/g;
|
|
8
|
+
|
|
9
|
+
// The cells from a to b in reading order, clamped to the pane: [y, from, to] per row.
|
|
10
|
+
export function spans(rect, a, b) {
|
|
11
|
+
let [start, end] = a.y < b.y || a.y === b.y && a.x <= b.x ? [a, b] : [b, a];
|
|
12
|
+
if (start.y < rect.y0) start = { x: rect.x0, y: rect.y0 };
|
|
13
|
+
if (end.y > rect.y1) end = { x: rect.x1, y: rect.y1 };
|
|
14
|
+
const rows = [];
|
|
15
|
+
for (let y = Math.max(start.y, rect.y0); y <= Math.min(end.y, rect.y1); y++) {
|
|
16
|
+
const from = Math.max(rect.x0, y === start.y ? start.x : rect.x0), to = Math.min(rect.x1, y === end.y ? end.x : rect.x1);
|
|
17
|
+
if (from <= to) rows.push([y, from, to]);
|
|
18
|
+
}
|
|
19
|
+
return rows;
|
|
20
|
+
}
|
|
21
|
+
// The text in those cells. joinAt(y) says how row y joins the one above: '\n', or '' / ' ' at a soft wrap.
|
|
22
|
+
// clean(row) takes out what is drawn beside the text and is not part of it, such as a message's bar.
|
|
23
|
+
export function spanText(lines, rows, joinAt = () => '\n', clean = row => row) {
|
|
24
|
+
let text = '';
|
|
25
|
+
rows.forEach(([y, from, to], index) => {
|
|
26
|
+
const cells = lines[y] ?? [];
|
|
27
|
+
let row = '';
|
|
28
|
+
for (let x = from; x <= to && x < cells.length; x++) if (cells[x][1] !== '\x03') row += cells[x][1];
|
|
29
|
+
text += (index ? joinAt(y) : '') + clean(row).replace(/\s+$/, '');
|
|
30
|
+
});
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
// The word around x in a row of cells, as [from, to].
|
|
34
|
+
export function wordSpan(cells, x, x0, x1) {
|
|
35
|
+
const solid = at => at >= x0 && at <= x1 && cells[at] && /\S/.test(cells[at][1]) && cells[at][1] !== '\x03' || cells[at]?.[1] === '\x03';
|
|
36
|
+
if (!solid(x)) return null;
|
|
37
|
+
let from = x, to = x;
|
|
38
|
+
while (solid(from - 1)) from--;
|
|
39
|
+
while (solid(to + 1)) to++;
|
|
40
|
+
return [from, to];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The clipboard: pbcopy on macOS, else the terminal's OSC 52. Tests pass their own.
|
|
44
|
+
export function systemClipboard({ write, platform = process.platform } = {}) {
|
|
45
|
+
const osc = text => write?.(`\x1b]52;c;${Buffer.from(text).toString('base64')}\x07`);
|
|
46
|
+
return text => {
|
|
47
|
+
if (platform !== 'darwin') return osc(text);
|
|
48
|
+
try { const child = spawn('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'] }); child.on('error', () => osc(text)); child.stdin.on('error', () => {}); child.stdin.end(text); }
|
|
49
|
+
catch { osc(text); }
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The pane a cell belongs to: the deepest element there, or the nearest scrolling or bordered
|
|
54
|
+
// one around it. A scrollbar column is not text.
|
|
55
|
+
function paneAt(screen, x, y) {
|
|
56
|
+
let found = null;
|
|
57
|
+
const walk = el => {
|
|
58
|
+
for (const child of el.children ?? []) {
|
|
59
|
+
if (child.detached || child.hidden || !child.visible) continue;
|
|
60
|
+
const pos = child.lpos;
|
|
61
|
+
if (pos && x >= pos.xi && x < pos.xl && y >= pos.yi && y < pos.yl) found = child;
|
|
62
|
+
walk(child);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk(screen);
|
|
66
|
+
let pane = found;
|
|
67
|
+
while (pane && pane !== screen && !pane.selectText && !pane.scrollable && !pane.border) pane = pane.parent;
|
|
68
|
+
if (!pane || pane === screen || !pane.lpos) return { el: null, rect: { x0: 0, y0: 0, x1: screen.width - 1, y1: screen.height - 1 } };
|
|
69
|
+
const pos = pane.lpos;
|
|
70
|
+
return { el: pane, rect: { x0: pos.xi + pane.ileft, y0: pos.yi + pane.itop, x1: pos.xl - 1 - pane.iright - (pane.scrollbar ? 1 : 0), y1: pos.yl - 1 - pane.ibottom } };
|
|
71
|
+
}
|
|
72
|
+
// Rows wrapped by blessed join without a newline; a space dropped at the wrap comes back.
|
|
73
|
+
function joiner(el) {
|
|
74
|
+
const lines = el?._clines, pos = el?.lpos;
|
|
75
|
+
if (!lines?.rtof || !pos) return () => '\n';
|
|
76
|
+
return y => {
|
|
77
|
+
const row = y - pos.yi - el.itop + (el.childBase || 0);
|
|
78
|
+
if (row <= 0 || lines.rtof[row] === undefined || lines.rtof[row] !== lines.rtof[row - 1]) return '\n';
|
|
79
|
+
return /\s$/.test(String(lines[row - 1] ?? '').replace(ESCAPES, '')) ? ' ' : '';
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The report's button code without the protocol offset: SGR sends it as is; X10, UTF-8 and
|
|
84
|
+
// urxvt add 32. Bit 32 of the code means the mouse moved with a button held (a drag).
|
|
85
|
+
export function buttonCode(data) {
|
|
86
|
+
if (!data?.raw) return 0;
|
|
87
|
+
return data.type === 'sgr' ? data.raw[0] : data.raw[0] - 32;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// One mouse report in a form blessed reads: SGR, urxvt, or X10 with three coordinates (in the
|
|
91
|
+
// UTF-8 form a coordinate past 95 is two bytes). Matched on the bytes as latin1 text.
|
|
92
|
+
const REPORT = /\x1b\[<(\d+);\d+;\d+([mM])|\x1b\[(\d+);\d+;\d+M|\x1b\[M((?:[\xc2-\xdf][\x80-\xbf]|[\x00-\xff]){3})/g;
|
|
93
|
+
// The start of a report that the end of a read cut off. No key begins this way.
|
|
94
|
+
const CUT = /\x1b\[<[\d;]*$|\x1b\[M(?:[\xc2-\xdf][\x80-\xbf]|[\x00-\xff]){0,2}$/;
|
|
95
|
+
|
|
96
|
+
// Splits each read of input into the parts blessed can take one at a time: every mouse report
|
|
97
|
+
// on its own, and the keys between them. blessed reads one report from the start of a read
|
|
98
|
+
// and drops the rest, so a release that arrived with the last drag report was lost, and the
|
|
99
|
+
// drag never ended. Of a run of motion reports only the last is passed on. A report cut by
|
|
100
|
+
// the end of a read waits for the rest of it.
|
|
101
|
+
export function reportSplitter() {
|
|
102
|
+
let held = Buffer.alloc(0);
|
|
103
|
+
return chunk => {
|
|
104
|
+
const fresh = typeof chunk === 'string' ? Buffer.from(chunk) : chunk, data = held.length ? Buffer.concat([held, fresh]) : fresh;
|
|
105
|
+
held = Buffer.alloc(0);
|
|
106
|
+
const text = data.toString('latin1'), parts = [];
|
|
107
|
+
let at = 0, last = null;
|
|
108
|
+
const keys = to => { if (to > at) { parts.push(data.subarray(at, to)); last = null; } };
|
|
109
|
+
for (const found of text.matchAll(REPORT)) {
|
|
110
|
+
keys(found.index);
|
|
111
|
+
// The button code with the motion bit: SGR and urxvt as sent, X10 less its offset of 32.
|
|
112
|
+
const code = found[1] !== undefined ? Number(found[1]) : found[3] !== undefined ? Number(found[3]) - 32 : found[4].charCodeAt(0) - 32;
|
|
113
|
+
const motion = Boolean(code & 32) && found[2] !== 'm' ? code : null;
|
|
114
|
+
const part = data.subarray(found.index, found.index + found[0].length);
|
|
115
|
+
if (motion !== null && motion === last) parts[parts.length - 1] = part; else parts.push(part);
|
|
116
|
+
last = motion; at = found.index + found[0].length;
|
|
117
|
+
}
|
|
118
|
+
const cut = text.slice(at).search(CUT);
|
|
119
|
+
if (cut >= 0) { keys(at + cut); held = Buffer.from(data.subarray(at + cut)); } else keys(text.length);
|
|
120
|
+
return parts;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// Passes every read of the screen's input through reportSplitter, to all that listen to it:
|
|
124
|
+
// blessed's mouse and key readers, and the paste reader. Call it once they are attached.
|
|
125
|
+
export function attachReports(screen) {
|
|
126
|
+
const input = screen.program.input, readers = input.listeners('data');
|
|
127
|
+
if (!readers.length) return;
|
|
128
|
+
const split = reportSplitter(), each = chunk => { for (const part of split(chunk)) for (const reader of readers) reader.call(input, part); };
|
|
129
|
+
for (const reader of readers) input.removeListener('data', reader);
|
|
130
|
+
input.on('data', each);
|
|
131
|
+
const destroy = screen.destroy.bind(screen);
|
|
132
|
+
screen.destroy = function() { input.removeListener('data', each); return destroy(); };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function attachSelection(screen, { copy, onCopy, clean } = {}) {
|
|
136
|
+
// blessed asks xterm-like terminals for UTF-8 reports; SGR has no column limit, so ask for it too.
|
|
137
|
+
// The mode joins the ones blessed resets on exit.
|
|
138
|
+
if (screen.program.mouseEnabled) screen.program.setMouse({ sgrMouse: true }, true);
|
|
139
|
+
let pane = null, anchor = null, head = null, dragging = false, moved = false, shown = [], last = { at: 0, x: -1, y: -1, count: 0 };
|
|
140
|
+
const selected = () => pane && anchor && head ? spans(pane.rect, anchor, head) : [];
|
|
141
|
+
// Just before each frame is drawn the selected cells are inverted; once it is out they go
|
|
142
|
+
// back, so the buffer always holds the real text. Elements have all rendered by then.
|
|
143
|
+
const invert = () => { for (const [y, from, to] of shown) { const cells = screen.lines[y]; if (!cells) continue; for (let x = from; x <= to && x < cells.length; x++) cells[x][0] ^= INVERSE; cells.dirty = true; } };
|
|
144
|
+
const draw = screen.draw.bind(screen);
|
|
145
|
+
screen.draw = function(start, end) { shown = selected(); invert(); return draw(start, end); };
|
|
146
|
+
screen.on('render', () => { invert(); for (const [y] of shown) if (screen.lines[y]) screen.lines[y].dirty = false; });
|
|
147
|
+
const clear = () => {
|
|
148
|
+
if (!anchor) return;
|
|
149
|
+
for (const [y] of shown) if (screen.lines[y]) screen.lines[y].dirty = true;
|
|
150
|
+
pane = anchor = head = null; shown = []; screen.render();
|
|
151
|
+
};
|
|
152
|
+
// The pane and the first and last selected cells, or null.
|
|
153
|
+
const range = () => {
|
|
154
|
+
const rows = selected(); if (!rows.length) return null;
|
|
155
|
+
const first = rows[0], end = rows.at(-1);
|
|
156
|
+
return { el: pane.el, from: { x: first[1], y: first[0] }, to: { x: end[2], y: end[0] } };
|
|
157
|
+
};
|
|
158
|
+
// The cells as they are on the terminal. Setting a pane's content blanks its cells until
|
|
159
|
+
// the next frame, and the app sets content on a timer without drawing when nothing
|
|
160
|
+
// changed. Drawing first makes the cells hold the text again.
|
|
161
|
+
const cells = () => { screen.render(); return screen.lines; };
|
|
162
|
+
const text = () => {
|
|
163
|
+
const rows = selected(); if (!rows.length) return '';
|
|
164
|
+
if (pane.el?.selectText) { const { from, to } = range(); return pane.el.selectText(from, to); }
|
|
165
|
+
return spanText(cells(), rows, joiner(pane.el), clean);
|
|
166
|
+
};
|
|
167
|
+
const finish = () => { const value = text(); if (value) { copy?.(value); onCopy?.(value); } };
|
|
168
|
+
screen.program.prependListener('mouse', data => {
|
|
169
|
+
const motion = data.action === 'mousemove' || data.action === 'mousedown' && Boolean(buttonCode(data) & 32);
|
|
170
|
+
if (data.action === 'wheelup' || data.action === 'wheeldown') { clear(); return; }
|
|
171
|
+
if (motion) {
|
|
172
|
+
if (!dragging) return;
|
|
173
|
+
if (data.button !== 'left' && data.action === 'mousemove') { dragging = false; if (moved) finish(); return; }
|
|
174
|
+
data.action = 'mousedrag';
|
|
175
|
+
if (head && head.x === data.x && head.y === data.y) return;
|
|
176
|
+
head = { x: data.x, y: data.y }; moved = true; screen.render(); return;
|
|
177
|
+
}
|
|
178
|
+
if (data.action === 'mousedown') {
|
|
179
|
+
if (data.button !== 'left') return;
|
|
180
|
+
const now = Date.now(), again = now - last.at < 500 && last.x === data.x && last.y === data.y;
|
|
181
|
+
last = { at: now, x: data.x, y: data.y, count: again ? last.count + 1 : 1 };
|
|
182
|
+
clear();
|
|
183
|
+
pane = paneAt(screen, data.x, data.y); dragging = true; moved = false;
|
|
184
|
+
if (last.count === 2) {
|
|
185
|
+
const span = wordSpan(cells()[data.y] ?? [], data.x, pane.rect.x0, pane.rect.x1);
|
|
186
|
+
if (span) { anchor = { x: span[0], y: data.y }; head = { x: span[1], y: data.y }; dragging = false; screen.render(); finish(); return; }
|
|
187
|
+
}
|
|
188
|
+
if (last.count >= 3) { anchor = { x: pane.rect.x0, y: data.y }; head = { x: pane.rect.x1, y: data.y }; dragging = false; screen.render(); finish(); return; }
|
|
189
|
+
anchor = { x: data.x, y: data.y }; head = null; return;
|
|
190
|
+
}
|
|
191
|
+
if (data.action === 'mouseup' && dragging) {
|
|
192
|
+
dragging = false;
|
|
193
|
+
if (!moved) { anchor = null; return; }
|
|
194
|
+
// A drag is not a click: the element under the release does nothing.
|
|
195
|
+
data.action = 'mouseselect'; screen.mouseDown = null; finish();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// Any key ends the highlight, but for Ctrl-C: the app copies the highlight on it.
|
|
199
|
+
screen.program.on('keypress', (ch, key) => { if (key?.full !== 'C-c') clear(); });
|
|
200
|
+
screen.on('resize', () => clear());
|
|
201
|
+
return { clear, text, range, get active() { return Boolean(anchor && head); } };
|
|
202
|
+
}
|