agent-office-studio 0.5.0 → 0.7.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 +17 -0
- package/README.md +14 -0
- package/dist/assets/index-BtHSEs3B.js +4475 -0
- package/dist/assets/index-DVuFVmWF.css +1 -0
- package/dist/index.html +2 -2
- package/docs/configuration.md +1 -1
- package/docs/linked-offices.md +71 -7
- package/package.json +2 -2
- package/server/guests.js +375 -13
- package/server/index.js +234 -20
- package/server/links.js +48 -9
- package/server/office-mcp.js +19 -0
- package/server/settings.js +1 -1
- package/server/visits.js +475 -0
- package/dist/assets/index-Cdivbl1d.css +0 -1
- package/dist/assets/index-CfqwI0KM.js +0 -4462
package/server/guests.js
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
// clearance cap, tokens per day). The guest's office is never trusted: it
|
|
7
7
|
// only ever sees its own jobs, and the feed it gets is cut down to them.
|
|
8
8
|
//
|
|
9
|
-
// As guest, the
|
|
10
|
-
//
|
|
9
|
+
// As guest, we keep the host's hello (its agents, the projects and clearance
|
|
10
|
+
// it shares, our jobs there) for as long as the link lives, and fold its feed
|
|
11
|
+
// into our own office: the boss tags the host's agents in our chat like our
|
|
12
|
+
// own, and their jobs sit in our list. Ids are namespaced `<link>.<id>` so a
|
|
13
|
+
// host can never name one of our jobs or agents. The older /ws/visit/:link
|
|
14
|
+
// page still works off the same requests.
|
|
11
15
|
// See docs/proposals/linked-offices.md, "Guests".
|
|
12
16
|
|
|
13
17
|
import crypto from 'node:crypto';
|
|
@@ -18,10 +22,14 @@ export const CLEARANCE = ['plan', 'edit', 'auto'];
|
|
|
18
22
|
const MAX_OPEN_JOBS = 3; // queued or working, per guest
|
|
19
23
|
const MAX_PROMPT = 20_000;
|
|
20
24
|
const REQ_TIMEOUT_MS = 15_000;
|
|
25
|
+
const CLOSE_TIMEOUT_MS = 60_000; // telling a host the work ended isn't on anyone's clock, and the relay can be slow to carry it
|
|
21
26
|
const CONNECT_WAIT_MS = 5_000;
|
|
22
27
|
const FEED_EVERY_MS = 250;
|
|
23
28
|
const FEED_BYTES = 48 * 1024; // under the relay's 64 KB a frame
|
|
24
29
|
const FEED_TYPES = new Set(['job', 'log', 'staff']);
|
|
30
|
+
const HELLO_RETRY_MS = 30_000;
|
|
31
|
+
const REMOTE_LOG = 600;
|
|
32
|
+
const MAX_THREADS = 300;
|
|
25
33
|
const utcDay = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 10);
|
|
26
34
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
27
35
|
|
|
@@ -104,6 +112,10 @@ export function cleanFeed(m) {
|
|
|
104
112
|
if (m.t === 'job') { const job = cleanJob(m.job); return job && { t: 'job', job }; }
|
|
105
113
|
if (m.t === 'log') { const entry = cleanEntry(m.entry); return entry && id(m.jobId) && { t: 'log', jobId: m.jobId, entry }; }
|
|
106
114
|
if (m.t === 'staff') return Array.isArray(m.dismissed) ? { t: 'staff', dismissed: m.dismissed.filter((x) => id(x)).slice(0, 50) } : null;
|
|
115
|
+
// The host changed what we may do there; we ask for its hello again.
|
|
116
|
+
if (m.t === 'policy') return { t: 'policy' };
|
|
117
|
+
// The host stopped one of our agents working in its project.
|
|
118
|
+
if (m.t === 'visit') return id(m.job) && m.ended === 'stopped' ? { t: 'visit', job: m.job, ended: 'stopped' } : null;
|
|
107
119
|
return null;
|
|
108
120
|
}
|
|
109
121
|
|
|
@@ -115,6 +127,13 @@ const CLEAN_BODY = {
|
|
|
115
127
|
reply: () => ({ ok: true }),
|
|
116
128
|
stop: () => ({ ok: true }),
|
|
117
129
|
hello: (b) => b,
|
|
130
|
+
'visit.open': (b) => {
|
|
131
|
+
if (typeof b.sid !== 'string' || !/^[0-9a-f]{32}$/.test(b.sid)) throw fail(502, 'The host sent back a session that doesn’t look right.');
|
|
132
|
+
return { sid: b.sid, access: b.access === 'edit' ? 'edit' : 'read' };
|
|
133
|
+
},
|
|
134
|
+
// What the host says about its own files goes only to our agent, as text.
|
|
135
|
+
'visit.fs': (b) => ({ text: str(b.text, 64 * 1024) }),
|
|
136
|
+
'visit.close': () => ({ ok: true }),
|
|
118
137
|
};
|
|
119
138
|
|
|
120
139
|
// What a guest may see of one of its own jobs.
|
|
@@ -124,17 +143,31 @@ export function guestJob(j, tail = 40) {
|
|
|
124
143
|
}
|
|
125
144
|
|
|
126
145
|
export class Guests {
|
|
127
|
-
// office: { jobs(), createJob(body, extra), replyJob(job, body), stopJob(job), diff(job, path), staff(), modes() }
|
|
128
|
-
|
|
146
|
+
// office: { jobs(), repoPath(name), createJob(body, extra), replyJob(job, body), stopJob(job), diff(job, path), staff(), modes() }
|
|
147
|
+
// office also has send(msg), which broadcasts to our own browsers.
|
|
148
|
+
// visits (as host): our side of a guest's own agents working in our
|
|
149
|
+
// projects; see visits.js.
|
|
150
|
+
constructor(dataDir, { links, office, visits = null }) {
|
|
129
151
|
this.links = links;
|
|
130
152
|
this.office = office;
|
|
153
|
+
this.copies = visits;
|
|
131
154
|
this.file = path.join(dataDir, 'guests.json');
|
|
132
155
|
this.used = {};
|
|
133
|
-
|
|
156
|
+
// Where each of our jobs at a host was asked for (chat thread and
|
|
157
|
+
// channel), and the project last used at each host.
|
|
158
|
+
this.asked = { threads: {}, lastRepo: {} };
|
|
159
|
+
try {
|
|
160
|
+
const saved = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
161
|
+
this.used = saved.used || {};
|
|
162
|
+
this.asked = { threads: saved.asked?.threads || {}, lastRepo: saved.asked?.lastRepo || {} };
|
|
163
|
+
} catch {}
|
|
164
|
+
this.remote = new Map(); // link id -> { link, host, staff, repos, clearance, jobs: Map(host job id -> job) }
|
|
165
|
+
this.helloing = new Map(); // link id -> retry-after time, while asking or after a failure
|
|
134
166
|
this.queue = new Map(); // link id -> messages waiting for the next feed frame
|
|
135
167
|
this.flushTimer = null;
|
|
136
168
|
this.visits = new Map(); // link id -> Set of browser sockets (as guest)
|
|
137
169
|
this.pending = new Map(); // req id -> { resolve, reject, timer } (as guest)
|
|
170
|
+
this.away = new Map(); // our job id -> { sid, access } while one of our agents works in a host's project
|
|
138
171
|
}
|
|
139
172
|
|
|
140
173
|
// Every req, res and feed frame on a guest link comes through here.
|
|
@@ -199,21 +232,40 @@ export class Guests {
|
|
|
199
232
|
staff: this.office.staff(),
|
|
200
233
|
modes: Object.fromEntries(Object.entries(this.office.modes()).filter(([k]) => CLEARANCE.indexOf(k) <= CLEARANCE.indexOf(p.clearance))),
|
|
201
234
|
jobs: this.jobsOf(link.id).map((j) => guestJob(j)),
|
|
202
|
-
policy: {
|
|
235
|
+
policy: {
|
|
236
|
+
repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id),
|
|
237
|
+
ourAgents: p.ourAgents !== false, theirAgents: this.copies ? p.theirAgents || 'off' : 'off',
|
|
238
|
+
},
|
|
239
|
+
// We can run a lent agent in one of the guest's own projects (createJob's `home`).
|
|
240
|
+
home: true,
|
|
203
241
|
};
|
|
242
|
+
case 'visit.open': case 'visit.fs': case 'visit.close': {
|
|
243
|
+
if (!this.copies) throw fail(400, 'Guests can’t do that here.');
|
|
244
|
+
if (op === 'visit.open') return this.copies.open(link, p, args);
|
|
245
|
+
// Every file call is held to the limits as they are now, not as they
|
|
246
|
+
// were when the work started.
|
|
247
|
+
if (!['read', 'edit'].includes(p.theirAgents)) throw fail(403, 'This office no longer lets your agents work in its projects.');
|
|
248
|
+
return op === 'visit.fs' ? this.copies.fs(link, args, p) : this.copies.close(link, args);
|
|
249
|
+
}
|
|
204
250
|
case 'createJob': {
|
|
205
|
-
if (
|
|
251
|
+
if (p.ourAgents === false) throw fail(403, 'This office lets your own agents work in its projects, but doesn’t lend its agents. Send one of yours instead.');
|
|
252
|
+
// `home`: the guest's own project, not one of ours. The agent runs here
|
|
253
|
+
// and its file tools go back to the guest over the link where we are
|
|
254
|
+
// theirs (an away job), so it needs that link too, and that project shared on it.
|
|
255
|
+
const home = typeof args.home === 'string' && args.home ? await this.homeFolder(link, args.home) : null;
|
|
256
|
+
if (!home && !p.repos.includes(args.repo)) throw fail(403, 'That project isn’t shared with you.');
|
|
206
257
|
const mode = this.capMode(p, args.mode);
|
|
207
258
|
this.checkTokens(link, p);
|
|
208
259
|
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
260
|
const prompt = String(args.prompt ?? '').slice(0, MAX_PROMPT);
|
|
210
261
|
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 } },
|
|
262
|
+
{ repo: home ? `${home.repo}@${home.host}` : args.repo, prompt, mode, model: typeof args.model === 'string' ? args.model : '', agentId: typeof args.agentId === 'string' ? args.agentId : '' },
|
|
263
|
+
{ from: `@${link.peer}`, guest: { link: link.id }, ...(home && { away: home }) },
|
|
213
264
|
);
|
|
214
265
|
return { job: guestJob(job) };
|
|
215
266
|
}
|
|
216
267
|
case 'reply': {
|
|
268
|
+
if (p.ourAgents === false) throw fail(403, 'This office doesn’t lend its agents any more.');
|
|
217
269
|
const job = this.ownJob(link, args.job);
|
|
218
270
|
const mode = args.mode === undefined ? undefined : this.capMode(p, args.mode);
|
|
219
271
|
this.checkTokens(link, p);
|
|
@@ -232,6 +284,21 @@ export class Guests {
|
|
|
232
284
|
}
|
|
233
285
|
}
|
|
234
286
|
|
|
287
|
+
// The guest's project `repo`, reached over the link where the guest is our
|
|
288
|
+
// host, as an away job's `away`. The relay names both ends of a link, so a
|
|
289
|
+
// guest can only point us at its own office.
|
|
290
|
+
async homeFolder(link, repo) {
|
|
291
|
+
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
292
|
+
let e = [...this.remote.values()].find((x) => norm(x.host) === norm(link.peer));
|
|
293
|
+
const back = !e && this.links.links.find((l) => l.kind === 'guest' && l.role === 'guest' && norm(l.peer) === norm(link.peer));
|
|
294
|
+
if (back) e = await this.remoteHello(back).catch(() => null);
|
|
295
|
+
if (!e) throw fail(403, `To use our agents in your own projects, make @${this.links.status().handle || 'us'} your guest too, with their agents allowed in your projects.`);
|
|
296
|
+
if (!['read', 'edit'].includes(e.theirAgents)) throw fail(403, `You haven’t let @${this.links.status().handle || 'us'}’s agents work in your projects. Turn that on where we’re your guest.`);
|
|
297
|
+
const r = e.repos.find((x) => norm(x) === norm(repo));
|
|
298
|
+
if (!r) throw fail(403, `You haven’t shared ${repo} with @${this.links.status().handle || 'us'}. Add it where we’re your guest.`);
|
|
299
|
+
return { link: e.link, host: e.host, repo: r, access: e.theirAgents };
|
|
300
|
+
}
|
|
301
|
+
|
|
235
302
|
// Tokens a guest job used: counted against its guest, and a guest over its
|
|
236
303
|
// day's allowance has its running jobs stopped.
|
|
237
304
|
addTokens(job, n) {
|
|
@@ -254,7 +321,9 @@ export class Guests {
|
|
|
254
321
|
for (const [id, u] of Object.entries(this.used)) if (u.day !== day) delete this.used[id];
|
|
255
322
|
try {
|
|
256
323
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
257
|
-
|
|
324
|
+
const threads = Object.entries(this.asked.threads);
|
|
325
|
+
if (threads.length > MAX_THREADS) this.asked.threads = Object.fromEntries(threads.slice(-MAX_THREADS));
|
|
326
|
+
fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used, asked: this.asked }));
|
|
258
327
|
fs.renameSync(this.file + '.tmp', this.file);
|
|
259
328
|
} catch (err) { console.error('[guests] could not save', err.message); }
|
|
260
329
|
}
|
|
@@ -305,6 +374,7 @@ export class Guests {
|
|
|
305
374
|
for (const ws of socks) ws.close(4404, 'This office unlinked you.');
|
|
306
375
|
this.visits.delete(linkId);
|
|
307
376
|
}
|
|
377
|
+
this.syncRemote();
|
|
308
378
|
}
|
|
309
379
|
|
|
310
380
|
// ---------- as guest ----------
|
|
@@ -315,14 +385,14 @@ export class Guests {
|
|
|
315
385
|
return link;
|
|
316
386
|
}
|
|
317
387
|
|
|
318
|
-
async request(linkId, op, args = {}) {
|
|
388
|
+
async request(linkId, op, args = {}, timeoutMs = REQ_TIMEOUT_MS) {
|
|
319
389
|
const link = this.guestLink(linkId);
|
|
320
390
|
// Just after linking (or a reconnect) the relay socket can still be
|
|
321
391
|
// opening; a visit started then shouldn't fail for it.
|
|
322
392
|
for (const end = Date.now() + CONNECT_WAIT_MS; !this.links.status().connected && Date.now() < end;) await new Promise((r) => setTimeout(r, 100));
|
|
323
393
|
const id = crypto.randomUUID();
|
|
324
394
|
return new Promise((resolve, reject) => {
|
|
325
|
-
const timer = setTimeout(() => { this.pending.delete(id); reject(fail(504, `@${link.peer} didn’t answer.`)); },
|
|
395
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(fail(504, `@${link.peer} didn’t answer.`)); }, timeoutMs);
|
|
326
396
|
const clean = CLEAN_BODY[op] || (() => ({}));
|
|
327
397
|
this.pending.set(id, { resolve: (body) => { try { resolve(clean(body)); } catch (err) { reject(err); } }, reject, timer });
|
|
328
398
|
if (!this.links.send({ t: 'req', link: link.id, id, op, args })) {
|
|
@@ -343,11 +413,13 @@ export class Guests {
|
|
|
343
413
|
}
|
|
344
414
|
|
|
345
415
|
fromHost(link, msg) {
|
|
416
|
+
if (!Array.isArray(msg.msgs)) return;
|
|
346
417
|
const socks = this.visits.get(link.id);
|
|
347
|
-
if (!socks?.size || !Array.isArray(msg.msgs)) return;
|
|
348
418
|
for (const raw of msg.msgs.slice(0, 500)) {
|
|
349
419
|
const m = cleanFeed(raw);
|
|
350
420
|
if (!m) continue;
|
|
421
|
+
this.remoteFeed(link, m);
|
|
422
|
+
if (!socks?.size) continue;
|
|
351
423
|
const data = JSON.stringify(m);
|
|
352
424
|
for (const ws of socks) if (ws.readyState === 1) ws.send(data);
|
|
353
425
|
}
|
|
@@ -370,6 +442,8 @@ export class Guests {
|
|
|
370
442
|
link: link.id, host: link.peer,
|
|
371
443
|
repos: (Array.isArray(policy.repos) ? policy.repos : []).filter((r) => typeof r === 'string' && REPO.test(r)).slice(0, 50),
|
|
372
444
|
clearance, tokensPerDay: count(policy.tokensPerDay), tokensToday: count(policy.tokensToday),
|
|
445
|
+
ourAgents: policy.ourAgents !== false, theirAgents: ['read', 'edit'].includes(policy.theirAgents) ? policy.theirAgents : 'off',
|
|
446
|
+
home: hello?.home === true,
|
|
373
447
|
},
|
|
374
448
|
};
|
|
375
449
|
}
|
|
@@ -388,4 +462,292 @@ export class Guests {
|
|
|
388
462
|
if (ws.readyState !== 1) return;
|
|
389
463
|
ws.send(JSON.stringify({ ...extra(), ...this.cleanHello(link, hello) }));
|
|
390
464
|
}
|
|
465
|
+
|
|
466
|
+
// ---------- a host's agents, from our own office ----------
|
|
467
|
+
|
|
468
|
+
// Ask every host that made us its guest for its hello, once per link (and
|
|
469
|
+
// again after a failure, no sooner than HELLO_RETRY_MS). Cheap to call on
|
|
470
|
+
// every links change: it does nothing for hosts it already knows.
|
|
471
|
+
syncRemote() {
|
|
472
|
+
const hosts = this.links.links.filter((l) => l.kind === 'guest' && l.role === 'guest');
|
|
473
|
+
let gone = false;
|
|
474
|
+
for (const id of this.remote.keys()) if (!hosts.some((l) => l.id === id)) { this.remote.delete(id); gone = true; }
|
|
475
|
+
if (gone) this.office.send({ t: 'remote', offices: this.remoteStatus(), away: this.awayFolders() });
|
|
476
|
+
if (!this.links.status().connected) return;
|
|
477
|
+
for (const l of hosts) {
|
|
478
|
+
if (this.remote.has(l.id) || (this.helloing.get(l.id) || 0) > Date.now()) continue;
|
|
479
|
+
this.remoteHello(l).catch(() => {});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async remoteHello(link) {
|
|
484
|
+
this.helloing.set(link.id, Infinity);
|
|
485
|
+
try {
|
|
486
|
+
const hello = this.cleanHello(link, await this.request(link.id, 'hello'));
|
|
487
|
+
const entry = {
|
|
488
|
+
link: link.id, host: link.peer, staff: hello.staff, repos: hello.visiting.repos, clearance: hello.visiting.clearance,
|
|
489
|
+
ourAgents: hello.visiting.ourAgents, theirAgents: hello.visiting.theirAgents, home: hello.visiting.home,
|
|
490
|
+
jobs: new Map(hello.jobs.map((j) => [j.id, j])),
|
|
491
|
+
};
|
|
492
|
+
if (!this.guestLinkOrNull(link.id)) return null; // unlinked while we asked
|
|
493
|
+
this.remote.set(link.id, entry);
|
|
494
|
+
this.helloing.delete(link.id);
|
|
495
|
+
this.office.send({ t: 'remote', offices: this.remoteStatus(), away: this.awayFolders() });
|
|
496
|
+
for (const j of entry.jobs.values()) this.office.send({ t: 'job', job: this.localJob(entry, j) });
|
|
497
|
+
return entry;
|
|
498
|
+
} catch (err) {
|
|
499
|
+
this.helloing.set(link.id, Date.now() + HELLO_RETRY_MS);
|
|
500
|
+
throw err;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
guestLinkOrNull(linkId) {
|
|
505
|
+
try { return this.guestLink(linkId); } catch { return null; }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// A host's job as our page sees it: namespaced ids, where it was asked
|
|
509
|
+
// for, and which office it runs in. `from` is us, so it's dropped.
|
|
510
|
+
localJob(entry, j, tail = 40) {
|
|
511
|
+
const key = `${entry.link}.${j.id}`;
|
|
512
|
+
const { guest: _g, from: _f, ...rest } = j;
|
|
513
|
+
return {
|
|
514
|
+
...rest, id: key, agentId: j.agentId ? `${entry.link}.${j.agentId}` : null,
|
|
515
|
+
remote: { link: entry.link, host: entry.host }, ...this.asked.threads[key],
|
|
516
|
+
log: (j.log || []).slice(-tail),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// The host's agents, for our page. `here` says who it has on the floor,
|
|
521
|
+
// from its presence; staff it hasn't hired can't be tagged.
|
|
522
|
+
remoteStatus() {
|
|
523
|
+
return [...this.remote.values()].map((e) => {
|
|
524
|
+
const present = this.links.presentAt(e.link);
|
|
525
|
+
const here = present && new Set(present.map((a) => a.id));
|
|
526
|
+
return {
|
|
527
|
+
link: e.link, host: e.host, repos: e.repos, clearance: e.clearance, theirAgents: e.theirAgents || 'off',
|
|
528
|
+
// A host that only lets our own agents in has none of its to tag.
|
|
529
|
+
staff: e.ourAgents === false ? [] : e.staff.map((s) => ({ ...s, id: `${e.link}.${s.id}`, here: here ? here.has(s.id) : true })),
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
remoteJobs() {
|
|
535
|
+
return [...this.remote.values()].flatMap((e) => [...e.jobs.values()].map((j) => this.localJob(e, j)));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// One feed message from a host, folded into our office.
|
|
539
|
+
remoteFeed(link, m) {
|
|
540
|
+
if (m.t === 'policy') {
|
|
541
|
+
this.remote.delete(link.id);
|
|
542
|
+
this.helloing.delete(link.id);
|
|
543
|
+
this.syncRemote();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (m.t === 'visit') {
|
|
547
|
+
this.office.awayStopped?.(m.job, link.id);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const entry = this.remote.get(link.id);
|
|
551
|
+
if (!entry) return;
|
|
552
|
+
if (m.t === 'job') {
|
|
553
|
+
const prev = entry.jobs.get(m.job.id);
|
|
554
|
+
const job = { ...m.job, log: prev?.log || [] };
|
|
555
|
+
entry.jobs.set(job.id, job);
|
|
556
|
+
this.office.send({ t: 'job', job: this.localJob(entry, job, 0) });
|
|
557
|
+
if (prev?.status === 'working' && job.status !== 'working' && job.status !== 'queued') this.office.remoteEnded?.(this.localJob(entry, job, 1), prev);
|
|
558
|
+
} else if (m.t === 'log') {
|
|
559
|
+
const job = entry.jobs.get(m.jobId);
|
|
560
|
+
if (!job) return;
|
|
561
|
+
job.log.push(m.entry);
|
|
562
|
+
if (job.log.length > REMOTE_LOG) job.log.splice(0, job.log.length - REMOTE_LOG);
|
|
563
|
+
this.office.send({ t: 'log', jobId: `${entry.link}.${job.id}`, entry: m.entry });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// `<link>.<host job id>` -> the host and its id, or null if it isn't one.
|
|
568
|
+
remoteKey(key) {
|
|
569
|
+
const m = /^([A-Za-z0-9_-]{1,64})\.([A-Za-z0-9_-]{1,64})$/.exec(String(key ?? ''));
|
|
570
|
+
const entry = m && this.remote.get(m[1]);
|
|
571
|
+
return entry ? { entry, id: m[2] } : null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// The remote job a chat thread is about, if any.
|
|
575
|
+
threadJob(root) {
|
|
576
|
+
const key = Object.keys(this.asked.threads).findLast((k) => this.asked.threads[k].thread === root);
|
|
577
|
+
return key && this.remoteKey(key) ? key : null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The highest clearance at or below `mode` that the host allows.
|
|
581
|
+
capRemote(entry, mode) {
|
|
582
|
+
const want = CLEARANCE.includes(mode) ? mode : 'edit';
|
|
583
|
+
return CLEARANCE[Math.min(CLEARANCE.indexOf(want), CLEARANCE.indexOf(entry.clearance))];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// What /api/jobs/<link>.<id> does for a host's job.
|
|
587
|
+
async remoteOp(key, op, args = {}) {
|
|
588
|
+
const r = this.remoteKey(key);
|
|
589
|
+
if (!r) throw fail(404, 'No such job.');
|
|
590
|
+
const { entry, id } = r;
|
|
591
|
+
if (op === 'job') {
|
|
592
|
+
const { job } = await this.request(entry.link, 'job', { job: id });
|
|
593
|
+
entry.jobs.set(job.id, job);
|
|
594
|
+
return this.localJob(entry, job, 500);
|
|
595
|
+
}
|
|
596
|
+
if (op === 'diff') return this.request(entry.link, 'diff', { job: id, path: String(args.path ?? '') });
|
|
597
|
+
if (op === 'stop') return this.request(entry.link, 'stop', { job: id });
|
|
598
|
+
if (op === 'reply') {
|
|
599
|
+
if (Array.isArray(args.files) && args.files.length) throw fail(400, `Files can’t go to @${entry.host}’s office. Paste the text instead.`);
|
|
600
|
+
const text = String(args.message ?? args.text ?? '').trim();
|
|
601
|
+
if (!text) throw fail(400, 'Say something first.');
|
|
602
|
+
return this.request(entry.link, 'reply', { job: id, text, mode: args.mode === undefined ? undefined : this.capRemote(entry, args.mode) });
|
|
603
|
+
}
|
|
604
|
+
throw fail(400, 'Can’t do that with a linked office’s job.');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// A host agent tagged in `text`. `@Name@host` always means the host's;
|
|
608
|
+
// a bare `@Name` only when none of ours answers to it (isLocal).
|
|
609
|
+
tagged(text, isLocal) {
|
|
610
|
+
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
611
|
+
const offices = this.remoteStatus();
|
|
612
|
+
const find = (name, host) => {
|
|
613
|
+
for (const o of offices) {
|
|
614
|
+
if (host && norm(o.host) !== norm(host)) continue;
|
|
615
|
+
const s = o.staff.find((x) => x.here && norm(x.name) === norm(name));
|
|
616
|
+
if (s) return { link: o.link, host: o.host, staff: s };
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
};
|
|
620
|
+
for (const m of text.matchAll(/@([\w-]+)@([\w-]+)/g)) {
|
|
621
|
+
const hit = find(m[1], m[2]);
|
|
622
|
+
if (hit) return { ...hit, clean: text.replace(m[0], ' ').replace(/[ \t]+/g, ' ').trim() };
|
|
623
|
+
}
|
|
624
|
+
for (const m of text.matchAll(/(^|[^\w@])@([\w-]+)(?![\w@-])/g)) {
|
|
625
|
+
if (isLocal(m[2])) continue;
|
|
626
|
+
const hit = find(m[2]);
|
|
627
|
+
if (hit) {
|
|
628
|
+
const re = new RegExp(`@${m[2]}(?![\\w@-])`);
|
|
629
|
+
return { ...hit, clean: text.replace(re, ' ').replace(/[ \t]+/g, ' ').trim() };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// One of our own projects, for a host's lent agent: the host runs it and its
|
|
636
|
+
// file tools come back to us over the link where the host is our guest
|
|
637
|
+
// (visits.js), so that link must let their agents into this project.
|
|
638
|
+
lentHome(entry, repo) {
|
|
639
|
+
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
640
|
+
const h = entry.host;
|
|
641
|
+
if (!entry.home) throw fail(400, `@${h}’s office is on an older version that can only run its agents in its own projects. Ask @${h} to update.`);
|
|
642
|
+
const back = this.links.links.find((l) => l.kind === 'guest' && l.role === 'host' && norm(l.peer) === norm(h));
|
|
643
|
+
const p = back && this.links.guestPolicy(back.id);
|
|
644
|
+
if (!p) throw fail(400, `To use @${h}’s agents in #${repo}, make @${h} your guest too, with their agents allowed in your projects.`);
|
|
645
|
+
if (!['read', 'edit'].includes(p.theirAgents)) throw fail(400, `To use @${h}’s agents in #${repo}, let their agents work in your projects: Change, where @${h} is your guest.`);
|
|
646
|
+
const r = p.repos.find((x) => norm(x) === norm(repo));
|
|
647
|
+
if (!r) throw fail(400, `You haven’t shared ${repo} with @${h}. Add it under Change, where @${h} is your guest, and ask again.`);
|
|
648
|
+
return r;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// The project a job at a host goes in: the only one it shares, one tagged
|
|
652
|
+
// or named in the brief, or the one used there last time.
|
|
653
|
+
remoteRepo(entry, text) {
|
|
654
|
+
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
655
|
+
const { repos } = entry;
|
|
656
|
+
if (!repos.length) throw fail(403, `@${entry.host} hasn’t shared any projects with you.`);
|
|
657
|
+
const tags = [...text.matchAll(/(^|\s)#([\w.-]+)/g)];
|
|
658
|
+
// A project of ours, not theirs: their agent would run in their folder
|
|
659
|
+
// and never see ours. Say so, rather than quietly working somewhere else.
|
|
660
|
+
const ours = tags.find((t) => !repos.some((r) => norm(r) === norm(t[2])) && this.office.repoPath(t[2]));
|
|
661
|
+
if (ours) return { repo: this.lentHome(entry, ours[2]), home: true, text: text.replace(ours[0], ' ').replace(/[ \t]+/g, ' ').trim() };
|
|
662
|
+
if (repos.length === 1) return { repo: repos[0], text };
|
|
663
|
+
for (const t of tags) {
|
|
664
|
+
const repo = repos.find((r) => norm(r) === norm(t[2]));
|
|
665
|
+
if (repo) return { repo, text: text.replace(t[0], ' ').replace(/[ \t]+/g, ' ').trim() };
|
|
666
|
+
}
|
|
667
|
+
const said = norm(text);
|
|
668
|
+
const named = repos.filter((r) => norm(r).length >= 3 && said.includes(norm(r)));
|
|
669
|
+
if (named.length === 1) return { repo: named[0], text };
|
|
670
|
+
const last = this.asked.lastRepo[entry.link];
|
|
671
|
+
if (repos.includes(last)) return { repo: last, text };
|
|
672
|
+
throw fail(400, `Which of @${entry.host}’s projects is that for? Say ${repos.map((r) => r).join(', ')} in the message.`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ---------- our agents in a host's projects ----------
|
|
676
|
+
//
|
|
677
|
+
// A host can let our own agents work in its projects: the agent runs here,
|
|
678
|
+
// on our account, and its file tools are requests to the host (visit.*),
|
|
679
|
+
// which works on a copy and answers in text. See visits.js for the host's side.
|
|
680
|
+
|
|
681
|
+
// `#web@friend`: a host's project our agents may work in, or null.
|
|
682
|
+
awayFolder(repo, host) {
|
|
683
|
+
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
684
|
+
for (const e of this.remote.values()) {
|
|
685
|
+
if (norm(e.host) !== norm(host) || !['read', 'edit'].includes(e.theirAgents)) continue;
|
|
686
|
+
const r = e.repos.find((x) => norm(x) === norm(repo));
|
|
687
|
+
if (r) return { link: e.link, host: e.host, repo: r, access: e.theirAgents };
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Every host project our agents may work in, for the page's # menu.
|
|
693
|
+
awayFolders() {
|
|
694
|
+
return [...this.remote.values()].filter((e) => ['read', 'edit'].includes(e.theirAgents))
|
|
695
|
+
.flatMap((e) => e.repos.map((repo) => ({ link: e.link, host: e.host, repo, access: e.theirAgents })));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// One file call from our agent's job, as a request to the host. The first
|
|
699
|
+
// opens the session there; a session the host has lost is opened again once.
|
|
700
|
+
async awayFs(job, agent, op, args) {
|
|
701
|
+
const { link, repo } = job.away;
|
|
702
|
+
const open = async () => {
|
|
703
|
+
const s = await this.request(link, 'visit.open', { repo, job: job.id, title: job.title, agent });
|
|
704
|
+
this.away.set(job.id, s);
|
|
705
|
+
job.away.access = s.access;
|
|
706
|
+
return s;
|
|
707
|
+
};
|
|
708
|
+
let s = this.away.get(job.id) || await open();
|
|
709
|
+
try {
|
|
710
|
+
return (await this.request(link, 'visit.fs', { ...args, sid: s.sid, op })).text;
|
|
711
|
+
} catch (err) {
|
|
712
|
+
if (err.code !== 404 || !/session has ended/.test(err.message)) throw err;
|
|
713
|
+
this.away.delete(job.id);
|
|
714
|
+
s = await open();
|
|
715
|
+
return (await this.request(link, 'visit.fs', { ...args, sid: s.sid, op })).text;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Our job ended: the host closes its side and works out what changed.
|
|
720
|
+
async awayClose(job, status, summary) {
|
|
721
|
+
const s = this.away.get(job.id);
|
|
722
|
+
if (!s) return;
|
|
723
|
+
this.away.delete(job.id);
|
|
724
|
+
const args = { sid: s.sid, status, summary: String(summary || '').slice(0, 2000) };
|
|
725
|
+
// A close that outruns the wait may still land, so ask again once. The
|
|
726
|
+
// host ends a visit once, and an answer that says it already has is fine.
|
|
727
|
+
for (let tries = 0; ; tries++) {
|
|
728
|
+
try {
|
|
729
|
+
await this.request(job.away.link, 'visit.close', args, CLOSE_TIMEOUT_MS);
|
|
730
|
+
return;
|
|
731
|
+
} catch (err) {
|
|
732
|
+
if (err.code === 404 || err.code === 410) return;
|
|
733
|
+
if (err.code !== 504 || tries) throw err;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Start a job with one of a host's agents, from our chat.
|
|
739
|
+
async startRemote({ link, staff, text, mode, model, thread, channel }) {
|
|
740
|
+
const entry = this.remote.get(link) || await this.remoteHello(this.guestLink(link));
|
|
741
|
+
const { repo, home, text: brief } = this.remoteRepo(entry, text);
|
|
742
|
+
if (!brief) throw fail(400, 'Say what the job is, as well as who it’s for.');
|
|
743
|
+
const agentId = staff.id.slice(link.length + 1);
|
|
744
|
+
const { job } = await this.request(link, 'createJob', { ...(home ? { home: repo } : { repo }), prompt: brief, mode: this.capRemote(entry, mode), model, agentId });
|
|
745
|
+
this.asked.threads[`${link}.${job.id}`] = { thread, channel };
|
|
746
|
+
if (!home) this.asked.lastRepo[link] = repo;
|
|
747
|
+
this.save();
|
|
748
|
+
entry.jobs.set(job.id, job);
|
|
749
|
+
const local = this.localJob(entry, job);
|
|
750
|
+
this.office.send({ t: 'job', job: local });
|
|
751
|
+
return { job: local, entry, repo, home: !!home, capped: this.capRemote(entry, mode) !== mode };
|
|
752
|
+
}
|
|
391
753
|
}
|