agent-office-studio 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +13 -1
  3. package/bin/agentoffice.js +6 -1
  4. package/dist/assets/code-Dtdckl8K.js +32 -0
  5. package/dist/assets/index-DxWppRAd.css +1 -0
  6. package/dist/assets/index-Y1a-xw5C.js +5040 -0
  7. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  8. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  9. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  10. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  11. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  12. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  13. package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
  14. package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
  15. package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
  16. package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
  17. package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
  18. package/dist/assets/pdf-BtCWNl9F.js +55 -0
  19. package/dist/assets/pdf-a7kwa8Fi.js +1 -0
  20. package/dist/assets/pdf.worker.min-BJd0dv5W.js +53 -0
  21. package/dist/assets/pdf.worker.min-D_0FwaWY.js +1 -0
  22. package/dist/index.html +32 -26
  23. package/dist/theme-boot.js +13 -0
  24. package/docs/desktop.md +52 -0
  25. package/docs/phone.md +51 -3
  26. package/docs/security.md +8 -3
  27. package/package.json +20 -2
  28. package/server/changes.js +13 -2
  29. package/server/chief.js +17 -0
  30. package/server/cloudflared.js +84 -0
  31. package/server/economy.js +478 -0
  32. package/server/files.js +104 -0
  33. package/server/guests.js +59 -21
  34. package/server/index.js +743 -59
  35. package/server/journeys.js +101 -0
  36. package/server/links.js +11 -4
  37. package/server/machine.js +164 -0
  38. package/server/office-mcp.js +29 -0
  39. package/server/phone.js +70 -0
  40. package/server/procstat.js +12 -6
  41. package/server/settings.js +5 -0
  42. package/server/talk.js +51 -0
  43. package/server/tunnel.js +136 -0
  44. package/server/visits.js +14 -4
  45. package/shared/shop.js +134 -0
  46. package/dist/assets/index-BtHSEs3B.js +0 -4475
  47. package/dist/assets/index-DVuFVmWF.css +0 -1
  48. package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
  49. package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
  50. package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
  51. package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
  52. package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
  53. package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
@@ -0,0 +1,478 @@
1
+ // The office economy: pay, the office pot, the shop and achievements.
2
+ //
3
+ // Agents are paid when a job finishes, by the active minute and scaled by the
4
+ // model that did the work (see shared/shop.js). Of every naira earned, the
5
+ // pot takes its share for the boss to spend on the office; the agent keeps
6
+ // the rest and can spend it on their own desk. The boss can also buy an
7
+ // agent something, out of the pot.
8
+ //
9
+ // Everything lives in one plain object, kept in state.json with the rest of
10
+ // the office, and every change to a balance goes through credit() or charge()
11
+ // and leaves a line in the ledger, so the books always add up.
12
+
13
+ import { PAY, ITEMS, ACHIEVEMENTS, DEFAULTS, SINGLE, itemById, levelOf, streakBoost, TIP, modelPay, modelTier, split, naira } from '../shared/shop.js';
14
+
15
+ const LEDGER_MAX = 400;
16
+ // Weeks start on Monday 00:00 UTC (1970-01-01 was a Thursday).
17
+ const weekStart = (ms) => { const d = Math.floor(ms / 86400000); return (d - ((d + 3) % 7)) * 86400000; };
18
+ // Bets are small and slow on purpose. A stake is locked out of the wallet the
19
+ // moment it is offered, so a wallet can never go below zero.
20
+ export const BET = { minStake: 100, maxStake: 5000, maxOpen: 3, maxMinutes: 60, openMs: 6 * 3600000, liveMs: 24 * 3600000, keep: 40 };
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;
24
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
25
+
26
+ export function emptyBooks() {
27
+ return { wallets: {}, pot: 0, stats: {}, desks: {}, office: [], using: { ...DEFAULTS }, achievements: {}, kudos: [], ledger: [], weekly: {}, totals: { earned: 0, spent: 0 },
28
+ // The team goal ({} when none), the goals already reached, agent-to-agent
29
+ // bets, and the stakes held on them (wallets + pot + escrow = earned - spent).
30
+ goal: {}, goalsDone: [], bets: [], escrow: 0 };
31
+ }
32
+
33
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
34
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
35
+
36
+ export class Economy {
37
+ // `books` is the stored object, filled in where older state lacks a part.
38
+ // `onChange` gets each ledger entry as it happens, to save and broadcast.
39
+ constructor(books, { now = () => Date.now(), onChange = () => {} } = {}) {
40
+ const b = isObj(books) ? books : {};
41
+ const fresh = emptyBooks();
42
+ // typeof says 'object' for null and arrays and 'number' for NaN, so check
43
+ // the real shape of each part, and start that part over if it is damaged.
44
+ for (const k of Object.keys(fresh)) {
45
+ const ok = Array.isArray(fresh[k]) ? Array.isArray(b[k]) : isObj(fresh[k]) ? isObj(b[k]) : isNum(b[k]);
46
+ if (!ok) b[k] = fresh[k];
47
+ }
48
+ for (const id of Object.keys(b.wallets)) if (!isNum(b.wallets[id])) b.wallets[id] = 0;
49
+ // Books from before the running totals: what is held now counts as earned.
50
+ if (!isNum(b.totals.earned) || !isNum(b.totals.spent)) {
51
+ b.totals = { earned: b.pot + Object.values(b.wallets).reduce((a, x) => a + x, 0), spent: 0 };
52
+ }
53
+ b.using = { ...DEFAULTS, ...b.using };
54
+ this.b = b;
55
+ this.now = now;
56
+ this.onChange = onChange;
57
+ }
58
+
59
+ get books() { return this.b; }
60
+ wallet(id) { return this.b.wallets[id] || 0; }
61
+ statsOf(id) {
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 });
63
+ }
64
+
65
+ record(entry) {
66
+ const e = { at: this.now(), ...entry };
67
+ this.b.ledger.push(e);
68
+ if (this.b.ledger.length > LEDGER_MAX) this.b.ledger.splice(0, this.b.ledger.length - LEDGER_MAX);
69
+ this.onChange(e);
70
+ return e;
71
+ }
72
+
73
+ // Money in: split between the agent and the pot.
74
+ credit(agentId, gross, entry) {
75
+ const s = split(gross);
76
+ if (!s.gross) return null;
77
+ this.b.wallets[agentId] = this.wallet(agentId) + s.agent;
78
+ this.b.pot += s.pot;
79
+ this.b.totals.earned += s.gross;
80
+ this.statsOf(agentId).earned += s.gross;
81
+ const e = this.record({ ...entry, agentId, gross: s.gross, agent: s.agent, pot: s.pot });
82
+ this.checkGoal();
83
+ return e;
84
+ }
85
+
86
+ // A turn of work, as it ends: `ms` of model time on `model`. Kept on the
87
+ // job until it finishes, since only a finished job pays.
88
+ accrue(job, ms, model) {
89
+ const min = Math.max(0, Number(ms) || 0) / 60000;
90
+ if (!min) return;
91
+ job.payMinutes = (job.payMinutes || 0) + min;
92
+ job.payWeighted = (job.payWeighted || 0) + min * modelPay(model);
93
+ }
94
+
95
+ // What a finished job earns, without paying it.
96
+ quote(job) {
97
+ const minutes = job.payMinutes || 0;
98
+ const capped = minutes > PAY.maxMinutes ? (job.payWeighted || 0) * (PAY.maxMinutes / minutes) : job.payWeighted || 0;
99
+ const mult = modelPay(job.model);
100
+ return { minutes, tier: modelTier(job.model), mult, wage: capped * PAY.perMinute, bonus: PAY.finishBonus * mult };
101
+ }
102
+
103
+ // A job finished. Pays it, counts it, and checks for milestones.
104
+ pay(job) {
105
+ if (!job?.agentId) return null;
106
+ const q = this.quote(job);
107
+ job.payMinutes = 0;
108
+ job.payWeighted = 0;
109
+ // A reply can reopen a finished job. The new minutes are paid, but the
110
+ // finish bonus, the job count and the milestones are for the first finish.
111
+ const again = !!job.payCounted;
112
+ job.payCounted = true;
113
+ const s = this.statsOf(job.agentId);
114
+ if (again) { job.reopens = (job.reopens || 0) + 1; s.comebacks = (s.comebacks || 0) + 1; }
115
+ let boost = 0;
116
+ if (!again) {
117
+ // The day's first finish keeps or restarts the streak, and the
118
+ // streak lifts this job's wage.
119
+ const today = day(this.now());
120
+ if (s.lastDay !== today) {
121
+ const yesterday = day(this.now() - 86400000);
122
+ s.streak = s.lastDay === yesterday ? s.streak + 1 : 1;
123
+ s.lastDay = today;
124
+ }
125
+ boost = streakBoost(s.streak);
126
+ }
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 } : {}) });
128
+ if (e) job.earned = (job.earned || 0) + e.gross;
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));
134
+ // A bet that cannot be settled cleanly must never hold up the pay.
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
+ }
144
+ s.jobs++;
145
+ if (q.tier) s.byTier[q.tier] = (s.byTier[q.tier] || 0) + 1;
146
+ const won = this.milestones(job.agentId, { ...q, quick });
147
+ return { pay: e, achievements: won };
148
+ }
149
+
150
+ // Unlocked milestones pay their bonus once each.
151
+ milestones(agentId, job) {
152
+ const have = (this.b.achievements[agentId] ||= []);
153
+ const won = [];
154
+ // Twice round, since a bonus can push `earned` past the next milestone.
155
+ for (let pass = 0; pass < 2; pass++) {
156
+ for (const a of ACHIEVEMENTS) {
157
+ if (have.includes(a.id) || !a.test(this.statsOf(agentId), job)) continue;
158
+ have.push(a.id);
159
+ won.push(a);
160
+ this.credit(agentId, a.bonus, { kind: 'achievement', achievement: a.id, title: a.name });
161
+ }
162
+ }
163
+ return won;
164
+ }
165
+
166
+ // The boss liked a finished job: its finish bonus again, once.
167
+ kudos(job) {
168
+ if (!job || job.status !== 'done' || job.awaitingBoss) throw new Error('Only a finished job can get kudos.');
169
+ if (job.guest) throw new Error('That job belongs to another office.');
170
+ if (job.kudosPaid || this.b.kudos.includes(job.id)) throw new Error('That job already got kudos.');
171
+ job.kudosPaid = true;
172
+ this.b.kudos.push(job.id);
173
+ if (this.b.kudos.length > 2000) this.b.kudos.splice(0, 1000);
174
+ const ks = this.statsOf(job.agentId);
175
+ ks.kudos++;
176
+ if (!job.reopens) ks.cleanKudos = (ks.cleanKudos || 0) + 1;
177
+ const e = this.credit(job.agentId, PAY.finishBonus * modelPay(job.model), { kind: 'kudos', jobId: job.id, title: job.title });
178
+ return { pay: e, achievements: this.milestones(job.agentId) };
179
+ }
180
+
181
+ owns(item, agentId) {
182
+ if (item.price === 0 && SINGLE.has(item.scope)) return true;
183
+ if (item.scope === 'desk') return (this.b.desks[agentId] || []).includes(item.id);
184
+ return this.b.office.includes(item.id);
185
+ }
186
+
187
+ // `by` is 'boss' or an agent's id; `forAgent` is whose desk a desk item
188
+ // goes on. Agents pay from their own wallet and only for their own desk;
189
+ // the boss pays from the pot.
190
+ buy(itemId, { by, forAgent } = {}) {
191
+ const item = itemById(itemId);
192
+ if (!item) throw new Error(`Nothing in the shop is called "${itemId}".`);
193
+ const boss = by === 'boss';
194
+ if (!boss && item.scope !== 'desk') throw new Error(`${item.name} is for the office, and only the boss can buy those, out of the pot.`);
195
+ const owner = item.scope === 'desk' ? (boss ? forAgent : by) : null;
196
+ if (item.scope === 'desk' && !owner) throw new Error('Say whose desk it is for.');
197
+ if (this.owns(item, owner)) throw new Error(owner ? `${item.name} is already on that desk.` : `The office already has ${item.name}.`);
198
+ const balance = boss ? this.b.pot : this.wallet(by);
199
+ if (balance < item.price) throw new Error(`${item.name} costs ${naira(item.price)} and ${boss ? 'the pot has' : 'you have'} ${naira(balance)}.`);
200
+ if (boss) this.b.pot -= item.price;
201
+ else this.b.wallets[by] = balance - item.price;
202
+ this.b.totals.spent += item.price;
203
+ if (owner) (this.b.desks[owner] ||= []).push(item.id);
204
+ else this.b.office.push(item.id);
205
+ // Buying a new look puts it straight into use.
206
+ if (SINGLE.has(item.scope)) this.b.using[item.scope] = item.id;
207
+ const e = this.record({ kind: 'buy', item: item.id, title: item.name, price: item.price, by, agentId: owner || undefined, from: boss ? 'pot' : 'wallet' });
208
+ let won = [];
209
+ if (owner && !boss) {
210
+ this.statsOf(owner).bought++;
211
+ won = this.milestones(owner);
212
+ }
213
+ return { buy: e, achievements: won };
214
+ }
215
+
216
+ // The boss tips an agent out of the pot. A plain transfer: it is not
217
+ // earned, so it neither splits nor counts toward totals.
218
+ tip(agentId, amount) {
219
+ const n = Math.floor(Number(amount));
220
+ if (!agentId) throw new Error('Say who the tip is for.');
221
+ if (!(n >= TIP.min && n <= TIP.max)) throw new Error(`A tip is between ${naira(TIP.min)} and ${naira(TIP.max)}.`);
222
+ if (this.b.pot < n) throw new Error(`The pot has ${naira(this.b.pot)}.`);
223
+ this.b.pot -= n;
224
+ this.b.wallets[agentId] = this.wallet(agentId) + n;
225
+ return this.record({ kind: 'tip', agentId, amount: n, by: 'boss', title: 'Tip from the boss' });
226
+ }
227
+
228
+ // Title and streak, per agent. A streak only counts while it is alive:
229
+ // finished a job today or yesterday.
230
+ perks(id) {
231
+ const st = this.b.stats[id] || {};
232
+ const live = st.lastDay === day(this.now()) || st.lastDay === day(this.now() - 86400000);
233
+ const streak = live ? st.streak || 0 : 0;
234
+ return { ...levelOf(st.earned || 0), streak, boost: streakBoost(streak) };
235
+ }
236
+
237
+ // Switch to a floor, wall or layout the office owns.
238
+ use(itemId) {
239
+ const item = itemById(itemId);
240
+ if (!item || !SINGLE.has(item.scope)) throw new Error('Only a floor, wall or layout can be put into use.');
241
+ if (!this.owns(item)) throw new Error(`The office doesn't own ${item.name} yet.`);
242
+ this.b.using[item.scope] = item.id;
243
+ this.record({ kind: 'use', item: item.id, title: item.name, by: 'boss' });
244
+ return this.b.using;
245
+ }
246
+
247
+ // Desk items an agent could buy right now.
248
+ affordable(agentId) {
249
+ return ITEMS.filter((i) => i.scope === 'desk' && i.price <= this.wallet(agentId) && !this.owns(i, agentId));
250
+ }
251
+
252
+ // The rank each agent held when the week began, kept in the books so a
253
+ // restart doesn't lose it. Older state has none: the first look takes one,
254
+ // and from then on it rolls over every Monday.
255
+ rollWeek() {
256
+ const w = this.b.weekly;
257
+ const start = weekStart(this.now());
258
+ const ok = isNum(w.start) && isObj(w.ranks);
259
+ if (ok && w.start >= start) return w;
260
+ const ranks = {};
261
+ this.richList().forEach((r, i) => { ranks[r.id] = i + 1; });
262
+ // The very first snapshot has nothing to compare against, so it is
263
+ // marked `first` and shows no movement until a week has passed.
264
+ this.b.weekly = { start, ranks, first: !ok };
265
+ return this.b.weekly;
266
+ }
267
+
268
+ // Net worth is what an agent holds plus what they have bought for their
269
+ // desk, at shop price. Ties go to whoever has earned more over their time.
270
+ richList() {
271
+ const b = this.b;
272
+ const ids = new Set([...Object.keys(b.wallets), ...Object.keys(b.desks), ...Object.keys(b.stats)]);
273
+ return [...ids].map((id) => {
274
+ const wallet = this.wallet(id);
275
+ const desk = (b.desks[id] || []).reduce((a, x) => a + (itemById(x)?.price || 0), 0);
276
+ const st = b.stats[id] || {};
277
+ const locked = this.staked(id);
278
+ return { id, worth: wallet + desk + locked, wallet, desk, earned: st.earned || 0, jobs: st.jobs || 0 };
279
+ }).sort((a, c) => c.worth - a.worth || c.earned - a.earned || a.id.localeCompare(c.id));
280
+ }
281
+
282
+ // The rich list with the week's movement: `moved` is places climbed since
283
+ // the week began (negative for places lost), `rival` the agents just above
284
+ // and below with the gap in naira, and `comeback` marks whoever climbed
285
+ // most (ties go to the higher rank; nobody if nobody climbed). Agents who
286
+ // joined the list this week, and the whole list in the first week, have
287
+ // `moved: null`.
288
+ weekly() {
289
+ const w = this.rollWeek();
290
+ const list = this.richList();
291
+ const rows = list.map((r, i) => {
292
+ const was = !w.first && isNum(w.ranks[r.id]) ? w.ranks[r.id] : null;
293
+ const above = list[i - 1], below = list[i + 1];
294
+ return {
295
+ ...r, rank: i + 1, was, moved: was === null ? null : was - (i + 1),
296
+ rival: { above: above ? { id: above.id, gap: above.worth - r.worth } : null, below: below ? { id: below.id, gap: r.worth - below.worth } : null },
297
+ comeback: false,
298
+ };
299
+ });
300
+ const best = rows.reduce((m, r) => (r.moved > (m?.moved || 0) ? r : m), null);
301
+ if (best) best.comeback = true;
302
+ return { since: w.start, first: !!w.first, rows };
303
+ }
304
+
305
+ // ---------- team goal ----------
306
+
307
+ // The boss names a target for the pot ("pool party", ₦5,000,000). Reaching
308
+ // it is celebrated once; the goal then stays as reached until replaced.
309
+ setGoal({ name, target } = {}) {
310
+ name = String(name || '').replace(/\s+/g, ' ').trim().slice(0, GOAL.maxName);
311
+ target = Math.round(Number(target));
312
+ if (!name) throw new Error('Give the goal a name.');
313
+ if (!Number.isFinite(target) || target < GOAL.minTarget) throw new Error(`The target must be at least ${naira(GOAL.minTarget)}.`);
314
+ if (target > GOAL.maxTarget) throw new Error(`The target can be at most ${naira(GOAL.maxTarget)}.`);
315
+ if (target <= this.b.pot) throw new Error(`The pot already has ${naira(this.b.pot)}. Aim higher than that.`);
316
+ this.b.goal = { name, target, setAt: this.now(), reachedAt: null };
317
+ this.record({ kind: 'goal', phase: 'set', title: name, target, by: 'boss' });
318
+ return this.goalStatus();
319
+ }
320
+
321
+ clearGoal() {
322
+ if (!this.b.goal.target) return this.goalStatus();
323
+ const was = this.b.goal;
324
+ this.b.goal = {};
325
+ this.record({ kind: 'goal', phase: 'cleared', title: was.name, target: was.target, by: 'boss' });
326
+ return this.goalStatus();
327
+ }
328
+
329
+ // Called whenever the pot grows. Fires once per goal: reachedAt is the latch,
330
+ // and it survives the pot being spent down afterwards.
331
+ checkGoal() {
332
+ const g = this.b.goal;
333
+ if (!g.target || g.reachedAt || this.b.pot < g.target) return null;
334
+ g.reachedAt = this.now();
335
+ this.b.goalsDone.push({ name: g.name, target: g.target, reachedAt: g.reachedAt });
336
+ if (this.b.goalsDone.length > 20) this.b.goalsDone.splice(0, this.b.goalsDone.length - 20);
337
+ return this.record({ kind: 'goal', phase: 'reached', title: g.name, target: g.target, pot: this.b.pot });
338
+ }
339
+
340
+ goalStatus() {
341
+ const g = this.b.goal;
342
+ if (!g.target) return null;
343
+ const pot = this.b.pot;
344
+ return { name: g.name, target: g.target, setAt: g.setAt, reachedAt: g.reachedAt || null, pot, progress: g.reachedAt ? 1 : Math.min(1, pot / g.target), done: this.b.goalsDone.length };
345
+ }
346
+
347
+ // ---------- bets ----------
348
+ //
349
+ // One agent (the backer) says another agent's next job will finish in under
350
+ // N active minutes and stakes naira on it; a second agent (the doubter) takes
351
+ // the other side with a matching stake. Both stakes are locked in escrow, and
352
+ // when the job first finishes the winner gets both. Nobody can be the subject
353
+ // of their own bet, so nobody gains by dragging a job out or rushing it for a
354
+ // bet. Money only ever moves wallet -> escrow -> wallet, never through the
355
+ // pot, so earned and spent do not change.
356
+
357
+ staked(agentId) {
358
+ return this.b.bets.reduce((a, x) => a + (x.state === 'open' && x.by === agentId ? x.stake : x.state === 'live' && (x.by === agentId || x.against === agentId) ? x.stake : 0), 0);
359
+ }
360
+
361
+ // Wallet -> escrow. Never lets a wallet go below zero.
362
+ charge(agentId, amount, entry) {
363
+ if (!(amount > 0) || this.wallet(agentId) < amount) throw new Error(`${naira(amount)} is more than ${agentId} has (${naira(this.wallet(agentId))}).`);
364
+ this.b.wallets[agentId] = this.wallet(agentId) - amount;
365
+ this.b.escrow += amount;
366
+ return this.record({ ...entry, agentId, amount: -amount });
367
+ }
368
+
369
+ // Escrow -> wallet.
370
+ release(agentId, amount, entry) {
371
+ if (!(amount > 0)) return null;
372
+ if (this.b.escrow < amount) throw new Error('The escrow is short; refusing to pay out more than is held.');
373
+ this.b.escrow -= amount;
374
+ this.b.wallets[agentId] = this.wallet(agentId) + amount;
375
+ return this.record({ ...entry, agentId, amount });
376
+ }
377
+
378
+ offerBet({ by, subject, minutes, stake } = {}) {
379
+ this.sweep();
380
+ minutes = Math.round(Number(minutes));
381
+ stake = Math.round(Number(stake));
382
+ if (!by || !subject) throw new Error('Say who is betting and on whom.');
383
+ if (by === subject) throw new Error('You cannot bet on your own job.');
384
+ if (!Number.isFinite(minutes) || minutes < 1 || minutes > BET.maxMinutes) throw new Error(`Pick a time between 1 and ${BET.maxMinutes} minutes.`);
385
+ if (!Number.isFinite(stake) || stake < BET.minStake || stake > BET.maxStake) throw new Error(`A stake is between ${naira(BET.minStake)} and ${naira(BET.maxStake)}.`);
386
+ const mine = this.b.bets.filter((x) => (x.state === 'open' || x.state === 'live') && (x.by === by || x.against === by)).length;
387
+ if (mine >= BET.maxOpen) throw new Error(`You already have ${BET.maxOpen} bets going. Wait for one to settle.`);
388
+ const bet = { id: 'b' + this.now().toString(36) + Math.random().toString(36).slice(2, 6), by, subject, minutes, stake, against: null, state: 'open', createdAt: this.now() };
389
+ this.charge(by, stake, { kind: 'bet', phase: 'stake', betId: bet.id, subject, minutes });
390
+ this.b.bets.push(bet);
391
+ return bet;
392
+ }
393
+
394
+ takeBet(id, { by } = {}) {
395
+ this.sweep();
396
+ const bet = this.b.bets.find((x) => x.id === id);
397
+ if (!bet || bet.state !== 'open') throw new Error('That bet is not open.');
398
+ if (by === bet.by) throw new Error('That is your own bet.');
399
+ if (by === bet.subject) throw new Error('You cannot bet against your own job.');
400
+ const mine = this.b.bets.filter((x) => (x.state === 'open' || x.state === 'live') && (x.by === by || x.against === by)).length;
401
+ if (mine >= BET.maxOpen) throw new Error(`You already have ${BET.maxOpen} bets going. Wait for one to settle.`);
402
+ this.charge(by, bet.stake, { kind: 'bet', phase: 'stake', betId: bet.id, subject: bet.subject, minutes: bet.minutes });
403
+ Object.assign(bet, { against: by, state: 'live', acceptedAt: this.now() });
404
+ return bet;
405
+ }
406
+
407
+ // The backer (or the boss) takes back an offer nobody has taken.
408
+ cancelBet(id, { by } = {}) {
409
+ const bet = this.b.bets.find((x) => x.id === id);
410
+ if (!bet || bet.state !== 'open') throw new Error('That bet is not open.');
411
+ if (by !== 'boss' && by !== bet.by) throw new Error('Only whoever offered it can take it back.');
412
+ return this.close(bet, 'void', 'taken back');
413
+ }
414
+
415
+ // Ends a bet and moves the money. Winner takes both stakes; a void gives
416
+ // each their own back. Idempotent: a bet that is closed stays closed.
417
+ close(bet, state, why, winner) {
418
+ if (bet.state !== 'open' && bet.state !== 'live') return bet;
419
+ const parties = [bet.by, bet.against].filter(Boolean);
420
+ if (this.b.escrow < bet.stake * (state === 'void' ? parties.length : 2)) throw new Error('The escrow is short; refusing to move any of it.');
421
+ const entry = { kind: 'bet', phase: state, betId: bet.id, subject: bet.subject, minutes: bet.minutes, stake: bet.stake, why };
422
+ if (state === 'void') for (const id of parties) this.release(id, bet.stake, { ...entry });
423
+ else this.release(winner, bet.stake * 2, { ...entry, winner, loser: parties.find((x) => x !== winner) });
424
+ Object.assign(bet, { state, why, winner: winner || null, settledAt: this.now() });
425
+ const old = this.b.bets.filter((x) => x.state !== 'open' && x.state !== 'live');
426
+ if (old.length > BET.keep) for (const x of old.slice(0, old.length - BET.keep)) this.b.bets.splice(this.b.bets.indexOf(x), 1);
427
+ return bet;
428
+ }
429
+
430
+ // A bet belongs to the subject's first job that started after it was taken.
431
+ boundTo(bet, job) {
432
+ return bet.state === 'live' && bet.subject === job.agentId && Number.isFinite(job.startedAt) && bet.acceptedAt <= job.startedAt;
433
+ }
434
+
435
+ inBet(bet, id) {
436
+ return !!id && (id === bet.by || id === bet.against);
437
+ }
438
+
439
+ // The job finished for the first time, in `minutes` of active work.
440
+ settleBets(job, minutes) {
441
+ this.sweep();
442
+ for (const bet of this.b.bets.filter((x) => this.boundTo(x, job))) {
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; }
446
+ const under = minutes < bet.minutes;
447
+ this.close(bet, under ? 'won' : 'lost', `${Math.round(minutes * 10) / 10} min on "${job.title}"`, under ? bet.by : bet.against);
448
+ }
449
+ }
450
+
451
+ // The job did not finish (failed, stopped, cancelled): stakes come back.
452
+ voidBets(job, why = 'the job did not finish') {
453
+ for (const bet of this.b.bets.filter((x) => this.boundTo(x, job))) this.close(bet, 'void', why);
454
+ }
455
+
456
+ // Bets nobody took, or that never settled, run out and refund.
457
+ sweep() {
458
+ const t = this.now();
459
+ for (const bet of this.b.bets.slice()) {
460
+ if (bet.state === 'open' && t - bet.createdAt > BET.openMs) this.close(bet, 'void', 'nobody took it');
461
+ else if (bet.state === 'live' && t - bet.acceptedAt > BET.liveMs) this.close(bet, 'void', 'the job never finished');
462
+ }
463
+ }
464
+
465
+ betsStatus() {
466
+ return this.b.bets.slice(-BET.keep - 12);
467
+ }
468
+
469
+ status() {
470
+ const wk = this.weekly();
471
+ const b = this.b;
472
+ return {
473
+ rich: wk.rows.map((r) => ({ ...r, ...this.perks(r.id) })), goal: this.goalStatus(), bets: this.betsStatus(), escrow: b.escrow, week: { since: wk.since, first: wk.first }, pay: { perMinute: PAY.perMinute, finishBonus: PAY.finishBonus },
474
+ pot: b.pot, wallets: b.wallets, stats: b.stats, desks: b.desks, office: b.office, using: b.using,
475
+ achievements: b.achievements, kudos: b.kudos.slice(-200), ledger: b.ledger.slice(-60),
476
+ };
477
+ }
478
+ }
@@ -0,0 +1,104 @@
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
+ // `mtime` is what read() gave; null means the file must not exist yet.
81
+ export function write(root, rel, text, mtime) {
82
+ if (typeof text !== 'string') throw fail(400, 'Nothing to save.');
83
+ if (Buffer.byteLength(text) > MAX_BYTES) throw fail(413, `Too big to save here (the limit is ${MAX_BYTES / 1024} KB).`);
84
+ const { abs, rel: at } = resolve(root, rel, { mustExist: false });
85
+ if (!at) throw fail(400, 'Which file?');
86
+ let st = null;
87
+ try { st = fs.statSync(abs); } catch {}
88
+ if (st?.isDirectory()) throw fail(400, 'That is a folder.');
89
+ if (st && mtime !== st.mtimeMs) throw fail(409, 'This file changed on disk since you opened it. Reload it to see what changed.');
90
+ if (!st && mtime != null) throw fail(409, 'This file was deleted since you opened it.');
91
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
92
+ // Written beside it and renamed, so a crash never leaves half a file, and
93
+ // the file keeps its permissions.
94
+ const tmp = `${abs}.${process.pid}.${Date.now()}.tmp`;
95
+ try {
96
+ fs.writeFileSync(tmp, text, { mode: st ? st.mode & 0o7777 : 0o644 });
97
+ fs.renameSync(tmp, abs);
98
+ } catch (err) {
99
+ try { fs.unlinkSync(tmp); } catch {}
100
+ throw fail(500, `Could not save: ${err.message}`);
101
+ }
102
+ const now = fs.statSync(abs);
103
+ return { path: at, mtime: now.mtimeMs, size: now.size };
104
+ }