agent-office-studio 0.2.0 → 0.3.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 +19 -1
- package/README.md +1 -1
- package/bin/agentoffice.js +65 -5
- package/dist/assets/index-D5IzEWKU.css +32 -0
- package/dist/assets/index-DgXF8Kqx.js +4457 -0
- package/dist/index.html +7 -2
- package/docs/README.md +1 -0
- package/docs/configuration.md +3 -2
- package/docs/getting-started.md +4 -1
- package/docs/guides/troubleshooting.md +13 -2
- package/docs/leaderboard.md +8 -6
- package/docs/linked-offices.md +81 -0
- package/docs/phone.md +4 -0
- package/docs/security.md +34 -2
- package/package.json +3 -2
- package/server/changes.js +4 -2
- package/server/chief.js +18 -2
- package/server/guests.js +391 -0
- package/server/index.js +285 -18
- package/server/leaderboard.js +63 -14
- package/server/links.js +350 -0
- package/server/supervise.js +2 -0
- package/server/voice.js +3 -1
- package/dist/assets/index-CozPk6R9.js +0 -4457
- package/dist/assets/index-i7sU9SN-.css +0 -32
package/server/index.js
CHANGED
|
@@ -16,6 +16,8 @@ import { Terminals } from './terminal.js';
|
|
|
16
16
|
import { snapshotTree, summarize, fileDiff } from './changes.js';
|
|
17
17
|
import { Auth } from './auth.js';
|
|
18
18
|
import { Leaderboard } from './leaderboard.js';
|
|
19
|
+
import { Links } from './links.js';
|
|
20
|
+
import { Guests } from './guests.js';
|
|
19
21
|
|
|
20
22
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
23
|
const APP = path.resolve(HERE, '..');
|
|
@@ -26,6 +28,12 @@ const DATA = path.resolve(process.env.OFFICE_DATA || path.join(APP, 'data'));
|
|
|
26
28
|
const STATE_FILE = path.join(DATA, 'state.json');
|
|
27
29
|
const DIST = path.join(APP, 'dist');
|
|
28
30
|
const UPLOADS = path.join(DATA, 'uploads');
|
|
31
|
+
// Each running job's office MCP config, token and all. On the command line it
|
|
32
|
+
// would be readable by every process on the machine (`ps`); here it is a 0600
|
|
33
|
+
// file in a 0700 folder, gone when the job's session closes. Leftovers from a
|
|
34
|
+
// crash hold tokens that died with that server, so they are cleared at start.
|
|
35
|
+
const MCP_DIR = path.join(DATA, 'mcp');
|
|
36
|
+
fs.rmSync(MCP_DIR, { recursive: true, force: true });
|
|
29
37
|
const auth = new Auth(DATA);
|
|
30
38
|
const board = new Leaderboard(DATA, { onChange: () => send({ t: 'leaderboard', leaderboard: board.status() }) });
|
|
31
39
|
const MAX_UPLOAD = 25 * 1024 * 1024;
|
|
@@ -272,9 +280,62 @@ function save() {
|
|
|
272
280
|
// ---------- broadcasting ----------
|
|
273
281
|
|
|
274
282
|
const clients = new Set();
|
|
283
|
+
|
|
284
|
+
// Linked offices. Our agents as other offices see them: who, what they look
|
|
285
|
+
// like, and what they're doing, down to the job title.
|
|
286
|
+
// A job's title is the first line of its prompt, which can say more than the
|
|
287
|
+
// boss means to share, so linked offices see only that an agent is busy.
|
|
288
|
+
// OFFICE_SHARE_JOB_TITLES=1 sends the titles too.
|
|
289
|
+
const SHARE_TITLES = process.env.OFFICE_SHARE_JOB_TITLES === '1';
|
|
290
|
+
function presence() {
|
|
291
|
+
return hired().map((s) => {
|
|
292
|
+
const job = state.jobs.find((j) => j.agentId === s.id && j.status === 'working');
|
|
293
|
+
const doing =
|
|
294
|
+
state.dismissed.includes(s.id) ? 'away'
|
|
295
|
+
: standupNow?.who.includes(s.id) ? 'meeting'
|
|
296
|
+
: job ? 'working'
|
|
297
|
+
: chill?.who.includes(s.id) ? 'break'
|
|
298
|
+
: 'idle';
|
|
299
|
+
return { id: s.id, name: s.name, role: s.role, look: { shirt: s.shirt, hair: s.hair, skin: s.skin }, state: doing, job: job && SHARE_TITLES ? { title: job.title } : null };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
// Set just below; anything broadcast before then has no guests to tell.
|
|
303
|
+
let guests = null;
|
|
304
|
+
const links = new Links(DATA, {
|
|
305
|
+
board,
|
|
306
|
+
presence,
|
|
307
|
+
onChange: () => {
|
|
308
|
+
guests?.linksChanged();
|
|
309
|
+
send({ t: 'links', ...links.status() });
|
|
310
|
+
send({ t: 'visitors', offices: links.visiting() });
|
|
311
|
+
},
|
|
312
|
+
onGuest: (link, msg) => guests?.onGuest(link, msg),
|
|
313
|
+
});
|
|
314
|
+
// Guest offices running jobs here, and our visits to offices that host us.
|
|
315
|
+
guests = new Guests(DATA, {
|
|
316
|
+
links,
|
|
317
|
+
office: {
|
|
318
|
+
jobs: () => state.jobs,
|
|
319
|
+
createJob: (body, extra) => createJob(body, extra),
|
|
320
|
+
replyJob: (job, body) => replyJob(job, body),
|
|
321
|
+
stopJob: (job, why) => stopJob(job, why),
|
|
322
|
+
diff: async (job, p) => {
|
|
323
|
+
const cwd = repoPath(job.repo);
|
|
324
|
+
if (!cwd) throw Object.assign(new Error('That project folder is gone.'), { code: 404 });
|
|
325
|
+
return fileDiff(cwd, job.changes, p);
|
|
326
|
+
},
|
|
327
|
+
staff: () => STAFF,
|
|
328
|
+
modes: () => Object.fromEntries(Object.entries(MODES).map(([k, v]) => [k, v.label])),
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
// Broadcasts that can't change what an agent is doing don't need presence checked.
|
|
332
|
+
const NOT_PRESENCE = new Set(['links', 'visitors', 'proc', 'leaderboard', 'auth', 'usage']);
|
|
333
|
+
|
|
275
334
|
function send(msg) {
|
|
276
335
|
const data = JSON.stringify(msg);
|
|
277
336
|
for (const ws of clients) if (ws.readyState === 1) ws.send(data);
|
|
337
|
+
guests?.feed(msg);
|
|
338
|
+
if (!NOT_PRESENCE.has(msg.t)) links.sendPresence();
|
|
278
339
|
}
|
|
279
340
|
const publicJob = (j, tail = 40) => {
|
|
280
341
|
const { stopRequested, lastTurn, sent, handoffs, ...rest } = j;
|
|
@@ -294,6 +355,8 @@ const snapshot = () => ({
|
|
|
294
355
|
usage,
|
|
295
356
|
auth: auth.status(),
|
|
296
357
|
leaderboard: board.status(),
|
|
358
|
+
links: links.status(),
|
|
359
|
+
visitors: links.visiting(),
|
|
297
360
|
procs,
|
|
298
361
|
machine,
|
|
299
362
|
dismissed: state.dismissed,
|
|
@@ -524,6 +587,10 @@ const OFFICE_TOOLS = [
|
|
|
524
587
|
'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
|
|
525
588
|
'mcp__office__restart_office',
|
|
526
589
|
];
|
|
590
|
+
// A guest's job can see who is in and ask the boss, nothing more: no
|
|
591
|
+
// messaging colleagues, handing off or channels. Enforced again in agentApi,
|
|
592
|
+
// since full autonomy ignores --allowedTools.
|
|
593
|
+
const GUEST_TOOLS = ['mcp__office__office_roster', 'mcp__office__flag_for_boss'];
|
|
527
594
|
const endGrace = (job) => { clearTimeout(graces.get(job.id)); graces.delete(job.id); };
|
|
528
595
|
|
|
529
596
|
function repoPath(name) {
|
|
@@ -540,7 +607,8 @@ function listRepos() {
|
|
|
540
607
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
|
541
608
|
}
|
|
542
609
|
|
|
543
|
-
|
|
610
|
+
// `extra` tags a job, as a guest's (`from`, `guest`); it must be set before the job can start.
|
|
611
|
+
function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agentId = '' }, extra = {}) {
|
|
544
612
|
if (!repoPath(repo)) throw new Error('Unknown project folder.');
|
|
545
613
|
prompt = String(prompt || '').trim();
|
|
546
614
|
if (!prompt) throw new Error('Give the job a brief.');
|
|
@@ -555,6 +623,7 @@ function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agen
|
|
|
555
623
|
sessionId: null, agentId: null, cost: 0, turns: 0, tools: 0, xp: 0,
|
|
556
624
|
pending: prompt, denials: [], error: null, log: [],
|
|
557
625
|
wantAgent: STAFF.some((st) => st.id === agentId) ? agentId : null,
|
|
626
|
+
...extra,
|
|
558
627
|
};
|
|
559
628
|
state.jobs.push(job);
|
|
560
629
|
if (job.wantAgent && state.dismissed.includes(job.wantAgent)) recall([job.wantAgent]);
|
|
@@ -588,10 +657,18 @@ function start(job, agentId) {
|
|
|
588
657
|
tokens.set(job.id, token);
|
|
589
658
|
const mcp = { mcpServers: { office: { command: process.execPath, args: [path.join(HERE, 'office-mcp.js')],
|
|
590
659
|
env: { OFFICE_URL: `http://127.0.0.1:${PORT}`, OFFICE_JOB: job.id, OFFICE_TOKEN: token } } } };
|
|
660
|
+
const mcpFile = path.join(MCP_DIR, `${job.id}-${crypto.randomUUID().slice(0, 8)}.json`);
|
|
591
661
|
// Attachments live outside the project, so agents need leave to read them.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
662
|
+
// A guest's job gets none of them: they are the boss's files.
|
|
663
|
+
if (!job.guest) {
|
|
664
|
+
fs.mkdirSync(UPLOADS, { recursive: true });
|
|
665
|
+
args.push('--add-dir', UPLOADS);
|
|
666
|
+
} else {
|
|
667
|
+
// Nor the boss's connectors (mail, Slack, calendar), user settings and
|
|
668
|
+
// hooks, or memory: at Full autonomy those would all be the guest's.
|
|
669
|
+
args.push('--strict-mcp-config', '--setting-sources', 'project,local', '--settings', JSON.stringify({ autoMemoryEnabled: false }));
|
|
670
|
+
}
|
|
671
|
+
args.push('--mcp-config', mcpFile, '--allowedTools', (job.guest ? GUEST_TOOLS : OFFICE_TOOLS).join(','), '--append-system-prompt', job.guest ? guestPrompt(agentId, job) : officePrompt(agentId, job));
|
|
595
672
|
// Being given work in a channel is how you end up in it.
|
|
596
673
|
try { joinChannel(channelOf(job.channel), agentId); } catch {}
|
|
597
674
|
const first = job.pending;
|
|
@@ -610,7 +687,14 @@ function start(job, agentId) {
|
|
|
610
687
|
|
|
611
688
|
// stdin stays open for the whole job, so the chief can hand the agent
|
|
612
689
|
// new messages while it works.
|
|
613
|
-
|
|
690
|
+
try {
|
|
691
|
+
fs.mkdirSync(MCP_DIR, { recursive: true, mode: 0o700 });
|
|
692
|
+
fs.chmodSync(MCP_DIR, 0o700);
|
|
693
|
+
fs.writeFileSync(mcpFile, JSON.stringify(mcp), { mode: 0o600, flag: 'wx' });
|
|
694
|
+
} catch (err) {
|
|
695
|
+
return finish(job, 'failed', `Could not write the office tools' config: ${err.message}`);
|
|
696
|
+
}
|
|
697
|
+
const child = spawn(CLAUDE, args, { cwd, env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: AUTOCOMPACT_PCT, ...(job.guest ? { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } : {}), ...auth.env() }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
614
698
|
running.set(job.id, child);
|
|
615
699
|
startProcs();
|
|
616
700
|
child.stdin.on('error', () => {});
|
|
@@ -633,10 +717,11 @@ function start(job, agentId) {
|
|
|
633
717
|
|
|
634
718
|
const ended = (why) => {
|
|
635
719
|
running.delete(job.id);
|
|
720
|
+
fs.rm(mcpFile, { force: true }, () => {});
|
|
636
721
|
if (procs) { delete procs[job.id]; }
|
|
637
722
|
endGrace(job);
|
|
638
723
|
if (job.status !== 'working') return;
|
|
639
|
-
if (job.stopRequested) finish(job, 'stopped', 'Stopped by you.');
|
|
724
|
+
if (job.stopRequested) finish(job, 'stopped', job.stopWhy || 'Stopped by you.');
|
|
640
725
|
else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
|
|
641
726
|
else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
|
|
642
727
|
};
|
|
@@ -718,6 +803,7 @@ function onLine(job, line) {
|
|
|
718
803
|
const u = e.usage || {};
|
|
719
804
|
job.context = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
720
805
|
board.addTokens((u.input_tokens || 0) + (u.output_tokens || 0));
|
|
806
|
+
guests.addTokens(job, (u.input_tokens || 0) + (u.output_tokens || 0));
|
|
721
807
|
board.addTime(e.duration_ms);
|
|
722
808
|
nudgeUsage();
|
|
723
809
|
job.denials = [...new Set((e.permission_denials || []).map((d) => d.tool_name))];
|
|
@@ -803,7 +889,7 @@ function finish(job, status, error) {
|
|
|
803
889
|
// gets it, and the asker's own finish is the one worth announcing).
|
|
804
890
|
if (!retry && status !== 'stopped' && !job.requestedBy) {
|
|
805
891
|
const line = awaiting ? `${nameOf(job.agentId)} is waiting on you for "${clip(job.title, 80)}": ${clip(job.asking.summary, 200)}`
|
|
806
|
-
: status === 'done' ? `${nameOf(job.agentId)} finished "${clip(job.title, 80)}"
|
|
892
|
+
: status === 'done' ? `${nameOf(job.agentId)} finished "${clip(job.title, 80)}".${briefOf(job.lastTurn?.text) ? `\n\n${briefOf(job.lastTurn.text)}` : ''}` : `${nameOf(job.agentId)} hit a wall on "${clip(job.title, 80)}": ${clip(error || 'something went wrong', 200)}`;
|
|
807
893
|
post('chief', `**${awaiting ? 'Waiting on you' : status === 'done' ? 'Done' : 'Failed'}** · ${line}`, { kind: 'finished', status, awaiting, jobId: job.id, thread: null, channel: GENERAL });
|
|
808
894
|
}
|
|
809
895
|
|
|
@@ -882,20 +968,21 @@ function addWait(job, w) {
|
|
|
882
968
|
waits.set(job.id, [...(waits.get(job.id) || []), w]);
|
|
883
969
|
}
|
|
884
970
|
|
|
885
|
-
function stopJob(job) {
|
|
971
|
+
function stopJob(job, why) {
|
|
886
972
|
if (job.status === 'queued') {
|
|
887
973
|
job.status = 'stopped';
|
|
888
|
-
job.error = 'Pulled from the queue.';
|
|
974
|
+
job.error = why || 'Pulled from the queue.';
|
|
889
975
|
job.endedAt = Date.now();
|
|
890
976
|
pushJob(job);
|
|
891
977
|
} else if (job.status === 'working') {
|
|
892
978
|
job.stopRequested = true;
|
|
979
|
+
job.stopWhy = why || null;
|
|
893
980
|
const child = running.get(job.id);
|
|
894
981
|
if (child) {
|
|
895
982
|
child.kill('SIGTERM');
|
|
896
983
|
setTimeout(() => running.get(job.id) === child && child.kill('SIGKILL'), 5000);
|
|
897
984
|
} else {
|
|
898
|
-
finish(job, 'stopped', 'Stopped by you.');
|
|
985
|
+
finish(job, 'stopped', why || 'Stopped by you.');
|
|
899
986
|
}
|
|
900
987
|
}
|
|
901
988
|
}
|
|
@@ -1235,6 +1322,21 @@ const clip = (s, n) => {
|
|
|
1235
1322
|
s = String(s || '').replace(/\s+/g, ' ').trim();
|
|
1236
1323
|
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
1237
1324
|
};
|
|
1325
|
+
// A short plain-text brief of an agent's final reply: markdown stripped, the
|
|
1326
|
+
// opening sentences kept up to n characters, cut at a sentence end if possible.
|
|
1327
|
+
function briefOf(text, n = 280) {
|
|
1328
|
+
const plain = String(text || '')
|
|
1329
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
1330
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
1331
|
+
.replace(/^\s*(#{1,6}|[-*>]|\d+\.)\s+/gm, '')
|
|
1332
|
+
.replace(/[*_`]+/g, '')
|
|
1333
|
+
.replace(/\s+/g, ' ')
|
|
1334
|
+
.trim();
|
|
1335
|
+
if (plain.length <= n) return plain;
|
|
1336
|
+
const cut = plain.slice(0, n);
|
|
1337
|
+
const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
|
|
1338
|
+
return end > n * 0.4 ? cut.slice(0, end + 1) : clip(plain, n);
|
|
1339
|
+
}
|
|
1238
1340
|
const ENDED = { done: 'Shipped', failed: 'Hit a wall on', stopped: 'Stopped' };
|
|
1239
1341
|
|
|
1240
1342
|
// What each agent can honestly report since the last standup.
|
|
@@ -1390,13 +1492,25 @@ function officePrompt(agentId, job) {
|
|
|
1390
1492
|
`This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general.` + standupBrief(agentId);
|
|
1391
1493
|
}
|
|
1392
1494
|
|
|
1495
|
+
// A guest's job: the agent works for someone from another office, so it is
|
|
1496
|
+
// told nothing about this office's other work and can't reach colleagues.
|
|
1497
|
+
function guestPrompt(agentId, job) {
|
|
1498
|
+
return `You are ${nameOf(agentId)}, an agent in the Agent Office, working on a job for ${job.from}, a guest from another office. ` + persona(agentId) +
|
|
1499
|
+
`Work only in this project. Don't share anything about this office's other projects, jobs or people beyond what office_roster shows. ` +
|
|
1500
|
+
`Your only office tools are office_roster and flag_for_boss; the boss here is the host, not ${job.from}. Talk to ${job.from} in your replies.`;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1393
1503
|
const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5;
|
|
1394
1504
|
const FLAGS = { question: 'Question for you', decision: 'Needs your decision', blocker: 'Blocked', review: 'Ready for your review' };
|
|
1395
1505
|
|
|
1396
1506
|
function caller(req) {
|
|
1397
1507
|
const id = req.headers['x-office-job'];
|
|
1398
1508
|
const job = state.jobs.find((j) => j.id === id);
|
|
1399
|
-
if (!job || job.status !== 'working' || !tokens.get(id)
|
|
1509
|
+
if (!job || job.status !== 'working' || !tokens.get(id)) return null;
|
|
1510
|
+
// Compared in constant time, so the token can't be guessed a byte at a time.
|
|
1511
|
+
const want = Buffer.from(tokens.get(id));
|
|
1512
|
+
const got = Buffer.from(String(req.headers['x-office-token'] || ''));
|
|
1513
|
+
if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return null;
|
|
1400
1514
|
return job;
|
|
1401
1515
|
}
|
|
1402
1516
|
|
|
@@ -1412,6 +1526,15 @@ function recentJobOf(agentId) {
|
|
|
1412
1526
|
return last?.status === 'done' && Date.now() - last.endedAt < RESUME_WINDOW_MS ? last : null;
|
|
1413
1527
|
}
|
|
1414
1528
|
|
|
1529
|
+
// Who is in, without what anyone is working on or where: a guest's job
|
|
1530
|
+
// shouldn't learn this office's other work.
|
|
1531
|
+
function guestRoster(me) {
|
|
1532
|
+
const lines = [`You are ${nameOf(me.agentId)}, on a job for ${me.from}.`, 'In the office today:'];
|
|
1533
|
+
for (const s of onFloor()) if (s.id !== me.agentId) lines.push(`- ${s.role ? `${s.name} (${s.role})` : s.name}`);
|
|
1534
|
+
lines.push('', `Project: ${me.repo}`);
|
|
1535
|
+
return lines.join('\n');
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1415
1538
|
const agentApi = {
|
|
1416
1539
|
office_roster(me) {
|
|
1417
1540
|
const lines = [`You are ${nameOf(me.agentId)}.`, 'Colleagues:'];
|
|
@@ -1437,6 +1560,8 @@ const agentApi = {
|
|
|
1437
1560
|
if (!theirs || (theirs.status === 'working' && !running.has(theirs.id))) {
|
|
1438
1561
|
throw new Error(`${target.name} isn't on a job right now. Use hand_off to give them work.`);
|
|
1439
1562
|
}
|
|
1563
|
+
// A guest reads that job's log, so nothing from this office goes into it.
|
|
1564
|
+
if (theirs.guest) throw new Error(`${target.name} is on a job for a guest from another office and can't take messages.`);
|
|
1440
1565
|
|
|
1441
1566
|
const myName = nameOf(me.agentId);
|
|
1442
1567
|
deliver(theirs, `Message from ${myName} (working on "${me.title}" in ${me.repo}): ${message}` +
|
|
@@ -1718,14 +1843,66 @@ const terminals = new Terminals({ root: ROOT, dataDir: DATA, folders: listRepos
|
|
|
1718
1843
|
// machine's Tailscale name when `tailscale serve` fronts the office. Everything
|
|
1719
1844
|
// else is still refused, which is what keeps rebinding sites out.
|
|
1720
1845
|
const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]', ...String(process.env.OFFICE_ALLOWED_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);
|
|
1846
|
+
const DEV_PORT = '5317'; // vite.config.js
|
|
1847
|
+
|
|
1848
|
+
// The office key. Host and Origin checks keep other websites out, but not
|
|
1849
|
+
// other accounts on this machine (or the tailnet, with OFFICE_ALLOWED_HOSTS):
|
|
1850
|
+
// they can reach 127.0.0.1 too. So the page and its API need a secret only
|
|
1851
|
+
// this account can read. It is opened once with ?key=, which leaves it in an
|
|
1852
|
+
// HttpOnly cookie, and it stays the same across restarts. Delete the file to
|
|
1853
|
+
// sign every browser out. Agents use their job tokens instead.
|
|
1854
|
+
const KEY_FILE = path.join(DATA, 'office-key');
|
|
1855
|
+
const OFFICE_KEY = (() => {
|
|
1856
|
+
try { const k = fs.readFileSync(KEY_FILE, 'utf8').trim(); if (/^[0-9a-f]{64}$/.test(k)) return k; } catch {}
|
|
1857
|
+
const k = crypto.randomBytes(32).toString('hex');
|
|
1858
|
+
fs.mkdirSync(DATA, { recursive: true });
|
|
1859
|
+
fs.writeFileSync(KEY_FILE, k + '\n', { mode: 0o600 });
|
|
1860
|
+
return k;
|
|
1861
|
+
})();
|
|
1862
|
+
try { fs.chmodSync(KEY_FILE, 0o600); } catch {}
|
|
1863
|
+
// Cookies ignore ports, so two offices on one machine each need their own name.
|
|
1864
|
+
const KEY_COOKIE = `office_key_${PORT}`;
|
|
1865
|
+
const sameKey = (k) => {
|
|
1866
|
+
const a = Buffer.from(String(k || '')), b = Buffer.from(OFFICE_KEY);
|
|
1867
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
1868
|
+
};
|
|
1869
|
+
function hasKey(req) {
|
|
1870
|
+
for (const c of String(req.headers.cookie || '').split(';')) {
|
|
1871
|
+
const i = c.indexOf('=');
|
|
1872
|
+
if (i > 0 && c.slice(0, i).trim() === KEY_COOKIE) return sameKey(c.slice(i + 1).trim());
|
|
1873
|
+
}
|
|
1874
|
+
return false;
|
|
1875
|
+
}
|
|
1876
|
+
const keyLink = (host = `localhost:${PORT}`) => `http://${host}/?key=${OFFICE_KEY}`;
|
|
1877
|
+
|
|
1878
|
+
// What a browser without the key sees instead of the office.
|
|
1879
|
+
function locked(res) {
|
|
1880
|
+
res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
1881
|
+
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Agent Office</title>
|
|
1882
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#11131a;color:#e8e9ee}main{max-width:30rem;padding:2rem}h1{font-size:1.25rem;margin:0 0 .75rem}p{margin:.5rem 0;color:#b4b7c3}code{font:.9em ui-monospace,monospace;background:#1d2030;padding:.1em .35em;border-radius:4px;color:#e8e9ee}</style>
|
|
1883
|
+
<main><h1>This office is locked</h1>
|
|
1884
|
+
<p>Open it with the link it printed when it started: the line that begins <code>Agent Office open on</code>.</p>
|
|
1885
|
+
<p>Lost it? Run <code>agentoffice --link</code> in a terminal on this machine.</p></main>`);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1721
1888
|
function trusted(req) {
|
|
1722
1889
|
// Anything that can spawn claude with full permissions must refuse
|
|
1723
1890
|
// requests from other websites and from rebinding hostnames.
|
|
1724
1891
|
const host = String(req.headers.host || '').replace(/:\d+$/, '');
|
|
1725
1892
|
if (!LOCAL.has(host)) return false;
|
|
1726
1893
|
const origin = req.headers.origin;
|
|
1894
|
+
// No Origin is not proof of a local caller: another site's <img> or <link>
|
|
1895
|
+
// GET arrives without one. So every GET must stay read-only, with no side
|
|
1896
|
+
// effects; anything that changes state or spawns goes behind another method.
|
|
1727
1897
|
if (!origin) return true;
|
|
1728
|
-
|
|
1898
|
+
// Another port on localhost is another site: a project's dev server, or
|
|
1899
|
+
// anything an agent started. Only the office itself and its vite dev page.
|
|
1900
|
+
try {
|
|
1901
|
+
const o = new URL(origin);
|
|
1902
|
+
if (!LOCAL.has(o.hostname)) return false;
|
|
1903
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(o.hostname)) return true;
|
|
1904
|
+
return [String(PORT), DEV_PORT].includes(o.port);
|
|
1905
|
+
} catch { return false; }
|
|
1729
1906
|
}
|
|
1730
1907
|
|
|
1731
1908
|
function readBody(req) {
|
|
@@ -1750,8 +1927,18 @@ const json = (res, code, body) => {
|
|
|
1750
1927
|
};
|
|
1751
1928
|
|
|
1752
1929
|
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.woff': 'font/woff', '.svg': 'image/svg+xml', '.png': 'image/png', '.webmanifest': 'application/manifest+json', '.ico': 'image/x-icon' };
|
|
1930
|
+
// Build files are public code with nothing of the boss's in them; anything
|
|
1931
|
+
// else falls back to the page, which needs the key.
|
|
1932
|
+
function isAsset(pathname) {
|
|
1933
|
+
let f;
|
|
1934
|
+
try { f = path.join(DIST, path.normalize(decodeURIComponent(pathname)).replace(/^(\.\.[\/\\])+/, '')); } catch { return false; }
|
|
1935
|
+
if (!f.startsWith(DIST + path.sep) || f === path.join(DIST, 'index.html')) return false;
|
|
1936
|
+
try { return fs.statSync(f).isFile(); } catch { return false; }
|
|
1937
|
+
}
|
|
1753
1938
|
function serveStatic(req, res) {
|
|
1754
|
-
|
|
1939
|
+
let url;
|
|
1940
|
+
// A bad escape (`/%E0`) throws; outside a try that would take the office down.
|
|
1941
|
+
try { url = decodeURIComponent(new URL(req.url, 'http://x').pathname); } catch { return json(res, 400, { error: 'Bad path.' }); }
|
|
1755
1942
|
let file = path.join(DIST, path.normalize(url).replace(/^(\.\.[\/\\])+/, ''));
|
|
1756
1943
|
if (!file.startsWith(DIST)) return json(res, 403, { error: 'No.' });
|
|
1757
1944
|
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(DIST, 'index.html');
|
|
@@ -1770,7 +1957,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
1770
1957
|
if (!trusted(req)) return json(res, 403, { error: 'Local only.' });
|
|
1771
1958
|
const url = new URL(req.url, 'http://x');
|
|
1772
1959
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
1773
|
-
if (parts[0] !== 'api')
|
|
1960
|
+
if (parts[0] !== 'api') {
|
|
1961
|
+
if (url.searchParams.has('key')) {
|
|
1962
|
+
if (!sameKey(url.searchParams.get('key'))) return locked(res);
|
|
1963
|
+
url.searchParams.delete('key');
|
|
1964
|
+
res.writeHead(303, {
|
|
1965
|
+
'set-cookie': `${KEY_COOKIE}=${OFFICE_KEY}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`,
|
|
1966
|
+
location: url.pathname + url.search, 'cache-control': 'no-store',
|
|
1967
|
+
});
|
|
1968
|
+
return res.end();
|
|
1969
|
+
}
|
|
1970
|
+
if (!hasKey(req) && !isAsset(url.pathname)) return locked(res);
|
|
1971
|
+
return serveStatic(req, res);
|
|
1972
|
+
}
|
|
1973
|
+
// Agents prove who they are with their job token (caller); everyone else
|
|
1974
|
+
// with the office key.
|
|
1975
|
+
if (parts[1] !== 'agent' && !hasKey(req)) return json(res, 401, { error: 'Open the office with its link first.' });
|
|
1774
1976
|
|
|
1775
1977
|
try {
|
|
1776
1978
|
if (req.method === 'GET' && url.pathname === '/api/repos') return json(res, 200, { root: ROOT, repos: listRepos() });
|
|
@@ -1784,6 +1986,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1784
1986
|
if (req.method === 'POST' && parts[1] === 'agent' && agentApi[parts[2]]) {
|
|
1785
1987
|
const me = caller(req);
|
|
1786
1988
|
if (!me) return json(res, 403, { error: 'Not on the clock.' });
|
|
1989
|
+
if (me.guest && !GUEST_TOOLS.includes(`mcp__office__${parts[2]}`)) return json(res, 403, { error: 'Not on a guest’s job.' });
|
|
1990
|
+
if (me.guest && parts[2] === 'office_roster') return json(res, 200, { text: guestRoster(me) });
|
|
1787
1991
|
return json(res, 200, { text: agentApi[parts[2]](me, await readBody(req)) });
|
|
1788
1992
|
}
|
|
1789
1993
|
if (req.method === 'POST' && url.pathname === '/api/uploads') {
|
|
@@ -1800,14 +2004,40 @@ const server = http.createServer(async (req, res) => {
|
|
|
1800
2004
|
return json(res, 200, status);
|
|
1801
2005
|
}
|
|
1802
2006
|
// The leaderboard. Joining claims a handle with the service and starts
|
|
1803
|
-
// sharing day counters
|
|
2007
|
+
// sharing day counters. Leaving deletes the counters; the handle goes too
|
|
2008
|
+
// unless linked offices still need it.
|
|
1804
2009
|
if (url.pathname === '/api/leaderboard' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
|
|
1805
2010
|
let status;
|
|
1806
|
-
try {
|
|
1807
|
-
|
|
2011
|
+
try {
|
|
2012
|
+
if (req.method === 'GET') status = board.status();
|
|
2013
|
+
else if (req.method === 'POST') status = await board.join((await readBody(req)).handle);
|
|
2014
|
+
else {
|
|
2015
|
+
const keepHandle = links.links.length > 0;
|
|
2016
|
+
status = await board.leave({ keepHandle });
|
|
2017
|
+
if (!keepHandle) links.forget();
|
|
2018
|
+
}
|
|
2019
|
+
} catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
|
|
1808
2020
|
if (req.method !== 'GET') send({ t: 'leaderboard', leaderboard: status });
|
|
1809
2021
|
return json(res, 200, status);
|
|
1810
2022
|
}
|
|
2023
|
+
// A handle without joining the board, for linking offices.
|
|
2024
|
+
if (req.method === 'POST' && url.pathname === '/api/leaderboard/handle') {
|
|
2025
|
+
let status;
|
|
2026
|
+
try { status = await board.claim((await readBody(req)).handle); }
|
|
2027
|
+
catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
|
|
2028
|
+
send({ t: 'leaderboard', leaderboard: status });
|
|
2029
|
+
return json(res, 200, status);
|
|
2030
|
+
}
|
|
2031
|
+
// Linked offices: codes to make and use, and the links they made.
|
|
2032
|
+
if (url.pathname === '/api/links' || parts[1] === 'links') {
|
|
2033
|
+
const linkErr = (err) => json(res, err.code >= 400 && err.code < 600 ? err.code : 502, { error: err.message });
|
|
2034
|
+
try {
|
|
2035
|
+
if (req.method === 'GET' && url.pathname === '/api/links') return json(res, 200, await links.refresh().catch(() => links.status()));
|
|
2036
|
+
if (req.method === 'POST' && url.pathname === '/api/links/invite') return json(res, 201, await links.invite(await readBody(req)));
|
|
2037
|
+
if (req.method === 'POST' && url.pathname === '/api/links/redeem') return json(res, 200, await links.redeem(await readBody(req)));
|
|
2038
|
+
if (req.method === 'DELETE' && parts[2] && !parts[3]) return json(res, 200, await links.unlink(parts[2]));
|
|
2039
|
+
} catch (err) { return linkErr(err); }
|
|
2040
|
+
}
|
|
1811
2041
|
if (req.method === 'GET' && url.pathname === '/api/leaderboard/board') {
|
|
1812
2042
|
try { return json(res, 200, await board.board(url.searchParams.get('period'))); }
|
|
1813
2043
|
catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 502, { error: err.message }); }
|
|
@@ -1886,6 +2116,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
1886
2116
|
send(snapshot());
|
|
1887
2117
|
return json(res, 200, { ok: true });
|
|
1888
2118
|
}
|
|
2119
|
+
// Visiting an office that made us its guest: each call is a request to
|
|
2120
|
+
// the host over the relay, which checks it against the guest's limits.
|
|
2121
|
+
if (parts[1] === 'visit' && parts[2] && parts[3] === 'jobs') {
|
|
2122
|
+
const link = parts[2], id = parts[4];
|
|
2123
|
+
try {
|
|
2124
|
+
if (req.method === 'POST' && !id) return json(res, 201, await guests.request(link, 'createJob', await readBody(req)));
|
|
2125
|
+
if (req.method === 'GET' && id && !parts[5]) return json(res, 200, await guests.request(link, 'job', { job: id }));
|
|
2126
|
+
if (req.method === 'GET' && id && parts[5] === 'diff') return json(res, 200, await guests.request(link, 'diff', { job: id, path: url.searchParams.get('path') || '' }));
|
|
2127
|
+
if (req.method === 'POST' && id && parts[5] === 'reply') { const b = await readBody(req); return json(res, 200, await guests.request(link, 'reply', { job: id, text: b.text ?? b.message, mode: b.mode })); }
|
|
2128
|
+
if (req.method === 'POST' && id && parts[5] === 'stop') return json(res, 200, await guests.request(link, 'stop', { job: id }));
|
|
2129
|
+
} catch (err) {
|
|
2130
|
+
return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message });
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
1889
2133
|
if (parts[1] === 'jobs' && parts[2]) {
|
|
1890
2134
|
const job = state.jobs.find((j) => j.id === parts[2]);
|
|
1891
2135
|
if (!job) return json(res, 404, { error: 'No such job.' });
|
|
@@ -1911,6 +2155,7 @@ const termWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
|
|
|
1911
2155
|
server.on('upgrade', (req, socket, head) => {
|
|
1912
2156
|
const url = new URL(req.url, 'http://x');
|
|
1913
2157
|
if (!trusted(req)) return socket.destroy();
|
|
2158
|
+
if (!hasKey(req)) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return; }
|
|
1914
2159
|
if (url.pathname === '/ws') {
|
|
1915
2160
|
return wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1916
2161
|
clients.add(ws);
|
|
@@ -1918,6 +2163,14 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
1918
2163
|
ws.send(JSON.stringify(snapshot()));
|
|
1919
2164
|
});
|
|
1920
2165
|
}
|
|
2166
|
+
const visit = /^\/ws\/visit\/([\w-]{1,64})$/.exec(url.pathname);
|
|
2167
|
+
if (visit) {
|
|
2168
|
+
// The host's office, as its guest. Our own fields fill what the host
|
|
2169
|
+
// doesn't send, so the page has everything it expects.
|
|
2170
|
+
return wss.handleUpgrade(req, socket, head, (ws) => guests.visit(ws, visit[1], () => ({
|
|
2171
|
+
...snapshot(), dismissed: [], standup: null, chiefBusy: false, procs: null, visitors: [],
|
|
2172
|
+
})));
|
|
2173
|
+
}
|
|
1921
2174
|
// No terminal, no socket: with the door shut there is nothing to hold open
|
|
1922
2175
|
// while someone guesses at tickets.
|
|
1923
2176
|
if (url.pathname === '/ws/terminal' && terminals.on) {
|
|
@@ -1926,8 +2179,19 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
1926
2179
|
socket.destroy();
|
|
1927
2180
|
});
|
|
1928
2181
|
|
|
2182
|
+
// A taken port won't free up by trying again, so say so and tell the
|
|
2183
|
+
// supervisor not to retry (78).
|
|
2184
|
+
server.on('error', (err) => {
|
|
2185
|
+
if (err.code !== 'EADDRINUSE') throw err;
|
|
2186
|
+
console.error(`Port ${PORT} is already in use. Start the office with another: agentstudio --port <n>, or PORT=<n>.`);
|
|
2187
|
+
process.exit(78);
|
|
2188
|
+
});
|
|
1929
2189
|
server.listen(PORT, '127.0.0.1', () => {
|
|
1930
|
-
|
|
2190
|
+
// The key is in this line on purpose: it's how the boss gets in. It only
|
|
2191
|
+
// goes to this terminal (or the supervisor's), never to a log file.
|
|
2192
|
+
console.log(`Agent Office open on ${keyLink()} (projects in ${ROOT})`);
|
|
2193
|
+
// For `agentoffice --link`, which can't know which free port we got.
|
|
2194
|
+
try { fs.writeFileSync(path.join(DATA, 'office-port'), String(PORT)); } catch {}
|
|
1931
2195
|
// Shell access being open is not something to find out by accident.
|
|
1932
2196
|
terminals.status().then((t) => console.log(t.enabled
|
|
1933
2197
|
? `Terminal ON — anything in this browser gets a shell as ${process.env.USER || 'you'}. Up to ${t.max}, ${t.idleMinutes} min idle.`
|
|
@@ -1948,5 +2212,8 @@ function shutdown(code = 0) {
|
|
|
1948
2212
|
fs.writeFileSync(STATE_FILE, JSON.stringify(state));
|
|
1949
2213
|
process.exit(code);
|
|
1950
2214
|
}
|
|
2215
|
+
// One bad request must not take the office down: a crash skips shutdown(),
|
|
2216
|
+
// so state isn't saved and running agents are orphaned.
|
|
2217
|
+
process.on('unhandledRejection', (err) => console.error('[office] unhandled', err));
|
|
1951
2218
|
process.on('SIGINT', () => shutdown());
|
|
1952
2219
|
process.on('SIGTERM', () => shutdown());
|
package/server/leaderboard.js
CHANGED
|
@@ -11,8 +11,9 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
13
|
|
|
14
|
-
// Where the hosted service lives
|
|
15
|
-
|
|
14
|
+
// Where the hosted service lives (leaderboard/, deployed on Cloudflare).
|
|
15
|
+
// OFFICE_LEADERBOARD_URL overrides it, for a self-hosted board or a test.
|
|
16
|
+
const DEFAULT_URL = 'https://agent-office-leaderboard.agent-office-leaderboard.workers.dev';
|
|
16
17
|
const WINDOW_DAYS = 14;
|
|
17
18
|
const DEBOUNCE_MS = 45 * 1000;
|
|
18
19
|
const RETRY_MS = 10 * 60 * 1000;
|
|
@@ -39,7 +40,7 @@ export class Leaderboard {
|
|
|
39
40
|
try { this.me = JSON.parse(fs.readFileSync(this.idFile, 'utf8')); } catch {}
|
|
40
41
|
this.error = null;
|
|
41
42
|
this.lastSync = this.me.lastSync || 0;
|
|
42
|
-
this.dirty =
|
|
43
|
+
this.dirty = this.joined;
|
|
43
44
|
this.timer = null;
|
|
44
45
|
this.saveTimer = null;
|
|
45
46
|
this.busy = false;
|
|
@@ -47,12 +48,18 @@ export class Leaderboard {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
get url() { return (process.env.OFFICE_LEADERBOARD_URL || DEFAULT_URL).replace(/\/+$/, ''); }
|
|
50
|
-
|
|
51
|
+
// A handle is this office's name to the service, and linked offices need
|
|
52
|
+
// one too. Being on the board is a separate choice: `share` is false for an
|
|
53
|
+
// office that only claimed a handle. Older files have no `share`, and they
|
|
54
|
+
// all joined to share.
|
|
55
|
+
get named() { return !!(this.me.secret && this.me.handle); }
|
|
56
|
+
get joined() { return this.named && this.me.share !== false; }
|
|
51
57
|
|
|
52
58
|
status() {
|
|
53
59
|
return {
|
|
54
60
|
configured: !!this.url,
|
|
55
61
|
joined: this.joined,
|
|
62
|
+
sharing: this.joined,
|
|
56
63
|
handle: this.me.handle || null,
|
|
57
64
|
lastSync: this.lastSync || null,
|
|
58
65
|
error: this.error,
|
|
@@ -108,12 +115,31 @@ export class Leaderboard {
|
|
|
108
115
|
this.timer.unref?.();
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
// The service address, checked, for anything that talks to it: https (or
|
|
119
|
+
// this machine), and, when the office's secret goes along, the very service
|
|
120
|
+
// that secret was made for. Older files don't say which that was, so the
|
|
121
|
+
// first service they are used with is taken as it.
|
|
122
|
+
endpoint({ withSecret = true } = {}) {
|
|
123
|
+
const url = this.url;
|
|
124
|
+
if (!url) throw fail(400, 'No leaderboard is set up for this office yet. Set OFFICE_LEADERBOARD_URL.');
|
|
125
|
+
if (!/^https:\/\//.test(url) && !/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(url)) throw fail(400, 'The leaderboard address must be https.');
|
|
126
|
+
if (withSecret && this.me.secret) {
|
|
127
|
+
if (!this.me.service) {
|
|
128
|
+
this.me.service = url;
|
|
129
|
+
writeAtomic(this.idFile, this.me, 0o600);
|
|
130
|
+
} else if (this.me.service !== url) {
|
|
131
|
+
throw fail(421, `This office’s handle belongs to ${this.me.service}, so its secret isn’t sent to ${url}. Set OFFICE_LEADERBOARD_URL back to use it.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return url;
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
async call(method, route, { body, secret = this.me.secret } = {}) {
|
|
112
|
-
|
|
113
|
-
|
|
138
|
+
// A brand-new secret (a first claim) is made for whichever service this is.
|
|
139
|
+
const url = this.endpoint({ withSecret: !!secret && secret === this.me.secret });
|
|
114
140
|
let res;
|
|
115
141
|
try {
|
|
116
|
-
res = await fetch(
|
|
142
|
+
res = await fetch(url + route, {
|
|
117
143
|
method,
|
|
118
144
|
headers: { 'content-type': 'application/json', ...(secret ? { authorization: `Bearer ${secret}` } : {}) },
|
|
119
145
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -127,16 +153,27 @@ export class Leaderboard {
|
|
|
127
153
|
return out;
|
|
128
154
|
}
|
|
129
155
|
|
|
130
|
-
|
|
156
|
+
// Claims a handle without joining the board. An office that already has
|
|
157
|
+
// one keeps it; the service holds one handle per secret.
|
|
158
|
+
async claim(handle) {
|
|
159
|
+
if (this.named && (handle === undefined || String(handle).trim().toLowerCase() === this.me.handle.toLowerCase())) return this.status();
|
|
131
160
|
handle = String(handle ?? '').trim();
|
|
132
161
|
if (!HANDLE_RE.test(handle)) throw fail(400, 'A handle is 3 to 20 letters, numbers, - or _.');
|
|
133
162
|
// A secret made once and kept: if the answer to a claim is lost, asking
|
|
134
163
|
// again with the same secret is safe.
|
|
135
164
|
const secret = this.me.secret || crypto.randomBytes(32).toString('hex');
|
|
136
165
|
const out = await this.call('POST', '/v1/claim', { body: { handle }, secret });
|
|
137
|
-
this.me = { ...this.me, secret, handle: out.handle, lastSync: 0 };
|
|
166
|
+
this.me = { share: false, ...this.me, secret, service: this.url, handle: out.handle, lastSync: 0 };
|
|
138
167
|
writeAtomic(this.idFile, this.me, 0o600);
|
|
139
168
|
this.error = null;
|
|
169
|
+
this.onChange();
|
|
170
|
+
return this.status();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async join(handle) {
|
|
174
|
+
await this.claim(handle);
|
|
175
|
+
this.me.share = true;
|
|
176
|
+
writeAtomic(this.idFile, this.me, 0o600);
|
|
140
177
|
this.dirty = true;
|
|
141
178
|
this.later(500);
|
|
142
179
|
return this.status();
|
|
@@ -167,11 +204,23 @@ export class Leaderboard {
|
|
|
167
204
|
}
|
|
168
205
|
}
|
|
169
206
|
|
|
170
|
-
// Stops sharing and asks the service to
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
207
|
+
// Stops sharing and asks the service to delete the counters. With
|
|
208
|
+
// `keepHandle` (the office has linked offices) the handle stays, so the
|
|
209
|
+
// links do; otherwise the service forgets this office entirely. If it can't
|
|
210
|
+
// be reached nothing local changes, so the boss can try again rather than
|
|
211
|
+
// be left believing it is gone.
|
|
212
|
+
async leave({ keepHandle = false } = {}) {
|
|
213
|
+
if (keepHandle && this.named) {
|
|
214
|
+
try { await this.call('DELETE', '/v1/days'); }
|
|
215
|
+
catch (err) { if (err.code !== 401) throw err; }
|
|
216
|
+
clearTimeout(this.timer);
|
|
217
|
+
this.me.share = false;
|
|
218
|
+
writeAtomic(this.idFile, this.me, 0o600);
|
|
219
|
+
this.dirty = false;
|
|
220
|
+
this.error = null;
|
|
221
|
+
return this.status();
|
|
222
|
+
}
|
|
223
|
+
if (this.named) {
|
|
175
224
|
try { await this.call('DELETE', '/v1/me'); }
|
|
176
225
|
catch (err) { if (err.code !== 401) throw err; } // 401: the service has already forgotten it
|
|
177
226
|
}
|