agent-office-studio 0.7.0 → 0.9.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 (53) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +13 -1
  3. package/bin/agentoffice.js +6 -1
  4. package/dist/assets/code-Dtdckl8K.js +32 -0
  5. package/dist/assets/index-DxWppRAd.css +1 -0
  6. package/dist/assets/index-Y1a-xw5C.js +5040 -0
  7. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  8. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  9. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  10. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  11. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  12. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  13. package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
  14. package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
  15. package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
  16. package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
  17. package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
  18. package/dist/assets/pdf-BtCWNl9F.js +55 -0
  19. package/dist/assets/pdf-a7kwa8Fi.js +1 -0
  20. package/dist/assets/pdf.worker.min-BJd0dv5W.js +53 -0
  21. package/dist/assets/pdf.worker.min-D_0FwaWY.js +1 -0
  22. package/dist/index.html +32 -26
  23. package/dist/theme-boot.js +13 -0
  24. package/docs/desktop.md +52 -0
  25. package/docs/phone.md +51 -3
  26. package/docs/security.md +8 -3
  27. package/package.json +20 -2
  28. package/server/changes.js +13 -2
  29. package/server/chief.js +17 -0
  30. package/server/cloudflared.js +84 -0
  31. package/server/economy.js +478 -0
  32. package/server/files.js +104 -0
  33. package/server/guests.js +59 -21
  34. package/server/index.js +743 -59
  35. package/server/journeys.js +101 -0
  36. package/server/links.js +11 -4
  37. package/server/machine.js +164 -0
  38. package/server/office-mcp.js +29 -0
  39. package/server/phone.js +70 -0
  40. package/server/procstat.js +12 -6
  41. package/server/settings.js +5 -0
  42. package/server/talk.js +51 -0
  43. package/server/tunnel.js +136 -0
  44. package/server/visits.js +14 -4
  45. package/shared/shop.js +134 -0
  46. package/dist/assets/index-BtHSEs3B.js +0 -4475
  47. package/dist/assets/index-DVuFVmWF.css +0 -1
  48. package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
  49. package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
  50. package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
  51. package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
  52. package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
  53. package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
@@ -0,0 +1,101 @@
1
+ // User journey maps, one per project: the routes a person takes through the
2
+ // app, as steps and the arrows between them. An agent reads the project and
3
+ // writes the map; the browser draws it as a flow canvas.
4
+ //
5
+ // Maps live in the office's data folder, not the project, so mapping never
6
+ // leaves a file in someone's repo. Whatever the agent wrote is cut down to
7
+ // the shape below before the browser sees it.
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export const KINDS = ['entry', 'screen', 'action', 'decision', 'system', 'end'];
13
+ const MAX_JOURNEYS = 24;
14
+ const MAX_STEPS = 60;
15
+ const MAX_EDGES = 120;
16
+ const ID_RE = /^[A-Za-z0-9_.-]{1,48}$/;
17
+
18
+ const text = (v, n) => (typeof v === 'string' ? v.trim().slice(0, n) : '');
19
+
20
+ export class Journeys {
21
+ constructor(dataDir) {
22
+ this.dir = path.join(dataDir, 'journeys');
23
+ }
24
+
25
+ file(repo) {
26
+ return path.join(this.dir, `${repo}.json`);
27
+ }
28
+
29
+ ensureDir() {
30
+ fs.mkdirSync(this.dir, { recursive: true });
31
+ return this.dir;
32
+ }
33
+
34
+ read(repo) {
35
+ let raw;
36
+ try { raw = JSON.parse(fs.readFileSync(this.file(repo), 'utf8')); } catch { return null; }
37
+ let at = 0;
38
+ try { at = fs.statSync(this.file(repo)).mtimeMs; } catch {}
39
+ return clean(raw, at);
40
+ }
41
+
42
+ // What the mapping agent is told. It writes one file and touches nothing else.
43
+ brief(repo) {
44
+ const file = this.file(repo);
45
+ return [
46
+ `Map the user journeys of ${repo}.`,
47
+ '',
48
+ 'Read the project to find every route a person takes through it: signing up, the main tasks, settings, payments, errors and dead ends. ' +
49
+ 'Use the code (routes, screens, handlers, API calls) as the source of truth, not the README alone. Do not change any file in the project.',
50
+ '',
51
+ `Write the map as JSON to ${file} (create it, or replace the old one). That is the only file you write. Shape:`,
52
+ '',
53
+ '{ "summary": "one line on what the product is",',
54
+ ' "journeys": [ { "id": "signup", "name": "Sign up", "summary": "what the person is trying to do",',
55
+ ' "steps": [ { "id": "s1", "kind": "entry", "label": "Lands on home", "detail": "one or two lines", "where": "src/pages/Home.tsx" } ],',
56
+ ' "edges": [ { "from": "s1", "to": "s2", "label": "optional, e.g. \\"valid\\" or \\"no account\\"" } ] } ] }',
57
+ '',
58
+ `kind is one of: ${KINDS.join(', ')}. entry is where the journey starts; screen a page or view; action something the person does; ` +
59
+ 'decision a branch (label its edges); system work the app does out of sight (an API call, an email, a job); end where the journey finishes, good or bad.',
60
+ 'Keep labels short (under 40 characters). Ids are letters, digits, dashes. Order journeys by how often a person would take them. ' +
61
+ `At most ${MAX_JOURNEYS} journeys and ${MAX_STEPS} steps each. If the project has no user-facing flow (a library, a CLI), map how a developer uses it instead.`,
62
+ '',
63
+ 'When the file is written, reply with one line per journey.',
64
+ ].join('\n');
65
+ }
66
+ }
67
+
68
+ // Only the shape the canvas draws gets through: known kinds, short strings,
69
+ // edges between steps that exist.
70
+ function clean(raw, at) {
71
+ if (!raw || !Array.isArray(raw.journeys)) return null;
72
+ const journeys = [];
73
+ for (const j of raw.journeys.slice(0, MAX_JOURNEYS)) {
74
+ if (!j || !Array.isArray(j.steps)) continue;
75
+ const steps = [];
76
+ const ids = new Set();
77
+ for (const s of j.steps.slice(0, MAX_STEPS)) {
78
+ const id = typeof s?.id === 'string' && ID_RE.test(s.id) ? s.id : null;
79
+ if (!id || ids.has(id)) continue;
80
+ ids.add(id);
81
+ steps.push({
82
+ id,
83
+ kind: KINDS.includes(s.kind) ? s.kind : 'screen',
84
+ label: text(s.label, 60) || id,
85
+ detail: text(s.detail, 400),
86
+ where: text(s.where, 200),
87
+ });
88
+ }
89
+ if (!steps.length) continue;
90
+ const edges = (Array.isArray(j.edges) ? j.edges : []).slice(0, MAX_EDGES)
91
+ .filter((e) => ids.has(e?.from) && ids.has(e?.to) && e.from !== e.to)
92
+ .map((e) => ({ from: e.from, to: e.to, label: text(e.label, 30) }));
93
+ journeys.push({
94
+ id: typeof j.id === 'string' && ID_RE.test(j.id) ? j.id : `j${journeys.length + 1}`,
95
+ name: text(j.name, 60) || `Journey ${journeys.length + 1}`,
96
+ summary: text(j.summary, 300),
97
+ steps, edges,
98
+ });
99
+ }
100
+ return { summary: text(raw.summary, 300), updatedAt: at, journeys };
101
+ }
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 || !ID_RE.test(String(l.id))) return null;
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: ID_RE.test(String(l.invite ?? '')) ? String(l.invite) : null,
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
- return { repos, clearance, tokensPerDay, ourAgents, theirAgents };
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] || null : null;
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 };
@@ -35,6 +35,11 @@ const TOOLS = [
35
35
  description: 'Surface something in the main chat for the boss: a question, decision, blocker, or work ready to review. Not for routine progress. One or two self-contained sentences.',
36
36
  inputSchema: obj({ kind: { type: 'string', enum: ['question', 'decision', 'blocker', 'review'] }, summary: { type: 'string' } }, ['kind', 'summary']),
37
37
  },
38
+ {
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.',
41
+ inputSchema: obj({ paths: names }, ['paths']),
42
+ },
38
43
  {
39
44
  name: 'restart_office',
40
45
  description: 'Restart the office server so changes to its own code (agent-office/server) take effect. Waits until nobody is mid-turn, so end your turn right after; you are messaged when it is back. Not needed for web changes: a build is enough.',
@@ -45,6 +50,30 @@ const TOOLS = [
45
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.',
46
51
  inputSchema: obj({ notes: { type: 'string' } }, ['notes']),
47
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
+ { name: 'office_shop', description: 'Your pay so far, how pay works, and what you can buy for your desk.', inputSchema: NONE },
64
+ {
65
+ name: 'buy_item',
66
+ description: 'Buy something for your own desk with your pay, by its id from office_shop. Only once your work is done or between steps; it is your money.',
67
+ inputSchema: obj({ item: { type: 'string' } }, ['item']),
68
+ },
69
+ { name: 'bets', description: 'Open and live bets between colleagues, your wallet, and the stake limits.', inputSchema: NONE },
70
+ {
71
+ name: 'place_bet',
72
+ description: 'Stake your own naira that a colleague\'s next job finishes in under N active minutes. Held until someone takes the other side; the winner gets both stakes. Not on your own job.',
73
+ inputSchema: obj({ on: { type: 'string', description: 'Colleague id or name' }, minutes: { type: 'number' }, stake: { type: 'number' } }, ['on', 'minutes', 'stake']),
74
+ },
75
+ { name: 'take_bet', description: 'Take the other side of an open bet (it takes N minutes or more) with a matching stake. Ids are in bets.', inputSchema: obj({ id: { type: 'string' } }, ['id']) },
76
+ { name: 'cancel_bet', description: 'Take back a bet you offered that nobody has taken; your stake comes back.', inputSchema: obj({ id: { type: 'string' } }, ['id']) },
48
77
  { name: 'list_channels', description: 'List chat channels: purpose, members, and which one your work is in.', inputSchema: NONE },
49
78
  {
50
79
  name: 'create_channel',
@@ -0,0 +1,70 @@
1
+ // The phone link: where a phone can reach this office, and a QR code for it.
2
+ //
3
+ // By default the office listens on 127.0.0.1 only, so there is nothing for a
4
+ // phone to reach. Two ways in, both opt-in:
5
+ // - OFFICE_ALLOWED_HOSTS names a host that fronts the office (Tailscale);
6
+ // the link points at that name.
7
+ // - OFFICE_LAN=1 listens on the local network too, and the link points at
8
+ // this machine's private addresses. Plain http, so anyone else on that
9
+ // Wi-Fi who sees the key can open the office: home networks only.
10
+
11
+ import os from 'node:os';
12
+ import qrcode from 'qrcode-generator';
13
+
14
+ // Private IPv4 addresses (10/8, 172.16/12, 192.168/16), the only ones we
15
+ // would put a key behind. Link-local, CGNAT and public ones are skipped.
16
+ export function lanAddresses(ifaces = os.networkInterfaces()) {
17
+ const out = [];
18
+ for (const list of Object.values(ifaces)) {
19
+ for (const a of list || []) {
20
+ if (a.family !== 'IPv4' || a.internal) continue;
21
+ const [p, q] = a.address.split('.').map(Number);
22
+ if (p === 10 || (p === 172 && q >= 16 && q <= 31) || (p === 192 && q === 168)) out.push(a.address);
23
+ }
24
+ }
25
+ return out;
26
+ }
27
+
28
+ // The links a phone could use, best first: named hosts over https, then LAN.
29
+ export function phoneLinks({ key, port, lan, hosts = [], addresses = lanAddresses() }) {
30
+ const links = hosts
31
+ .filter((h) => !['localhost', '127.0.0.1', '[::1]'].includes(h))
32
+ .map((h) => ({ kind: 'host', label: h, url: `https://${h}/?key=${key}` }));
33
+ if (lan) for (const a of addresses) links.push({ kind: 'lan', label: `${a}:${port}`, url: `http://${a}:${port}/?key=${key}` });
34
+ return links;
35
+ }
36
+
37
+ const code = (text) => {
38
+ const qr = qrcode(0, 'M'); // type 0: smallest version that fits
39
+ qr.addData(text);
40
+ qr.make();
41
+ return qr;
42
+ };
43
+
44
+ // An SVG with a quiet zone, which scanners need. Dark on white whatever the
45
+ // theme: they read contrast, not colours.
46
+ export function qrSvg(text) {
47
+ const qr = code(text), n = qr.getModuleCount(), q = 4;
48
+ let d = '';
49
+ for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (qr.isDark(r, c)) d += `M${c + q} ${r + q}h1v1h-1z`;
50
+ const s = n + q * 2;
51
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${s} ${s}" shape-rendering="crispEdges"><rect width="${s}" height="${s}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
52
+ }
53
+
54
+ // Half-blocks for a terminal: two module rows per text row. Inverted (light
55
+ // modules drawn as blocks) so it reads on a dark terminal, which is most.
56
+ export function qrText(text) {
57
+ const qr = code(text), n = qr.getModuleCount(), q = 2;
58
+ const dark = (r, c) => r >= q && c >= q && r < n + q && c < n + q && qr.isDark(r - q, c - q);
59
+ const s = n + q * 2;
60
+ const rows = [];
61
+ for (let r = 0; r < s; r += 2) {
62
+ let line = '';
63
+ for (let c = 0; c < s; c++) {
64
+ const top = !dark(r, c), bottom = r + 1 < s ? !dark(r + 1, c) : true;
65
+ line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
66
+ }
67
+ rows.push(line);
68
+ }
69
+ return rows.join('\n');
70
+ }
@@ -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: 8 << 20 }, (err, out) => {
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
- procs.set(pid, { pid, ppid, rss: Number(m[3]) * 1024, cpu: cpuSeconds(m[4]) });
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: Date.now() });
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() {
@@ -14,11 +14,16 @@ 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
 
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 },
21
+ { key: 'payFinishBonus', group: 'Pay', label: 'Bonus for finishing a job', kind: 'number', min: 0, max: 1_000_000, step: 100, unit: 'naira', hint: 'Paid once when a job finishes, scaled by model like the minute rate. Kudos pays it again.', fallback: () => 2500 },
22
+
19
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') },
20
24
  { key: 'standupWeekends', group: 'Standup', label: 'Hold standups on weekends', kind: 'bool', fallback: () => env('STANDUP_WEEKENDS') === '1' },
21
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' },
22
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')) },
23
28
  { key: 'banterModel', group: 'Break room', label: 'Break-room model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], fallback: () => env('BANTER_MODEL') || 'haiku' },
24
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) },
package/server/talk.js ADDED
@@ -0,0 +1,51 @@
1
+ // Click-an-agent chat. One short model call answers as that agent, from their
2
+ // sheet, what they are doing right now and what has just been said, so a quick
3
+ // "how's it going?" costs a fraction of a job. It cannot start work: anything
4
+ // the boss wants built goes through the message box, and the agent says so.
5
+
6
+ import { spawn } from 'node:child_process';
7
+
8
+ const SCHEMA = {
9
+ type: 'object',
10
+ properties: { reply: { type: 'string' } },
11
+ required: ['reply'],
12
+ additionalProperties: false,
13
+ };
14
+
15
+ const SYSTEM = `You are one coworker in an office of coding agents, answering the boss, who just clicked on you to say something. You get your own sheet (role, personality, background, story, ties), what you are working on right now, and the last few lines of this chat.
16
+
17
+ Rules:
18
+ - Reply in character, in one to three short sentences, under 240 characters in total. Spoken out loud, so plain words.
19
+ - Answer from the facts you were given. Never invent progress, files, results or numbers. If you were not told, say you would have to check.
20
+ - This chat cannot start, change or stop work. If the boss asks for that, say to send it through the message box (or press N for a new job), warmly and briefly.
21
+ - Let your story and ties colour the reply only sometimes. Never force a reference in.
22
+ - Use real names for people. No markdown, emoji, quotation marks around the reply, or stage directions.`;
23
+
24
+ export function writeReply(context, { bin = 'claude', model = 'haiku', timeoutMs = 20000, env = process.env } = {}) {
25
+ return new Promise((resolve, reject) => {
26
+ const args = [
27
+ '-p', '--model', model, '--output-format', 'json', '--json-schema', JSON.stringify(SCHEMA),
28
+ '--tools', '', '--setting-sources', '', '--strict-mcp-config', '--no-session-persistence',
29
+ '--system-prompt', SYSTEM,
30
+ ];
31
+ const child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
32
+ let out = '', err = '';
33
+ child.stdout.on('data', (d) => (out += d));
34
+ child.stderr.on('data', (d) => (err += d));
35
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
36
+ child.on('error', (e) => { clearTimeout(timer); reject(e); });
37
+ child.on('close', () => {
38
+ clearTimeout(timer);
39
+ try {
40
+ const r = JSON.parse(out);
41
+ const { reply } = r.structured_output || JSON.parse(r.result);
42
+ const clean = String(reply).replace(/\s+/g, ' ').trim().slice(0, 300);
43
+ if (!clean) throw new Error('Empty.');
44
+ resolve({ reply: clean, cost: r.total_cost_usd || 0 });
45
+ } catch {
46
+ reject(new Error(err.trim().split('\n').pop() || 'They did not answer.'));
47
+ }
48
+ });
49
+ child.stdin.end(context);
50
+ });
51
+ }