agent-office-studio 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/guests.js CHANGED
@@ -6,8 +6,12 @@
6
6
  // clearance cap, tokens per day). The guest's office is never trusted: it
7
7
  // only ever sees its own jobs, and the feed it gets is cut down to them.
8
8
  //
9
- // As guest, the browser opens /ws/visit/:link. We ask the host for a hello,
10
- // then pass on its feed; the composer's posts become `req` frames.
9
+ // As guest, we keep the host's hello (its agents, the projects and clearance
10
+ // it shares, our jobs there) for as long as the link lives, and fold its feed
11
+ // into our own office: the boss tags the host's agents in our chat like our
12
+ // own, and their jobs sit in our list. Ids are namespaced `<link>.<id>` so a
13
+ // host can never name one of our jobs or agents. The older /ws/visit/:link
14
+ // page still works off the same requests.
11
15
  // See docs/proposals/linked-offices.md, "Guests".
12
16
 
13
17
  import crypto from 'node:crypto';
@@ -22,6 +26,9 @@ const CONNECT_WAIT_MS = 5_000;
22
26
  const FEED_EVERY_MS = 250;
23
27
  const FEED_BYTES = 48 * 1024; // under the relay's 64 KB a frame
24
28
  const FEED_TYPES = new Set(['job', 'log', 'staff']);
29
+ const HELLO_RETRY_MS = 30_000;
30
+ const REMOTE_LOG = 600;
31
+ const MAX_THREADS = 300;
25
32
  const utcDay = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 10);
26
33
  const fail = (code, message) => Object.assign(new Error(message), { code });
27
34
 
@@ -104,6 +111,10 @@ export function cleanFeed(m) {
104
111
  if (m.t === 'job') { const job = cleanJob(m.job); return job && { t: 'job', job }; }
105
112
  if (m.t === 'log') { const entry = cleanEntry(m.entry); return entry && id(m.jobId) && { t: 'log', jobId: m.jobId, entry }; }
106
113
  if (m.t === 'staff') return Array.isArray(m.dismissed) ? { t: 'staff', dismissed: m.dismissed.filter((x) => id(x)).slice(0, 50) } : null;
114
+ // The host changed what we may do there; we ask for its hello again.
115
+ if (m.t === 'policy') return { t: 'policy' };
116
+ // The host stopped one of our agents working in its project.
117
+ if (m.t === 'visit') return id(m.job) && m.ended === 'stopped' ? { t: 'visit', job: m.job, ended: 'stopped' } : null;
107
118
  return null;
108
119
  }
109
120
 
@@ -115,6 +126,13 @@ const CLEAN_BODY = {
115
126
  reply: () => ({ ok: true }),
116
127
  stop: () => ({ ok: true }),
117
128
  hello: (b) => b,
129
+ 'visit.open': (b) => {
130
+ if (typeof b.sid !== 'string' || !/^[0-9a-f]{32}$/.test(b.sid)) throw fail(502, 'The host sent back a session that doesn’t look right.');
131
+ return { sid: b.sid, access: b.access === 'edit' ? 'edit' : 'read' };
132
+ },
133
+ // What the host says about its own files goes only to our agent, as text.
134
+ 'visit.fs': (b) => ({ text: str(b.text, 64 * 1024) }),
135
+ 'visit.close': () => ({ ok: true }),
118
136
  };
119
137
 
120
138
  // What a guest may see of one of its own jobs.
@@ -125,16 +143,30 @@ export function guestJob(j, tail = 40) {
125
143
 
126
144
  export class Guests {
127
145
  // office: { jobs(), createJob(body, extra), replyJob(job, body), stopJob(job), diff(job, path), staff(), modes() }
128
- constructor(dataDir, { links, office }) {
146
+ // office also has send(msg), which broadcasts to our own browsers.
147
+ // visits (as host): our side of a guest's own agents working in our
148
+ // projects; see visits.js.
149
+ constructor(dataDir, { links, office, visits = null }) {
129
150
  this.links = links;
130
151
  this.office = office;
152
+ this.copies = visits;
131
153
  this.file = path.join(dataDir, 'guests.json');
132
154
  this.used = {};
133
- try { this.used = JSON.parse(fs.readFileSync(this.file, 'utf8')).used || {}; } catch {}
155
+ // Where each of our jobs at a host was asked for (chat thread and
156
+ // channel), and the project last used at each host.
157
+ this.asked = { threads: {}, lastRepo: {} };
158
+ try {
159
+ const saved = JSON.parse(fs.readFileSync(this.file, 'utf8'));
160
+ this.used = saved.used || {};
161
+ this.asked = { threads: saved.asked?.threads || {}, lastRepo: saved.asked?.lastRepo || {} };
162
+ } catch {}
163
+ this.remote = new Map(); // link id -> { link, host, staff, repos, clearance, jobs: Map(host job id -> job) }
164
+ this.helloing = new Map(); // link id -> retry-after time, while asking or after a failure
134
165
  this.queue = new Map(); // link id -> messages waiting for the next feed frame
135
166
  this.flushTimer = null;
136
167
  this.visits = new Map(); // link id -> Set of browser sockets (as guest)
137
168
  this.pending = new Map(); // req id -> { resolve, reject, timer } (as guest)
169
+ this.away = new Map(); // our job id -> { sid, access } while one of our agents works in a host's project
138
170
  }
139
171
 
140
172
  // Every req, res and feed frame on a guest link comes through here.
@@ -199,9 +231,21 @@ export class Guests {
199
231
  staff: this.office.staff(),
200
232
  modes: Object.fromEntries(Object.entries(this.office.modes()).filter(([k]) => CLEARANCE.indexOf(k) <= CLEARANCE.indexOf(p.clearance))),
201
233
  jobs: this.jobsOf(link.id).map((j) => guestJob(j)),
202
- policy: { repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id) },
234
+ policy: {
235
+ repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id),
236
+ ourAgents: p.ourAgents !== false, theirAgents: this.copies ? p.theirAgents || 'off' : 'off',
237
+ },
203
238
  };
239
+ case 'visit.open': case 'visit.fs': case 'visit.close': {
240
+ if (!this.copies) throw fail(400, 'Guests can’t do that here.');
241
+ if (op === 'visit.open') return this.copies.open(link, p, args);
242
+ // Every file call is held to the limits as they are now, not as they
243
+ // were when the work started.
244
+ if (!['read', 'edit'].includes(p.theirAgents)) throw fail(403, 'This office no longer lets your agents work in its projects.');
245
+ return op === 'visit.fs' ? this.copies.fs(link, args, p) : this.copies.close(link, args);
246
+ }
204
247
  case 'createJob': {
248
+ if (p.ourAgents === false) throw fail(403, 'This office lets your own agents work in its projects, but doesn’t lend its agents. Send one of yours instead.');
205
249
  if (!p.repos.includes(args.repo)) throw fail(403, 'That project isn’t shared with you.');
206
250
  const mode = this.capMode(p, args.mode);
207
251
  this.checkTokens(link, p);
@@ -214,6 +258,7 @@ export class Guests {
214
258
  return { job: guestJob(job) };
215
259
  }
216
260
  case 'reply': {
261
+ if (p.ourAgents === false) throw fail(403, 'This office doesn’t lend its agents any more.');
217
262
  const job = this.ownJob(link, args.job);
218
263
  const mode = args.mode === undefined ? undefined : this.capMode(p, args.mode);
219
264
  this.checkTokens(link, p);
@@ -254,7 +299,9 @@ export class Guests {
254
299
  for (const [id, u] of Object.entries(this.used)) if (u.day !== day) delete this.used[id];
255
300
  try {
256
301
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
257
- fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used }));
302
+ const threads = Object.entries(this.asked.threads);
303
+ if (threads.length > MAX_THREADS) this.asked.threads = Object.fromEntries(threads.slice(-MAX_THREADS));
304
+ fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used, asked: this.asked }));
258
305
  fs.renameSync(this.file + '.tmp', this.file);
259
306
  } catch (err) { console.error('[guests] could not save', err.message); }
260
307
  }
@@ -305,6 +352,7 @@ export class Guests {
305
352
  for (const ws of socks) ws.close(4404, 'This office unlinked you.');
306
353
  this.visits.delete(linkId);
307
354
  }
355
+ this.syncRemote();
308
356
  }
309
357
 
310
358
  // ---------- as guest ----------
@@ -343,11 +391,13 @@ export class Guests {
343
391
  }
344
392
 
345
393
  fromHost(link, msg) {
394
+ if (!Array.isArray(msg.msgs)) return;
346
395
  const socks = this.visits.get(link.id);
347
- if (!socks?.size || !Array.isArray(msg.msgs)) return;
348
396
  for (const raw of msg.msgs.slice(0, 500)) {
349
397
  const m = cleanFeed(raw);
350
398
  if (!m) continue;
399
+ this.remoteFeed(link, m);
400
+ if (!socks?.size) continue;
351
401
  const data = JSON.stringify(m);
352
402
  for (const ws of socks) if (ws.readyState === 1) ws.send(data);
353
403
  }
@@ -370,6 +420,7 @@ export class Guests {
370
420
  link: link.id, host: link.peer,
371
421
  repos: (Array.isArray(policy.repos) ? policy.repos : []).filter((r) => typeof r === 'string' && REPO.test(r)).slice(0, 50),
372
422
  clearance, tokensPerDay: count(policy.tokensPerDay), tokensToday: count(policy.tokensToday),
423
+ ourAgents: policy.ourAgents !== false, theirAgents: ['read', 'edit'].includes(policy.theirAgents) ? policy.theirAgents : 'off',
373
424
  },
374
425
  };
375
426
  }
@@ -388,4 +439,261 @@ export class Guests {
388
439
  if (ws.readyState !== 1) return;
389
440
  ws.send(JSON.stringify({ ...extra(), ...this.cleanHello(link, hello) }));
390
441
  }
442
+
443
+ // ---------- a host's agents, from our own office ----------
444
+
445
+ // Ask every host that made us its guest for its hello, once per link (and
446
+ // again after a failure, no sooner than HELLO_RETRY_MS). Cheap to call on
447
+ // every links change: it does nothing for hosts it already knows.
448
+ syncRemote() {
449
+ const hosts = this.links.links.filter((l) => l.kind === 'guest' && l.role === 'guest');
450
+ let gone = false;
451
+ for (const id of this.remote.keys()) if (!hosts.some((l) => l.id === id)) { this.remote.delete(id); gone = true; }
452
+ if (gone) this.office.send({ t: 'remote', offices: this.remoteStatus(), away: this.awayFolders() });
453
+ if (!this.links.status().connected) return;
454
+ for (const l of hosts) {
455
+ if (this.remote.has(l.id) || (this.helloing.get(l.id) || 0) > Date.now()) continue;
456
+ this.remoteHello(l).catch(() => {});
457
+ }
458
+ }
459
+
460
+ async remoteHello(link) {
461
+ this.helloing.set(link.id, Infinity);
462
+ try {
463
+ const hello = this.cleanHello(link, await this.request(link.id, 'hello'));
464
+ const entry = {
465
+ link: link.id, host: link.peer, staff: hello.staff, repos: hello.visiting.repos, clearance: hello.visiting.clearance,
466
+ ourAgents: hello.visiting.ourAgents, theirAgents: hello.visiting.theirAgents,
467
+ jobs: new Map(hello.jobs.map((j) => [j.id, j])),
468
+ };
469
+ if (!this.guestLinkOrNull(link.id)) return null; // unlinked while we asked
470
+ this.remote.set(link.id, entry);
471
+ this.helloing.delete(link.id);
472
+ this.office.send({ t: 'remote', offices: this.remoteStatus(), away: this.awayFolders() });
473
+ for (const j of entry.jobs.values()) this.office.send({ t: 'job', job: this.localJob(entry, j) });
474
+ return entry;
475
+ } catch (err) {
476
+ this.helloing.set(link.id, Date.now() + HELLO_RETRY_MS);
477
+ throw err;
478
+ }
479
+ }
480
+
481
+ guestLinkOrNull(linkId) {
482
+ try { return this.guestLink(linkId); } catch { return null; }
483
+ }
484
+
485
+ // A host's job as our page sees it: namespaced ids, where it was asked
486
+ // for, and which office it runs in. `from` is us, so it's dropped.
487
+ localJob(entry, j, tail = 40) {
488
+ const key = `${entry.link}.${j.id}`;
489
+ const { guest: _g, from: _f, ...rest } = j;
490
+ return {
491
+ ...rest, id: key, agentId: j.agentId ? `${entry.link}.${j.agentId}` : null,
492
+ remote: { link: entry.link, host: entry.host }, ...this.asked.threads[key],
493
+ log: (j.log || []).slice(-tail),
494
+ };
495
+ }
496
+
497
+ // The host's agents, for our page. `here` says who it has on the floor,
498
+ // from its presence; staff it hasn't hired can't be tagged.
499
+ remoteStatus() {
500
+ return [...this.remote.values()].map((e) => {
501
+ const present = this.links.presentAt(e.link);
502
+ const here = present && new Set(present.map((a) => a.id));
503
+ return {
504
+ link: e.link, host: e.host, repos: e.repos, clearance: e.clearance, theirAgents: e.theirAgents || 'off',
505
+ // A host that only lets our own agents in has none of its to tag.
506
+ staff: e.ourAgents === false ? [] : e.staff.map((s) => ({ ...s, id: `${e.link}.${s.id}`, here: here ? here.has(s.id) : true })),
507
+ };
508
+ });
509
+ }
510
+
511
+ remoteJobs() {
512
+ return [...this.remote.values()].flatMap((e) => [...e.jobs.values()].map((j) => this.localJob(e, j)));
513
+ }
514
+
515
+ // One feed message from a host, folded into our office.
516
+ remoteFeed(link, m) {
517
+ if (m.t === 'policy') {
518
+ this.remote.delete(link.id);
519
+ this.helloing.delete(link.id);
520
+ this.syncRemote();
521
+ return;
522
+ }
523
+ if (m.t === 'visit') {
524
+ this.office.awayStopped?.(m.job, link.id);
525
+ return;
526
+ }
527
+ const entry = this.remote.get(link.id);
528
+ if (!entry) return;
529
+ if (m.t === 'job') {
530
+ const prev = entry.jobs.get(m.job.id);
531
+ const job = { ...m.job, log: prev?.log || [] };
532
+ entry.jobs.set(job.id, job);
533
+ this.office.send({ t: 'job', job: this.localJob(entry, job, 0) });
534
+ if (prev?.status === 'working' && job.status !== 'working' && job.status !== 'queued') this.office.remoteEnded?.(this.localJob(entry, job, 1), prev);
535
+ } else if (m.t === 'log') {
536
+ const job = entry.jobs.get(m.jobId);
537
+ if (!job) return;
538
+ job.log.push(m.entry);
539
+ if (job.log.length > REMOTE_LOG) job.log.splice(0, job.log.length - REMOTE_LOG);
540
+ this.office.send({ t: 'log', jobId: `${entry.link}.${job.id}`, entry: m.entry });
541
+ }
542
+ }
543
+
544
+ // `<link>.<host job id>` -> the host and its id, or null if it isn't one.
545
+ remoteKey(key) {
546
+ const m = /^([A-Za-z0-9_-]{1,64})\.([A-Za-z0-9_-]{1,64})$/.exec(String(key ?? ''));
547
+ const entry = m && this.remote.get(m[1]);
548
+ return entry ? { entry, id: m[2] } : null;
549
+ }
550
+
551
+ // The remote job a chat thread is about, if any.
552
+ threadJob(root) {
553
+ const key = Object.keys(this.asked.threads).findLast((k) => this.asked.threads[k].thread === root);
554
+ return key && this.remoteKey(key) ? key : null;
555
+ }
556
+
557
+ // The highest clearance at or below `mode` that the host allows.
558
+ capRemote(entry, mode) {
559
+ const want = CLEARANCE.includes(mode) ? mode : 'edit';
560
+ return CLEARANCE[Math.min(CLEARANCE.indexOf(want), CLEARANCE.indexOf(entry.clearance))];
561
+ }
562
+
563
+ // What /api/jobs/<link>.<id> does for a host's job.
564
+ async remoteOp(key, op, args = {}) {
565
+ const r = this.remoteKey(key);
566
+ if (!r) throw fail(404, 'No such job.');
567
+ const { entry, id } = r;
568
+ if (op === 'job') {
569
+ const { job } = await this.request(entry.link, 'job', { job: id });
570
+ entry.jobs.set(job.id, job);
571
+ return this.localJob(entry, job, 500);
572
+ }
573
+ if (op === 'diff') return this.request(entry.link, 'diff', { job: id, path: String(args.path ?? '') });
574
+ if (op === 'stop') return this.request(entry.link, 'stop', { job: id });
575
+ if (op === 'reply') {
576
+ if (Array.isArray(args.files) && args.files.length) throw fail(400, `Files can’t go to @${entry.host}’s office. Paste the text instead.`);
577
+ const text = String(args.message ?? args.text ?? '').trim();
578
+ if (!text) throw fail(400, 'Say something first.');
579
+ return this.request(entry.link, 'reply', { job: id, text, mode: args.mode === undefined ? undefined : this.capRemote(entry, args.mode) });
580
+ }
581
+ throw fail(400, 'Can’t do that with a linked office’s job.');
582
+ }
583
+
584
+ // A host agent tagged in `text`. `@Name@host` always means the host's;
585
+ // a bare `@Name` only when none of ours answers to it (isLocal).
586
+ tagged(text, isLocal) {
587
+ const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
588
+ const offices = this.remoteStatus();
589
+ const find = (name, host) => {
590
+ for (const o of offices) {
591
+ if (host && norm(o.host) !== norm(host)) continue;
592
+ const s = o.staff.find((x) => x.here && norm(x.name) === norm(name));
593
+ if (s) return { link: o.link, host: o.host, staff: s };
594
+ }
595
+ return null;
596
+ };
597
+ for (const m of text.matchAll(/@([\w-]+)@([\w-]+)/g)) {
598
+ const hit = find(m[1], m[2]);
599
+ if (hit) return { ...hit, clean: text.replace(m[0], ' ').replace(/[ \t]+/g, ' ').trim() };
600
+ }
601
+ for (const m of text.matchAll(/(^|[^\w@])@([\w-]+)(?![\w@-])/g)) {
602
+ if (isLocal(m[2])) continue;
603
+ const hit = find(m[2]);
604
+ if (hit) {
605
+ const re = new RegExp(`@${m[2]}(?![\\w@-])`);
606
+ return { ...hit, clean: text.replace(re, ' ').replace(/[ \t]+/g, ' ').trim() };
607
+ }
608
+ }
609
+ return null;
610
+ }
611
+
612
+ // The project a job at a host goes in: the only one it shares, one tagged
613
+ // or named in the brief, or the one used there last time.
614
+ remoteRepo(entry, text) {
615
+ const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
616
+ const { repos } = entry;
617
+ if (!repos.length) throw fail(403, `@${entry.host} hasn’t shared any projects with you.`);
618
+ if (repos.length === 1) return { repo: repos[0], text };
619
+ const tags = [...text.matchAll(/(^|\s)#([\w.-]+)/g)];
620
+ for (const t of tags) {
621
+ const repo = repos.find((r) => norm(r) === norm(t[2]));
622
+ if (repo) return { repo, text: text.replace(t[0], ' ').replace(/[ \t]+/g, ' ').trim() };
623
+ }
624
+ const said = norm(text);
625
+ const named = repos.filter((r) => norm(r).length >= 3 && said.includes(norm(r)));
626
+ if (named.length === 1) return { repo: named[0], text };
627
+ const last = this.asked.lastRepo[entry.link];
628
+ if (repos.includes(last)) return { repo: last, text };
629
+ throw fail(400, `Which of @${entry.host}’s projects is that for? Say ${repos.map((r) => r).join(', ')} in the message.`);
630
+ }
631
+
632
+ // ---------- our agents in a host's projects ----------
633
+ //
634
+ // A host can let our own agents work in its projects: the agent runs here,
635
+ // on our account, and its file tools are requests to the host (visit.*),
636
+ // which works on a copy and answers in text. See visits.js for the host's side.
637
+
638
+ // `#web@friend`: a host's project our agents may work in, or null.
639
+ awayFolder(repo, host) {
640
+ const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
641
+ for (const e of this.remote.values()) {
642
+ if (norm(e.host) !== norm(host) || !['read', 'edit'].includes(e.theirAgents)) continue;
643
+ const r = e.repos.find((x) => norm(x) === norm(repo));
644
+ if (r) return { link: e.link, host: e.host, repo: r, access: e.theirAgents };
645
+ }
646
+ return null;
647
+ }
648
+
649
+ // Every host project our agents may work in, for the page's # menu.
650
+ awayFolders() {
651
+ return [...this.remote.values()].filter((e) => ['read', 'edit'].includes(e.theirAgents))
652
+ .flatMap((e) => e.repos.map((repo) => ({ link: e.link, host: e.host, repo, access: e.theirAgents })));
653
+ }
654
+
655
+ // One file call from our agent's job, as a request to the host. The first
656
+ // opens the session there; a session the host has lost is opened again once.
657
+ async awayFs(job, agent, op, args) {
658
+ const { link, repo } = job.away;
659
+ const open = async () => {
660
+ const s = await this.request(link, 'visit.open', { repo, job: job.id, title: job.title, agent });
661
+ this.away.set(job.id, s);
662
+ job.away.access = s.access;
663
+ return s;
664
+ };
665
+ let s = this.away.get(job.id) || await open();
666
+ try {
667
+ return (await this.request(link, 'visit.fs', { ...args, sid: s.sid, op })).text;
668
+ } catch (err) {
669
+ if (err.code !== 404 || !/session has ended/.test(err.message)) throw err;
670
+ this.away.delete(job.id);
671
+ s = await open();
672
+ return (await this.request(link, 'visit.fs', { ...args, sid: s.sid, op })).text;
673
+ }
674
+ }
675
+
676
+ // Our job ended: the host closes its side and works out what changed.
677
+ async awayClose(job, status, summary) {
678
+ const s = this.away.get(job.id);
679
+ if (!s) return;
680
+ this.away.delete(job.id);
681
+ await this.request(job.away.link, 'visit.close', { sid: s.sid, status, summary: String(summary || '').slice(0, 2000) });
682
+ }
683
+
684
+ // Start a job with one of a host's agents, from our chat.
685
+ async startRemote({ link, staff, text, mode, model, thread, channel }) {
686
+ const entry = this.remote.get(link) || await this.remoteHello(this.guestLink(link));
687
+ const { repo, text: brief } = this.remoteRepo(entry, text);
688
+ if (!brief) throw fail(400, 'Say what the job is, as well as who it’s for.');
689
+ const agentId = staff.id.slice(link.length + 1);
690
+ const { job } = await this.request(link, 'createJob', { repo, prompt: brief, mode: this.capRemote(entry, mode), model, agentId });
691
+ this.asked.threads[`${link}.${job.id}`] = { thread, channel };
692
+ this.asked.lastRepo[link] = repo;
693
+ this.save();
694
+ entry.jobs.set(job.id, job);
695
+ const local = this.localJob(entry, job);
696
+ this.office.send({ t: 'job', job: local });
697
+ return { job: local, entry, repo, capped: this.capRemote(entry, mode) !== mode };
698
+ }
391
699
  }
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Finds a project's own app icon or favicon so the office can show it beside
5
+ // the project's name. Looks in the usual places, then a shallow scan.
6
+
7
+ const TYPES = { '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
8
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.expo', 'Pods', 'vendor', 'coverage', 'out', 'target', 'data', 'DerivedData']);
9
+ const NAME = /^(favicon|app-?icon|apple-touch-icon|icon|logo|ic_launcher)([-_.@x\d\w]*)\.(svg|png|ico|jpe?g|webp)$/i;
10
+ const MAX_BYTES = 1024 * 1024;
11
+ const MAX_DEPTH = 4;
12
+ const MAX_VISITS = 1500;
13
+
14
+ const score = (rel, size) => {
15
+ const parts = rel.split(path.sep);
16
+ const base = parts[parts.length - 1].toLowerCase();
17
+ const ext = path.extname(base);
18
+ let s = 0;
19
+ if (/^(app-?icon|icon)\./.test(base)) s += 40;
20
+ else if (/^apple-touch-icon/.test(base)) s += 36;
21
+ else if (/^favicon/.test(base)) s += 30;
22
+ else if (/^logo/.test(base)) s += 20;
23
+ else s += 10;
24
+ if (ext === '.svg') s += 12; else if (ext === '.png') s += 8; else if (ext === '.ico') s -= 4;
25
+ if (parts.some((p) => /^(public|assets|static|resources|app|icons?|images|web)$/i.test(p))) s += 6;
26
+ if (parts.some((p) => /^(test|tests|__tests__|docs?|examples?|fixtures?|storybook|screenshots?)$/i.test(p))) s -= 25;
27
+ s -= (parts.length - 1) * 3;
28
+ s += Math.min(size / 20000, 4); // among equals, the sharper one
29
+ return s;
30
+ };
31
+
32
+ function scan(root) {
33
+ const found = [];
34
+ let visits = 0;
35
+ const walk = (dir, depth) => {
36
+ let entries;
37
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
38
+ for (const e of entries) {
39
+ if (++visits > MAX_VISITS) return;
40
+ const full = path.join(dir, e.name);
41
+ if (e.isDirectory()) {
42
+ if (!SKIP.has(e.name) && !e.name.startsWith('.') && depth < MAX_DEPTH) {
43
+ // iOS keeps its icons in an appiconset; the biggest PNG is the one to use.
44
+ if (e.name === 'AppIcon.appiconset') {
45
+ try {
46
+ const pngs = fs.readdirSync(full).filter((f) => /\.png$/i.test(f)).map((f) => [f, fs.statSync(path.join(full, f)).size]).sort((a, b) => b[1] - a[1]);
47
+ if (pngs[0] && pngs[0][1] <= MAX_BYTES) found.push({ full: path.join(full, pngs[0][0]), score: 38 - depth * 3 });
48
+ } catch {}
49
+ } else walk(full, depth + 1);
50
+ }
51
+ } else if (e.isFile() && NAME.test(e.name)) {
52
+ try {
53
+ const size = fs.statSync(full).size;
54
+ if (size > 0 && size <= MAX_BYTES) found.push({ full, score: score(path.relative(root, full), size) });
55
+ } catch {}
56
+ }
57
+ }
58
+ };
59
+ walk(root, 0);
60
+ return found.sort((a, b) => b.score - a.score)[0]?.full || null;
61
+ }
62
+
63
+ const cache = new Map(); // repo dir -> { at, file }
64
+ const TTL = 5 * 60 * 1000;
65
+
66
+ // Returns { file, type } for a project folder, or null when it has no icon.
67
+ export function findIcon(dir) {
68
+ const hit = cache.get(dir);
69
+ if (hit && Date.now() - hit.at < TTL) return hit.file;
70
+ let file = null;
71
+ try {
72
+ const found = scan(dir);
73
+ const real = found && fs.realpathSync(found);
74
+ // A symlink must not lead out of the project.
75
+ if (real && real.startsWith(fs.realpathSync(dir) + path.sep)) {
76
+ const type = TYPES[path.extname(real).toLowerCase()];
77
+ if (type) file = { file: real, type };
78
+ }
79
+ } catch {}
80
+ cache.set(dir, { at: Date.now(), file });
81
+ return file;
82
+ }