@oddessentials/agent-guild 0.24.1 → 0.26.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.
Files changed (80) hide show
  1. package/README.md +46 -11
  2. package/bin/agent-guild.mjs +2 -0
  3. package/package.json +2 -2
  4. package/src/manager/folder-opener.mjs +96 -0
  5. package/src/manager/folder-picker.mjs +116 -0
  6. package/src/manager/folder-picker.ps1 +84 -0
  7. package/src/manager/github-views.mjs +171 -0
  8. package/src/manager/github.mjs +56 -1
  9. package/src/manager/main.mjs +4 -1
  10. package/src/manager/server.mjs +81 -12
  11. package/web/activity-favicon.js +71 -0
  12. package/web/app.js +1136 -94
  13. package/web/index.html +140 -91
  14. package/web/layout.js +75 -0
  15. package/web/repo-search.js +91 -0
  16. package/web/skins/gnomeland/skin.css +1 -1
  17. package/web/skins/goblinville/characters/anthropic/idle.avif +0 -0
  18. package/web/skins/goblinville/characters/anthropic/idle.webp +0 -0
  19. package/web/skins/goblinville/characters/anthropic/locked.avif +0 -0
  20. package/web/skins/goblinville/characters/anthropic/locked.webp +0 -0
  21. package/web/skins/goblinville/characters/anthropic/working.avif +0 -0
  22. package/web/skins/goblinville/characters/anthropic/working.webp +0 -0
  23. package/web/skins/goblinville/characters/google/idle.avif +0 -0
  24. package/web/skins/goblinville/characters/google/idle.webp +0 -0
  25. package/web/skins/goblinville/characters/google/locked.avif +0 -0
  26. package/web/skins/goblinville/characters/google/locked.webp +0 -0
  27. package/web/skins/goblinville/characters/google/working.avif +0 -0
  28. package/web/skins/goblinville/characters/google/working.webp +0 -0
  29. package/web/skins/goblinville/characters/openai/idle.avif +0 -0
  30. package/web/skins/goblinville/characters/openai/idle.webp +0 -0
  31. package/web/skins/goblinville/characters/openai/locked.avif +0 -0
  32. package/web/skins/goblinville/characters/openai/locked.webp +0 -0
  33. package/web/skins/goblinville/characters/openai/working.avif +0 -0
  34. package/web/skins/goblinville/characters/openai/working.webp +0 -0
  35. package/web/skins/goblinville/characters/shell/idle.avif +0 -0
  36. package/web/skins/goblinville/characters/shell/idle.webp +0 -0
  37. package/web/skins/goblinville/characters/shell/locked.avif +0 -0
  38. package/web/skins/goblinville/characters/shell/locked.webp +0 -0
  39. package/web/skins/goblinville/characters/shell/working.avif +0 -0
  40. package/web/skins/goblinville/characters/shell/working.webp +0 -0
  41. package/web/skins/goblinville/characters/xai/idle.avif +0 -0
  42. package/web/skins/goblinville/characters/xai/idle.webp +0 -0
  43. package/web/skins/goblinville/characters/xai/locked.avif +0 -0
  44. package/web/skins/goblinville/characters/xai/locked.webp +0 -0
  45. package/web/skins/goblinville/characters/xai/working.avif +0 -0
  46. package/web/skins/goblinville/characters/xai/working.webp +0 -0
  47. package/web/skins/goblinville/familiars/aether.png +0 -0
  48. package/web/skins/goblinville/familiars/flame.png +0 -0
  49. package/web/skins/goblinville/familiars/leaf.png +0 -0
  50. package/web/skins/goblinville/familiars/night.png +0 -0
  51. package/web/skins/goblinville/fonts/OFL.txt +94 -0
  52. package/web/skins/goblinville/fonts/germania-one.woff2 +0 -0
  53. package/web/skins/goblinville/icons/anthropic.avif +0 -0
  54. package/web/skins/goblinville/icons/anthropic.webp +0 -0
  55. package/web/skins/goblinville/icons/google.avif +0 -0
  56. package/web/skins/goblinville/icons/google.webp +0 -0
  57. package/web/skins/goblinville/icons/openai.avif +0 -0
  58. package/web/skins/goblinville/icons/openai.webp +0 -0
  59. package/web/skins/goblinville/icons/shell.avif +0 -0
  60. package/web/skins/goblinville/icons/shell.webp +0 -0
  61. package/web/skins/goblinville/icons/xai.avif +0 -0
  62. package/web/skins/goblinville/icons/xai.webp +0 -0
  63. package/web/skins/goblinville/page-light.avif +0 -0
  64. package/web/skins/goblinville/page-light.webp +0 -0
  65. package/web/skins/goblinville/page.avif +0 -0
  66. package/web/skins/goblinville/page.webp +0 -0
  67. package/web/skins/goblinville/skin.css +422 -0
  68. package/web/skins/goblinville/ui/empty-state.avif +0 -0
  69. package/web/skins/goblinville/ui/empty-state.webp +0 -0
  70. package/web/skins/goblinville/ui/frame.svg +1 -0
  71. package/web/skins/goblinville/ui/gauge.svg +1 -0
  72. package/web/skins/goblinville/ui/level.svg +1 -0
  73. package/web/skins/goblinville/ui/lock.svg +1 -0
  74. package/web/skins/goblinville/ui/nut.svg +1 -0
  75. package/web/skins/goblinville/ui/tube.svg +1 -0
  76. package/web/skins/grove/skin.css +1 -1
  77. package/web/skins/guild/skin.css +3 -3
  78. package/web/skins/orbital/skin.css +1 -1
  79. package/web/styles.css +246 -38
  80. package/web/theme.js +1 -0
@@ -101,6 +101,34 @@ export function localState(target, fullName) {
101
101
  }
102
102
  }
103
103
 
104
+ /** The GitHub repository (lowercase owner/name) that `dir`, or the Git work tree holding it, has as its origin. */
105
+ export function folderOrigin(dir) {
106
+ for (let current = path.resolve(dir); ; current = path.dirname(current)) {
107
+ const dotGit = path.join(current, '.git');
108
+ let stat = null;
109
+ try { stat = fs.statSync(dotGit); } catch { /* look further up */ }
110
+ if (stat) {
111
+ try {
112
+ return remoteRepo(originUrl(fs.readFileSync(path.join(commonGitDir(dotGit, stat), 'config'), 'utf8')));
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+ if (path.dirname(current) === current) return null;
118
+ }
119
+ }
120
+
121
+ /** A worktree's .git is a file naming its own Git folder, whose `commondir` names the folder holding the shared config. */
122
+ function commonGitDir(dotGit, stat) {
123
+ if (stat.isDirectory()) return dotGit;
124
+ const named = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, 'utf8'));
125
+ if (!named) throw new Error('no gitdir');
126
+ const gitDir = path.resolve(path.dirname(dotGit), named[1]);
127
+ let common = null;
128
+ try { common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim(); } catch { /* not a worktree */ }
129
+ return common ? path.resolve(gitDir, common) : gitDir;
130
+ }
131
+
104
132
  /** Quote one word for the POSIX shell Git runs core.sshCommand with (Git for Windows ships one too). */
105
133
  export function shellQuote(word) {
106
134
  return `'${String(word).replaceAll('\'', '\'\\\'\'')}'`;
@@ -395,7 +423,7 @@ export class GitHub extends EventEmitter {
395
423
  if (!res.ok) {
396
424
  const body = await res.json().catch(() => null);
397
425
  const limited = (res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0';
398
- throw refusal(502, 'github_error', limited ? 'GitHub API rate limit exceeded; try again later' : `GitHub answered HTTP ${res.status}${body?.message ? `: ${body.message}` : ''}`, { github: res.status });
426
+ throw refusal(502, 'github_error', limited ? 'GitHub API rate limit exceeded; try again later' : `GitHub answered HTTP ${res.status}${body?.message ? `: ${body.message}` : ''}`, { github: res.status, limited });
399
427
  }
400
428
  return { body: await res.json(), res };
401
429
  }
@@ -613,6 +641,33 @@ export class GitHub extends EventEmitter {
613
641
  };
614
642
  }
615
643
 
644
+ /** Every signed-in account's repositories in one list. One account's failure does not hide the others'. */
645
+ async allRepos({ refresh = false } = {}) {
646
+ const settled = await Promise.all(this.accounts.map(async (account) => {
647
+ try {
648
+ return { account, page: await this.repos(account.id, { refresh }) };
649
+ } catch (err) {
650
+ return { account, err };
651
+ }
652
+ }));
653
+ const repos = [];
654
+ const errors = [];
655
+ let truncated = false;
656
+ let oldest = null;
657
+ for (const { account, page, err } of settled) {
658
+ if (err) {
659
+ errors.push({ accountId: account.id, login: account.login, code: err.code || 'github_error', message: err.status ? err.message : 'Could not list repositories' });
660
+ continue;
661
+ }
662
+ truncated ||= page.truncated;
663
+ const at = Date.parse(page.fetchedAt);
664
+ if (oldest === null || at < oldest) oldest = at;
665
+ for (const { target, local, ...repo } of page.repos) repos.push({ accountId: account.id, login: account.login, ...repo });
666
+ }
667
+ repos.sort((a, b) => (Date.parse(b.pushedAt) || 0) - (Date.parse(a.pushedAt) || 0) || a.fullName.localeCompare(b.fullName) || a.accountId - b.accountId);
668
+ return { repos, truncated, errors, fetchedAt: oldest === null ? null : new Date(oldest).toISOString() };
669
+ }
670
+
616
671
  async _loadRepos(account) {
617
672
  const seen = new Map();
618
673
  let url = '/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member';
@@ -12,7 +12,7 @@ import { ModelStats } from './model-stats.mjs';
12
12
  import { NewsFeed } from './news.mjs';
13
13
  import { Changelog } from './changelog.mjs';
14
14
  import { GitHub } from './github.mjs';
15
- import { createManagerServer } from './server.mjs';
15
+ import { createManagerServer, parseAllowedHosts } from './server.mjs';
16
16
  import { SelfUpdate } from './self-update.mjs';
17
17
  import { resolveBaseEnv, pathReader } from './shell-env.mjs';
18
18
  import { writeReportShims } from './report-shims.mjs';
@@ -39,6 +39,8 @@ const VERSION_REFRESH_MS = 60 * 60 * 1000;
39
39
 
40
40
  /** `version`, `packageFile` and `github` (GitHub's URLs and client id) stand in for the real ones in tests. */
41
41
  export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {} } = {}) {
42
+ // Validate before creating files, processes or timers, so a typo fails startup cleanly.
43
+ const extraHosts = parseAllowedHosts(process.env.AGENT_GUILD_ALLOWED_HOSTS);
42
44
  ensureDataDir();
43
45
  const token = loadOrCreateToken();
44
46
  const baseEnv = resolveBaseEnv();
@@ -133,6 +135,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
133
135
  webDir,
134
136
  version,
135
137
  selfUpdate,
138
+ extraHosts,
136
139
  extraOrigins,
137
140
  launcher: launcherPath(),
138
141
  onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
@@ -4,9 +4,14 @@
4
4
  import http from 'node:http';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import { isIP } from 'node:net';
7
8
  import { createRequire } from 'node:module';
8
9
  import { WebSocketServer } from 'ws';
9
10
  import { timingSafeEqualString } from './session-manager.mjs';
11
+ import { folderOrigin } from './github.mjs';
12
+ import { createViews } from './github-views.mjs';
13
+ import { createFolderOpener } from './folder-opener.mjs';
14
+ import { createFolderPicker } from './folder-picker.mjs';
10
15
 
11
16
  const require = createRequire(import.meta.url);
12
17
  const API = '/api/v1';
@@ -57,15 +62,19 @@ class HttpError extends Error {
57
62
  }
58
63
  }
59
64
 
60
- function sendJson(res, status, body) {
61
- const payload = JSON.stringify(body);
62
- res.writeHead(status, {
63
- ...SECURITY_HEADERS,
64
- 'Content-Type': 'application/json; charset=utf-8',
65
- 'Cache-Control': 'no-store',
66
- 'Content-Length': Buffer.byteLength(payload),
67
- });
68
- res.end(payload);
65
+ /** Exact Host authorities, also safe to include as CSP WebSocket sources. */
66
+ export function parseAllowedHosts(value = '') {
67
+ const entries = value.split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean);
68
+ for (const entry of entries) {
69
+ const match = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([1-9]\d{0,4}))?$/.exec(entry);
70
+ const name = match?.[1];
71
+ const validName = name?.startsWith('[') ? isIP(name.slice(1, -1)) === 6
72
+ : name && name.length <= 253 && name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
73
+ if (!validName || (match[2] && Number(match[2]) > 65535)) {
74
+ throw new Error(`Invalid AGENT_GUILD_ALLOWED_HOSTS entry ${JSON.stringify(entry)}. Use a hostname with an optional port, such as guild.example.ts.net or guild.example.ts.net:8443; URLs and wildcards are not allowed.`);
75
+ }
76
+ }
77
+ return [...new Set(entries)];
69
78
  }
70
79
 
71
80
  function readJsonBody(req) {
@@ -115,18 +124,28 @@ export function createManagerServer({
115
124
  webDir,
116
125
  version = '0.0.0',
117
126
  selfUpdate = null,
127
+ extraHosts = [],
118
128
  extraOrigins = [],
129
+ folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
130
+ folderPicker = createFolderPicker({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
119
131
  /** The double-click launcher file for this platform, or null when the package carries none. */
120
132
  launcher = null,
121
133
  /** @type {(opts: { restart: boolean }) => void} */
122
134
  onShutdownRequest = () => {},
123
135
  }) {
136
+ const proxyHosts = parseAllowedHosts(extraHosts.join(','));
137
+ const socketSources = proxyHosts.flatMap((authority) => [`ws://${authority}`, `wss://${authority}`]);
138
+ const securityHeaders = {
139
+ ...SECURITY_HEADERS,
140
+ 'Content-Security-Policy': SECURITY_HEADERS['Content-Security-Policy'].replace(
141
+ "connect-src 'self'", ["connect-src 'self'", ...socketSources].join(' ')),
142
+ };
124
143
  const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
125
144
  const startedAt = new Date().toISOString();
126
145
  const vendor = vendorFiles();
127
146
  let boundPort = port;
128
147
 
129
- const allowedHosts = () => new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`]);
148
+ const allowedHosts = () => new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...proxyHosts]);
130
149
  const allowedOrigins = () =>
131
150
  new Set([`http://127.0.0.1:${boundPort}`, `http://localhost:${boundPort}`, `http://[::1]:${boundPort}`, ...extraOrigins]);
132
151
 
@@ -154,13 +173,24 @@ export function createManagerServer({
154
173
  }
155
174
  }
156
175
 
176
+ function sendJson(res, status, body) {
177
+ const payload = JSON.stringify(body);
178
+ res.writeHead(status, {
179
+ ...securityHeaders,
180
+ 'Content-Type': 'application/json; charset=utf-8',
181
+ 'Cache-Control': 'no-store',
182
+ 'Content-Length': Buffer.byteLength(payload),
183
+ });
184
+ res.end(payload);
185
+ }
186
+
157
187
  function serveFile(req, res, file, { cache = false } = {}) {
158
188
  const notFound = () => sendJson(res, 404, { error: { code: 'not_found', message: 'not found' } });
159
189
  fs.stat(file, (statErr, stat) => {
160
190
  if (statErr || !stat.isFile()) return notFound();
161
191
  const etag = `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
162
192
  const headers = {
163
- ...SECURITY_HEADERS,
193
+ ...securityHeaders,
164
194
  'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
165
195
  'Cache-Control': cache ? 'public, max-age=3600' : 'no-cache',
166
196
  ETag: etag,
@@ -239,8 +269,21 @@ export function createManagerServer({
239
269
  warnings: registry.warnings,
240
270
  upgrade: upgradeInfo(),
241
271
  launcher,
272
+ folderOpener: folderOpener.describe(),
273
+ folderPicker: folderPicker.describe(),
242
274
  });
243
275
  }
276
+ if (route === '/open-folder' && method === 'POST') {
277
+ const { cwd } = await readJsonBody(req);
278
+ await folderOpener.open(cwd);
279
+ return sendJson(res, 200, { ok: true });
280
+ }
281
+ if (route === '/pick-folder' && method === 'POST') {
282
+ const { cwd } = await readJsonBody(req);
283
+ const gone = new AbortController();
284
+ res.once('close', () => gone.abort());
285
+ return sendJson(res, 200, { path: await folderPicker.pick(cwd, { signal: gone.signal }) });
286
+ }
244
287
  if (route === '/upgrade' && method === 'POST') {
245
288
  const session = await manager.upgrade();
246
289
  return sendJson(res, 201, { session: session.toJSON() });
@@ -362,6 +405,8 @@ export function createManagerServer({
362
405
  throw new HttpError(404, `no route for ${method} ${url.pathname}`, 'not_found');
363
406
  }
364
407
 
408
+ const views = github ? createViews(github) : null;
409
+
365
410
  async function handleGitHub(req, res, url, route, method) {
366
411
  const snapshot = () => ({ github: github.snapshot() });
367
412
  if (route === '/github' && method === 'GET') return sendJson(res, 200, snapshot());
@@ -378,6 +423,30 @@ export function createManagerServer({
378
423
  const session = manager.clone({ account: body.account, repo: body.repo, parent: body.parent });
379
424
  return sendJson(res, 201, { session: session.toJSON() });
380
425
  }
426
+ if (route === '/github/repos' && method === 'GET') {
427
+ return sendJson(res, 200, await github.allRepos({ refresh: url.searchParams.get('refresh') === '1' }));
428
+ }
429
+ if (route === '/github/origin' && method === 'GET') {
430
+ const folder = manager.resolveCwd(url.searchParams.get('cwd'));
431
+ return sendJson(res, 200, { folder, repo: folderOrigin(folder) });
432
+ }
433
+ const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls)(?:\/([^/]+))?$/);
434
+ if (view) {
435
+ let parts;
436
+ try { parts = view.map((part) => (part === undefined ? part : decodeURIComponent(part))); } catch { throw new HttpError(400, 'repo must be a GitHub repository written as owner/name', 'bad_repo'); }
437
+ const [, id, owner, name, kind, number] = parts;
438
+ if (kind === 'issues' && number === undefined && method === 'GET') {
439
+ return sendJson(res, 200, await views.issues(id, owner, name, { state: url.searchParams.get('state') || 'open' }));
440
+ }
441
+ if (kind === 'issues' && number === undefined && method === 'POST') {
442
+ return sendJson(res, 201, { issue: await views.createIssue(id, owner, name, await readJsonBody(req)) });
443
+ }
444
+ if (kind === 'issues' && number !== undefined && method === 'PATCH') {
445
+ return sendJson(res, 200, { issue: await views.updateIssue(id, owner, name, number, await readJsonBody(req)) });
446
+ }
447
+ if (kind === 'actions' && number === undefined && method === 'GET') return sendJson(res, 200, await views.actions(id, owner, name));
448
+ if (kind === 'pulls' && number === undefined && method === 'GET') return sendJson(res, 200, await views.pulls(id, owner, name));
449
+ }
381
450
  const match = route.match(/^\/github\/accounts\/([^/]+)(\/repos|\/ssh)?$/);
382
451
  if (match) {
383
452
  const [, id, action] = match;
@@ -462,7 +531,7 @@ export function createManagerServer({
462
531
 
463
532
  function handleEvents(ws) {
464
533
  eventClients.add(ws);
465
- safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
534
+ safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, folderOpener: folderOpener.describe(), folderPicker: folderPicker.describe(), upgrade: upgradeInfo(), sessions: manager.list() });
466
535
  ws.on('close', () => eventClients.delete(ws));
467
536
  ws.on('message', () => { /* events socket is server -> client only */ });
468
537
  }
@@ -0,0 +1,71 @@
1
+ // The favicon mirrors the card label, including providers that stay active.
2
+ export const isSessionWorking = (session) => session.status !== 'exited' && session.activity === 'active';
3
+
4
+ const FRAME_MS = 100;
5
+ const FRAME_COUNT = 24;
6
+
7
+ /** One cached animation per page. Browser throttling may leave any frame visible. */
8
+ export function createActivityFavicon({
9
+ link, reducedMotion, document = globalThis.document, Image = globalThis.Image,
10
+ setTimer = setTimeout, clearTimer = clearTimeout, now = () => performance.now(),
11
+ }) {
12
+ const original = link?.href;
13
+ let frames = [], timer = null, working = false, paused = false, started = 0;
14
+
15
+ function paint() {
16
+ clearTimer(timer);
17
+ timer = null;
18
+ if (!link) return;
19
+ const animate = working && !paused && !reducedMotion.matches && frames.length > 0;
20
+ const frame = animate ? Math.floor((now() - started) / FRAME_MS) % frames.length : 0;
21
+ const href = working && frames.length ? frames[frame] : original;
22
+ if (link.href !== href) link.href = href;
23
+ if (animate) timer = setTimer(paint, FRAME_MS);
24
+ }
25
+
26
+ if (link) {
27
+ const image = new Image();
28
+ image.onload = () => {
29
+ try {
30
+ const canvas = document.createElement('canvas');
31
+ canvas.width = canvas.height = 32;
32
+ const ctx = canvas.getContext('2d');
33
+ if (!ctx) return;
34
+ // Encode once, not on every tick. Every frame has a distinct working ring.
35
+ frames = Array.from({ length: FRAME_COUNT }, (_, index) => {
36
+ ctx.clearRect(0, 0, 32, 32);
37
+ ctx.drawImage(image, 5, 5, 22, 22);
38
+ ctx.lineWidth = 3;
39
+ ctx.strokeStyle = '#164e3b';
40
+ ctx.beginPath();
41
+ ctx.arc(16, 16, 14, 0, Math.PI * 2);
42
+ ctx.stroke();
43
+ ctx.strokeStyle = '#4ade80';
44
+ ctx.lineCap = 'round';
45
+ ctx.beginPath();
46
+ const angle = index * Math.PI * 2 / FRAME_COUNT - Math.PI / 2;
47
+ ctx.arc(16, 16, 14, angle, angle + Math.PI / 2);
48
+ ctx.stroke();
49
+ return canvas.toDataURL('image/png');
50
+ });
51
+ paint();
52
+ } catch { /* An optional indicator must never prevent the cards from rendering. */ }
53
+ };
54
+ image.onerror = () => {}; // Retain the original favicon if the asset cannot load.
55
+ image.src = original;
56
+ }
57
+ reducedMotion.addEventListener('change', paint);
58
+
59
+ return {
60
+ setWorking(value) {
61
+ if (working === value) return;
62
+ working = value;
63
+ if (working) started = now();
64
+ paint();
65
+ },
66
+ setPaused(value) {
67
+ paused = value;
68
+ paint();
69
+ },
70
+ };
71
+ }