agent-office-studio 0.8.0 → 0.9.2
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/CHANGELOG.md +61 -0
- package/README.md +12 -0
- package/dist/assets/code-Dtdckl8K.js +32 -0
- package/dist/assets/index-BcqWv4NW.css +1 -0
- package/dist/assets/index-qdaAZdZT.js +5065 -0
- package/dist/assets/pdf-BtCWNl9F.js +55 -0
- package/dist/assets/pdf-CKZBNrtF.js +1 -0
- package/dist/assets/pdf.worker.min-BJd0dv5W.js +53 -0
- package/dist/assets/pdf.worker.min-D_0FwaWY.js +1 -0
- package/dist/index.html +39 -35
- package/dist/theme-boot.js +13 -0
- package/docs/desktop.md +52 -0
- package/docs/features.md +96 -0
- package/package.json +19 -2
- package/server/apps.js +146 -0
- package/server/changes.js +13 -2
- package/server/chief.js +20 -1
- package/server/economy.js +26 -3
- package/server/files.js +115 -0
- package/server/guests.js +59 -21
- package/server/index.js +897 -72
- package/server/links.js +11 -4
- package/server/machine.js +164 -0
- package/server/office-mcp.js +26 -1
- package/server/procstat.js +12 -6
- package/server/settings.js +2 -0
- package/server/telegram.js +320 -0
- package/server/visits.js +14 -4
- package/shared/shop.js +11 -4
- package/dist/assets/index-BAvX7Imu.css +0 -1
- package/dist/assets/index-C01GpSoa.js +0 -4999
package/server/links.js
CHANGED
|
@@ -26,6 +26,9 @@ const MAX_AGENTS = 16;
|
|
|
26
26
|
const STATES = new Set(['working', 'idle', 'break', 'meeting', 'away']);
|
|
27
27
|
const CODE_RE = /^[0-9A-HJKMNP-TV-Z]{8}$/;
|
|
28
28
|
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
29
|
+
// Ids key plain objects (saved guests, tokens used), so none may name their prototype.
|
|
30
|
+
const NOT_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
31
|
+
const isId = (v) => ID_RE.test(String(v ?? '')) && !NOT_IDS.has(String(v));
|
|
29
32
|
const HEX_RE = /^#[0-9a-f]{6}$/i;
|
|
30
33
|
const CLEARANCES = new Set(['plan', 'edit', 'auto']); // MODES in index.js: auto is Full autonomy
|
|
31
34
|
|
|
@@ -40,13 +43,13 @@ export function normaliseCode(code) {
|
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
function cleanLink(l) {
|
|
43
|
-
if (!l || !
|
|
46
|
+
if (!l || !isId(l.id)) return null;
|
|
44
47
|
return {
|
|
45
48
|
id: String(l.id),
|
|
46
49
|
peer: text(l.peer, 20),
|
|
47
50
|
kind: l.kind === 'guest' ? 'guest' : 'link',
|
|
48
51
|
role: ['host', 'guest'].includes(l.role) ? l.role : 'peer',
|
|
49
|
-
invite:
|
|
52
|
+
invite: isId(l.invite) ? String(l.invite) : null,
|
|
50
53
|
createdAt: Number.isFinite(l.createdAt) ? l.createdAt : null,
|
|
51
54
|
};
|
|
52
55
|
}
|
|
@@ -77,7 +80,11 @@ function cleanPolicy(guest) {
|
|
|
77
80
|
const ourAgents = guest?.ourAgents !== false;
|
|
78
81
|
const theirAgents = ['read', 'edit'].includes(guest?.theirAgents) ? guest.theirAgents : 'off';
|
|
79
82
|
if (!ourAgents && theirAgents === 'off') throw fail(400, 'Let them use your agents, or let their agents work here, or both.');
|
|
80
|
-
|
|
83
|
+
// Which of our agents they may use. Absent (a guest made before this existed)
|
|
84
|
+
// means all of them.
|
|
85
|
+
const agents = ourAgents && Array.isArray(guest?.agents) ? [...new Set(guest.agents.map(String).filter((a) => ID_RE.test(a)))] : null;
|
|
86
|
+
if (agents && !agents.length) throw fail(400, 'Pick at least one of your agents to lend.');
|
|
87
|
+
return { repos, clearance, tokensPerDay, ourAgents, theirAgents, agents };
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
export class Links {
|
|
@@ -145,7 +152,7 @@ export class Links {
|
|
|
145
152
|
// A guest link's limits, as the host set them. Null if this isn't one we host.
|
|
146
153
|
guestPolicy(linkId) {
|
|
147
154
|
const l = this.links.find((x) => x.id === linkId);
|
|
148
|
-
return l?.role === 'host' ? this.saved.guests[l.id]
|
|
155
|
+
return l?.role === 'host' && Object.hasOwn(this.saved.guests, l.id) ? this.saved.guests[l.id] : null;
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
save() {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Keeping the agents from sinking the machine they run on: how hard it is
|
|
2
|
+
// swapping right now, and cleaning up what an agent left running.
|
|
3
|
+
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { ps, tree } from './procstat.js';
|
|
8
|
+
|
|
9
|
+
// ---------- pressure ----------
|
|
10
|
+
|
|
11
|
+
// Swap traffic is the signal that matters: "free memory" on macOS stays near
|
|
12
|
+
// zero by design, and its memory level reads healthy while the disk thrashes.
|
|
13
|
+
// Pages swapped in and out, as running totals.
|
|
14
|
+
function swapPages() {
|
|
15
|
+
if (process.platform === 'linux') {
|
|
16
|
+
try {
|
|
17
|
+
const v = fs.readFileSync('/proc/vmstat', 'utf8');
|
|
18
|
+
const n = (k) => Number(v.match(new RegExp(`^${k} (\\d+)`, 'm'))?.[1] || 0);
|
|
19
|
+
return Promise.resolve({ pages: n('pswpin') + n('pswpout'), pageSize: 4096 });
|
|
20
|
+
} catch { return Promise.resolve(null); }
|
|
21
|
+
}
|
|
22
|
+
if (process.platform !== 'darwin') return Promise.resolve(null);
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
execFile('/usr/bin/vm_stat', { timeout: 3000 }, (err, out) => {
|
|
25
|
+
if (err) return resolve(null);
|
|
26
|
+
const n = (k) => Number(out.match(new RegExp(`${k}:\\s+(\\d+)`))?.[1] || 0);
|
|
27
|
+
const pageSize = Number(out.match(/page size of (\d+)/)?.[1] || 16384);
|
|
28
|
+
resolve({ pages: n('Swapins') + n('Swapouts'), pageSize });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Readings over the last minute or so; the rate is taken across all of them
|
|
34
|
+
// so one burst doesn't hold the queue.
|
|
35
|
+
const WINDOW_MS = 60_000;
|
|
36
|
+
const readings = [];
|
|
37
|
+
|
|
38
|
+
export async function samplePressure() {
|
|
39
|
+
const r = await swapPages();
|
|
40
|
+
if (!r) return;
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
readings.push({ at: now, ...r });
|
|
43
|
+
while (readings.length > 2 && now - readings[1].at > WINDOW_MS) readings.shift();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// MB a second swapped over the window, or null with too little to go on.
|
|
47
|
+
export function swapRate() {
|
|
48
|
+
if (readings.length < 2) return null;
|
|
49
|
+
const a = readings[0], b = readings[readings.length - 1];
|
|
50
|
+
const secs = (b.at - a.at) / 1000;
|
|
51
|
+
if (secs < 5 || b.pages < a.pages) return null;
|
|
52
|
+
return ((b.pages - a.pages) * b.pageSize) / secs / (1 << 20);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resetPressure() {
|
|
56
|
+
readings.length = 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Above these the machine is thrashing: swap traffic of a size that stalls
|
|
60
|
+
// everything, or three times more runnable work than there are cores.
|
|
61
|
+
export const LIMITS = { swapMBs: 100, loadPerCore: 3 };
|
|
62
|
+
|
|
63
|
+
// Why a new agent should not start now, in words for the boss, or null.
|
|
64
|
+
export function overloaded() {
|
|
65
|
+
if (process.env.OFFICE_PRESSURE_CHECK === 'off') return null;
|
|
66
|
+
const swap = swapRate();
|
|
67
|
+
if (swap !== null && swap > LIMITS.swapMBs) return `the machine is swapping memory to disk (about ${Math.round(swap)} MB/s)`;
|
|
68
|
+
const cores = os.cpus().length;
|
|
69
|
+
const load = os.loadavg()[0];
|
|
70
|
+
if (load > cores * LIMITS.loadPerCore) return `the processor is overloaded (load ${load.toFixed(0)} on ${cores} cores)`;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------- leftovers ----------
|
|
75
|
+
|
|
76
|
+
// What each job's agent has started, from the process samples taken while it
|
|
77
|
+
// works: jobId -> Map(pid -> { cmd, pgid }). Kept across the sessions of one
|
|
78
|
+
// job (a cleared context or a model switch), so a dev server started before
|
|
79
|
+
// the clear is still the job's.
|
|
80
|
+
const started = new Map();
|
|
81
|
+
// And the process groups they ran in: jobId -> Map(pgid -> leader as seen, or
|
|
82
|
+
// null if it wasn't). Each command an
|
|
83
|
+
// agent runs gets a group of its own, and whatever it puts in the background
|
|
84
|
+
// stays in that group after the shell exits, so the group finds those too.
|
|
85
|
+
const groups = new Map();
|
|
86
|
+
|
|
87
|
+
export function remember(jobId, procs, root) {
|
|
88
|
+
let mine = started.get(jobId);
|
|
89
|
+
if (!mine) started.set(jobId, (mine = new Map()));
|
|
90
|
+
let gs = groups.get(jobId);
|
|
91
|
+
if (!gs) groups.set(jobId, (gs = new Map()));
|
|
92
|
+
// The agent's own group is the office's, which is nobody's to clean up.
|
|
93
|
+
const own = procs.find((p) => p.pid === root)?.pgid;
|
|
94
|
+
for (const p of procs) {
|
|
95
|
+
if (p.pid === root) continue;
|
|
96
|
+
if (!mine.has(p.pid)) mine.set(p.pid, { cmd: p.cmd, pgid: p.pgid, start: p.start });
|
|
97
|
+
if (p.pgid > 1 && p.pgid !== own && !gs.get(p.pgid)) gs.set(p.pgid, p.pid === p.pgid ? { cmd: p.cmd, start: p.start } : null);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Whether a process is the job's: seen under it, or in a group it ran.
|
|
102
|
+
export function startedByJob(jobId, p) {
|
|
103
|
+
if (started.get(jobId)?.get(p.pid)?.cmd === p.cmd) return true;
|
|
104
|
+
// A new group leader that merely reuses an old group's number is not.
|
|
105
|
+
return !!groups.get(jobId)?.has(p.pgid) && p.pid !== p.pgid;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function forget(jobId) {
|
|
109
|
+
started.delete(jobId);
|
|
110
|
+
groups.delete(jobId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const name = (cmd) => {
|
|
114
|
+
const words = String(cmd).split(/\s+/);
|
|
115
|
+
const bin = (words[0] || '').split('/').pop();
|
|
116
|
+
// "node .../vite" says less than "vite".
|
|
117
|
+
const script = /^(node|python\d?|ruby|sh|bash|zsh|npm|npx|pnpm|yarn|bun|deno)$/.test(bin) && words.slice(1).find((w) => w && !w.startsWith('-'));
|
|
118
|
+
return (script ? script.split('/').pop() : bin).slice(0, 40) || 'process';
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Daemons that any build or tool connects to, whoever started them. Stopping
|
|
122
|
+
// one under another agent's running build fails that build, so they are only
|
|
123
|
+
// stopped when nobody else is working.
|
|
124
|
+
const SHARED = /GradleDaemon|KotlinCompileDaemon|GradleWrapperMain.*--daemon|\bwatchman\b|\badb\b.*(fork-server|server)/;
|
|
125
|
+
|
|
126
|
+
// Stop whatever the job started that is still running, except what the agent
|
|
127
|
+
// asked to keep and anything inside `live` (another agent's process tree).
|
|
128
|
+
// A pid only counts if its command and start are the ones recorded, so a
|
|
129
|
+
// reused pid is left alone. Returns the names of what was stopped.
|
|
130
|
+
const same = (now, was) => !!now && !!was && now.cmd === was.cmd && (now.start == null || was.start == null || Math.abs(now.start - was.start) < 2000);
|
|
131
|
+
export async function reap(jobId, { keep = [], live = [] } = {}) {
|
|
132
|
+
const mine = started.get(jobId);
|
|
133
|
+
const gs = groups.get(jobId) || new Map();
|
|
134
|
+
forget(jobId);
|
|
135
|
+
if (!mine?.size) return [];
|
|
136
|
+
const snap = await ps();
|
|
137
|
+
if (!snap) return [];
|
|
138
|
+
// Never the office itself or what runs it.
|
|
139
|
+
const spare = new Set();
|
|
140
|
+
for (let pid = process.pid; pid > 1 && !spare.has(pid); pid = snap.procs.get(pid)?.ppid) spare.add(pid);
|
|
141
|
+
for (const root of [...live, ...keep]) for (const p of tree(snap, root)) spare.add(p.pid);
|
|
142
|
+
const roots = [];
|
|
143
|
+
for (const [pid, was] of mine) if (same(snap.procs.get(pid), was)) roots.push(pid);
|
|
144
|
+
// A group's number can't be reused while anything is still in it, so one
|
|
145
|
+
// with no leader left is the job's. One with a leader is only if that
|
|
146
|
+
// leader is the one seen while the job worked, not a newcomer with its pid.
|
|
147
|
+
const ours = (g) => !snap.procs.has(g) || same(snap.procs.get(g), gs.get(g));
|
|
148
|
+
for (const p of snap.procs.values()) if (gs.has(p.pgid) && p.pid !== p.pgid && ours(p.pgid)) roots.push(p.pid);
|
|
149
|
+
const doomed = new Map();
|
|
150
|
+
for (const pid of roots) {
|
|
151
|
+
if (spare.has(pid)) continue;
|
|
152
|
+
for (const p of tree(snap, pid)) if (!spare.has(p.pid) && !(live.length && SHARED.test(p.cmd))) doomed.set(p.pid, p);
|
|
153
|
+
}
|
|
154
|
+
if (!doomed.size) return [];
|
|
155
|
+
for (const pid of doomed.keys()) try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
156
|
+
// Whatever ignores a polite stop gets a firm one, if it is still the same process.
|
|
157
|
+
setTimeout(async () => {
|
|
158
|
+
const later = await ps();
|
|
159
|
+
for (const [pid, p] of doomed) if (same(later?.procs.get(pid), p)) try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
160
|
+
}, 5000).unref();
|
|
161
|
+
return [...doomed.values()].map((p) => name(p.cmd));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export { name as procName };
|
package/server/office-mcp.js
CHANGED
|
@@ -37,7 +37,7 @@ const TOOLS = [
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
name: 'attach_files',
|
|
40
|
-
description: 'Attach images or files (screenshots, say) to your next message to the boss: your final reply, a flag_for_boss, or a post_to_channel. Absolute paths, up to 6, 25 MB each. Nothing is sent by this call itself.',
|
|
40
|
+
description: 'Attach images or files (screenshots, say) to your next message to the boss: your final reply, a flag_for_boss, or a post_to_channel. Absolute paths, up to 6, 25 MB each. Nothing is sent by this call itself. They arrive where the boss wrote from (Telegram or the web chat; the brief and each boss message say which).',
|
|
41
41
|
inputSchema: obj({ paths: names }, ['paths']),
|
|
42
42
|
},
|
|
43
43
|
{
|
|
@@ -50,6 +50,31 @@ const TOOLS = [
|
|
|
50
50
|
description: 'Drop this session\'s history to cut token use, at a natural break between steps. Give notes to yourself: what is done, what is left, what you learned (files, decisions, gotchas). End your turn right after; you resume fresh from the brief and your notes. Not for the end of a job.',
|
|
51
51
|
inputSchema: obj({ notes: { type: 'string' } }, ['notes']),
|
|
52
52
|
},
|
|
53
|
+
{
|
|
54
|
+
name: 'heavy_work',
|
|
55
|
+
description: 'One heavy build or test run at a time on this shared laptop (Gradle, Xcode, emulator, jest or vitest across a whole app, docker build). start before it: you either get the slot or are told to end your turn and are messaged when it is yours. done straight after it ends, even if it failed.',
|
|
56
|
+
inputSchema: obj({ action: { type: 'string', enum: ['start', 'done'] }, what: { type: 'string', description: 'What you are running, like "the Android Gradle build"' } }, ['action']),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'keep_running',
|
|
60
|
+
description: 'Keep a process you started (a dev server the boss needs to look at, say) running after this job ends; everything else you leave running is stopped then. The boss is told what was kept.',
|
|
61
|
+
inputSchema: obj({ pid: { type: 'number' }, why: { type: 'string' } }, ['pid', 'why']),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'add_app',
|
|
65
|
+
description: 'Put an app on the boss\'s home screen, or update one (same id). An app is one self-contained HTML file in a project (inline scripts, styles and data; it runs sandboxed with no network). It opens in its own window from the icon.',
|
|
66
|
+
inputSchema: obj({
|
|
67
|
+
name: { type: 'string', description: 'What the icon says, up to 40 characters' },
|
|
68
|
+
project: { type: 'string', description: 'Project folder the file is in, per office_roster' },
|
|
69
|
+
path: { type: 'string', description: 'The HTML file, relative to the project folder' },
|
|
70
|
+
icon: { type: 'string', description: 'An emoji for the icon' },
|
|
71
|
+
color: { type: 'string', description: 'Icon tile colour, hex like #1d4ed8' },
|
|
72
|
+
description: { type: 'string', description: 'One line on what it does' },
|
|
73
|
+
id: { type: 'string', description: 'Lowercase slug; defaults from the name. Give an existing id to update that app' },
|
|
74
|
+
w: { type: 'number', description: 'Starting window width in pixels' },
|
|
75
|
+
h: { type: 'number', description: 'Starting window height in pixels' },
|
|
76
|
+
}, ['name', 'project', 'path']),
|
|
77
|
+
},
|
|
53
78
|
{ name: 'office_shop', description: 'Your pay so far, how pay works, and what you can buy for your desk.', inputSchema: NONE },
|
|
54
79
|
{
|
|
55
80
|
name: 'buy_item',
|
package/server/procstat.js
CHANGED
|
@@ -14,26 +14,28 @@ function cpuSeconds(s) {
|
|
|
14
14
|
return Number(days) * 86400 + rest.split(':').reduce((a, p) => a * 60 + parseFloat(p), 0);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
function ps() {
|
|
17
|
+
export function ps() {
|
|
18
18
|
return new Promise((resolve) => {
|
|
19
|
-
execFile(PS, ['-axo', 'pid=,ppid=,rss=,cputime='], { timeout: 4000, maxBuffer:
|
|
19
|
+
execFile(PS, ['-axo', 'pid=,ppid=,pgid=,rss=,cputime=,etime=,command='], { timeout: 4000, maxBuffer: 16 << 20 }, (err, out) => {
|
|
20
20
|
if (err) return resolve(null);
|
|
21
|
+
const at = Date.now();
|
|
21
22
|
const procs = new Map();
|
|
22
23
|
const kids = new Map();
|
|
23
24
|
for (const line of out.split('\n')) {
|
|
24
|
-
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)$/);
|
|
25
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/);
|
|
25
26
|
if (!m) continue;
|
|
26
27
|
const pid = Number(m[1]), ppid = Number(m[2]);
|
|
27
|
-
|
|
28
|
+
// start: when it began, to the second, so a reused pid can be told apart.
|
|
29
|
+
procs.set(pid, { pid, ppid, pgid: Number(m[3]), rss: Number(m[4]) * 1024, cpu: cpuSeconds(m[5]), start: at - cpuSeconds(m[6]) * 1000, cmd: m[7] });
|
|
28
30
|
if (!kids.has(ppid)) kids.set(ppid, []);
|
|
29
31
|
kids.get(ppid).push(pid);
|
|
30
32
|
}
|
|
31
|
-
resolve({ procs, kids, at
|
|
33
|
+
resolve({ procs, kids, at });
|
|
32
34
|
});
|
|
33
35
|
});
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
function tree(snap, root) {
|
|
38
|
+
export function tree(snap, root) {
|
|
37
39
|
const out = [];
|
|
38
40
|
const queue = [root];
|
|
39
41
|
const seen = new Set();
|
|
@@ -78,6 +80,10 @@ export async function sampleProcs(roots) {
|
|
|
78
80
|
return by;
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
// Every process under `root` as of the last sample, for remembering what an
|
|
84
|
+
// agent started so it can be cleaned up once the agent is gone.
|
|
85
|
+
export const lastTree = (root) => (prev ? tree(prev, root) : []);
|
|
86
|
+
|
|
81
87
|
// Starting fresh after a quiet spell: the next sample has nothing to diff
|
|
82
88
|
// against, so don't report a percentage for it.
|
|
83
89
|
export function resetProcs() {
|
package/server/settings.js
CHANGED
|
@@ -14,6 +14,7 @@ export const SETTINGS = [
|
|
|
14
14
|
|
|
15
15
|
{ key: 'tokenLimit', group: 'Agents', label: 'Token limit', kind: 'number', min: 0, max: 10_000_000, step: 10000, unit: 'tokens', hint: 'Past this much context, an agent is nudged to clear it; if it does not, the office clears it. 0 turns this off.', fallback: () => Math.max(0, parseInt(env('AGENT_CLEAR_TOKENS') ?? '150000', 10) || 0) },
|
|
16
16
|
{ key: 'compactPct', group: 'Agents', label: 'Compact at', kind: 'number', min: 10, max: 95, step: 5, unit: '%', hint: 'How full an agent’s context gets before Claude Code compacts it. Applies to jobs that start after you save.', fallback: () => Math.min(95, Math.max(10, parseInt(env('AGENT_COMPACT_PCT') ?? '60', 10) || 60)) },
|
|
17
|
+
{ key: 'maxAgents', group: 'Agents', label: 'Agents working at once', kind: 'number', min: 0, max: 50, step: 1, unit: 'agents', hint: 'More jobs than this wait in the queue, and you are told why. Jobs also wait while the machine is swapping or overloaded. 0 turns the limit off.', fallback: () => Math.max(0, parseInt(env('OFFICE_MAX_AGENTS') ?? '4', 10) || 0) },
|
|
17
18
|
{ key: 'chiefModel', group: 'Agents', label: 'Chief of staff model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], hint: 'Routes your messages and writes the standup.', fallback: () => env('CHIEF_MODEL') || 'sonnet' },
|
|
18
19
|
|
|
19
20
|
{ key: 'payPerMinute', group: 'Pay', label: 'Pay per active minute', kind: 'number', min: 0, max: 1_000_000, step: 100, unit: 'naira', hint: 'What an agent earns for each minute the model was working, on Opus. Haiku pays 3×, Sonnet 1.75×. Applies to work paid from now on.', fallback: () => 1000 },
|
|
@@ -22,6 +23,7 @@ export const SETTINGS = [
|
|
|
22
23
|
{ key: 'standupAt', group: 'Standup', label: 'Standup time', kind: 'time', hint: 'Local time, 24-hour. Blank turns the daily standup off.', fallback: () => (env('STANDUP_AT') ?? '09:30') === 'off' ? '' : (env('STANDUP_AT') ?? '09:30') },
|
|
23
24
|
{ key: 'standupWeekends', group: 'Standup', label: 'Hold standups on weekends', kind: 'bool', fallback: () => env('STANDUP_WEEKENDS') === '1' },
|
|
24
25
|
|
|
26
|
+
{ key: 'breakRoom', group: 'Break room', label: 'Break-room chatter and pool', kind: 'bool', hint: 'Off: idle agents just wait, with no small talk or pool games, and nothing is posted to #chill-room.', fallback: () => env('BREAK_ROOM') === 'on' || env('BREAK_ROOM') === '1' },
|
|
25
27
|
{ key: 'banter', group: 'Break room', label: 'Written break-room talk', kind: 'bool', hint: 'Off: the break room uses canned lines only.', fallback: () => on(env('BANTER')) },
|
|
26
28
|
{ key: 'banterModel', group: 'Break room', label: 'Break-room model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], fallback: () => env('BANTER_MODEL') || 'haiku' },
|
|
27
29
|
{ key: 'banterGap', group: 'Break room', label: 'Time between conversations', kind: 'number', min: 10, max: 86400, step: 10, unit: 'seconds', hint: 'At most one written conversation per this long.', fallback: () => Math.max(10, Number(env('BANTER_GAP_SECONDS') ?? 120) || 120) },
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Telegram: the boss talks to the office from their phone, and hears back.
|
|
2
|
+
//
|
|
3
|
+
// A bot the boss makes with @BotFather. The office long-polls Telegram for
|
|
4
|
+
// the bot's messages (getUpdates), so it only ever dials out: no tunnel, no
|
|
5
|
+
// webhook, nothing listening. Off until the boss turns it on in the office.
|
|
6
|
+
//
|
|
7
|
+
// Exactly one Telegram account may use it. Pairing proves which one: the
|
|
8
|
+
// office shows a one-time code (in the office only, never in Telegram), and
|
|
9
|
+
// whoever sends `/pair <code>` to the bot in a private chat becomes the owner.
|
|
10
|
+
// From then on anything from any other account or chat is dropped unread and
|
|
11
|
+
// logged; nobody else can talk to the bot, see replies, or pair over it.
|
|
12
|
+
//
|
|
13
|
+
// The token and the owner's id live in DATA/telegram.json (0600), never in
|
|
14
|
+
// state.json or the chat, so they don't travel with the office's history.
|
|
15
|
+
// Not read from the environment on purpose: test servers inherit that, and
|
|
16
|
+
// would steal the bot's messages and text the boss their test chatter.
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
const API = 'https://api.telegram.org';
|
|
23
|
+
const TOKEN_RE = /^\d{5,15}:[A-Za-z0-9_-]{30,60}$/;
|
|
24
|
+
const PAIR_TTL_MS = 15 * 60 * 1000;
|
|
25
|
+
const MAX_PAIR_TRIES = 5;
|
|
26
|
+
const HINT_GAP_MS = 10 * 60 * 1000; // how often an unpaired bot says how to pair
|
|
27
|
+
const MAX_TEXT = 4000; // Telegram allows 4096
|
|
28
|
+
const MAX_MAP = 500; // Telegram message id → office thread, for replies
|
|
29
|
+
const MAX_CAPTION = 1000; // Telegram allows 1024
|
|
30
|
+
const MAX_SEND_BYTES = 50 * 1024 * 1024; // what a bot may upload
|
|
31
|
+
const MAX_PHOTO_BYTES = 10 * 1024 * 1024; // bigger photos go as files
|
|
32
|
+
const MAX_FETCH_BYTES = 20 * 1024 * 1024; // what a bot may download
|
|
33
|
+
|
|
34
|
+
// How a file goes out, by its extension: Telegram shows photos and videos
|
|
35
|
+
// inline, and anything else as a file to download.
|
|
36
|
+
const KIND = {
|
|
37
|
+
'.png': 'photo', '.jpg': 'photo', '.jpeg': 'photo', '.webp': 'photo',
|
|
38
|
+
'.gif': 'animation',
|
|
39
|
+
'.mp4': 'video', '.mov': 'video', '.m4v': 'video',
|
|
40
|
+
'.m4a': 'audio', '.mp3': 'audio', '.ogg': 'audio', '.oga': 'audio', '.opus': 'audio', '.wav': 'audio', '.aac': 'audio', '.flac': 'audio',
|
|
41
|
+
};
|
|
42
|
+
const METHOD = { photo: 'sendPhoto', animation: 'sendAnimation', video: 'sendVideo', audio: 'sendAudio', document: 'sendDocument' };
|
|
43
|
+
|
|
44
|
+
const log = (...a) => console.log('[telegram]', ...a);
|
|
45
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
46
|
+
|
|
47
|
+
// The office writes **bold**; sent as plain text, so drop the markers rather
|
|
48
|
+
// than risk Telegram rejecting a message over unbalanced markup.
|
|
49
|
+
export function plain(text) {
|
|
50
|
+
return String(text || '').replace(/\*\*(.+?)\*\*/gs, '$1').replace(/`{3}\w*\n?/g, '').trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clipText(text) {
|
|
54
|
+
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 40).trimEnd()}\n\n… (cut short: open the office for the rest)` : text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class Telegram {
|
|
58
|
+
// onMessage({ text, files, replyTo }) hands the boss's words to the office;
|
|
59
|
+
// files are upload ids, replyTo the office thread their Telegram reply
|
|
60
|
+
// points at, if any. saveUpload(name, buffer) keeps a file the boss sent
|
|
61
|
+
// and returns its upload id. onNotice(text) tells the boss something in the
|
|
62
|
+
// office itself.
|
|
63
|
+
constructor({ dataDir, onMessage, saveUpload = () => null, onNotice = () => {}, fetchImpl = fetch, api = API }) {
|
|
64
|
+
Object.assign(this, { onMessage, saveUpload, onNotice, fetch: fetchImpl, api });
|
|
65
|
+
this.file = path.join(dataDir, 'telegram.json');
|
|
66
|
+
this.cfg = this.read();
|
|
67
|
+
this.pairing = null; // { code, until, tries }
|
|
68
|
+
this.offset = 0;
|
|
69
|
+
this.map = new Map();
|
|
70
|
+
this.outbox = Promise.resolve();
|
|
71
|
+
this.running = false;
|
|
72
|
+
this.bot = null; // @username, once getMe has answered
|
|
73
|
+
this.error = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
read() {
|
|
77
|
+
try { return JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch { return {}; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
write() {
|
|
81
|
+
const tmp = `${this.file}.tmp`;
|
|
82
|
+
fs.writeFileSync(tmp, JSON.stringify(this.cfg), { mode: 0o600 });
|
|
83
|
+
fs.renameSync(tmp, this.file);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get paired() { return !!(this.cfg.token && this.cfg.owner); }
|
|
87
|
+
|
|
88
|
+
status() {
|
|
89
|
+
return { on: !!this.cfg.token, paired: this.paired, bot: this.bot, error: this.error };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
start() {
|
|
93
|
+
if (!this.cfg.token || this.running) return;
|
|
94
|
+
this.running = true;
|
|
95
|
+
this.loop().catch((err) => { log('poller stopped:', err.message); this.running = false; });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stop() { this.running = false; this.abort?.abort(); }
|
|
99
|
+
|
|
100
|
+
// `/telegram <token>` in the office. Checks the token with Telegram before
|
|
101
|
+
// keeping it, then hands back a pairing code for the boss to send the bot.
|
|
102
|
+
async connect(token) {
|
|
103
|
+
token = String(token || '').trim();
|
|
104
|
+
if (!TOKEN_RE.test(token)) throw new Error('That does not look like a bot token. It comes from @BotFather and looks like 123456789:AAH…');
|
|
105
|
+
const me = await this.call('getMe', {}, token);
|
|
106
|
+
this.stop();
|
|
107
|
+
await sleep(50);
|
|
108
|
+
this.cfg = { token };
|
|
109
|
+
this.write();
|
|
110
|
+
this.bot = me.username;
|
|
111
|
+
this.error = null;
|
|
112
|
+
this.offset = 0;
|
|
113
|
+
this.start();
|
|
114
|
+
log(`connected as @${me.username}; waiting to be paired`);
|
|
115
|
+
return { bot: me.username, code: this.newPairing() };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
newPairing() {
|
|
119
|
+
const code = String(crypto.randomInt(100000, 1000000));
|
|
120
|
+
this.pairing = { code, until: Date.now() + PAIR_TTL_MS, tries: 0 };
|
|
121
|
+
return code;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
disconnect() {
|
|
125
|
+
this.stop();
|
|
126
|
+
this.cfg = {};
|
|
127
|
+
this.pairing = null;
|
|
128
|
+
this.bot = null;
|
|
129
|
+
try { fs.rmSync(this.file, { force: true }); } catch {}
|
|
130
|
+
log('disconnected; token and owner forgotten');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async call(method, body, token = this.cfg.token, signal) {
|
|
134
|
+
const form = body instanceof FormData;
|
|
135
|
+
const res = await this.fetch(`${this.api}/bot${token}/${method}`, {
|
|
136
|
+
method: 'POST', ...(!form && { headers: { 'content-type': 'application/json' } }), body: form ? body : JSON.stringify(body), signal,
|
|
137
|
+
});
|
|
138
|
+
const data = await res.json().catch(() => ({}));
|
|
139
|
+
if (!data.ok) {
|
|
140
|
+
const err = new Error(data.description || `Telegram answered ${res.status}`);
|
|
141
|
+
err.status = res.status;
|
|
142
|
+
err.retryAfter = data.parameters?.retry_after;
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
return data.result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async loop() {
|
|
149
|
+
let backoff = 1000;
|
|
150
|
+
if (!this.bot) try { this.bot = (await this.call('getMe', {})).username; } catch {}
|
|
151
|
+
while (this.running) {
|
|
152
|
+
try {
|
|
153
|
+
this.abort = new AbortController();
|
|
154
|
+
const updates = await this.call('getUpdates', { offset: this.offset, timeout: 50, allowed_updates: ['message'] }, this.cfg.token, this.abort.signal);
|
|
155
|
+
backoff = 1000;
|
|
156
|
+
this.error = null;
|
|
157
|
+
for (const u of updates) {
|
|
158
|
+
this.offset = u.update_id + 1;
|
|
159
|
+
try { await this.handle(u.message); } catch (err) { log('could not handle a message:', err.message); }
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (!this.running) break;
|
|
163
|
+
if (err.status === 401 || err.status === 404) {
|
|
164
|
+
this.error = 'Telegram no longer accepts the bot token. Run /telegram with a new one.';
|
|
165
|
+
log(this.error);
|
|
166
|
+
this.running = false;
|
|
167
|
+
this.onNotice(this.error);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
// 409: another copy of this bot is polling (a second office). Back off.
|
|
171
|
+
log(`poll failed (${err.status || err.name}): ${err.message}; retrying in ${backoff / 1000}s`);
|
|
172
|
+
await sleep(err.retryAfter ? err.retryAfter * 1000 : backoff);
|
|
173
|
+
backoff = Math.min(backoff * 2, 60_000);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async handle(m) {
|
|
179
|
+
if (!m?.from || !m.chat) return;
|
|
180
|
+
const who = `${m.from.id}${m.from.username ? ` (@${m.from.username})` : ''}`;
|
|
181
|
+
const text = String(m.text ?? m.caption ?? '').trim();
|
|
182
|
+
// Only private chats: in a group, others would read the replies.
|
|
183
|
+
if (m.chat.type !== 'private') { log(`ignored a message from ${who} in a ${m.chat.type} chat`); return; }
|
|
184
|
+
|
|
185
|
+
if (!this.cfg.owner) {
|
|
186
|
+
// Nothing is downloaded or passed to the office before pairing; the
|
|
187
|
+
// only answer is how to pair, at most once a while per account.
|
|
188
|
+
const pair = /^\/pair(?:@\w+)?\s+(\d{6})$/.exec(text);
|
|
189
|
+
const p = this.pairing;
|
|
190
|
+
if (!pair || !p || Date.now() > p.until || p.tries >= MAX_PAIR_TRIES) {
|
|
191
|
+
log(`unpaired bot: ignored a message from ${who}`);
|
|
192
|
+
const last = (this.hinted ||= new Map()).get(m.from.id) || 0;
|
|
193
|
+
if (Date.now() - last > HINT_GAP_MS) {
|
|
194
|
+
this.hinted.set(m.from.id, Date.now());
|
|
195
|
+
await this.call('sendMessage', { chat_id: m.chat.id, text: 'This bot is not paired yet. In the office, type /telegram pair to get a code, then send it here as /pair 123456.' }).catch((err) => log(`hint failed: ${err.message}`));
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (pair[1] !== p.code) {
|
|
200
|
+
p.tries++;
|
|
201
|
+
log(`wrong pairing code from ${who} (${p.tries}/${MAX_PAIR_TRIES})`);
|
|
202
|
+
if (p.tries >= MAX_PAIR_TRIES) this.onNotice('Too many wrong Telegram pairing codes; that code is void. Run /telegram pair for a new one.');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
this.cfg.owner = m.from.id;
|
|
206
|
+
this.cfg.chat = m.chat.id;
|
|
207
|
+
this.pairing = null;
|
|
208
|
+
this.write();
|
|
209
|
+
log(`paired with ${who}`);
|
|
210
|
+
this.onNotice(`Telegram is paired with ${m.from.username ? `@${m.from.username}` : 'your account'}. Only that account can use the bot.`);
|
|
211
|
+
await this.send('Paired. Message me here and it goes to the office; replies, finished jobs and anything an agent flags for you come back here. Reply to a message to answer in its thread. Reply /grant to a blocked job to give it full autonomy (or send /grant all for every blocked job), or start a request with /auto to run it at full autonomy.');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (m.from.id !== this.cfg.owner) { log(`dropped a message from ${who}: not the owner`); return; }
|
|
216
|
+
this.cfg.chat ??= m.chat.id;
|
|
217
|
+
const media = m.photo?.at(-1) || m.video || m.animation || m.document || m.voice || m.video_note || m.audio;
|
|
218
|
+
let files = [];
|
|
219
|
+
if (media) {
|
|
220
|
+
try { files = [await this.fetchFile(media, m)]; } catch (err) {
|
|
221
|
+
log(`could not fetch a file from the boss: ${err.message}`);
|
|
222
|
+
await this.send(`I couldn't bring that file in: ${err.message}`);
|
|
223
|
+
if (!text) return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!text && !files.length) { await this.send('That kind of message doesn\'t get through. Text, photos, videos, voice notes and files do.'); return; }
|
|
227
|
+
if (/^\/start\b/.test(text)) { await this.send('You are paired. Write here and it goes to the office.'); return; }
|
|
228
|
+
if (/^\/telegram\b/i.test(text)) { await this.send('Telegram settings change from the office itself, not from here.'); return; }
|
|
229
|
+
const replyTo = m.reply_to_message && this.map.get(m.reply_to_message.message_id);
|
|
230
|
+
log(`from the boss${replyTo ? ` (reply in ${replyTo})` : ''}: ${text.length} chars${files.length ? `, ${files.length} file` : ''}`);
|
|
231
|
+
this.onMessage({ text, files, replyTo });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// A file the boss sent, downloaded into the office's uploads.
|
|
235
|
+
async fetchFile(media, m) {
|
|
236
|
+
if (media.file_size > MAX_FETCH_BYTES) throw new Error('it is over 20 MB, the most a bot can download. Send it through the office instead.');
|
|
237
|
+
const info = await this.call('getFile', { file_id: media.file_id });
|
|
238
|
+
const res = await this.fetch(`${this.api}/file/bot${this.cfg.token}/${info.file_path}`);
|
|
239
|
+
if (!res.ok) throw new Error(`Telegram answered ${res.status}`);
|
|
240
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
241
|
+
const fromPath = path.basename(info.file_path || '');
|
|
242
|
+
const name = media.file_name || (m.photo ? `photo-${m.message_id}.jpg` : fromPath || `file-${m.message_id}`);
|
|
243
|
+
const id = this.saveUpload(name, buf);
|
|
244
|
+
if (!id) throw new Error('the office could not keep it (over 25 MB?)');
|
|
245
|
+
return id;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// An office attachment, to the owner. Photos and videos show inline; too big
|
|
249
|
+
// for that, or a type Telegram rejects, and it goes as a plain file.
|
|
250
|
+
sendFile(file, { name, caption, thread } = {}) {
|
|
251
|
+
if (!this.paired || !this.cfg.chat) return Promise.resolve();
|
|
252
|
+
name ||= path.basename(file);
|
|
253
|
+
const go = async () => {
|
|
254
|
+
let size;
|
|
255
|
+
try { size = fs.statSync(file).size; } catch { log(`send file: ${name} is gone`); return; }
|
|
256
|
+
if (size > MAX_SEND_BYTES) { await this.sendNow(`(${name} is over 50 MB, too big for Telegram: open the office to get it.)`, thread); return; }
|
|
257
|
+
let kind = KIND[path.extname(name).toLowerCase()] || 'document';
|
|
258
|
+
if (kind === 'photo' && size > MAX_PHOTO_BYTES) kind = 'document';
|
|
259
|
+
for (const k of kind === 'document' ? ['document'] : [kind, 'document']) {
|
|
260
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
261
|
+
try {
|
|
262
|
+
const form = new FormData();
|
|
263
|
+
form.append('chat_id', String(this.cfg.chat));
|
|
264
|
+
if (caption) form.append('caption', clipText(plain(caption)).slice(0, MAX_CAPTION));
|
|
265
|
+
if (k === 'video') form.append('supports_streaming', 'true');
|
|
266
|
+
form.append(k, await fs.openAsBlob(file), name);
|
|
267
|
+
const sent = await this.call(METHOD[k], form);
|
|
268
|
+
this.remember(sent, thread);
|
|
269
|
+
log(`sent ${name} as ${k} (${Math.round(size / 1024)} KB)`);
|
|
270
|
+
return;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err.status === 429 || !err.status || err.status >= 500) {
|
|
273
|
+
log(`send ${name} failed (${err.status || err.message}); retry ${attempt + 1}`);
|
|
274
|
+
await sleep(err.retryAfter ? err.retryAfter * 1000 : 2000 * (attempt + 1));
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
log(`send ${name} as ${k} refused: ${err.message}`);
|
|
278
|
+
break; // try it as a plain file next
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
log(`send ${name} given up`);
|
|
283
|
+
};
|
|
284
|
+
this.outbox = this.outbox.then(go, go);
|
|
285
|
+
return this.outbox;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
remember(sent, thread) {
|
|
289
|
+
if (!thread || !sent?.message_id) return;
|
|
290
|
+
this.map.set(sent.message_id, thread);
|
|
291
|
+
if (this.map.size > MAX_MAP) this.map.delete(this.map.keys().next().value);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async sendNow(text, thread) {
|
|
295
|
+
try { this.remember(await this.call('sendMessage', { chat_id: this.cfg.chat, text, disable_web_page_preview: true }), thread); } catch (err) { log(`send failed: ${err.message}`); }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Sends to the owner, one at a time so order holds, waiting out rate limits.
|
|
299
|
+
// `thread` is the office thread this belongs to, so a reply finds it.
|
|
300
|
+
send(text, thread) {
|
|
301
|
+
if (!this.paired || !this.cfg.chat) return Promise.resolve();
|
|
302
|
+
text = clipText(plain(text));
|
|
303
|
+
if (!text) return Promise.resolve();
|
|
304
|
+
const go = async () => {
|
|
305
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
306
|
+
try {
|
|
307
|
+
this.remember(await this.call('sendMessage', { chat_id: this.cfg.chat, text, disable_web_page_preview: true }), thread);
|
|
308
|
+
return;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (err.status && err.status < 500 && err.status !== 429) { log(`send failed: ${err.message}`); return; }
|
|
311
|
+
log(`send failed (${err.status || err.message}); retry ${attempt + 1}`);
|
|
312
|
+
await sleep(err.retryAfter ? err.retryAfter * 1000 : 2000 * (attempt + 1));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
log('send given up after 4 tries');
|
|
316
|
+
};
|
|
317
|
+
this.outbox = this.outbox.then(go, go);
|
|
318
|
+
return this.outbox;
|
|
319
|
+
}
|
|
320
|
+
}
|