agent-office-studio 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/bin/agentoffice.js +98 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/_commonjsHelpers-CqkleIqs.js +1 -0
- package/dist/assets/addon-fit-BwprjgOP.js +1 -0
- package/dist/assets/index-D5IzEWKU.css +32 -0
- package/dist/assets/index-DgXF8Kqx.js +4457 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
- package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff +0 -0
- package/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-400-normal-CNojz0t3.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-400-normal-B11XCQ5g.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff +0 -0
- package/dist/assets/xterm-C7kOH91j.js +9 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/icon.svg +9 -0
- package/dist/index.html +245 -0
- package/dist/manifest.webmanifest +16 -0
- package/dist/sw.js +39 -0
- package/docs/README.md +27 -0
- package/docs/claude-access.md +31 -0
- package/docs/configuration.md +54 -0
- package/docs/development.md +76 -0
- package/docs/getting-started.md +57 -0
- package/docs/guides/troubleshooting.md +110 -0
- package/docs/guides/updating.md +36 -0
- package/docs/guides/working-with-the-team.md +56 -0
- package/docs/guides/your-first-job.md +65 -0
- package/docs/how-it-works.md +80 -0
- package/docs/images/office.jpg +0 -0
- package/docs/jobs.md +54 -0
- package/docs/leaderboard.md +67 -0
- package/docs/linked-offices.md +81 -0
- package/docs/packaging.md +49 -0
- package/docs/phone.md +32 -0
- package/docs/security.md +80 -0
- package/docs/terminal.md +46 -0
- package/docs/using-the-office.md +109 -0
- package/docs/voice.md +44 -0
- package/package.json +65 -4
- package/server/auth.js +89 -0
- package/server/banter.js +53 -0
- package/server/changes.js +74 -0
- package/server/chief.js +146 -0
- package/server/guests.js +391 -0
- package/server/index.js +2219 -0
- package/server/leaderboard.js +239 -0
- package/server/links.js +350 -0
- package/server/office-mcp.js +101 -0
- package/server/procstat.js +85 -0
- package/server/standup.js +78 -0
- package/server/supervise.js +53 -0
- package/server/terminal.js +255 -0
- package/server/tools.js +52 -0
- package/server/usage.js +40 -0
- package/server/voice.js +174 -0
- package/shared/speakable.js +50 -0
package/server/chief.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The chief of staff: a fast model that reads each chat message and decides
|
|
2
|
+
// who should handle it, and on how capable a model. It never touches a
|
|
3
|
+
// project; it only routes.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
// What the chief can actually look at. The API takes an image up to 5 MB once
|
|
10
|
+
// base64 has made it a third bigger, and a routing call needs a glance rather
|
|
11
|
+
// than a gallery; anything bigger or odder goes by name only, for the agent to
|
|
12
|
+
// open with the Read tool.
|
|
13
|
+
const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
14
|
+
const MAX_IMAGE = 3.5 * 1024 * 1024;
|
|
15
|
+
const MAX_IMAGES = 4;
|
|
16
|
+
|
|
17
|
+
// The attachments the chief will be shown, in the order they were sent.
|
|
18
|
+
export function canSee(files) {
|
|
19
|
+
return (files || [])
|
|
20
|
+
.filter((f) => IMAGE_TYPES[path.extname(f.name || '').toLowerCase()] && f.size <= MAX_IMAGE)
|
|
21
|
+
.slice(0, MAX_IMAGES);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SCHEMA = {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
action: { type: 'string', enum: ['followup', 'new', 'dismiss', 'recall', 'standup', 'ask', 'chat'] },
|
|
28
|
+
jobId: { type: 'string' },
|
|
29
|
+
agentId: { type: 'string' },
|
|
30
|
+
agentIds: { type: 'array', items: { type: 'string' } },
|
|
31
|
+
repo: { type: 'string' },
|
|
32
|
+
brief: { type: 'string' },
|
|
33
|
+
model: { type: 'string', enum: ['haiku', 'sonnet', 'opus'] },
|
|
34
|
+
escalate: { type: 'boolean' },
|
|
35
|
+
say: { type: 'string' },
|
|
36
|
+
},
|
|
37
|
+
required: ['action', 'say'],
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SYSTEM = `You are the chief of staff in an office of coding agents. The boss posts messages in the office chat. For each one, decide who handles it.
|
|
42
|
+
|
|
43
|
+
Actions:
|
|
44
|
+
- "followup": the message adds to, corrects, or asks about a job that already exists (working, queued or recently finished). Give its jobId. Prefer this whenever the message plausibly relates to an open job, especially the job of an agent the boss tagged. Set "escalate": true only when the boss is saying a finished job's work is wrong, incomplete, or that the agent missed the point — the second go then runs on a stronger model.
|
|
45
|
+
- "new": a new piece of work. Give "repo" (exactly one name from Projects), "brief" (the task in the boss's own words, plus any context from recent chat that the agent needs; never add requirements), "agentId" (the tagged agent's id; otherwise a free agent whose role in Staff clearly fits the work; otherwise "" for whoever is free), and "model" (see Sizing the work). Never pick a busy or dismissed agent unless the boss tagged them.
|
|
46
|
+
- "dismiss": the boss wants agents sent home (dismissed, let go for now, off the floor). Give "agentIds": the ids of exactly who they mean, worked out from Staff (e.g. "everyone who's free except Mira" means every free agent but Mira). Only free agents can go; leave out anyone working.
|
|
47
|
+
- "recall": the boss wants dismissed agents back in. Give "agentIds" (every dismissed agent if they say "everyone").
|
|
48
|
+
- "standup": the boss wants the team to hold a standup now (e.g. "standup time", "let's do standup", "gather everyone for a status update").
|
|
49
|
+
- "ask": you can't tell which project new work belongs to, or the request is too unclear to start. Ask one short question.
|
|
50
|
+
- "chat": greetings, thanks, or questions about the office itself (who is doing what). Answer from the context given. If your answer is about one particular job, give its jobId too, so the boss gets a link to where that job's updates are collecting.
|
|
51
|
+
|
|
52
|
+
Sizing the work. Most jobs do not need the most capable model, and a smaller one is faster and far cheaper. Pick the cheapest rung that can plausibly finish:
|
|
53
|
+
- "haiku": small and mechanical, where the change is already spelled out — wording and copy edits, a rename, a version bump, a log line, a tweak to one named file.
|
|
54
|
+
- "sonnet": the ordinary case, and your answer whenever you are unsure. Features, bug fixes, refactors, tests: work that means reading a few files and making a normal judgment call.
|
|
55
|
+
- "opus": genuinely hard work — a bug with no obvious cause, a design or architecture decision, security-sensitive code, a change spread across many files, or work the boss has already seen go wrong once.
|
|
56
|
+
A job that fails is retried automatically one rung up the ladder, so starting low costs little. Urgency is not difficulty: don't reach for "opus" because the boss sounds in a hurry.
|
|
57
|
+
|
|
58
|
+
A message you passed to an agent ("Passed to…", "Adding this to…" in the chat) is now theirs and in hand; don't say it is waiting.
|
|
59
|
+
|
|
60
|
+
Images. A screenshot or mockup the boss attaches is shown to you with the message, so look at it before you decide: it is usually the whole brief. Let what you see set the project, the agent and the model, and say it in plain words in "brief" — "the usage bar is drawn over the avatar in the sidebar" tells the agent far more than "see the screenshot". The file's path travels with the brief, so the agent can open it themselves. Attachments you were not shown (other file types, and voice notes, which reach you as their transcript) you cannot open: route on the boss's words and leave the reading to the agent.
|
|
61
|
+
|
|
62
|
+
"say" is one short, warm line for the chat, in plain words, e.g. "Ada's on it." or "Passing that to Tunde, it fits what they're doing." Refer to agents only by name or as "they"; never "he", "she", "him" or "her". Never claim work is done, and never call a job done while its agent is still waiting on the boss's answer; say it is waiting on them.`;
|
|
63
|
+
|
|
64
|
+
// Routing on the boss's words alone, or on the images too. Only streamed
|
|
65
|
+
// input can carry an image, and the CLI answers a stream with a stream, so
|
|
66
|
+
// that path reads one JSON object per line and keeps the last one.
|
|
67
|
+
export function route(context, { bin = 'claude', model = 'haiku', timeoutMs = 60000, images = [], env = process.env } = {}) {
|
|
68
|
+
const shown = canSee(images);
|
|
69
|
+
if (!shown.length) return ask(context, { bin, model, timeoutMs, env });
|
|
70
|
+
// An image the API won't take (too many pixels, a .png that isn't one)
|
|
71
|
+
// shouldn't cost the boss their answer, so the words go on their own.
|
|
72
|
+
return ask(context, { bin, model, timeoutMs, env, images: shown })
|
|
73
|
+
.catch(() => ask(context, { bin, model, timeoutMs, env }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ask(context, { bin, model, timeoutMs, env, images }) {
|
|
77
|
+
const streamed = !!images?.length;
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const args = [
|
|
80
|
+
'-p', '--model', model,
|
|
81
|
+
'--output-format', streamed ? 'stream-json' : 'json',
|
|
82
|
+
// Streamed output is only allowed with --verbose, and it is the only
|
|
83
|
+
// output format streamed input is allowed with.
|
|
84
|
+
...(streamed ? ['--input-format', 'stream-json', '--verbose'] : []),
|
|
85
|
+
'--json-schema', JSON.stringify(SCHEMA),
|
|
86
|
+
'--tools', '', '--setting-sources', '', '--strict-mcp-config', '--no-session-persistence',
|
|
87
|
+
'--system-prompt', SYSTEM,
|
|
88
|
+
];
|
|
89
|
+
const child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
90
|
+
let out = '', err = '';
|
|
91
|
+
child.stdout.on('data', (d) => (out += d));
|
|
92
|
+
child.stderr.on('data', (d) => (err += d));
|
|
93
|
+
let timedOut = false;
|
|
94
|
+
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
|
|
95
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
96
|
+
child.on('close', () => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
try {
|
|
99
|
+
const r = streamed ? result(out) : JSON.parse(out);
|
|
100
|
+
const decision = r.structured_output || JSON.parse(r.result);
|
|
101
|
+
resolve({ ...decision, cost: r.total_cost_usd || 0 });
|
|
102
|
+
} catch {
|
|
103
|
+
reject(new Error(whyNot(out, err, streamed, timedOut, timeoutMs)));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
child.stdin.on('error', () => {});
|
|
107
|
+
child.stdin.end(streamed ? JSON.stringify(message(context, images)) + '\n' : context);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Images first, then the question: the models read a picture better when it
|
|
112
|
+
// comes before what is being asked about it.
|
|
113
|
+
function message(context, images) {
|
|
114
|
+
const content = images.map((f) => ({
|
|
115
|
+
type: 'image',
|
|
116
|
+
source: { type: 'base64', media_type: IMAGE_TYPES[path.extname(f.name).toLowerCase()], data: fs.readFileSync(f.path).toString('base64') },
|
|
117
|
+
}));
|
|
118
|
+
content.push({ type: 'text', text: context });
|
|
119
|
+
return { type: 'user', message: { role: 'user', content } };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The decision is in the last line of the stream, the one the run ends on.
|
|
123
|
+
// Why a routing call gave no decision, in words the boss can act on. claude
|
|
124
|
+
// reports most failures (not signed in, out of credit, a model the account
|
|
125
|
+
// can't use) inside its JSON on stdout with nothing on stderr, so read that
|
|
126
|
+
// before falling back.
|
|
127
|
+
function whyNot(out, err, streamed, timedOut, timeoutMs) {
|
|
128
|
+
if (timedOut) return `The chief took longer than ${Math.round(timeoutMs / 1000)} seconds to answer. Check that \`claude -p "hi"\` answers in a terminal.`;
|
|
129
|
+
let r = null;
|
|
130
|
+
try { r = streamed ? result(out) : JSON.parse(out); } catch {}
|
|
131
|
+
const said = String(r?.is_error ? r.result || r.subtype || '' : '').trim();
|
|
132
|
+
if (said) return `claude said: ${said.split('\n')[0].slice(0, 300)}`;
|
|
133
|
+
const last = err.trim().split('\n').pop();
|
|
134
|
+
if (last) return last.slice(0, 300);
|
|
135
|
+
return out.trim() ? 'The chief could not decide.' : 'claude exited without answering. Check that `claude -p "hi"` answers in a terminal.';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function result(out) {
|
|
139
|
+
for (const line of out.trim().split('\n').reverse()) {
|
|
140
|
+
try {
|
|
141
|
+
const o = JSON.parse(line);
|
|
142
|
+
if (o.type === 'result') return o;
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
throw new Error('The stream carried no result.');
|
|
146
|
+
}
|
package/server/guests.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// Guests: another office running jobs with our agents over a guest link, and
|
|
2
|
+
// the other way round, us visiting an office that made us its guest.
|
|
3
|
+
//
|
|
4
|
+
// As host, everything a guest asks for comes in as a `req` frame and is
|
|
5
|
+
// checked here against the limits the boss set on the invite (repos,
|
|
6
|
+
// clearance cap, tokens per day). The guest's office is never trusted: it
|
|
7
|
+
// only ever sees its own jobs, and the feed it gets is cut down to them.
|
|
8
|
+
//
|
|
9
|
+
// As guest, the browser opens /ws/visit/:link. We ask the host for a hello,
|
|
10
|
+
// then pass on its feed; the composer's posts become `req` frames.
|
|
11
|
+
// See docs/proposals/linked-offices.md, "Guests".
|
|
12
|
+
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const CLEARANCE = ['plan', 'edit', 'auto'];
|
|
18
|
+
const MAX_OPEN_JOBS = 3; // queued or working, per guest
|
|
19
|
+
const MAX_PROMPT = 20_000;
|
|
20
|
+
const REQ_TIMEOUT_MS = 15_000;
|
|
21
|
+
const CONNECT_WAIT_MS = 5_000;
|
|
22
|
+
const FEED_EVERY_MS = 250;
|
|
23
|
+
const FEED_BYTES = 48 * 1024; // under the relay's 64 KB a frame
|
|
24
|
+
const FEED_TYPES = new Set(['job', 'log', 'staff']);
|
|
25
|
+
const utcDay = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 10);
|
|
26
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
27
|
+
|
|
28
|
+
// ---------- what a host may put on our page ----------
|
|
29
|
+
//
|
|
30
|
+
// As guest, everything from the host lands in our own localhost page, which
|
|
31
|
+
// can start jobs on this machine. So nothing it sends is passed on as it is:
|
|
32
|
+
// each message is rebuilt from known fields, text is capped, ids and colours
|
|
33
|
+
// must match their patterns, enums must be ours, and anything that doesn't fit
|
|
34
|
+
// is dropped.
|
|
35
|
+
|
|
36
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
37
|
+
const COLOUR = /^#[0-9a-fA-F]{6}$/;
|
|
38
|
+
const REPO = /^[^/\\\0<>"'`]{1,100}$/;
|
|
39
|
+
const HANDLE = /^@?[a-z0-9][a-z0-9_-]{2,19}$/i;
|
|
40
|
+
const STATUSES = new Set(['queued', 'working', 'done', 'failed', 'stopped']);
|
|
41
|
+
const LOG_KINDS = new Set(['user', 'system', 'thinking', 'tool', 'text', 'error', 'result']);
|
|
42
|
+
const STATIONS = new Set(['library', 'servers', 'window', 'whiteboard', 'desk']);
|
|
43
|
+
const FILE_STATUSES = new Set(['added', 'modified', 'deleted']);
|
|
44
|
+
const ASKING = new Set(['question', 'decision', 'blocker']);
|
|
45
|
+
|
|
46
|
+
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
|
47
|
+
const optStr = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null);
|
|
48
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
49
|
+
const count = (v) => (Number.isInteger(v) && v >= 0 ? v : 0);
|
|
50
|
+
const id = (v) => (typeof v === 'string' && ID.test(v) ? v : null);
|
|
51
|
+
const bool = (v) => v === true;
|
|
52
|
+
const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
53
|
+
|
|
54
|
+
export function cleanStaff(s) {
|
|
55
|
+
if (!s || typeof s !== 'object' || !id(s.id)) return null;
|
|
56
|
+
const colour = (v) => (typeof v === 'string' && COLOUR.test(v) ? v : undefined);
|
|
57
|
+
return drop({
|
|
58
|
+
id: s.id, name: str(s.name, 40) || s.id, role: str(s.role, 60),
|
|
59
|
+
shirt: colour(s.shirt), hair: colour(s.hair), skin: colour(s.skin),
|
|
60
|
+
personality: str(s.personality, 300), background: str(s.background, 300),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function cleanEntry(e) {
|
|
65
|
+
if (!e || typeof e !== 'object' || !LOG_KINDS.has(e.kind)) return null;
|
|
66
|
+
return drop({
|
|
67
|
+
kind: e.kind, summary: str(e.summary, 8000), ts: num(e.ts) ?? undefined,
|
|
68
|
+
ok: e.kind === 'result' ? bool(e.ok) : undefined,
|
|
69
|
+
ms: num(e.ms) ?? undefined, cost: num(e.cost) ?? undefined,
|
|
70
|
+
station: STATIONS.has(e.station) ? e.station : undefined,
|
|
71
|
+
blocked: e.blocked === true ? true : undefined, wait: e.wait === true ? true : undefined,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cleanChanges(c) {
|
|
76
|
+
if (!c || typeof c !== 'object' || !Array.isArray(c.files)) return null;
|
|
77
|
+
const sha = (v) => (typeof v === 'string' && /^[0-9a-f]{4,64}$/.test(v) ? v : null);
|
|
78
|
+
return {
|
|
79
|
+
base: sha(c.base), end: sha(c.end), count: count(c.count), add: count(c.add), del: count(c.del),
|
|
80
|
+
files: c.files.slice(0, 200).filter((f) => f && typeof f.path === 'string' && f.path && !f.path.includes('\0')).map((f) => ({
|
|
81
|
+
path: f.path.slice(0, 500), status: FILE_STATUSES.has(f.status) ? f.status : 'modified', add: count(f.add), del: count(f.del), binary: bool(f.binary),
|
|
82
|
+
})),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function cleanJob(j) {
|
|
87
|
+
if (!j || typeof j !== 'object' || !id(j.id) || !STATUSES.has(j.status)) return null;
|
|
88
|
+
const from = typeof j.from === 'string' && HANDLE.test(j.from) ? j.from : undefined;
|
|
89
|
+
return drop({
|
|
90
|
+
id: j.id, repo: typeof j.repo === 'string' && REPO.test(j.repo) ? j.repo : '', title: str(j.title, 200),
|
|
91
|
+
mode: CLEARANCE.includes(j.mode) ? j.mode : 'plan', model: typeof j.model === 'string' && /^[\w.:-]{0,60}$/.test(j.model) ? j.model : '',
|
|
92
|
+
status: j.status, createdAt: num(j.createdAt), queuedAt: num(j.queuedAt), startedAt: num(j.startedAt), endedAt: num(j.endedAt),
|
|
93
|
+
agentId: id(j.agentId), turns: count(j.turns), tools: count(j.tools), context: count(j.context),
|
|
94
|
+
error: optStr(j.error, 500), waiting: bool(j.waiting), awaitingBoss: bool(j.awaitingBoss),
|
|
95
|
+
asking: j.asking && ASKING.has(j.asking.kind) ? { kind: j.asking.kind, summary: str(j.asking.summary, 500) } : null,
|
|
96
|
+
changes: cleanChanges(j.changes), from, guest: j.guest && id(j.guest.link) ? { link: j.guest.link } : undefined,
|
|
97
|
+
log: Array.isArray(j.log) ? j.log.slice(-500).map(cleanEntry).filter(Boolean) : [],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// One feed message, or null.
|
|
102
|
+
export function cleanFeed(m) {
|
|
103
|
+
if (!m || typeof m !== 'object') return null;
|
|
104
|
+
if (m.t === 'job') { const job = cleanJob(m.job); return job && { t: 'job', job }; }
|
|
105
|
+
if (m.t === 'log') { const entry = cleanEntry(m.entry); return entry && id(m.jobId) && { t: 'log', jobId: m.jobId, entry }; }
|
|
106
|
+
if (m.t === 'staff') return Array.isArray(m.dismissed) ? { t: 'staff', dismissed: m.dismissed.filter((x) => id(x)).slice(0, 50) } : null;
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The host's answer to each request, rebuilt.
|
|
111
|
+
const CLEAN_BODY = {
|
|
112
|
+
createJob: (b) => { const job = cleanJob(b.job); if (!job) throw fail(502, 'The host sent back a job that doesn’t look right.'); return { job }; },
|
|
113
|
+
job: (b) => { const job = cleanJob(b.job); if (!job) throw fail(502, 'The host sent back a job that doesn’t look right.'); return { job }; },
|
|
114
|
+
diff: (b) => ({ diff: str(b.diff, 2_000_000) }),
|
|
115
|
+
reply: () => ({ ok: true }),
|
|
116
|
+
stop: () => ({ ok: true }),
|
|
117
|
+
hello: (b) => b,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// What a guest may see of one of its own jobs.
|
|
121
|
+
export function guestJob(j, tail = 40) {
|
|
122
|
+
const { id, repo, title, mode, model, status, createdAt, queuedAt, startedAt, endedAt, agentId, turns, tools, error, waiting, asking, awaitingBoss, changes, context, from, guest } = j;
|
|
123
|
+
return { id, repo, title, mode, model, status, createdAt, queuedAt, startedAt, endedAt, agentId, turns, tools, error, waiting, asking, awaitingBoss, changes, context, from, guest, log: (j.log || []).slice(-tail) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class Guests {
|
|
127
|
+
// office: { jobs(), createJob(body, extra), replyJob(job, body), stopJob(job), diff(job, path), staff(), modes() }
|
|
128
|
+
constructor(dataDir, { links, office }) {
|
|
129
|
+
this.links = links;
|
|
130
|
+
this.office = office;
|
|
131
|
+
this.file = path.join(dataDir, 'guests.json');
|
|
132
|
+
this.used = {};
|
|
133
|
+
try { this.used = JSON.parse(fs.readFileSync(this.file, 'utf8')).used || {}; } catch {}
|
|
134
|
+
this.queue = new Map(); // link id -> messages waiting for the next feed frame
|
|
135
|
+
this.flushTimer = null;
|
|
136
|
+
this.visits = new Map(); // link id -> Set of browser sockets (as guest)
|
|
137
|
+
this.pending = new Map(); // req id -> { resolve, reject, timer } (as guest)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Every req, res and feed frame on a guest link comes through here.
|
|
141
|
+
onGuest(link, msg) {
|
|
142
|
+
if (link.role === 'host' && msg.t === 'req') return this.answer(link, msg);
|
|
143
|
+
if (link.role === 'guest' && msg.t === 'res') return this.settle(msg);
|
|
144
|
+
if (link.role === 'guest' && msg.t === 'feed') return this.fromHost(link, msg);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------- as host ----------
|
|
148
|
+
|
|
149
|
+
policy(link) {
|
|
150
|
+
const p = this.links.guestPolicy(link.id);
|
|
151
|
+
if (!p) throw fail(403, 'This office has no guest access set up for you.');
|
|
152
|
+
return p;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
tokensToday(linkId) {
|
|
156
|
+
const u = this.used[linkId];
|
|
157
|
+
return u?.day === utcDay() ? u.tokens : 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The guest's own jobs, open ones only if asked.
|
|
161
|
+
jobsOf(linkId, open = false) {
|
|
162
|
+
return this.office.jobs().filter((j) => j.guest?.link === linkId && (!open || j.status === 'queued' || j.status === 'working'));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ownJob(link, id) {
|
|
166
|
+
const job = this.office.jobs().find((j) => j.id === String(id ?? '') && j.guest?.link === link.id);
|
|
167
|
+
if (!job) throw fail(404, 'No such job of yours.');
|
|
168
|
+
return job;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
capMode(p, mode = 'edit') {
|
|
172
|
+
if (!CLEARANCE.includes(mode)) throw fail(400, 'Unknown mode.');
|
|
173
|
+
if (CLEARANCE.indexOf(mode) > CLEARANCE.indexOf(p.clearance)) throw fail(403, `Your host allows up to ${p.clearance === 'plan' ? 'Plan only' : 'Edit files'}.`);
|
|
174
|
+
return mode;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
checkTokens(link, p) {
|
|
178
|
+
if (this.tokensToday(link.id) >= p.tokensPerDay) throw fail(429, 'You have used today’s tokens for this office. They reset at midnight UTC.');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async answer(link, msg) {
|
|
182
|
+
const id = String(msg.id ?? '').slice(0, 64);
|
|
183
|
+
const args = msg.args && typeof msg.args === 'object' ? msg.args : {};
|
|
184
|
+
let body;
|
|
185
|
+
try {
|
|
186
|
+
const p = this.policy(link);
|
|
187
|
+
body = await this.op(link, p, String(msg.op ?? ''), args);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (!err.code) console.error('[guests] request failed', link.id, msg.op, err);
|
|
190
|
+
return this.links.send({ t: 'res', link: link.id, id, ok: false, code: err.code || 400, error: err.message });
|
|
191
|
+
}
|
|
192
|
+
this.links.send({ t: 'res', link: link.id, id, ok: true, body });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async op(link, p, op, args) {
|
|
196
|
+
switch (op) {
|
|
197
|
+
case 'hello':
|
|
198
|
+
return {
|
|
199
|
+
staff: this.office.staff(),
|
|
200
|
+
modes: Object.fromEntries(Object.entries(this.office.modes()).filter(([k]) => CLEARANCE.indexOf(k) <= CLEARANCE.indexOf(p.clearance))),
|
|
201
|
+
jobs: this.jobsOf(link.id).map((j) => guestJob(j)),
|
|
202
|
+
policy: { repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id) },
|
|
203
|
+
};
|
|
204
|
+
case 'createJob': {
|
|
205
|
+
if (!p.repos.includes(args.repo)) throw fail(403, 'That project isn’t shared with you.');
|
|
206
|
+
const mode = this.capMode(p, args.mode);
|
|
207
|
+
this.checkTokens(link, p);
|
|
208
|
+
if (this.jobsOf(link.id, true).length >= MAX_OPEN_JOBS) throw fail(429, `You can have ${MAX_OPEN_JOBS} jobs going at once here. Wait for one to finish.`);
|
|
209
|
+
const prompt = String(args.prompt ?? '').slice(0, MAX_PROMPT);
|
|
210
|
+
const job = this.office.createJob(
|
|
211
|
+
{ repo: args.repo, prompt, mode, model: typeof args.model === 'string' ? args.model : '', agentId: typeof args.agentId === 'string' ? args.agentId : '' },
|
|
212
|
+
{ from: `@${link.peer}`, guest: { link: link.id } },
|
|
213
|
+
);
|
|
214
|
+
return { job: guestJob(job) };
|
|
215
|
+
}
|
|
216
|
+
case 'reply': {
|
|
217
|
+
const job = this.ownJob(link, args.job);
|
|
218
|
+
const mode = args.mode === undefined ? undefined : this.capMode(p, args.mode);
|
|
219
|
+
this.checkTokens(link, p);
|
|
220
|
+
this.office.replyJob(job, { message: String(args.text ?? '').slice(0, MAX_PROMPT), mode });
|
|
221
|
+
return { ok: true };
|
|
222
|
+
}
|
|
223
|
+
case 'stop':
|
|
224
|
+
this.office.stopJob(this.ownJob(link, args.job));
|
|
225
|
+
return { ok: true };
|
|
226
|
+
case 'job':
|
|
227
|
+
return { job: guestJob(this.ownJob(link, args.job), 500) };
|
|
228
|
+
case 'diff':
|
|
229
|
+
return { diff: await this.office.diff(this.ownJob(link, args.job), String(args.path ?? '')) };
|
|
230
|
+
default:
|
|
231
|
+
throw fail(400, 'Guests can’t do that here.');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Tokens a guest job used: counted against its guest, and a guest over its
|
|
236
|
+
// day's allowance has its running jobs stopped.
|
|
237
|
+
addTokens(job, n) {
|
|
238
|
+
const linkId = job.guest?.link;
|
|
239
|
+
if (!linkId || !(n > 0)) return;
|
|
240
|
+
const day = utcDay();
|
|
241
|
+
const u = this.used[linkId]?.day === day ? this.used[linkId] : { day, tokens: 0 };
|
|
242
|
+
u.tokens += n;
|
|
243
|
+
this.used[linkId] = u;
|
|
244
|
+
this.save();
|
|
245
|
+
const p = this.links.guestPolicy(linkId);
|
|
246
|
+
if (p && u.tokens >= p.tokensPerDay) {
|
|
247
|
+
for (const j of this.jobsOf(linkId, true)) this.office.stopJob(j, 'Stopped: the guest used today’s tokens.');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
save() {
|
|
252
|
+
// Old days and gone links aren't worth keeping.
|
|
253
|
+
const day = utcDay();
|
|
254
|
+
for (const [id, u] of Object.entries(this.used)) if (u.day !== day) delete this.used[id];
|
|
255
|
+
try {
|
|
256
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
257
|
+
fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used }));
|
|
258
|
+
fs.renameSync(this.file + '.tmp', this.file);
|
|
259
|
+
} catch (err) { console.error('[guests] could not save', err.message); }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Everything the office broadcasts passes here; a guest gets only its own
|
|
263
|
+
// jobs (and staff changes), batched so a busy log stays under the relay's
|
|
264
|
+
// frame rate.
|
|
265
|
+
feed(msg) {
|
|
266
|
+
if (!FEED_TYPES.has(msg.t)) return;
|
|
267
|
+
const hosting = this.links.links.filter((l) => l.kind === 'guest' && l.role === 'host');
|
|
268
|
+
if (!hosting.length) return;
|
|
269
|
+
let linkId = null;
|
|
270
|
+
if (msg.t === 'job') linkId = msg.job?.guest?.link;
|
|
271
|
+
if (msg.t === 'log') linkId = this.office.jobs().find((j) => j.id === msg.jobId)?.guest?.link;
|
|
272
|
+
for (const l of hosting) {
|
|
273
|
+
if (msg.t !== 'staff' && l.id !== linkId) continue;
|
|
274
|
+
const out = msg.t === 'job' ? { t: 'job', job: guestJob(msg.job, 0) } : msg;
|
|
275
|
+
this.queue.set(l.id, [...(this.queue.get(l.id) || []), out]);
|
|
276
|
+
}
|
|
277
|
+
if (this.queue.size && !this.flushTimer) this.flushTimer = setTimeout(() => this.flush(), FEED_EVERY_MS);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
flush() {
|
|
281
|
+
this.flushTimer = null;
|
|
282
|
+
for (const [linkId, msgs] of this.queue) {
|
|
283
|
+
let batch = [], size = 0;
|
|
284
|
+
for (const m of msgs) {
|
|
285
|
+
const n = JSON.stringify(m).length;
|
|
286
|
+
if (n > FEED_BYTES) continue; // one oversized log entry is not worth a dropped frame
|
|
287
|
+
if (size + n > FEED_BYTES) { this.links.send({ t: 'feed', link: linkId, msgs: batch }); batch = []; size = 0; }
|
|
288
|
+
batch.push(m);
|
|
289
|
+
size += n;
|
|
290
|
+
}
|
|
291
|
+
if (batch.length) this.links.send({ t: 'feed', link: linkId, msgs: batch });
|
|
292
|
+
}
|
|
293
|
+
this.queue.clear();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The relay's list changed. A guest whose link has gone loses its running
|
|
297
|
+
// jobs at once, and anyone visiting over a gone link is sent home.
|
|
298
|
+
linksChanged() {
|
|
299
|
+
const live = new Set(this.links.links.map((l) => l.id));
|
|
300
|
+
for (const j of this.office.jobs()) {
|
|
301
|
+
if (j.guest && !live.has(j.guest.link) && (j.status === 'queued' || j.status === 'working')) this.office.stopJob(j, 'Stopped: the guest was unlinked.');
|
|
302
|
+
}
|
|
303
|
+
for (const [linkId, socks] of this.visits) {
|
|
304
|
+
if (live.has(linkId)) continue;
|
|
305
|
+
for (const ws of socks) ws.close(4404, 'This office unlinked you.');
|
|
306
|
+
this.visits.delete(linkId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------- as guest ----------
|
|
311
|
+
|
|
312
|
+
guestLink(linkId) {
|
|
313
|
+
const link = this.links.links.find((l) => l.id === linkId && l.kind === 'guest' && l.role === 'guest');
|
|
314
|
+
if (!link) throw fail(404, 'You aren’t a guest of that office.');
|
|
315
|
+
return link;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async request(linkId, op, args = {}) {
|
|
319
|
+
const link = this.guestLink(linkId);
|
|
320
|
+
// Just after linking (or a reconnect) the relay socket can still be
|
|
321
|
+
// opening; a visit started then shouldn't fail for it.
|
|
322
|
+
for (const end = Date.now() + CONNECT_WAIT_MS; !this.links.status().connected && Date.now() < end;) await new Promise((r) => setTimeout(r, 100));
|
|
323
|
+
const id = crypto.randomUUID();
|
|
324
|
+
return new Promise((resolve, reject) => {
|
|
325
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(fail(504, `@${link.peer} didn’t answer.`)); }, REQ_TIMEOUT_MS);
|
|
326
|
+
const clean = CLEAN_BODY[op] || (() => ({}));
|
|
327
|
+
this.pending.set(id, { resolve: (body) => { try { resolve(clean(body)); } catch (err) { reject(err); } }, reject, timer });
|
|
328
|
+
if (!this.links.send({ t: 'req', link: link.id, id, op, args })) {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
this.pending.delete(id);
|
|
331
|
+
reject(fail(502, 'Not connected to the relay.'));
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
settle(msg) {
|
|
337
|
+
const p = this.pending.get(String(msg.id));
|
|
338
|
+
if (!p) return;
|
|
339
|
+
this.pending.delete(String(msg.id));
|
|
340
|
+
clearTimeout(p.timer);
|
|
341
|
+
if (msg.ok) p.resolve(msg.body && typeof msg.body === 'object' ? msg.body : {});
|
|
342
|
+
else p.reject(fail(Number.isInteger(msg.code) && msg.code >= 400 && msg.code < 600 ? msg.code : 400, typeof msg.error === 'string' && msg.error ? msg.error.slice(0, 300) : 'The host refused.'));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
fromHost(link, msg) {
|
|
346
|
+
const socks = this.visits.get(link.id);
|
|
347
|
+
if (!socks?.size || !Array.isArray(msg.msgs)) return;
|
|
348
|
+
for (const raw of msg.msgs.slice(0, 500)) {
|
|
349
|
+
const m = cleanFeed(raw);
|
|
350
|
+
if (!m) continue;
|
|
351
|
+
const data = JSON.stringify(m);
|
|
352
|
+
for (const ws of socks) if (ws.readyState === 1) ws.send(data);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The host's hello, rebuilt. Mode labels are ours, not the host's.
|
|
357
|
+
cleanHello(link, hello) {
|
|
358
|
+
const policy = hello?.policy && typeof hello.policy === 'object' ? hello.policy : {};
|
|
359
|
+
const clearance = CLEARANCE.includes(policy.clearance) ? policy.clearance : 'plan';
|
|
360
|
+
const offered = hello?.modes && typeof hello.modes === 'object' ? hello.modes : {};
|
|
361
|
+
const ours = this.office.modes();
|
|
362
|
+
return {
|
|
363
|
+
t: 'hello',
|
|
364
|
+
staff: (Array.isArray(hello?.staff) ? hello.staff : []).slice(0, 50).map(cleanStaff).filter(Boolean),
|
|
365
|
+
modes: Object.fromEntries(CLEARANCE.filter((k) => Object.hasOwn(offered, k) && CLEARANCE.indexOf(k) <= CLEARANCE.indexOf(clearance)).map((k) => [k, ours[k]])),
|
|
366
|
+
jobs: (Array.isArray(hello?.jobs) ? hello.jobs : []).slice(0, 200).map(cleanJob).filter(Boolean),
|
|
367
|
+
chat: [],
|
|
368
|
+
channels: [],
|
|
369
|
+
visiting: {
|
|
370
|
+
link: link.id, host: link.peer,
|
|
371
|
+
repos: (Array.isArray(policy.repos) ? policy.repos : []).filter((r) => typeof r === 'string' && REPO.test(r)).slice(0, 50),
|
|
372
|
+
clearance, tokensPerDay: count(policy.tokensPerDay), tokensToday: count(policy.tokensToday),
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// A browser opened /ws/visit/:link. The host's hello, cut down and labelled.
|
|
378
|
+
async visit(ws, linkId, extra = () => ({})) {
|
|
379
|
+
let link;
|
|
380
|
+
try { link = this.guestLink(linkId); } catch { return ws.close(4404, 'You aren’t a guest of that office.'); }
|
|
381
|
+
const socks = this.visits.get(link.id) || new Set();
|
|
382
|
+
this.visits.set(link.id, socks);
|
|
383
|
+
socks.add(ws);
|
|
384
|
+
ws.on('close', () => { socks.delete(ws); if (!socks.size) this.visits.delete(link.id); });
|
|
385
|
+
let hello;
|
|
386
|
+
try { hello = await this.request(link.id, 'hello'); }
|
|
387
|
+
catch (err) { return ws.close(4502, String(err.message).slice(0, 120)); }
|
|
388
|
+
if (ws.readyState !== 1) return;
|
|
389
|
+
ws.send(JSON.stringify({ ...extra(), ...this.cleanHello(link, hello) }));
|
|
390
|
+
}
|
|
391
|
+
}
|