@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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/README.md +340 -2
  4. package/SECURITY.md +333 -0
  5. package/THIRD_PARTY_NOTICES.md +62 -0
  6. package/bin/elu-code.mjs +209 -0
  7. package/bin/elu.cjs +10 -0
  8. package/bin/start.cjs +6 -0
  9. package/docs/peer-network.md +257 -0
  10. package/docs/permissions.md +285 -0
  11. package/docs/vendors/antigravity.md +131 -0
  12. package/docs/vendors/claude.md +124 -0
  13. package/docs/vendors/codex.md +64 -0
  14. package/docs/vendors/grok.md +151 -0
  15. package/lib/acp-client.mjs +100 -0
  16. package/lib/adapters/agy.mjs +283 -0
  17. package/lib/adapters/claude.mjs +311 -0
  18. package/lib/adapters/codex.mjs +137 -0
  19. package/lib/adapters/grok.mjs +384 -0
  20. package/lib/adapters/index.mjs +13 -0
  21. package/lib/agents.mjs +77 -0
  22. package/lib/agy-hook.mjs +53 -0
  23. package/lib/approval-server.mjs +85 -0
  24. package/lib/approvals.mjs +128 -0
  25. package/lib/awake.mjs +45 -0
  26. package/lib/board.mjs +261 -0
  27. package/lib/bot-models.mjs +71 -0
  28. package/lib/bot-schedules.mjs +291 -0
  29. package/lib/bots.mjs +361 -0
  30. package/lib/changes.mjs +168 -0
  31. package/lib/checks.mjs +102 -0
  32. package/lib/conductor.mjs +337 -0
  33. package/lib/config.mjs +147 -0
  34. package/lib/conformance.mjs +109 -0
  35. package/lib/default-bots.mjs +150 -0
  36. package/lib/detect.mjs +108 -0
  37. package/lib/diff.mjs +217 -0
  38. package/lib/editor.mjs +165 -0
  39. package/lib/format.mjs +51 -0
  40. package/lib/git.mjs +173 -0
  41. package/lib/init.mjs +129 -0
  42. package/lib/install.mjs +130 -0
  43. package/lib/kanban.mjs +180 -0
  44. package/lib/mcp.mjs +79 -0
  45. package/lib/memory.mjs +35 -0
  46. package/lib/models.mjs +144 -0
  47. package/lib/modes.mjs +95 -0
  48. package/lib/never-list.mjs +110 -0
  49. package/lib/never-shims.mjs +311 -0
  50. package/lib/paste.mjs +56 -0
  51. package/lib/peer/a2a-http.mjs +166 -0
  52. package/lib/peer/a2a.mjs +357 -0
  53. package/lib/peer/asks.mjs +329 -0
  54. package/lib/peer/canonical.mjs +72 -0
  55. package/lib/peer/cli.mjs +154 -0
  56. package/lib/peer/daemon.mjs +525 -0
  57. package/lib/peer/identity.mjs +81 -0
  58. package/lib/peer/index.mjs +291 -0
  59. package/lib/peer/intake.mjs +207 -0
  60. package/lib/peer/invite.mjs +76 -0
  61. package/lib/peer/paths.mjs +7 -0
  62. package/lib/peer/peerdata.mjs +21 -0
  63. package/lib/peer/pictures.mjs +295 -0
  64. package/lib/peer/plans.mjs +182 -0
  65. package/lib/peer/relay.mjs +31 -0
  66. package/lib/peer/replies.mjs +128 -0
  67. package/lib/peer/roster.mjs +256 -0
  68. package/lib/peer/rpc.mjs +88 -0
  69. package/lib/peer/secrets.mjs +88 -0
  70. package/lib/peer/settings.mjs +45 -0
  71. package/lib/peer/shares.mjs +120 -0
  72. package/lib/peer/status.mjs +84 -0
  73. package/lib/peer/taskstore.mjs +108 -0
  74. package/lib/peer/transport.mjs +120 -0
  75. package/lib/pictures.mjs +104 -0
  76. package/lib/plan.mjs +238 -0
  77. package/lib/policy.mjs +98 -0
  78. package/lib/process.mjs +92 -0
  79. package/lib/project-settings.mjs +55 -0
  80. package/lib/project-trust.mjs +230 -0
  81. package/lib/projects.mjs +668 -0
  82. package/lib/publish.mjs +105 -0
  83. package/lib/runner.mjs +874 -0
  84. package/lib/sandbox.mjs +102 -0
  85. package/lib/schedules.mjs +270 -0
  86. package/lib/selection.mjs +202 -0
  87. package/lib/shell.mjs +101 -0
  88. package/lib/state.mjs +247 -0
  89. package/lib/terminal-bots.mjs +239 -0
  90. package/lib/terminal-commands.mjs +165 -0
  91. package/lib/terminal-plan.mjs +37 -0
  92. package/lib/terminal-visuals.mjs +270 -0
  93. package/lib/terminal.mjs +3181 -0
  94. package/lib/unit-file.mjs +17 -0
  95. package/lib/usage.mjs +249 -0
  96. package/lib/util.mjs +86 -0
  97. package/lib/vendor-check.mjs +96 -0
  98. package/lib/vendors.mjs +191 -0
  99. package/lib/workspace.mjs +168 -0
  100. package/lib/yaml.mjs +624 -0
  101. package/npm-shrinkwrap.json +827 -0
  102. package/package.json +70 -4
@@ -0,0 +1,71 @@
1
+ // The models a bot may name, as the vendor's own command line reports them. A vendor is
2
+ // read when its Model step first opens and kept until the app closes. Nothing here uses the
3
+ // network. A command line that is missing, slow or changed gives the built-in names or
4
+ // none, never an error.
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { findBin } from './util.mjs';
10
+ import { agentEnv } from './process.mjs';
11
+ import { botModelProblem } from './bots.mjs';
12
+
13
+ const MAX = 40, TIMEOUT_MS = 2000;
14
+ const option = (name, note, effort = null) => ({ name, note: String(note ?? '').replace(/\s+/g, ' ').trim().slice(0, 120), ...(effort ? { effort } : {}) });
15
+ const level = value => typeof value === 'string' && /^[a-z]{1,12}$/.test(value) ? value : null;
16
+ // `codex debug models --bundled` (0.166.0-alpha.5.1) prints the catalog inside the binary,
17
+ // with no refresh: { "models": [{ "slug", "description", "visibility", ... }] }. A row is
18
+ // "list" or "hide", and the hidden ones are left out. default_reasoning_level is the effort
19
+ // that model runs with when none is asked for.
20
+ export function codexModels(text) {
21
+ let models; try { models = JSON.parse(text).models; } catch { return []; }
22
+ return (Array.isArray(models) ? models : []).filter(model => model?.visibility === 'list' && typeof model.slug === 'string' && model.slug && !botModelProblem('codex', model.slug)).slice(0, MAX).map(model => option(model.slug.trim(), model.description, level(model.default_reasoning_level)));
23
+ }
24
+ // claude has no list of models. Its --help (2.1.322) names the aliases --model takes:
25
+ // --model <model> Model for the current session. Provide an alias for the latest model
26
+ // (e.g. 'fable', 'opus', or 'sonnet') or a model's full name.
27
+ const alias = name => option(name, `the latest ${name[0].toUpperCase()}${name.slice(1)} model`);
28
+ export function claudeModels(text) {
29
+ const entry = /(?<![\w-])--model <model>([\s\S]*?)(?=\n\s*-{1,2}[A-Za-z]|$)/.exec(String(text ?? ''))?.[1] ?? '';
30
+ return [...new Set([...entry.matchAll(/'([a-z][a-z0-9.-]{0,39})'/g)].map(match => match[1]))].slice(0, MAX).map(alias);
31
+ }
32
+ // How each vendor is asked, and the names to use when it cannot be. The claude names are
33
+ // the ones its --help showed. Codex names carry versions, so none are kept here. A vendor
34
+ // that is not in this table is never run for its models.
35
+ const SOURCES = {
36
+ claude: { args: ['--help'], parse: claudeModels, fallback: ['fable', 'opus', 'sonnet'].map(alias) },
37
+ codex: { args: ['debug', 'models', '--bundled'], parse: codexModels, fallback: [], efforts: true },
38
+ };
39
+ // What a command line prints, or null when it fails or takes too long. The output goes to
40
+ // a file: Claude Code 2.1.321 exits before a piped --help is fully written.
41
+ function printed(bin, args, env, timeout) {
42
+ let folder = null;
43
+ try {
44
+ folder = fs.mkdtempSync(path.join(os.tmpdir(), 'elu-code-models-'));
45
+ const file = path.join(folder, 'out'), fd = fs.openSync(file, 'w', 0o600); let result;
46
+ try { result = spawnSync(bin, args, { cwd: folder, env: agentEnv(env), stdio: ['ignore', fd, 'ignore'], timeout, killSignal: 'SIGKILL' }); } finally { fs.closeSync(fd); }
47
+ return result.error || result.status !== 0 ? null : fs.readFileSync(file, 'utf8');
48
+ } catch { return null; } finally { if (folder) fs.rmSync(folder, { recursive: true, force: true }); }
49
+ }
50
+ const read = new Map();
51
+ // The options of a vendor's Model step, as [{ name, note }]. name is what follows --model.
52
+ // An empty list means the vendor's models are not known here, so any name is taken.
53
+ export function botModels(vendor, { env = process.env, timeoutMs = TIMEOUT_MS } = {}) {
54
+ const source = Object.hasOwn(SOURCES, vendor) ? SOURCES[vendor] : null; if (!source) return [];
55
+ const bin = findBin(vendor, env); if (!bin) return source.fallback;
56
+ if (!read.has(bin)) { const rows = source.parse(printed(bin, source.args, env, timeoutMs) ?? ''); read.set(bin, rows.length ? rows : source.fallback); }
57
+ return read.get(bin);
58
+ }
59
+ // The effort a model runs with when a bot asks for none, where the vendor's own list says
60
+ // it (Codex's does). null when it is not known here: no value is made up.
61
+ export function ownEffort(vendor, model, options) {
62
+ if (!model || !(Object.hasOwn(SOURCES, vendor) && SOURCES[vendor].efforts)) return null;
63
+ return botModels(vendor, options).find(row => row.name === model)?.effort ?? null;
64
+ }
65
+ // The options a typed text narrows to: names that start with it, then names that hold it.
66
+ // No text gives them all.
67
+ export function matchModels(models, text) {
68
+ const wanted = String(text ?? '').trim().toLowerCase(); if (!wanted) return models;
69
+ const found = models.map(row => row.name.toLowerCase().indexOf(wanted));
70
+ return [...models.filter((_, index) => found[index] === 0), ...models.filter((_, index) => found[index] > 0)];
71
+ }
@@ -0,0 +1,291 @@
1
+ // When a bot runs by itself. A bot's schedule is text in the words /schedule takes, such as
2
+ // "every 1h" or "weekdays 9am". It repeats; a single date is not a bot's schedule.
3
+ // The schedule is on in every session whose chat lists the bot, from the moment the bot is
4
+ // added there or written to. Whether it is on in one session is kept with that session and
5
+ // never in the bot, so turning it off in one session leaves the others as they are, and
6
+ // editing the bot turns nothing back on.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { atomic, noSymlinks, personalDirectory, readJson, refuse } from './util.mjs';
10
+ import { parseWhen, nextRun, dateText, untilText } from './schedules.mjs';
11
+ import { readTask, taskDir, waitLock } from './state.mjs';
12
+ import { registeredProjects } from './projects.mjs';
13
+ import { pendingRequests } from './workspace.mjs';
14
+ import { readBoard, sessionClosed } from './kanban.mjs';
15
+ import { enqueue, startConductor } from './conductor.mjs';
16
+ import { botScheduled, botWorks, getBot, listBots, needBot } from './bots.mjs';
17
+ import { isPeerRun } from './modes.mjs';
18
+
19
+ const REPEATS = "A bot's schedule repeats. Write every 1h, daily 9:00 or weekdays 9am.";
20
+ // What is wrong with a bot's schedule text, or null. Empty means the bot has no schedule.
21
+ export function botScheduleProblem(text) {
22
+ const value = String(text ?? '').trim();
23
+ if (!value) return null;
24
+ let spec; try { spec = parseWhen(value); } catch (error) { return error.message; }
25
+ return !spec || spec.kind === 'once' ? REPEATS : null;
26
+ }
27
+ // The schedule as /schedule reads it ({ kind, ..., label }), or null when the bot has none
28
+ // or its text can no longer be read. Never throws.
29
+ export function botScheduleSpec(bot) {
30
+ const value = String(bot?.schedule ?? '').trim();
31
+ if (!value || botScheduleProblem(value)) return null;
32
+ try { return parseWhen(value); } catch { return null; }
33
+ }
34
+
35
+ // A session's bot schedules live beside its record, in a file of their own: a running turn
36
+ // holds the record and saves it whole, and the owner must be able to turn a schedule off
37
+ // while the bot is at work. One row per bot: { on, schedule, nextRunAt, lastRunAt,
38
+ // lastRequestId, runs, note }. schedule is the bot's text when the row was counted, so a
39
+ // changed schedule is counted again from now.
40
+ const stateFile = (repo, id) => path.join(taskDir(repo, id), 'bot-schedules.json');
41
+ function readState(repo, id) {
42
+ try { const value = readJson(stateFile(repo, id)); return value?.bots && typeof value.bots === 'object' && !Array.isArray(value.bots) ? value.bots : {}; }
43
+ catch { return {}; }
44
+ }
45
+ const writeState = (repo, id, bots) => atomic(stateFile(repo, id), { version: 1, bots });
46
+ // The open app and the login item both tick, and the owner's switch can land between a
47
+ // tick's read and its write: one lock for all of them.
48
+ function locked(run, attempts = 200) {
49
+ const release = waitLock({ state: personalDirectory() }, 'bot-schedules', attempts);
50
+ try { return run(); } finally { release(); }
51
+ }
52
+ // first: the bot just joined the session, so its first run is due now. since is when it
53
+ // joined: a message the owner writes to it after that is its start instead.
54
+ const row = (bot, spec, now, old = null, { first = false } = {}) => ({ on: old?.on !== false, schedule: bot.schedule, nextRunAt: (first ? now : nextRun(spec, now)).toISOString(), lastRunAt: old?.lastRunAt ?? null, lastRequestId: old?.lastRequestId ?? null, runs: old?.runs ?? 0, note: null, ...(first ? { first: true, since: now.toISOString() } : {}) });
55
+ // When each bot of a session was last written to, by the owner or by its schedule:
56
+ // { name: time }. A message with no readable time counts as old as the session.
57
+ function askedAt(task) {
58
+ const asked = {};
59
+ for (const value of Array.isArray(task?.messages) ? task.messages : []) {
60
+ if (typeof value?.to !== 'string' || value.from) continue;
61
+ const name = value.to.replace(/^@/, ''), at = Number.isFinite(Date.parse(value.at)) ? value.at : Number.isFinite(Date.parse(task.createdAt)) ? task.createdAt : new Date(0).toISOString();
62
+ if (!asked[name] || Date.parse(at) > Date.parse(asked[name])) asked[name] = at;
63
+ }
64
+ return asked;
65
+ }
66
+ // Whether a bot that has no row in a session yet starts there at once: not when the owner
67
+ // already wrote to it there, which was its start. Unknown (a row read by an older version) is no.
68
+ const startsAtOnce = (task, name) => Boolean(task?.asked) && !task.asked[name] && task.botChat !== name;
69
+ const BUSY = 'The session was busy.', ASKING = 'The session was waiting for your answer.', WHEN_FREE = 'Starts when the session is free.';
70
+ // The bots the owner put in a session's chat. A session saved with no list reviews with
71
+ // @reviewer, but nobody added a bot to it, so no schedule runs there.
72
+ const chatList = task => Array.isArray(task?.bots) ? task.bots : [];
73
+ // A schedule runs with Approve for me at most, and a session that edits the owner's
74
+ // checkout in place needs Full access for every turn.
75
+ const inPlace = task => (task?.place?.kind ?? task?.place) === 'here';
76
+ export const IN_PLACE = 'This session edits your checkout in place, which needs Full access. A schedule runs with Approve for me at most, so it does not run here.';
77
+ const scheduledText = label => `Scheduled run (${label}).`;
78
+ const RUN_NOW = 'Run now (asked with /bots run).';
79
+
80
+ // What the tick needs of each session of a project. A record is parsed again only after it
81
+ // changes: the open app asks every 30 seconds and the login item every minute, each in
82
+ // every project. What was read is kept in one small file, so a new process reads only the
83
+ // records that changed since.
84
+ const seenFile = () => path.join(personalDirectory(), 'bot-schedule-sessions.json');
85
+ let seen = null, changed = false;
86
+ function sessions(repo) {
87
+ if (!seen) { try { seen = new Map(Object.entries(readJson(seenFile()).files)); } catch { seen = new Map(); } }
88
+ const folder = path.join(repo.state, 'tasks'); noSymlinks(folder);
89
+ const rows = [], found = new Set();
90
+ for (const id of fs.existsSync(folder) ? fs.readdirSync(folder) : []) {
91
+ if (!/^[1-9][0-9]*$/.test(id)) continue;
92
+ const file = path.join(folder, id, 'task.json');
93
+ try {
94
+ const stat = fs.lstatSync(file), sig = `${stat.ino}:${stat.size}:${stat.mtimeMs}`, hit = seen.get(file);
95
+ found.add(file);
96
+ if (hit?.sig === sig) { rows.push(hit.row); continue; }
97
+ const task = readTask(repo, id), times = [...(task.runs ?? []), ...(task.messages ?? [])].map(value => Date.parse(value.at)).filter(Number.isFinite);
98
+ // runs holds only the latest activity: enough for sessionClosed to tell an archived session.
99
+ const slim = { id: task.id, state: task.state, createdAt: task.createdAt, runs: times.length ? [{ at: new Date(Math.max(...times)).toISOString() }] : [], messages: [], bots: chatList(task), peer: isPeerRun(task.origin), place: task.place ?? null, botChat: task.botChat ?? null, asked: askedAt(task) };
100
+ seen.set(file, { sig, row: slim }); changed = true; rows.push(slim);
101
+ } catch { /* A record that cannot be read runs no schedule. */ }
102
+ }
103
+ // A session that was deleted leaves the file.
104
+ for (const file of seen.keys()) if (path.dirname(path.dirname(file)) === folder && !found.has(file)) { seen.delete(file); changed = true; }
105
+ return rows;
106
+ }
107
+ function keepSeen() {
108
+ if (!changed) return;
109
+ try { atomic(seenFile(), { version: 1, files: Object.fromEntries(seen) }); changed = false; } catch { /* Read again next time. */ }
110
+ }
111
+ // Queues one turn for the bot in the session, or returns why it does not: a sentence for
112
+ // the owner. busy: a turn the schedule starts gives way to a session that is at work.
113
+ function queueRun(repo, task, bot, entry, at, pending, text, { busy = true } = {}) {
114
+ if (inPlace(task)) return IN_PLACE;
115
+ if (busy && ['running', 'preparing'].includes(task.state)) return BUSY;
116
+ // A question to the owner stays open: a run would put its own result in its place.
117
+ if (busy && task.state === 'needs_input') return ASKING;
118
+ const last = pending.find(value => value.scheduled && value.bot === bot.name && value.continuationTaskId === task.id && value.state !== 'done');
119
+ if (last) return `The last run is ${last.state}.`;
120
+ const request = enqueue(repo.root, text, { continuationTaskId: task.id, bot: bot.name, scheduled: true, priority: 1 });
121
+ Object.assign(entry, { lastRunAt: at.toISOString(), lastRequestId: request.id, runs: (entry.runs ?? 0) + 1, note: null });
122
+ return null;
123
+ }
124
+ const requests = repo => { try { return pendingRequests(repo.root); } catch { return []; } };
125
+
126
+ // Whether this process has asked for the login item since it started.
127
+ let installed = false;
128
+ // Queues a turn for every bot whose schedule is due, in every session of every project.
129
+ // A bot that joins a session runs there at once, then on its schedule; where the owner
130
+ // already wrote to it, that was its start. A first run that finds the session busy stays
131
+ // due and is tried on every tick until the session is free. A computer that
132
+ // slept through several runs gets one, then the next time is counted from now. A bot that
133
+ // was removed, or whose schedule can no longer be read, queues nothing. install: the login
134
+ // item's installer, asked for when a schedule is first on (the app passes it; the login
135
+ // item itself does not). Returns how many turns were queued; never throws.
136
+ export function runBotSchedules(now = new Date(), { start = startConductor, install = null } = {}) {
137
+ let fired = 0, waiting = false;
138
+ try {
139
+ // A bot that works with no schedule never runs by itself: the tick does not see it.
140
+ const bots = new Map(listBots().filter(botScheduled).map(bot => [bot.name, bot]));
141
+ if (!bots.size) return 0;
142
+ let on = 0, fresh = 0;
143
+ locked(() => {
144
+ for (const repo of registeredProjects()) {
145
+ let rows = [], board = null, pending = null;
146
+ try { rows = sessions(repo); } catch { continue; }
147
+ for (const task of rows) {
148
+ const names = task.bots.filter(name => bots.has(name));
149
+ if (task.peer || inPlace(task) || !names.length && !fs.existsSync(stateFile(repo, task.id))) continue;
150
+ try {
151
+ const state = readState(repo, task.id); let dirty = false, closed = null;
152
+ // A bot that left the chat counts from the start when it comes back; its off switch stays.
153
+ for (const [name, entry] of Object.entries(state)) if (!names.includes(name) && entry?.nextRunAt) { entry.nextRunAt = null; dirty = true; }
154
+ for (const name of names) {
155
+ const bot = bots.get(name), spec = botScheduleSpec(bot);
156
+ if (!spec) continue;
157
+ let entry = state[name];
158
+ if (!entry || entry.schedule !== bot.schedule || !Number.isFinite(Date.parse(entry.nextRunAt))) {
159
+ // New here, or back after it left: it starts now. A changed schedule counts again from now.
160
+ const first = entry ? entry.on !== false && entry.schedule === bot.schedule : startsAtOnce(task, name);
161
+ entry = state[name] = row(bot, spec, now, entry, { first }); dirty = true; if (entry.on) fresh++;
162
+ }
163
+ if (entry.on === false) continue;
164
+ on++;
165
+ if (!(Date.parse(entry.nextRunAt) <= now.getTime())) continue;
166
+ // An archived session runs nothing; the time still moves on, so bringing it back does not start a run at once.
167
+ if (closed === null) { try { board ??= readBoard(repo); closed = sessionClosed(board, task); } catch { closed = false; } }
168
+ // The owner wrote to the bot after it joined: that was its start.
169
+ const first = entry.first === true, begun = first && Date.parse(task.asked?.[name]) >= Date.parse(entry.since);
170
+ if (!closed && !begun) {
171
+ let skipped; try { skipped = queueRun(repo, task, bot, entry, now, pending ??= requests(repo), scheduledText(spec.label)); } catch (error) { skipped = error.message; }
172
+ // A first run waits for the session, tick after tick; a later one gives way until its next time.
173
+ if (first && [BUSY, ASKING].includes(skipped)) { if (entry.note !== WHEN_FREE) { entry.note = WHEN_FREE; dirty = true; } continue; }
174
+ if (!skipped) fired++;
175
+ else { waiting = true; entry.note = first ? null : `Skipped ${dateText(now)}: ${skipped}`; }
176
+ } else if (first) entry.note = null;
177
+ delete entry.first; delete entry.since;
178
+ entry.nextRunAt = nextRun(spec, now).toISOString(); dirty = true;
179
+ }
180
+ if (dirty) writeState(repo, task.id, state);
181
+ } catch { /* This session is tried again on the next tick. */ }
182
+ }
183
+ }
184
+ keepSeen();
185
+ }, 20);
186
+ if (install && on && (fresh || !installed)) { installed = true; try { install(); } catch { /* Schedules still run while ELU Code is open. */ } }
187
+ } catch { /* The next tick tries again. */ }
188
+ // A run that is still queued may be waiting for a conductor that is not there.
189
+ if (fired || waiting) { try { start(); } catch { /* The next queued message starts it. */ } }
190
+ return fired;
191
+ }
192
+ // Whether a bot's schedule is on in some session, so the login item is kept while one is.
193
+ // It only reads. An archived session still counts: a new message brings it back.
194
+ export function botSchedulesOn() {
195
+ try {
196
+ const bots = new Map(listBots().filter(bot => botScheduleSpec(bot)).map(bot => [bot.name, bot]));
197
+ if (!bots.size) return false;
198
+ return registeredProjects().some(repo => sessions(repo).some(task => !task.peer && !inPlace(task) && task.bots.some(name => bots.has(name) && readState(repo, task.id)[name]?.on !== false)));
199
+ } catch { return false; }
200
+ }
201
+
202
+ // One bot's schedule in one session, for the screen: null when the bot has no schedule.
203
+ // here: the bot is in this session's chat. on: it runs by itself here. label: the schedule
204
+ // as /schedule would say it, or null when its text cannot be read.
205
+ export function botScheduleIn(repo, task, name, now = new Date()) {
206
+ let bot = null; try { bot = getBot(name); } catch { /* An unreadable bots file shows no schedule. */ }
207
+ if (!botScheduled(bot)) return null;
208
+ const spec = botScheduleSpec(bot), here = chatList(task).includes(bot.name), entry = task ? readState(repo, task.id)[bot.name] ?? null : null;
209
+ const on = Boolean(spec) && here && entry?.on !== false && !inPlace(task), counted = Boolean(entry) && entry.schedule === bot.schedule && Number.isFinite(Date.parse(entry.nextRunAt));
210
+ // What the next tick does with a session it has not counted yet: the bot starts at once
211
+ // when it is new here or back after it left, and one interval from now otherwise.
212
+ let joins = false;
213
+ if (on && !counted) {
214
+ if (entry) joins = entry.schedule === bot.schedule;
215
+ else { let full = task; if (!Array.isArray(task.messages)) { try { full = readTask(repo, task.id); } catch { full = null; } } joins = Boolean(full) && startsAtOnce({ asked: askedAt(full), botChat: full.botChat ?? null }, bot.name); }
216
+ }
217
+ const next = on ? counted ? entry.nextRunAt : (joins ? now : nextRun(spec, now)).toISOString() : null;
218
+ // starting: its first run here has not been queued yet.
219
+ return { name: bot.name, label: spec?.label ?? null, here, on, off: entry?.on === false, starting: on && (counted ? entry.first === true : joins), nextRunAt: next, lastRunAt: entry?.lastRunAt ?? null, note: inPlace(task) && here ? IN_PLACE : entry?.note ?? null };
220
+ }
221
+ // "@elu every 1h · next Tue Nov 10 19:04", "@elu every 1h · off in this session": one
222
+ // line per scheduled bot of a session, for /bots.
223
+ export function botScheduleLines(repo, task, now = new Date()) {
224
+ return chatList(task).map(name => botScheduleIn(repo, task, name, now)).filter(Boolean).map(value => {
225
+ const state = !value.label ? 'its schedule cannot be read' : value.off ? 'off in this session' : value.starting ? 'starts when the session is free' : value.on ? `next ${dateText(new Date(value.nextRunAt))}` : 'not running here';
226
+ return `@${value.name} ${value.label ?? ''} · ${state}${value.note && !value.off && !value.starting ? ` · ${value.note}` : ''}`.replace(/\s+/g, ' ');
227
+ });
228
+ }
229
+ // The scheduled bots of each session given, for the screen: { [session id]: [botScheduleIn
230
+ // value, ...] }, holding only the sessions that have one in their chat.
231
+ export function botSchedulesShown(repo, tasks, now = new Date()) {
232
+ let names = []; try { names = listBots().filter(botScheduled).map(bot => bot.name); } catch { return {}; }
233
+ const shown = {};
234
+ for (const task of names.length ? tasks ?? [] : []) {
235
+ if (isPeerRun(task.origin)) continue;
236
+ let values = []; try { values = chatList(task).filter(name => names.includes(name)).map(name => botScheduleIn(repo, task, name, now)).filter(value => value?.here); } catch { /* A schedule that cannot be read shows nothing. */ }
237
+ if (values.length) shown[task.id] = values;
238
+ }
239
+ return shown;
240
+ }
241
+ // One of those values in a few words, for the title line of its session: "@elu every 1h ·
242
+ // next in 52m", "@elu every 1h · off in this session".
243
+ export function botScheduleBrief(value, now = Date.now()) {
244
+ const soon = value.on && Date.parse(value.nextRunAt) - now < 60000;
245
+ const state = !value.label ? 'its schedule cannot be read' : value.off ? 'off in this session' : value.starting ? 'starts when the session is free' : soon ? 'starts within a minute' : value.on ? `next ${untilText(value.nextRunAt, now)}` : 'not running here';
246
+ return `@${value.name} ${value.label ?? ''} · ${state}`.replace(/\s+/g, ' ');
247
+ }
248
+ // run: the owner asked for a run now, so a bot that works with no schedule is told how it starts.
249
+ function member(repo, taskId, name, { run = false } = {}) {
250
+ const bot = needBot(name);
251
+ if (!botScheduled(bot)) refuse(run && botWorks(bot) ? `@${bot.name} has no schedule to run. Write @${bot.name} and what to do, and it works.` : `@${bot.name} has no schedule. Give it one in the Bots tab.`);
252
+ const task = readTask(repo, taskId);
253
+ if (isPeerRun(task.origin)) refuse("Bots join your own sessions, not a teammate's question.");
254
+ if (!chatList(task).includes(bot.name)) refuse(`@${bot.name} is not in this session. /bots add ${bot.name} adds it.`);
255
+ return { bot, task };
256
+ }
257
+ // Turns a bot's schedule off or on in one session. Off stays off there until it is turned
258
+ // on again; on counts the next run from now.
259
+ export function setBotSchedule(repo, taskId, name, on, now = new Date()) {
260
+ const { bot, task } = member(repo, taskId, name), spec = botScheduleSpec(bot);
261
+ if (on && !spec) refuse(`The schedule of @${bot.name} cannot be read. ${botScheduleProblem(bot.schedule) ?? REPEATS}`);
262
+ if (on && inPlace(task)) refuse(IN_PLACE);
263
+ locked(() => {
264
+ const state = readState(repo, task.id), old = state[bot.name] ?? null;
265
+ state[bot.name] = on ? { ...row(bot, spec, now, old), on: true } : { ...(old ?? { schedule: bot.schedule, nextRunAt: null, lastRunAt: null, lastRequestId: null, runs: 0 }), on: false, note: null };
266
+ writeState(repo, task.id, state);
267
+ });
268
+ return botScheduleIn(repo, task, bot.name, now);
269
+ }
270
+ // Runs a scheduled bot now, once, whether or not its schedule is on in this session. Its
271
+ // next scheduled time does not move. It waits for the session's current turn to end.
272
+ export function runBotNow(repo, taskId, name, now = new Date(), { start = startConductor } = {}) {
273
+ const { bot, task } = member(repo, taskId, name, { run: true });
274
+ locked(() => {
275
+ const state = readState(repo, task.id), entry = state[bot.name] ??= { on: true, schedule: null, nextRunAt: null, lastRunAt: null, lastRequestId: null, runs: 0, note: null };
276
+ const skipped = queueRun(repo, task, bot, entry, now, requests(repo), RUN_NOW, { busy: false });
277
+ if (skipped) refuse(skipped === IN_PLACE ? skipped : `@${bot.name} is already on its way here. ${skipped}`);
278
+ writeState(repo, task.id, state);
279
+ });
280
+ start();
281
+ return true;
282
+ }
283
+ // The session that is a bot's own chat in a project: the one a message written to the bot
284
+ // in Chats continues. An archived one is left alone, and the next message starts another.
285
+ // starting is true while the first message of a new one is still on its way to a session.
286
+ export function botChatSession(repo, name) {
287
+ let board = null; try { board = readBoard(repo); } catch { /* Nothing counts as archived. */ }
288
+ const own = sessions(repo).filter(task => task.botChat === name && !(board && sessionClosed(board, task))).sort((a, b) => Number(b.id) - Number(a.id))[0];
289
+ if (own) return { id: own.id, starting: false };
290
+ return requests(repo).some(value => value.botChat === true && value.bot === name && !value.continuationTaskId && value.state !== 'done') ? { id: null, starting: true } : null;
291
+ }