agent-office-studio 0.6.0 → 0.8.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 +18 -1
- package/README.md +15 -1
- package/bin/agentoffice.js +6 -1
- package/dist/assets/index-BAvX7Imu.css +1 -0
- package/dist/assets/index-C01GpSoa.js +4999 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
- package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
- package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
- package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
- package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
- package/dist/index.html +11 -2
- package/docs/linked-offices.md +11 -0
- package/docs/phone.md +51 -3
- package/docs/security.md +8 -3
- package/package.json +4 -2
- package/server/cloudflared.js +84 -0
- package/server/economy.js +455 -0
- package/server/guests.js +67 -13
- package/server/index.js +371 -21
- package/server/journeys.js +101 -0
- package/server/office-mcp.js +19 -0
- package/server/phone.js +70 -0
- package/server/settings.js +3 -0
- package/server/talk.js +51 -0
- package/server/tunnel.js +136 -0
- package/shared/shop.js +127 -0
- package/dist/assets/index-B9ZRfVrF.js +0 -4475
- package/dist/assets/index-BiK6nglq.css +0 -1
- package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// User journey maps, one per project: the routes a person takes through the
|
|
2
|
+
// app, as steps and the arrows between them. An agent reads the project and
|
|
3
|
+
// writes the map; the browser draws it as a flow canvas.
|
|
4
|
+
//
|
|
5
|
+
// Maps live in the office's data folder, not the project, so mapping never
|
|
6
|
+
// leaves a file in someone's repo. Whatever the agent wrote is cut down to
|
|
7
|
+
// the shape below before the browser sees it.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const KINDS = ['entry', 'screen', 'action', 'decision', 'system', 'end'];
|
|
13
|
+
const MAX_JOURNEYS = 24;
|
|
14
|
+
const MAX_STEPS = 60;
|
|
15
|
+
const MAX_EDGES = 120;
|
|
16
|
+
const ID_RE = /^[A-Za-z0-9_.-]{1,48}$/;
|
|
17
|
+
|
|
18
|
+
const text = (v, n) => (typeof v === 'string' ? v.trim().slice(0, n) : '');
|
|
19
|
+
|
|
20
|
+
export class Journeys {
|
|
21
|
+
constructor(dataDir) {
|
|
22
|
+
this.dir = path.join(dataDir, 'journeys');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
file(repo) {
|
|
26
|
+
return path.join(this.dir, `${repo}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ensureDir() {
|
|
30
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
31
|
+
return this.dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
read(repo) {
|
|
35
|
+
let raw;
|
|
36
|
+
try { raw = JSON.parse(fs.readFileSync(this.file(repo), 'utf8')); } catch { return null; }
|
|
37
|
+
let at = 0;
|
|
38
|
+
try { at = fs.statSync(this.file(repo)).mtimeMs; } catch {}
|
|
39
|
+
return clean(raw, at);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// What the mapping agent is told. It writes one file and touches nothing else.
|
|
43
|
+
brief(repo) {
|
|
44
|
+
const file = this.file(repo);
|
|
45
|
+
return [
|
|
46
|
+
`Map the user journeys of ${repo}.`,
|
|
47
|
+
'',
|
|
48
|
+
'Read the project to find every route a person takes through it: signing up, the main tasks, settings, payments, errors and dead ends. ' +
|
|
49
|
+
'Use the code (routes, screens, handlers, API calls) as the source of truth, not the README alone. Do not change any file in the project.',
|
|
50
|
+
'',
|
|
51
|
+
`Write the map as JSON to ${file} (create it, or replace the old one). That is the only file you write. Shape:`,
|
|
52
|
+
'',
|
|
53
|
+
'{ "summary": "one line on what the product is",',
|
|
54
|
+
' "journeys": [ { "id": "signup", "name": "Sign up", "summary": "what the person is trying to do",',
|
|
55
|
+
' "steps": [ { "id": "s1", "kind": "entry", "label": "Lands on home", "detail": "one or two lines", "where": "src/pages/Home.tsx" } ],',
|
|
56
|
+
' "edges": [ { "from": "s1", "to": "s2", "label": "optional, e.g. \\"valid\\" or \\"no account\\"" } ] } ] }',
|
|
57
|
+
'',
|
|
58
|
+
`kind is one of: ${KINDS.join(', ')}. entry is where the journey starts; screen a page or view; action something the person does; ` +
|
|
59
|
+
'decision a branch (label its edges); system work the app does out of sight (an API call, an email, a job); end where the journey finishes, good or bad.',
|
|
60
|
+
'Keep labels short (under 40 characters). Ids are letters, digits, dashes. Order journeys by how often a person would take them. ' +
|
|
61
|
+
`At most ${MAX_JOURNEYS} journeys and ${MAX_STEPS} steps each. If the project has no user-facing flow (a library, a CLI), map how a developer uses it instead.`,
|
|
62
|
+
'',
|
|
63
|
+
'When the file is written, reply with one line per journey.',
|
|
64
|
+
].join('\n');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Only the shape the canvas draws gets through: known kinds, short strings,
|
|
69
|
+
// edges between steps that exist.
|
|
70
|
+
function clean(raw, at) {
|
|
71
|
+
if (!raw || !Array.isArray(raw.journeys)) return null;
|
|
72
|
+
const journeys = [];
|
|
73
|
+
for (const j of raw.journeys.slice(0, MAX_JOURNEYS)) {
|
|
74
|
+
if (!j || !Array.isArray(j.steps)) continue;
|
|
75
|
+
const steps = [];
|
|
76
|
+
const ids = new Set();
|
|
77
|
+
for (const s of j.steps.slice(0, MAX_STEPS)) {
|
|
78
|
+
const id = typeof s?.id === 'string' && ID_RE.test(s.id) ? s.id : null;
|
|
79
|
+
if (!id || ids.has(id)) continue;
|
|
80
|
+
ids.add(id);
|
|
81
|
+
steps.push({
|
|
82
|
+
id,
|
|
83
|
+
kind: KINDS.includes(s.kind) ? s.kind : 'screen',
|
|
84
|
+
label: text(s.label, 60) || id,
|
|
85
|
+
detail: text(s.detail, 400),
|
|
86
|
+
where: text(s.where, 200),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (!steps.length) continue;
|
|
90
|
+
const edges = (Array.isArray(j.edges) ? j.edges : []).slice(0, MAX_EDGES)
|
|
91
|
+
.filter((e) => ids.has(e?.from) && ids.has(e?.to) && e.from !== e.to)
|
|
92
|
+
.map((e) => ({ from: e.from, to: e.to, label: text(e.label, 30) }));
|
|
93
|
+
journeys.push({
|
|
94
|
+
id: typeof j.id === 'string' && ID_RE.test(j.id) ? j.id : `j${journeys.length + 1}`,
|
|
95
|
+
name: text(j.name, 60) || `Journey ${journeys.length + 1}`,
|
|
96
|
+
summary: text(j.summary, 300),
|
|
97
|
+
steps, edges,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return { summary: text(raw.summary, 300), updatedAt: at, journeys };
|
|
101
|
+
}
|
package/server/office-mcp.js
CHANGED
|
@@ -35,6 +35,11 @@ const TOOLS = [
|
|
|
35
35
|
description: 'Surface something in the main chat for the boss: a question, decision, blocker, or work ready to review. Not for routine progress. One or two self-contained sentences.',
|
|
36
36
|
inputSchema: obj({ kind: { type: 'string', enum: ['question', 'decision', 'blocker', 'review'] }, summary: { type: 'string' } }, ['kind', 'summary']),
|
|
37
37
|
},
|
|
38
|
+
{
|
|
39
|
+
name: 'attach_files',
|
|
40
|
+
description: 'Attach images or files (screenshots, say) to your next message to the boss: your final reply, a flag_for_boss, or a post_to_channel. Absolute paths, up to 6, 25 MB each. Nothing is sent by this call itself.',
|
|
41
|
+
inputSchema: obj({ paths: names }, ['paths']),
|
|
42
|
+
},
|
|
38
43
|
{
|
|
39
44
|
name: 'restart_office',
|
|
40
45
|
description: 'Restart the office server so changes to its own code (agent-office/server) take effect. Waits until nobody is mid-turn, so end your turn right after; you are messaged when it is back. Not needed for web changes: a build is enough.',
|
|
@@ -45,6 +50,20 @@ const TOOLS = [
|
|
|
45
50
|
description: 'Drop this session\'s history to cut token use, at a natural break between steps. Give notes to yourself: what is done, what is left, what you learned (files, decisions, gotchas). End your turn right after; you resume fresh from the brief and your notes. Not for the end of a job.',
|
|
46
51
|
inputSchema: obj({ notes: { type: 'string' } }, ['notes']),
|
|
47
52
|
},
|
|
53
|
+
{ name: 'office_shop', description: 'Your pay so far, how pay works, and what you can buy for your desk.', inputSchema: NONE },
|
|
54
|
+
{
|
|
55
|
+
name: 'buy_item',
|
|
56
|
+
description: 'Buy something for your own desk with your pay, by its id from office_shop. Only once your work is done or between steps; it is your money.',
|
|
57
|
+
inputSchema: obj({ item: { type: 'string' } }, ['item']),
|
|
58
|
+
},
|
|
59
|
+
{ name: 'bets', description: 'Open and live bets between colleagues, your wallet, and the stake limits.', inputSchema: NONE },
|
|
60
|
+
{
|
|
61
|
+
name: 'place_bet',
|
|
62
|
+
description: 'Stake your own naira that a colleague\'s next job finishes in under N active minutes. Held until someone takes the other side; the winner gets both stakes. Not on your own job.',
|
|
63
|
+
inputSchema: obj({ on: { type: 'string', description: 'Colleague id or name' }, minutes: { type: 'number' }, stake: { type: 'number' } }, ['on', 'minutes', 'stake']),
|
|
64
|
+
},
|
|
65
|
+
{ name: 'take_bet', description: 'Take the other side of an open bet (it takes N minutes or more) with a matching stake. Ids are in bets.', inputSchema: obj({ id: { type: 'string' } }, ['id']) },
|
|
66
|
+
{ name: 'cancel_bet', description: 'Take back a bet you offered that nobody has taken; your stake comes back.', inputSchema: obj({ id: { type: 'string' } }, ['id']) },
|
|
48
67
|
{ name: 'list_channels', description: 'List chat channels: purpose, members, and which one your work is in.', inputSchema: NONE },
|
|
49
68
|
{
|
|
50
69
|
name: 'create_channel',
|
package/server/phone.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The phone link: where a phone can reach this office, and a QR code for it.
|
|
2
|
+
//
|
|
3
|
+
// By default the office listens on 127.0.0.1 only, so there is nothing for a
|
|
4
|
+
// phone to reach. Two ways in, both opt-in:
|
|
5
|
+
// - OFFICE_ALLOWED_HOSTS names a host that fronts the office (Tailscale);
|
|
6
|
+
// the link points at that name.
|
|
7
|
+
// - OFFICE_LAN=1 listens on the local network too, and the link points at
|
|
8
|
+
// this machine's private addresses. Plain http, so anyone else on that
|
|
9
|
+
// Wi-Fi who sees the key can open the office: home networks only.
|
|
10
|
+
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import qrcode from 'qrcode-generator';
|
|
13
|
+
|
|
14
|
+
// Private IPv4 addresses (10/8, 172.16/12, 192.168/16), the only ones we
|
|
15
|
+
// would put a key behind. Link-local, CGNAT and public ones are skipped.
|
|
16
|
+
export function lanAddresses(ifaces = os.networkInterfaces()) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const list of Object.values(ifaces)) {
|
|
19
|
+
for (const a of list || []) {
|
|
20
|
+
if (a.family !== 'IPv4' || a.internal) continue;
|
|
21
|
+
const [p, q] = a.address.split('.').map(Number);
|
|
22
|
+
if (p === 10 || (p === 172 && q >= 16 && q <= 31) || (p === 192 && q === 168)) out.push(a.address);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The links a phone could use, best first: named hosts over https, then LAN.
|
|
29
|
+
export function phoneLinks({ key, port, lan, hosts = [], addresses = lanAddresses() }) {
|
|
30
|
+
const links = hosts
|
|
31
|
+
.filter((h) => !['localhost', '127.0.0.1', '[::1]'].includes(h))
|
|
32
|
+
.map((h) => ({ kind: 'host', label: h, url: `https://${h}/?key=${key}` }));
|
|
33
|
+
if (lan) for (const a of addresses) links.push({ kind: 'lan', label: `${a}:${port}`, url: `http://${a}:${port}/?key=${key}` });
|
|
34
|
+
return links;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const code = (text) => {
|
|
38
|
+
const qr = qrcode(0, 'M'); // type 0: smallest version that fits
|
|
39
|
+
qr.addData(text);
|
|
40
|
+
qr.make();
|
|
41
|
+
return qr;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// An SVG with a quiet zone, which scanners need. Dark on white whatever the
|
|
45
|
+
// theme: they read contrast, not colours.
|
|
46
|
+
export function qrSvg(text) {
|
|
47
|
+
const qr = code(text), n = qr.getModuleCount(), q = 4;
|
|
48
|
+
let d = '';
|
|
49
|
+
for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (qr.isDark(r, c)) d += `M${c + q} ${r + q}h1v1h-1z`;
|
|
50
|
+
const s = n + q * 2;
|
|
51
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${s} ${s}" shape-rendering="crispEdges"><rect width="${s}" height="${s}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Half-blocks for a terminal: two module rows per text row. Inverted (light
|
|
55
|
+
// modules drawn as blocks) so it reads on a dark terminal, which is most.
|
|
56
|
+
export function qrText(text) {
|
|
57
|
+
const qr = code(text), n = qr.getModuleCount(), q = 2;
|
|
58
|
+
const dark = (r, c) => r >= q && c >= q && r < n + q && c < n + q && qr.isDark(r - q, c - q);
|
|
59
|
+
const s = n + q * 2;
|
|
60
|
+
const rows = [];
|
|
61
|
+
for (let r = 0; r < s; r += 2) {
|
|
62
|
+
let line = '';
|
|
63
|
+
for (let c = 0; c < s; c++) {
|
|
64
|
+
const top = !dark(r, c), bottom = r + 1 < s ? !dark(r + 1, c) : true;
|
|
65
|
+
line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
|
|
66
|
+
}
|
|
67
|
+
rows.push(line);
|
|
68
|
+
}
|
|
69
|
+
return rows.join('\n');
|
|
70
|
+
}
|
package/server/settings.js
CHANGED
|
@@ -16,6 +16,9 @@ export const SETTINGS = [
|
|
|
16
16
|
{ key: 'compactPct', group: 'Agents', label: 'Compact at', kind: 'number', min: 10, max: 95, step: 5, unit: '%', hint: 'How full an agent’s context gets before Claude Code compacts it. Applies to jobs that start after you save.', fallback: () => Math.min(95, Math.max(10, parseInt(env('AGENT_COMPACT_PCT') ?? '60', 10) || 60)) },
|
|
17
17
|
{ key: 'chiefModel', group: 'Agents', label: 'Chief of staff model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], hint: 'Routes your messages and writes the standup.', fallback: () => env('CHIEF_MODEL') || 'sonnet' },
|
|
18
18
|
|
|
19
|
+
{ key: 'payPerMinute', group: 'Pay', label: 'Pay per active minute', kind: 'number', min: 0, max: 1_000_000, step: 100, unit: 'naira', hint: 'What an agent earns for each minute the model was working, on Opus. Haiku pays 3×, Sonnet 1.75×. Applies to work paid from now on.', fallback: () => 1000 },
|
|
20
|
+
{ key: 'payFinishBonus', group: 'Pay', label: 'Bonus for finishing a job', kind: 'number', min: 0, max: 1_000_000, step: 100, unit: 'naira', hint: 'Paid once when a job finishes, scaled by model like the minute rate. Kudos pays it again.', fallback: () => 2500 },
|
|
21
|
+
|
|
19
22
|
{ key: 'standupAt', group: 'Standup', label: 'Standup time', kind: 'time', hint: 'Local time, 24-hour. Blank turns the daily standup off.', fallback: () => (env('STANDUP_AT') ?? '09:30') === 'off' ? '' : (env('STANDUP_AT') ?? '09:30') },
|
|
20
23
|
{ key: 'standupWeekends', group: 'Standup', label: 'Hold standups on weekends', kind: 'bool', fallback: () => env('STANDUP_WEEKENDS') === '1' },
|
|
21
24
|
|
package/server/talk.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Click-an-agent chat. One short model call answers as that agent, from their
|
|
2
|
+
// sheet, what they are doing right now and what has just been said, so a quick
|
|
3
|
+
// "how's it going?" costs a fraction of a job. It cannot start work: anything
|
|
4
|
+
// the boss wants built goes through the message box, and the agent says so.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const SCHEMA = {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: { reply: { type: 'string' } },
|
|
11
|
+
required: ['reply'],
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SYSTEM = `You are one coworker in an office of coding agents, answering the boss, who just clicked on you to say something. You get your own sheet (role, personality, background, story, ties), what you are working on right now, and the last few lines of this chat.
|
|
16
|
+
|
|
17
|
+
Rules:
|
|
18
|
+
- Reply in character, in one to three short sentences, under 240 characters in total. Spoken out loud, so plain words.
|
|
19
|
+
- Answer from the facts you were given. Never invent progress, files, results or numbers. If you were not told, say you would have to check.
|
|
20
|
+
- This chat cannot start, change or stop work. If the boss asks for that, say to send it through the message box (or press N for a new job), warmly and briefly.
|
|
21
|
+
- Let your story and ties colour the reply only sometimes. Never force a reference in.
|
|
22
|
+
- Use real names for people. No markdown, emoji, quotation marks around the reply, or stage directions.`;
|
|
23
|
+
|
|
24
|
+
export function writeReply(context, { bin = 'claude', model = 'haiku', timeoutMs = 20000, env = process.env } = {}) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const args = [
|
|
27
|
+
'-p', '--model', model, '--output-format', 'json', '--json-schema', JSON.stringify(SCHEMA),
|
|
28
|
+
'--tools', '', '--setting-sources', '', '--strict-mcp-config', '--no-session-persistence',
|
|
29
|
+
'--system-prompt', SYSTEM,
|
|
30
|
+
];
|
|
31
|
+
const child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
32
|
+
let out = '', err = '';
|
|
33
|
+
child.stdout.on('data', (d) => (out += d));
|
|
34
|
+
child.stderr.on('data', (d) => (err += d));
|
|
35
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
36
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
37
|
+
child.on('close', () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
try {
|
|
40
|
+
const r = JSON.parse(out);
|
|
41
|
+
const { reply } = r.structured_output || JSON.parse(r.result);
|
|
42
|
+
const clean = String(reply).replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
43
|
+
if (!clean) throw new Error('Empty.');
|
|
44
|
+
resolve({ reply: clean, cost: r.total_cost_usd || 0 });
|
|
45
|
+
} catch {
|
|
46
|
+
reject(new Error(err.trim().split('\n').pop() || 'They did not answer.'));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
child.stdin.end(context);
|
|
50
|
+
});
|
|
51
|
+
}
|
package/server/tunnel.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// A public address for the office, so a phone can reach it from anywhere: a
|
|
2
|
+
// Cloudflare quick tunnel. `cloudflared` dials out from this machine and gives
|
|
3
|
+
// back a random https://….trycloudflare.com name that forwards to the office,
|
|
4
|
+
// so nothing listens on the network and no router is touched. No account.
|
|
5
|
+
//
|
|
6
|
+
// It is only ever started by an explicit click, never at boot, and it dies
|
|
7
|
+
// with the office. The address is new every time. Whoever has the link with
|
|
8
|
+
// the key can run Claude here, so the key stays the one thing that matters.
|
|
9
|
+
//
|
|
10
|
+
// It is only called on once the address works from outside: a phone that
|
|
11
|
+
// looks a name up too early caches "no such name" for minutes, and a tunnel
|
|
12
|
+
// can connect yet route nowhere (see emptyConfig). Either way the phone would
|
|
13
|
+
// show a blank page, so the QR waits.
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import https from 'node:https';
|
|
20
|
+
import { Resolver } from 'node:dns/promises';
|
|
21
|
+
|
|
22
|
+
// cloudflared reads ~/.cloudflared/config.yml even for a quick tunnel, and a
|
|
23
|
+
// named tunnel's ingress there (ending, as they do, in http_status:404) then
|
|
24
|
+
// answers every request to our address with an empty 404: a blank page on the
|
|
25
|
+
// phone. So it gets a config of its own, with nothing in it.
|
|
26
|
+
function emptyConfig() {
|
|
27
|
+
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'office-tunnel-')), 'config.yml');
|
|
28
|
+
fs.writeFileSync(file, '');
|
|
29
|
+
return file;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Does https://host/ reach this office from the internet? Looked up on public
|
|
33
|
+
// resolvers, not this machine's, which may have cached the name as missing.
|
|
34
|
+
// The office answers a keyless visit with 401, which Cloudflare never sends.
|
|
35
|
+
const resolver = new Resolver({ timeout: 3000, tries: 1 });
|
|
36
|
+
resolver.setServers(['1.1.1.1', '8.8.8.8']);
|
|
37
|
+
export async function reachable(host) {
|
|
38
|
+
let ip;
|
|
39
|
+
try { [ip] = await resolver.resolve4(host); } catch { return false; }
|
|
40
|
+
if (!ip) return false;
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const req = https.get({ host: ip, servername: host, headers: { host }, path: '/', timeout: 8000 }, (res) => { res.resume(); resolve(res.statusCode === 401); });
|
|
43
|
+
req.on('timeout', () => req.destroy());
|
|
44
|
+
req.on('error', () => resolve(false));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const URL_RE = /https:\/\/([a-z0-9-]+\.trycloudflare\.com)/i;
|
|
49
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
|
|
51
|
+
export class Tunnel {
|
|
52
|
+
// findBin() gives the cloudflared to run (it may download it, calling
|
|
53
|
+
// onStep('download')); allow(host) lets the office accept that Host and
|
|
54
|
+
// forget(host) takes it back; check(host) is the outside check.
|
|
55
|
+
constructor({ port, allow, forget, findBin, check = reachable, onChange = () => {} }) {
|
|
56
|
+
Object.assign(this, { port, allow, forget, findBin, check, onChange });
|
|
57
|
+
this.state = 'off'; // off | starting | on | error
|
|
58
|
+
this.step = null; // while starting: download | connect | check
|
|
59
|
+
this.host = null;
|
|
60
|
+
this.error = null;
|
|
61
|
+
this.child = null;
|
|
62
|
+
this.run = 0; // bumped by every start and stop, so a stale run stands down
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
status() {
|
|
66
|
+
return { state: this.state, step: this.state === 'starting' ? this.step : null, url: this.state === 'on' ? `https://${this.host}` : null, error: this.error };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
set(state, extra = {}) {
|
|
70
|
+
Object.assign(this, { state, error: null, ...extra });
|
|
71
|
+
this.onChange(this.status());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
start() {
|
|
75
|
+
if (this.state === 'starting' || this.state === 'on') return this.status();
|
|
76
|
+
const run = ++this.run;
|
|
77
|
+
this.set('starting', { host: null, step: 'connect' });
|
|
78
|
+
this.boot(run).catch((err) => { if (run === this.run) this.fail(err.message); });
|
|
79
|
+
return this.status();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async boot(run) {
|
|
83
|
+
const bin = await this.findBin((step) => { if (run === this.run) this.set('starting', { step }); });
|
|
84
|
+
if (run !== this.run) return;
|
|
85
|
+
this.set('starting', { step: 'connect' });
|
|
86
|
+
const host = await new Promise((resolve, reject) => {
|
|
87
|
+
const child = spawn(bin, ['tunnel', '--config', emptyConfig(), '--no-autoupdate', '--url', `http://127.0.0.1:${this.port}`], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
88
|
+
this.child = child;
|
|
89
|
+
let tail = '', name = null;
|
|
90
|
+
const timer = setTimeout(() => reject(new Error('Timed out waiting for Cloudflare. Check your connection and try again.')), 45000);
|
|
91
|
+
const done = (fn, v) => { clearTimeout(timer); fn(v); };
|
|
92
|
+
// The name is printed a second before the connection is up.
|
|
93
|
+
child.stderr.on('data', (d) => {
|
|
94
|
+
tail = (tail + d).slice(-4000);
|
|
95
|
+
const m = !name && URL_RE.exec(tail);
|
|
96
|
+
if (m && m[1] !== 'api.trycloudflare.com') name = m[1];
|
|
97
|
+
if (name && /Registered tunnel connection/.test(tail)) done(resolve, name);
|
|
98
|
+
});
|
|
99
|
+
child.on('error', (err) => done(reject, new Error(err.code === 'ENOENT' ? 'cloudflared couldn’t be started.' : err.message)));
|
|
100
|
+
child.on('exit', (code) => {
|
|
101
|
+
if (this.child !== child) return; // stopped on purpose
|
|
102
|
+
done(reject, new Error(this.state === 'on' ? 'The public address closed. Make a new one.' : `cloudflared stopped (${code}).`));
|
|
103
|
+
if (this.state === 'on' && run === this.run) this.fail('The public address closed. Make a new one.');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
if (run !== this.run) return;
|
|
107
|
+
this.allow(host);
|
|
108
|
+
this.host = host;
|
|
109
|
+
this.set('starting', { step: 'check' });
|
|
110
|
+
for (const end = Date.now() + 90000; Date.now() < end; await sleep(2000)) {
|
|
111
|
+
if (run !== this.run) return;
|
|
112
|
+
if (await this.check(host)) { if (run === this.run) this.set('on', { host }); return; }
|
|
113
|
+
}
|
|
114
|
+
throw new Error('Cloudflare connected, but the address doesn’t reach this office from outside. Try again in a minute.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fail(message) {
|
|
118
|
+
this.run++;
|
|
119
|
+
this.drop();
|
|
120
|
+
this.set('error', { error: message, host: null });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
drop() {
|
|
124
|
+
const child = this.child;
|
|
125
|
+
this.child = null;
|
|
126
|
+
if (this.host) this.forget(this.host);
|
|
127
|
+
if (child) { try { child.kill('SIGTERM'); } catch {} }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stop() {
|
|
131
|
+
this.run++;
|
|
132
|
+
this.drop();
|
|
133
|
+
this.set('off', { host: null });
|
|
134
|
+
return this.status();
|
|
135
|
+
}
|
|
136
|
+
}
|
package/shared/shop.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// What the office economy pays and sells.
|
|
2
|
+
//
|
|
3
|
+
// Shared by the server (which pays, charges and keeps the books) and the
|
|
4
|
+
// browser (which shows the shop and draws what was bought), so a price or a
|
|
5
|
+
// rate can never mean one thing in the ledger and another on screen.
|
|
6
|
+
|
|
7
|
+
// Pay. An agent earns by the active minute (time the model was actually
|
|
8
|
+
// working, not time spent waiting on the boss or a colleague) plus a bonus
|
|
9
|
+
// for finishing, and both are scaled by the model that did the work: getting
|
|
10
|
+
// a job done on a lighter model is worth more than brute-forcing it on the
|
|
11
|
+
// heaviest. Only finished jobs pay. A failed or stopped job pays nothing.
|
|
12
|
+
// All money is naira. perMinute and finishBonus are the defaults; the boss
|
|
13
|
+
// sets them in Settings and setPay() applies them, in the server and, from
|
|
14
|
+
// the economy status, in the browser.
|
|
15
|
+
export const PAY = {
|
|
16
|
+
perMinute: 1000,
|
|
17
|
+
finishBonus: 2500,
|
|
18
|
+
// Minutes beyond this on one job are not paid: a job that drags on is not
|
|
19
|
+
// a job done better.
|
|
20
|
+
maxMinutes: 60,
|
|
21
|
+
// The office's cut of every naira an agent earns, for the boss to spend.
|
|
22
|
+
potShare: 0.4,
|
|
23
|
+
// A boss's "great work" on a finished job pays its finish bonus again.
|
|
24
|
+
kudosOncePerJob: true,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const naira = (v) => `₦${Math.round(Number(v) || 0).toLocaleString('en-NG')}`;
|
|
28
|
+
export function setPay(p = {}) {
|
|
29
|
+
for (const k of ['perMinute', 'finishBonus']) if (Number.isFinite(p[k]) && p[k] >= 0) PAY[k] = p[k];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MODEL_PAY = { haiku: 3, sonnet: 1.75, opus: 1 };
|
|
33
|
+
export const modelTier = (model) => Object.keys(MODEL_PAY).find((t) => String(model || '').includes(t)) || '';
|
|
34
|
+
export const modelPay = (model) => MODEL_PAY[modelTier(model)] || 1;
|
|
35
|
+
|
|
36
|
+
// The cut, in whole naira. The agent's share is rounded down and the pot
|
|
37
|
+
// takes the rest, so the two always add up to what was earned.
|
|
38
|
+
export function split(gross) {
|
|
39
|
+
const g = Math.max(0, Math.round(gross));
|
|
40
|
+
const agent = Math.floor(g * (1 - PAY.potShare));
|
|
41
|
+
return { gross: g, agent, pot: g - agent };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Everything for sale. `scope` says who owns it and where it goes:
|
|
45
|
+
// desk on one agent's desk; an agent buys it with their own pay, or the
|
|
46
|
+
// boss buys it for them out of the pot
|
|
47
|
+
// office somewhere in the room; bought out of the pot
|
|
48
|
+
// floor / wall the room's finish; bought out of the pot, one in use at a time
|
|
49
|
+
// layout a different floor plan; bought out of the pot, one in use at a time
|
|
50
|
+
export const ITEMS = [
|
|
51
|
+
// desk
|
|
52
|
+
{ id: 'mug', scope: 'desk', name: 'Coffee mug', price: 2000, icon: '☕' },
|
|
53
|
+
{ id: 'duck', scope: 'desk', name: 'Rubber duck', price: 3000, icon: '🦆' },
|
|
54
|
+
{ id: 'cactus', scope: 'desk', name: 'Cactus', price: 3500, icon: '🌵' },
|
|
55
|
+
{ id: 'photo', scope: 'desk', name: 'Photo frame', price: 5000, icon: '🖼️' },
|
|
56
|
+
{ id: 'lamp', scope: 'desk', name: 'Desk lamp', price: 6000, icon: '💡' },
|
|
57
|
+
{ id: 'headphones', scope: 'desk', name: 'Headphones', price: 8000, icon: '🎧' },
|
|
58
|
+
{ id: 'lava', scope: 'desk', name: 'Lava lamp', price: 9000, icon: '🫧' },
|
|
59
|
+
{ id: 'bonsai', scope: 'desk', name: 'Bonsai', price: 12000, icon: '🌳' },
|
|
60
|
+
{ id: 'keyboard', scope: 'desk', name: 'Mechanical keyboard', price: 14000, icon: '⌨️' },
|
|
61
|
+
{ id: 'trophy', scope: 'desk', name: 'Little trophy', price: 15000, icon: '🏆' },
|
|
62
|
+
{ id: 'monitor', scope: 'desk', name: 'Second monitor', price: 20000, icon: '🖥️' },
|
|
63
|
+
{ id: 'chair', scope: 'desk', name: 'Gaming chair', price: 30000, icon: '💺' },
|
|
64
|
+
{ id: 'cat', scope: 'desk', name: 'Desk cat', price: 40000, icon: '🐈' },
|
|
65
|
+
// office
|
|
66
|
+
{ id: 'neon', scope: 'office', name: 'Neon sign', price: 50000, icon: '🔆' },
|
|
67
|
+
{ id: 'vending', scope: 'office', name: 'Vending machine', price: 70000, icon: '🥤' },
|
|
68
|
+
{ id: 'arcade', scope: 'office', name: 'Arcade cabinet', price: 80000, icon: '🕹️' },
|
|
69
|
+
{ id: 'jukebox', scope: 'office', name: 'Jukebox', price: 90000, icon: '🎵' },
|
|
70
|
+
{ id: 'aquarium', scope: 'office', name: 'Aquarium', price: 120000, icon: '🐠' },
|
|
71
|
+
{ id: 'pingpong', scope: 'office', name: 'Ping-pong table', price: 150000, icon: '🏓' },
|
|
72
|
+
// floor and walls
|
|
73
|
+
{ id: 'floor-oak', scope: 'floor', name: 'Oak floor', price: 0, color: '#ece3d4', icon: '🟫' },
|
|
74
|
+
{ id: 'floor-walnut', scope: 'floor', name: 'Walnut floor', price: 40000, color: '#b48a63', icon: '🟫' },
|
|
75
|
+
{ id: 'floor-terrazzo', scope: 'floor', name: 'Terrazzo', price: 40000, color: '#e6e1da', icon: '⬜' },
|
|
76
|
+
{ id: 'floor-concrete', scope: 'floor', name: 'Polished concrete', price: 40000, color: '#c9c9c4', icon: '⬛' },
|
|
77
|
+
{ id: 'wall-cream', scope: 'wall', name: 'Cream walls', price: 0, color: '#f5f0e8', icon: '🧱' },
|
|
78
|
+
{ id: 'wall-sage', scope: 'wall', name: 'Sage walls', price: 35000, color: '#d6e2d0', icon: '🧱' },
|
|
79
|
+
{ id: 'wall-navy', scope: 'wall', name: 'Navy walls', price: 35000, color: '#3d4f6e', icon: '🧱' },
|
|
80
|
+
{ id: 'wall-blush', scope: 'wall', name: 'Blush walls', price: 35000, color: '#f1d9d2', icon: '🧱' },
|
|
81
|
+
// layouts
|
|
82
|
+
{ id: 'layout-classic', scope: 'layout', name: 'Classic', price: 0, icon: '🏢', blurb: 'Two rows of desks, the library and server room on the back wall.' },
|
|
83
|
+
{ id: 'layout-pods', scope: 'layout', name: 'Pods', price: 150000, icon: '🧩', blurb: 'Desks in two back-to-back clusters of four, a table and rug between them.' },
|
|
84
|
+
{ id: 'layout-studio', scope: 'layout', name: 'Studio', price: 250000, icon: '🎨', blurb: 'One long row of desks along the back, the floor left open for the table and the pool.' },
|
|
85
|
+
{ id: 'layout-campus', scope: 'layout', name: 'Campus', price: 400000, icon: '🏛️', blurb: 'Desks facing in round a courtyard, with the pool table at its heart.' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
export const itemById = (id) => ITEMS.find((i) => i.id === id);
|
|
89
|
+
// What every office starts out owning and using.
|
|
90
|
+
export const DEFAULTS = { floor: 'floor-oak', wall: 'wall-cream', layout: 'layout-classic' };
|
|
91
|
+
// The scopes where one choice is in use at a time.
|
|
92
|
+
export const SINGLE = new Set(['floor', 'wall', 'layout']);
|
|
93
|
+
|
|
94
|
+
// Milestones. Each pays a one-off bonus, split like any pay.
|
|
95
|
+
export const ACHIEVEMENTS = [
|
|
96
|
+
{ id: 'first-job', name: 'First day', blurb: 'Finished a first job.', bonus: 2500, test: (s) => s.jobs >= 1 },
|
|
97
|
+
{ id: 'ten-jobs', name: 'Regular', blurb: 'Finished 10 jobs.', bonus: 10000, test: (s) => s.jobs >= 10 },
|
|
98
|
+
{ id: 'fifty-jobs', name: 'Veteran', blurb: 'Finished 50 jobs.', bonus: 40000, test: (s) => s.jobs >= 50 },
|
|
99
|
+
{ id: 'haiku-hero', name: 'Haiku hero', blurb: 'Finished 5 jobs on Haiku.', bonus: 15000, test: (s) => (s.byTier.haiku || 0) >= 5 },
|
|
100
|
+
{ id: 'lean', name: 'Lean machine', blurb: 'Finished a job on Haiku in under 2 active minutes.', bonus: 5000, test: (s, j) => j?.tier === 'haiku' && j.minutes < 2 },
|
|
101
|
+
{ id: 'kudos', name: 'Great work', blurb: 'Got kudos from the boss.', bonus: 5000, test: (s) => s.kudos >= 1 },
|
|
102
|
+
{ id: 'streak-3', name: 'On a roll', blurb: 'Finished jobs three days running.', bonus: 12000, test: (s) => s.streak >= 3 },
|
|
103
|
+
{ id: 'big-earner', name: 'Big earner', blurb: 'Earned ₦200,000 in all.', bonus: 20000, test: (s) => s.earned >= 200000 },
|
|
104
|
+
{ id: 'shopper', name: 'Treat yourself', blurb: 'Bought something for the desk.', bonus: 1500, test: (s) => s.bought >= 1 },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// Titles, from lifetime earnings. Shown on the name tag and in the chat.
|
|
108
|
+
export const LEVELS = [
|
|
109
|
+
{ at: 0, title: 'Intern' },
|
|
110
|
+
{ at: 25000, title: 'Associate' },
|
|
111
|
+
{ at: 100000, title: 'Specialist' },
|
|
112
|
+
{ at: 300000, title: 'Senior' },
|
|
113
|
+
{ at: 800000, title: 'Principal' },
|
|
114
|
+
{ at: 2000000, title: 'Distinguished' },
|
|
115
|
+
];
|
|
116
|
+
export function levelOf(earned) {
|
|
117
|
+
let n = 0;
|
|
118
|
+
LEVELS.forEach((l, i) => { if ((Number(earned) || 0) >= l.at) n = i; });
|
|
119
|
+
return { n: n + 1, title: LEVELS[n].title, next: LEVELS[n + 1]?.at ?? null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A streak of working days lifts the wage (not the finish bonus).
|
|
123
|
+
export const STREAK_BOOST = [{ days: 14, boost: 0.2 }, { days: 7, boost: 0.1 }, { days: 3, boost: 0.05 }];
|
|
124
|
+
export const streakBoost = (days) => STREAK_BOOST.find((b) => days >= b.days)?.boost || 0;
|
|
125
|
+
|
|
126
|
+
// What a boss may tip from the pot in one go.
|
|
127
|
+
export const TIP = { min: 500, max: 50000, presets: [1000, 5000, 20000] };
|