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/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 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.
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
- constructor(dataDir, { links, office }) {
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
- try { this.used = JSON.parse(fs.readFileSync(this.file, 'utf8')).used || {}; } catch {}
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: { repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id) },
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 (!p.repos.includes(args.repo)) throw fail(403, 'That project isn’t shared with you.');
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
- fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used }));
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.`)); }, REQ_TIMEOUT_MS);
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
  }