agent-office-studio 0.8.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +12 -0
- package/dist/assets/code-Dtdckl8K.js +32 -0
- package/dist/assets/index-BcqWv4NW.css +1 -0
- package/dist/assets/index-qdaAZdZT.js +5065 -0
- package/dist/assets/pdf-BtCWNl9F.js +55 -0
- package/dist/assets/pdf-CKZBNrtF.js +1 -0
- package/dist/assets/pdf.worker.min-BJd0dv5W.js +53 -0
- package/dist/assets/pdf.worker.min-D_0FwaWY.js +1 -0
- package/dist/index.html +39 -35
- package/dist/theme-boot.js +13 -0
- package/docs/desktop.md +52 -0
- package/docs/features.md +96 -0
- package/package.json +19 -2
- package/server/apps.js +146 -0
- package/server/changes.js +13 -2
- package/server/chief.js +20 -1
- package/server/economy.js +26 -3
- package/server/files.js +115 -0
- package/server/guests.js +59 -21
- package/server/index.js +897 -72
- package/server/links.js +11 -4
- package/server/machine.js +164 -0
- package/server/office-mcp.js +26 -1
- package/server/procstat.js +12 -6
- package/server/settings.js +2 -0
- package/server/telegram.js +320 -0
- package/server/visits.js +14 -4
- package/shared/shop.js +11 -4
- package/dist/assets/index-BAvX7Imu.css +0 -1
- package/dist/assets/index-C01GpSoa.js +0 -4999
package/server/economy.js
CHANGED
|
@@ -19,6 +19,8 @@ const weekStart = (ms) => { const d = Math.floor(ms / 86400000); return (d - ((d
|
|
|
19
19
|
// moment it is offered, so a wallet can never go below zero.
|
|
20
20
|
export const BET = { minStake: 100, maxStake: 5000, maxOpen: 3, maxMinutes: 60, openMs: 6 * 3600000, liveMs: 24 * 3600000, keep: 40 };
|
|
21
21
|
export const GOAL = { minTarget: 1000, maxTarget: 1e9, maxName: 40 };
|
|
22
|
+
// A handed-off job whose brief is a review, a test pass or an audit.
|
|
23
|
+
const REVIEW = /review|adversarial|audit|qa\b|test/i;
|
|
22
24
|
const day = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
23
25
|
|
|
24
26
|
export function emptyBooks() {
|
|
@@ -57,7 +59,7 @@ export class Economy {
|
|
|
57
59
|
get books() { return this.b; }
|
|
58
60
|
wallet(id) { return this.b.wallets[id] || 0; }
|
|
59
61
|
statsOf(id) {
|
|
60
|
-
return (this.b.stats[id] ||= { jobs: 0, earned: 0, byTier: {}, kudos: 0, bought: 0, streak: 0, lastDay: '' });
|
|
62
|
+
return (this.b.stats[id] ||= { jobs: 0, earned: 0, byTier: {}, kudos: 0, bought: 0, streak: 0, lastDay: '', minutes: 0, cleanKudos: 0, comebacks: 0, handed: 0, reviews: 0 });
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
record(entry) {
|
|
@@ -109,6 +111,7 @@ export class Economy {
|
|
|
109
111
|
const again = !!job.payCounted;
|
|
110
112
|
job.payCounted = true;
|
|
111
113
|
const s = this.statsOf(job.agentId);
|
|
114
|
+
if (again) { job.reopens = (job.reopens || 0) + 1; s.comebacks = (s.comebacks || 0) + 1; }
|
|
112
115
|
let boost = 0;
|
|
113
116
|
if (!again) {
|
|
114
117
|
// The day's first finish keeps or restarts the streak, and the
|
|
@@ -124,11 +127,23 @@ export class Economy {
|
|
|
124
127
|
const e = this.credit(job.agentId, q.wage * (1 + boost) + (again ? 0 : q.bonus), { kind: 'pay', jobId: job.id, title: job.title, minutes: Math.round(q.minutes * 10) / 10, tier: q.tier, mult: q.mult, ...(boost ? { streak: s.streak, boost } : {}) });
|
|
125
128
|
if (e) job.earned = (job.earned || 0) + e.gross;
|
|
126
129
|
if (again) return { pay: e, achievements: this.milestones(job.agentId) };
|
|
130
|
+
// A hand-off from someone betting on this job is not teamwork, so it
|
|
131
|
+
// earns no hand-off badges. Checked before the bets close.
|
|
132
|
+
this.sweep();
|
|
133
|
+
const fromBet = this.b.bets.some((x) => this.boundTo(x, job) && this.inBet(x, job.requestedByAgent));
|
|
127
134
|
// A bet that cannot be settled cleanly must never hold up the pay.
|
|
128
135
|
try { this.settleBets(job, q.minutes); } catch (err) { this.record({ kind: 'bet', phase: 'error', jobId: job.id, why: err.message }); }
|
|
136
|
+
// Quality counters. "Quick" is against this agent's own average, so a
|
|
137
|
+
// slower model is not penalised, and short jobs don't count.
|
|
138
|
+
const quick = s.jobs >= 5 && q.minutes >= 0.5 && q.minutes < 0.5 * ((s.minutes || 0) / s.jobs);
|
|
139
|
+
s.minutes = (s.minutes || 0) + q.minutes;
|
|
140
|
+
if (job.requestedBy && job.status === 'done' && !fromBet) {
|
|
141
|
+
s.handed = (s.handed || 0) + 1;
|
|
142
|
+
if (REVIEW.test(`${job.title || ''} ${job.prompt || ''}`)) s.reviews = (s.reviews || 0) + 1;
|
|
143
|
+
}
|
|
129
144
|
s.jobs++;
|
|
130
145
|
if (q.tier) s.byTier[q.tier] = (s.byTier[q.tier] || 0) + 1;
|
|
131
|
-
const won = this.milestones(job.agentId, q);
|
|
146
|
+
const won = this.milestones(job.agentId, { ...q, quick });
|
|
132
147
|
return { pay: e, achievements: won };
|
|
133
148
|
}
|
|
134
149
|
|
|
@@ -156,7 +171,9 @@ export class Economy {
|
|
|
156
171
|
job.kudosPaid = true;
|
|
157
172
|
this.b.kudos.push(job.id);
|
|
158
173
|
if (this.b.kudos.length > 2000) this.b.kudos.splice(0, 1000);
|
|
159
|
-
this.statsOf(job.agentId)
|
|
174
|
+
const ks = this.statsOf(job.agentId);
|
|
175
|
+
ks.kudos++;
|
|
176
|
+
if (!job.reopens) ks.cleanKudos = (ks.cleanKudos || 0) + 1;
|
|
160
177
|
const e = this.credit(job.agentId, PAY.finishBonus * modelPay(job.model), { kind: 'kudos', jobId: job.id, title: job.title });
|
|
161
178
|
return { pay: e, achievements: this.milestones(job.agentId) };
|
|
162
179
|
}
|
|
@@ -415,11 +432,17 @@ export class Economy {
|
|
|
415
432
|
return bet.state === 'live' && bet.subject === job.agentId && Number.isFinite(job.startedAt) && bet.acceptedAt <= job.startedAt;
|
|
416
433
|
}
|
|
417
434
|
|
|
435
|
+
inBet(bet, id) {
|
|
436
|
+
return !!id && (id === bet.by || id === bet.against);
|
|
437
|
+
}
|
|
438
|
+
|
|
418
439
|
// The job finished for the first time, in `minutes` of active work.
|
|
419
440
|
settleBets(job, minutes) {
|
|
420
441
|
this.sweep();
|
|
421
442
|
for (const bet of this.b.bets.filter((x) => this.boundTo(x, job))) {
|
|
422
443
|
if (!(minutes > 0)) { this.close(bet, 'void', 'no working time was recorded'); continue; }
|
|
444
|
+
// A job one side of the bet handed over is one they could have sized to win.
|
|
445
|
+
if (this.inBet(bet, job.requestedByAgent)) { this.close(bet, 'void', 'the job came from someone in the bet'); continue; }
|
|
423
446
|
const under = minutes < bet.minutes;
|
|
424
447
|
this.close(bet, under ? 'won' : 'lost', `${Math.round(minutes * 10) / 10} min on "${job.title}"`, under ? bet.by : bet.against);
|
|
425
448
|
}
|
package/server/files.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Edit a project's files from the office: list a folder, read a text file,
|
|
2
|
+
// save it back. Everything stays inside one project folder (symlinks that
|
|
3
|
+
// lead out are refused), .git is off limits, and a save only lands if the
|
|
4
|
+
// file is still as it was when it was opened, so an agent mid-job and the
|
|
5
|
+
// boss never silently overwrite each other.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const MAX_BYTES = 1024 * 1024;
|
|
11
|
+
const HIDDEN = new Set(['.git', 'node_modules', '.DS_Store']);
|
|
12
|
+
|
|
13
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
14
|
+
|
|
15
|
+
// The real path of `rel` inside `root`, or an error. A file that doesn't
|
|
16
|
+
// exist yet is checked through its nearest folder that does.
|
|
17
|
+
function resolve(root, rel, { mustExist = true } = {}) {
|
|
18
|
+
if (typeof rel !== 'string' || rel.includes('\0')) throw fail(400, 'Bad path.');
|
|
19
|
+
const parts = rel.split(/[\/\\]+/).filter((p) => p && p !== '.');
|
|
20
|
+
if (parts.includes('..')) throw fail(403, 'That path leads outside the project.');
|
|
21
|
+
if (parts.some((p) => p.toLowerCase() === '.git')) throw fail(403, 'The .git folder is off limits.');
|
|
22
|
+
const realRoot = fs.realpathSync(root);
|
|
23
|
+
const abs = path.join(realRoot, ...parts);
|
|
24
|
+
let probe = abs;
|
|
25
|
+
for (;;) {
|
|
26
|
+
try { fs.lstatSync(probe); break; } catch {
|
|
27
|
+
if (mustExist && probe === abs) throw fail(404, 'No such file.');
|
|
28
|
+
const up = path.dirname(probe);
|
|
29
|
+
if (up === probe) throw fail(404, 'No such file.');
|
|
30
|
+
probe = up;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const real = fs.realpathSync(probe);
|
|
34
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) throw fail(403, 'That path leads outside the project.');
|
|
35
|
+
return { abs, rel: parts.join('/'), realRoot };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function list(root, rel = '') {
|
|
39
|
+
const { abs, rel: at } = resolve(root, rel);
|
|
40
|
+
let entries;
|
|
41
|
+
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch (err) {
|
|
42
|
+
throw fail(err.code === 'ENOTDIR' ? 400 : 404, err.code === 'ENOTDIR' ? 'That is a file, not a folder.' : 'No such folder.');
|
|
43
|
+
}
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
if (HIDDEN.has(e.name)) continue;
|
|
47
|
+
// A link is shown as what it points to, if that is still inside.
|
|
48
|
+
let st;
|
|
49
|
+
try { st = fs.statSync(path.join(abs, e.name)); resolve(root, [at, e.name].filter(Boolean).join('/')); } catch { continue; }
|
|
50
|
+
out.push({ name: e.name, dir: st.isDirectory(), size: st.isDirectory() ? 0 : st.size });
|
|
51
|
+
}
|
|
52
|
+
out.sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
53
|
+
return { path: at, entries: out };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function read(root, rel) {
|
|
57
|
+
const { abs, rel: at } = resolve(root, rel);
|
|
58
|
+
const st = fs.statSync(abs);
|
|
59
|
+
if (!st.isFile()) throw fail(400, 'That is a folder, not a file.');
|
|
60
|
+
if (st.size > MAX_BYTES) throw fail(413, `Too big to edit here (${Math.round(st.size / 1024)} KB; the limit is ${MAX_BYTES / 1024} KB).`);
|
|
61
|
+
const buf = fs.readFileSync(abs);
|
|
62
|
+
if (buf.includes(0)) throw fail(415, 'That looks like a binary file, so it can’t be edited here.');
|
|
63
|
+
return { path: at, text: buf.toString('utf8'), mtime: st.mtimeMs, size: st.size };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A PDF to show in the viewer: its real path and size, or an error. Only files
|
|
67
|
+
// that are really PDFs are served, whatever they are called.
|
|
68
|
+
export function pdf(root, rel) {
|
|
69
|
+
const { abs } = resolve(root, rel);
|
|
70
|
+
const st = fs.statSync(abs);
|
|
71
|
+
if (!st.isFile()) throw fail(400, 'That is a folder, not a file.');
|
|
72
|
+
if (!/\.pdf$/i.test(abs)) throw fail(415, 'That is not a PDF.');
|
|
73
|
+
const fd = fs.openSync(abs, 'r');
|
|
74
|
+
const head = Buffer.alloc(5);
|
|
75
|
+
try { fs.readSync(fd, head, 0, 5, 0); } finally { fs.closeSync(fd); }
|
|
76
|
+
if (head.toString('latin1') !== '%PDF-') throw fail(415, 'That file is not a valid PDF.');
|
|
77
|
+
return { abs, size: st.size };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// An HTML page to show in the preview: its real path and size, or an error.
|
|
81
|
+
export const MAX_PAGE = 8 * 1024 * 1024;
|
|
82
|
+
export function page(root, rel) {
|
|
83
|
+
const { abs } = resolve(root, rel);
|
|
84
|
+
const st = fs.statSync(abs);
|
|
85
|
+
if (!st.isFile()) throw fail(400, 'That is a folder, not a file.');
|
|
86
|
+
if (!/\.html?$/i.test(abs)) throw fail(415, 'That is not an HTML page.');
|
|
87
|
+
if (st.size > MAX_PAGE) throw fail(413, `Too big to preview (${Math.round(st.size / 1048576)} MB; the limit is ${MAX_PAGE / 1048576} MB).`);
|
|
88
|
+
return { abs, size: st.size };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `mtime` is what read() gave; null means the file must not exist yet.
|
|
92
|
+
export function write(root, rel, text, mtime) {
|
|
93
|
+
if (typeof text !== 'string') throw fail(400, 'Nothing to save.');
|
|
94
|
+
if (Buffer.byteLength(text) > MAX_BYTES) throw fail(413, `Too big to save here (the limit is ${MAX_BYTES / 1024} KB).`);
|
|
95
|
+
const { abs, rel: at } = resolve(root, rel, { mustExist: false });
|
|
96
|
+
if (!at) throw fail(400, 'Which file?');
|
|
97
|
+
let st = null;
|
|
98
|
+
try { st = fs.statSync(abs); } catch {}
|
|
99
|
+
if (st?.isDirectory()) throw fail(400, 'That is a folder.');
|
|
100
|
+
if (st && mtime !== st.mtimeMs) throw fail(409, 'This file changed on disk since you opened it. Reload it to see what changed.');
|
|
101
|
+
if (!st && mtime != null) throw fail(409, 'This file was deleted since you opened it.');
|
|
102
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
103
|
+
// Written beside it and renamed, so a crash never leaves half a file, and
|
|
104
|
+
// the file keeps its permissions.
|
|
105
|
+
const tmp = `${abs}.${process.pid}.${Date.now()}.tmp`;
|
|
106
|
+
try {
|
|
107
|
+
fs.writeFileSync(tmp, text, { mode: st ? st.mode & 0o7777 : 0o644 });
|
|
108
|
+
fs.renameSync(tmp, abs);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
111
|
+
throw fail(500, `Could not save: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
const now = fs.statSync(abs);
|
|
114
|
+
return { path: at, mtime: now.mtimeMs, size: now.size };
|
|
115
|
+
}
|
package/server/guests.js
CHANGED
|
@@ -32,6 +32,9 @@ const REMOTE_LOG = 600;
|
|
|
32
32
|
const MAX_THREADS = 300;
|
|
33
33
|
const utcDay = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 10);
|
|
34
34
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
35
|
+
// Handles and project names match exactly, ignoring case only: the relay
|
|
36
|
+
// treats bob-x and bobx as two people, and so must we.
|
|
37
|
+
const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
|
|
35
38
|
|
|
36
39
|
// ---------- what a host may put on our page ----------
|
|
37
40
|
//
|
|
@@ -152,13 +155,13 @@ export class Guests {
|
|
|
152
155
|
this.office = office;
|
|
153
156
|
this.copies = visits;
|
|
154
157
|
this.file = path.join(dataDir, 'guests.json');
|
|
155
|
-
this.used =
|
|
158
|
+
this.used = Object.create(null);
|
|
156
159
|
// Where each of our jobs at a host was asked for (chat thread and
|
|
157
160
|
// channel), and the project last used at each host.
|
|
158
161
|
this.asked = { threads: {}, lastRepo: {} };
|
|
159
162
|
try {
|
|
160
163
|
const saved = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
161
|
-
this.used = saved.used
|
|
164
|
+
this.used = Object.assign(Object.create(null), saved.used);
|
|
162
165
|
this.asked = { threads: saved.asked?.threads || {}, lastRepo: saved.asked?.lastRepo || {} };
|
|
163
166
|
} catch {}
|
|
164
167
|
this.remote = new Map(); // link id -> { link, host, staff, repos, clearance, jobs: Map(host job id -> job) }
|
|
@@ -207,6 +210,17 @@ export class Guests {
|
|
|
207
210
|
return mode;
|
|
208
211
|
}
|
|
209
212
|
|
|
213
|
+
// A job's mode, lowered to what the host allows now.
|
|
214
|
+
lowerMode(p, mode) {
|
|
215
|
+
return CLEARANCE.includes(mode) && CLEARANCE.indexOf(mode) <= CLEARANCE.indexOf(p.clearance) ? mode : p.clearance;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Which of our agents may take the job: only those still lent to the guest.
|
|
219
|
+
lend(job, p) {
|
|
220
|
+
job.pool = p.agents || undefined;
|
|
221
|
+
if (p.agents && job.wantAgent && !p.agents.includes(job.wantAgent)) job.wantAgent = null;
|
|
222
|
+
}
|
|
223
|
+
|
|
210
224
|
checkTokens(link, p) {
|
|
211
225
|
if (this.tokensToday(link.id) >= p.tokensPerDay) throw fail(429, 'You have used today’s tokens for this office. They reset at midnight UTC.');
|
|
212
226
|
}
|
|
@@ -229,12 +243,13 @@ export class Guests {
|
|
|
229
243
|
switch (op) {
|
|
230
244
|
case 'hello':
|
|
231
245
|
return {
|
|
232
|
-
staff: this.office.staff(),
|
|
246
|
+
staff: this.office.staff().filter((s) => !p.agents || p.agents.includes(s.id)),
|
|
233
247
|
modes: Object.fromEntries(Object.entries(this.office.modes()).filter(([k]) => CLEARANCE.indexOf(k) <= CLEARANCE.indexOf(p.clearance))),
|
|
234
248
|
jobs: this.jobsOf(link.id).map((j) => guestJob(j)),
|
|
235
249
|
policy: {
|
|
236
250
|
repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id),
|
|
237
251
|
ourAgents: p.ourAgents !== false, theirAgents: this.copies ? p.theirAgents || 'off' : 'off',
|
|
252
|
+
agents: p.agents || null,
|
|
238
253
|
},
|
|
239
254
|
// We can run a lent agent in one of the guest's own projects (createJob's `home`).
|
|
240
255
|
home: true,
|
|
@@ -258,17 +273,23 @@ export class Guests {
|
|
|
258
273
|
this.checkTokens(link, p);
|
|
259
274
|
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.`);
|
|
260
275
|
const prompt = String(args.prompt ?? '').slice(0, MAX_PROMPT);
|
|
276
|
+
const agentId = typeof args.agentId === 'string' ? args.agentId : '';
|
|
277
|
+
if (p.agents && agentId && !p.agents.includes(agentId)) throw fail(403, 'That agent isn’t lent to you.');
|
|
261
278
|
const job = this.office.createJob(
|
|
262
|
-
{ repo: home ? `${home.repo}@${home.host}` : args.repo, prompt, mode, model: typeof args.model === 'string' ? args.model : '', agentId
|
|
263
|
-
{ from: `@${link.peer}`, guest: { link: link.id }, ...(home && { away: home }) },
|
|
279
|
+
{ repo: home ? `${home.repo}@${home.host}` : args.repo, prompt, mode, model: typeof args.model === 'string' ? args.model : '', agentId },
|
|
280
|
+
{ from: `@${link.peer}`, guest: { link: link.id }, ...(home && { away: home }), ...(p.agents && { pool: p.agents }) },
|
|
264
281
|
);
|
|
265
282
|
return { job: guestJob(job) };
|
|
266
283
|
}
|
|
267
284
|
case 'reply': {
|
|
268
285
|
if (p.ourAgents === false) throw fail(403, 'This office doesn’t lend its agents any more.');
|
|
269
286
|
const job = this.ownJob(link, args.job);
|
|
270
|
-
|
|
287
|
+
// Held to the host's limits as they are now: a job started at Full
|
|
288
|
+
// autonomy, in a project since unshared, doesn't carry on as it was.
|
|
289
|
+
const mode = args.mode === undefined ? this.lowerMode(p, job.mode) : this.capMode(p, args.mode);
|
|
290
|
+
if (!job.away && !p.repos.includes(job.repo)) throw fail(403, 'That project isn’t shared with you any more.');
|
|
271
291
|
this.checkTokens(link, p);
|
|
292
|
+
this.lend(job, p);
|
|
272
293
|
this.office.replyJob(job, { message: String(args.text ?? '').slice(0, MAX_PROMPT), mode });
|
|
273
294
|
return { ok: true };
|
|
274
295
|
}
|
|
@@ -288,17 +309,33 @@ export class Guests {
|
|
|
288
309
|
// host, as an away job's `away`. The relay names both ends of a link, so a
|
|
289
310
|
// guest can only point us at its own office.
|
|
290
311
|
async homeFolder(link, repo) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const back = !e && this.links.links.find((l) => l.kind === 'guest' && l.role === 'guest' && norm(l.peer) === norm(link.peer));
|
|
312
|
+
let e = [...this.remote.values()].find((x) => same(x.host, link.peer));
|
|
313
|
+
const back = !e && this.links.links.find((l) => l.kind === 'guest' && l.role === 'guest' && same(l.peer, link.peer));
|
|
294
314
|
if (back) e = await this.remoteHello(back).catch(() => null);
|
|
295
315
|
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
316
|
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) =>
|
|
317
|
+
const r = e.repos.find((x) => same(x, repo));
|
|
298
318
|
if (!r) throw fail(403, `You haven’t shared ${repo} with @${this.links.status().handle || 'us'}. Add it where we’re your guest.`);
|
|
299
319
|
return { link: e.link, host: e.host, repo: r, access: e.theirAgents };
|
|
300
320
|
}
|
|
301
321
|
|
|
322
|
+
// The host changed a guest's limits: open jobs the new ones rule out stop,
|
|
323
|
+
// and queued ones drop to the clearance and agents now allowed. A running
|
|
324
|
+
// session can't change its mode, so one above the new clearance stops too.
|
|
325
|
+
policyChanged(linkId) {
|
|
326
|
+
const p = this.links.guestPolicy(linkId);
|
|
327
|
+
for (const job of this.jobsOf(linkId, true)) {
|
|
328
|
+
const why = !p || p.ourAgents === false ? 'This office stopped lending its agents.'
|
|
329
|
+
: !job.away && !p.repos.includes(job.repo) ? 'That project isn’t shared with the guest any more.'
|
|
330
|
+
: job.status === 'working' && this.lowerMode(p, job.mode) !== job.mode ? 'The host lowered what this guest’s jobs may do.'
|
|
331
|
+
: job.status === 'working' && p.agents && !p.agents.includes(job.agentId) ? 'That agent isn’t lent to this guest any more.'
|
|
332
|
+
: null;
|
|
333
|
+
if (why) { this.office.stopJob(job, `Stopped: ${why}`); continue; }
|
|
334
|
+
job.mode = this.lowerMode(p, job.mode);
|
|
335
|
+
this.lend(job, p);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
302
339
|
// Tokens a guest job used: counted against its guest, and a guest over its
|
|
303
340
|
// day's allowance has its running jobs stopped.
|
|
304
341
|
addTokens(job, n) {
|
|
@@ -308,11 +345,14 @@ export class Guests {
|
|
|
308
345
|
const u = this.used[linkId]?.day === day ? this.used[linkId] : { day, tokens: 0 };
|
|
309
346
|
u.tokens += n;
|
|
310
347
|
this.used[linkId] = u;
|
|
311
|
-
this.save();
|
|
312
348
|
const p = this.links.guestPolicy(linkId);
|
|
313
349
|
if (p && u.tokens >= p.tokensPerDay) {
|
|
350
|
+
this.save();
|
|
314
351
|
for (const j of this.jobsOf(linkId, true)) this.office.stopJob(j, 'Stopped: the guest used today’s tokens.');
|
|
352
|
+
return;
|
|
315
353
|
}
|
|
354
|
+
// This runs on every API call, so the file is written at most every few seconds.
|
|
355
|
+
if (!this.saveTimer) { this.saveTimer = setTimeout(() => { this.saveTimer = null; this.save(); }, 5000); this.saveTimer.unref?.(); }
|
|
316
356
|
}
|
|
317
357
|
|
|
318
358
|
save() {
|
|
@@ -323,7 +363,7 @@ export class Guests {
|
|
|
323
363
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
324
364
|
const threads = Object.entries(this.asked.threads);
|
|
325
365
|
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 }));
|
|
366
|
+
fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used, asked: this.asked }), { mode: 0o600 });
|
|
327
367
|
fs.renameSync(this.file + '.tmp', this.file);
|
|
328
368
|
} catch (err) { console.error('[guests] could not save', err.message); }
|
|
329
369
|
}
|
|
@@ -611,7 +651,7 @@ export class Guests {
|
|
|
611
651
|
const offices = this.remoteStatus();
|
|
612
652
|
const find = (name, host) => {
|
|
613
653
|
for (const o of offices) {
|
|
614
|
-
if (host &&
|
|
654
|
+
if (host && !same(o.host, host)) continue;
|
|
615
655
|
const s = o.staff.find((x) => x.here && norm(x.name) === norm(name));
|
|
616
656
|
if (s) return { link: o.link, host: o.host, staff: s };
|
|
617
657
|
}
|
|
@@ -636,14 +676,13 @@ export class Guests {
|
|
|
636
676
|
// file tools come back to us over the link where the host is our guest
|
|
637
677
|
// (visits.js), so that link must let their agents into this project.
|
|
638
678
|
lentHome(entry, repo) {
|
|
639
|
-
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
640
679
|
const h = entry.host;
|
|
641
680
|
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' &&
|
|
681
|
+
const back = this.links.links.find((l) => l.kind === 'guest' && l.role === 'host' && same(l.peer, h));
|
|
643
682
|
const p = back && this.links.guestPolicy(back.id);
|
|
644
683
|
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
684
|
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) =>
|
|
685
|
+
const r = p.repos.find((x) => same(x, repo));
|
|
647
686
|
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
687
|
return r;
|
|
649
688
|
}
|
|
@@ -657,11 +696,11 @@ export class Guests {
|
|
|
657
696
|
const tags = [...text.matchAll(/(^|\s)#([\w.-]+)/g)];
|
|
658
697
|
// A project of ours, not theirs: their agent would run in their folder
|
|
659
698
|
// and never see ours. Say so, rather than quietly working somewhere else.
|
|
660
|
-
const ours = tags.find((t) => !repos.some((r) =>
|
|
699
|
+
const ours = tags.find((t) => !repos.some((r) => same(r, t[2])) && this.office.repoPath(t[2]));
|
|
661
700
|
if (ours) return { repo: this.lentHome(entry, ours[2]), home: true, text: text.replace(ours[0], ' ').replace(/[ \t]+/g, ' ').trim() };
|
|
662
701
|
if (repos.length === 1) return { repo: repos[0], text };
|
|
663
702
|
for (const t of tags) {
|
|
664
|
-
const repo = repos.find((r) =>
|
|
703
|
+
const repo = repos.find((r) => same(r, t[2]));
|
|
665
704
|
if (repo) return { repo, text: text.replace(t[0], ' ').replace(/[ \t]+/g, ' ').trim() };
|
|
666
705
|
}
|
|
667
706
|
const said = norm(text);
|
|
@@ -680,10 +719,9 @@ export class Guests {
|
|
|
680
719
|
|
|
681
720
|
// `#web@friend`: a host's project our agents may work in, or null.
|
|
682
721
|
awayFolder(repo, host) {
|
|
683
|
-
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
684
722
|
for (const e of this.remote.values()) {
|
|
685
|
-
if (
|
|
686
|
-
const r = e.repos.find((x) =>
|
|
723
|
+
if (!same(e.host, host) || !['read', 'edit'].includes(e.theirAgents)) continue;
|
|
724
|
+
const r = e.repos.find((x) => same(x, repo));
|
|
687
725
|
if (r) return { link: e.link, host: e.host, repo: r, access: e.theirAgents };
|
|
688
726
|
}
|
|
689
727
|
return null;
|