ai-agentmanager 1.7.0 → 1.9.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0 (2026-10-31)
4
+
5
+ - **Intent, spec and plan:** each task agent now has INTENT.md (the request as given, its Jira summary, attachments and who started it; written by AI Agent Manager), SPEC.md (requirements, acceptance criteria, out of scope; the agent writes it before changing code) and PLAN.md (steps it ticks off), next to its brief. **Intent · Spec · Plan** buttons and the split view's Docs menu show them; Explain, the documents and AI review read the spec and plan; **Push & open PR** puts them in the pull request's description (intent in full, spec and plan folded). `"specs": false` in config.json turns the spec and plan instructions off.
6
+ - **Export diagrams:** every drawn diagram has **Copy image** (paste into Slack, a PR comment or a doc), **PNG** and **SVG**; a document with diagrams also downloads as one self-contained **.html**. Diagrams now use SVG text labels so they export as images.
7
+ - **Listen:** **🔊 Listen** reads Explain, the documents and the change walkthrough aloud with the browser's own voices (nothing leaves the device; works on phones). The walkthrough is read change by change, showing each as it's read. Pause, stop and speed (0.85× to 1.75×).
8
+
9
+ ## 1.8.0 (2026-10-31)
10
+
11
+ - **Update from your phone:** **Update to …** and **About → Update and restart** now show over remote access too; the install and restart run on the computer and the page reloads when it's back. It installs only the official release, from where this copy came from.
12
+ - **Jira summaries:** an agent created from a Jira link shows the issue's summary as its description (in the list and its header, and as the issue's title under Bugs), fetched in the background from Jira Cloud. Until then, or when it can't be fetched, the words after the link. **Settings → Jira** takes your site, email and API token for private issues (also `JIRA_SITE` / `JIRA_EMAIL` / `JIRA_API_TOKEN`); only `*.atlassian.net` and that site are contacted, and the token goes only to that site. Saving fills in agents already created.
13
+ - **Theme:** Auto / Light / Dark in the ☰ menu, remembered in the browser; diagrams redraw in the theme's colors.
14
+
3
15
  ## 1.7.0 (2026-10-31)
4
16
 
5
17
  - **Split view:** **◫ Split** in the terminal bar: the terminal (or a shell, or a browser tab) on the left, and Code, Docs (Diagrams by default, or any other document) or Changes on the right. Resizable and remembered.
package/README.md CHANGED
@@ -7,6 +7,7 @@ Run one background [Claude Code](https://claude.com/claude-code) agent per inten
7
7
  ![The conflict radar: two agents changed the same lines of README.md; the AI judge rates it a collision and suggests which lands first](docs/images/conflict-radar.jpg)
8
8
 
9
9
  - **Intents in, agents out.** Give a list, one per line (say, a ticket each), and each becomes its own `claude` process. Or pick **One agent for all of it** and a long description goes to a single agent as written.
10
+ - **Issue links.** A request that starts with a Jira link is named after the issue (`ABC-123`) and described by its summary, fetched from Jira Cloud (public issues as they are; your company's site after **Settings → Jira**: site, email and API token, kept on this computer and sent only to that site). Without access, the words after the link describe it.
10
11
  - **Repo or folder.** Give a git URL (cloned once, then each agent gets its own worktree and branch) or a local folder.
11
12
  - **Fast clones.** URLs are cloned as partial clones (`--filter=blob:none`): history now, file contents only for what's checked out.
12
13
  - **Progress.** Clone and checkout progress shows in the UI and `agentmgr ls`.
@@ -84,13 +85,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
84
85
 
85
86
  ```sh
86
87
  npm install -g ai-agentmanager # the latest release
87
- npm install -g ai-agentmanager@1.7.0 # or a specific one
88
+ npm install -g ai-agentmanager@1.9.0 # or a specific one
88
89
  ```
89
90
 
90
91
  Or from the repository, at a release tag:
91
92
 
92
93
  ```sh
93
- git clone --branch v1.7.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
94
+ git clone --branch v1.9.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
94
95
  cd ai-agentmanager-core
95
96
  npm install
96
97
  npm link # optional: puts `agentmgr` on your PATH
@@ -105,12 +106,12 @@ npm install
105
106
  npm link
106
107
  ```
107
108
 
108
- **Updating.** `agentmgr update` installs the newest release and restarts onto it; running agents pause briefly and resume. In the app, **Update to …** appears in the top bar when a release is out (checked twice a day; turn it off in **☰ → Settings** or with `agentmgr update --auto-check off`), and **About → Update and restart** does the same. A clone at a release tag (or one you `npm link`ed) moves to the newest tag; a clone on a branch or with uncommitted changes is left alone. By hand, use the command for how you installed, then `agentmgr restart`:
109
+ **Updating.** `agentmgr update` installs the newest release and restarts onto it; running agents pause briefly and resume. In the app, **Update to …** appears in the top bar when a release is out (checked twice a day; turn it off in **☰ → Settings** or with `agentmgr update --auto-check off`), and **About → Update and restart** does the same. Both work from a signed-in phone too: the install and restart run on the computer, and the page reloads when it's back. A clone at a release tag (or one you `npm link`ed) moves to the newest tag; a clone on a branch or with uncommitted changes is left alone. By hand, use the command for how you installed, then `agentmgr restart`:
109
110
 
110
111
  | Installed with | Update |
111
112
  | --- | --- |
112
113
  | npm | `npm install -g ai-agentmanager@latest` |
113
- | A clone at a release tag | `git fetch --tags && git checkout v1.7.0 && npm install` (the tag you want) |
114
+ | A clone at a release tag | `git fetch --tags && git checkout v1.9.0 && npm install` (the tag you want) |
114
115
  | A clone of `main` | `git pull && npm install` |
115
116
 
116
117
  **Check your setup.** `agentmgr doctor` checks Node.js, Claude Code and its sign-in, git, `gh`, the data folder, the daemon's port and Chrome, and says how to fix anything missing.
@@ -330,6 +331,8 @@ The Brief panel's **Share for review** buttons, or `agentmgr doc <agent> <kind>`
330
331
  | **Human vs AI** | Your top 5 decisions, each with why it needs you, the stakes, options and what the work suggests, plus a box for your answer. Up to 5 questions the AI answers, each with its confidence, evidence and how to check it. A second model's verdict on each. |
331
332
  | **Diagrams** | Diagrams only, for a quick grasp: the problem, the architecture (with what changes highlighted), the design, the data flow (animated) and the key interaction, plus the plan. Drawn from the documents already written and the agent's work. Optional: **Write all** leaves it out. |
332
333
 
334
+ Each drawn diagram has **Copy image** (paste it into Slack, a PR comment or a doc), **PNG** and **SVG**, and a document with diagrams also downloads as one **.html** file. **🔊 Listen** reads Explain, documents and the change walkthrough aloud with the browser's own voices; the walkthrough is read change by change, showing each as it goes.
335
+
333
336
  **Human vs AI.** This review takes two separate read-only calls.
334
337
  1. **The split.** The agent's engine splits the open questions. *For you to decide* covers judgment calls such as intent, priorities, risk appetite and trade-offs. *Answered by AI* covers reasoning and evaluation: correctness, edge cases, test coverage and what's left.
335
338
  2. **The second opinion.** Another model reviews that split against the same context. It marks each AI answer *agrees*, *partly* or *disagrees*, with a correction where needed. It marks each of your questions *keep*, *AI could answer* or *reword*, and lists questions both lists missed.
@@ -386,7 +389,8 @@ Each card has at most **three points of about 12 words each**, so they're easy t
386
389
  - **Line comments.** Click **+** next to a line (or its line number) to comment on it, and add an overall comment if you like. **Send to agent** types them into the agent as one message, each with its file, line and the code it is about, and asks it to address them. Comments not sent yet are kept in this browser.
387
390
  - **Explain changes** explains each change one by one: a title, what it changes, why (from the request and the conversation), the risk and what to check, in the order a reviewer should read them. Each explanation sits above its hunk, and **◀ / ▶** (or **[** and **]**) step through them, opening folded files on the way. When the agent changes the code afterwards, the page says so and **Explain again** brings it up to date. From the CLI: `agentmgr doc <agent> walkthrough`.
388
391
  - **Commit all** commits everything in the worktree, new files included, with your git name and email.
389
- - **Push & open PR** pushes the branch and opens a pull request with `gh`, against the branch the agent started from (title from your text, or from the commits; **Draft** if you like). With a pull request already open, it pushes to it. The pull request is linked to the agent.
392
+ - **Intent, spec and plan.** Each agent keeps INTENT.md (what was asked, written by AI Agent Manager), SPEC.md (requirements and acceptance criteria, written by the agent before it changes code) and PLAN.md (steps, ticked off as it goes) next to its brief. Open them with **Intent · Spec · Plan** or in the split view's Docs; Explain, the documents and AI review read them too. `"specs": false` in config.json stops asking agents for the spec and plan.
393
+ - **Push & open PR** pushes the branch and opens a pull request with `gh`, against the branch the agent started from (title from your text, or from the commits; **Draft** if you like). Its description carries the intent, and the spec and plan in folded sections. With a pull request already open, it pushes to it. The pull request is linked to the agent.
390
394
 
391
395
  The same from the API: `GET /api/agents/:id/changes`, `POST …/changes/comments`, `…/changes/commit`, `…/changes/pr`. Plugins can refuse a commit or a pull request (`agent.commit`, `agent.pr`).
392
396
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Run background coding agents (Claude Code, Codex) per intent, and keep track of them from a web UI, your phone or the CLI",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.js CHANGED
@@ -6,6 +6,8 @@ import { touchScroll } from '/touchscroll.js';
6
6
  import { openStream, post, REMOTE } from '/transport.js';
7
7
  import { attachBox } from '/attach.js';
8
8
  import { initSide } from '/side.js';
9
+ import { addTools, download, htmlFile, initExport } from '/export.js';
10
+ import { bindListen, listenHtml, play, speakable, now as speaking, stop as stopSpeaking } from '/speak.js';
9
11
  import { initReview } from '/review.js';
10
12
  import { initLand, prChips } from '/land.js';
11
13
  import { initTriggers } from '/triggers.js';
@@ -164,6 +166,21 @@ function checksSummary(b) {
164
166
  }
165
167
 
166
168
  const diagrams = new Map(); // mermaid source -> rendered SVG (or an error)
169
+ /** Whether the page is dark: the menu's choice, else this device's setting. */
170
+ const isDark = () => {
171
+ const t = document.documentElement.dataset.theme;
172
+ return t ? t === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
173
+ };
174
+ // SVG text labels rather than HTML ones: a diagram then exports to PNG (the browser won't turn
175
+ // HTML-in-SVG into an image).
176
+ const mermaidConfig = () => ({ startOnLoad: false, securityLevel: 'strict', theme: isDark() ? 'dark' : 'default', htmlLabels: false, flowchart: { htmlLabels: false } });
177
+ // Diagrams are drawn in the theme's colors: redraw them when it changes.
178
+ window.addEventListener('agentmgr-theme', () => {
179
+ diagrams.clear();
180
+ mermaidLib?.initialize(mermaidConfig());
181
+ for (const el of document.querySelectorAll('[data-mermaid][data-filled]')) delete el.dataset.filled;
182
+ renderDiagrams(document);
183
+ });
167
184
  const expanded = new Set(); // "<agent id>:<section>" the user opened with Show more
168
185
 
169
186
  /** Long sections are clamped; Show more toggles them. */
@@ -189,8 +206,7 @@ async function renderDiagrams(root) {
189
206
  try {
190
207
  if (!mermaidLib) {
191
208
  mermaidLib = (await import('/vendor/mermaid/mermaid.esm.min.mjs')).default;
192
- const dark = matchMedia('(prefers-color-scheme: dark)').matches;
193
- mermaidLib.initialize({ startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' });
209
+ mermaidLib.initialize(mermaidConfig());
194
210
  }
195
211
  const { svg } = await mermaidLib.render(`mmd-${diagrams.size}-${Date.now()}`, src);
196
212
  diagrams.set(src, { svg });
@@ -203,7 +219,10 @@ async function renderDiagrams(root) {
203
219
  for (const el of document.querySelectorAll('[data-mermaid]')) {
204
220
  if (el.dataset.mermaid !== src || el.dataset.filled) continue;
205
221
  el.dataset.filled = '1';
206
- if (out.svg) el.innerHTML = out.svg;
222
+ if (out.svg) {
223
+ el.innerHTML = out.svg;
224
+ addTools(el);
225
+ }
207
226
  else el.innerHTML = `<div class="hint">Diagram could not be drawn (${esc(out.error)}). Source:</div><pre>${esc(src)}</pre>`;
208
227
  }
209
228
  }
@@ -221,7 +240,7 @@ function explainBox(a) {
221
240
  ${ex.markdown
222
241
  ? `${stale ? '<p class="hint">The brief has changed since this explanation; use <b>Explain again</b> to refresh it.</p>' : ''}
223
242
  ${clamp(a, 'explain', md(ex.markdown))}
224
- <p class="hint">Explained ${ago(ex.at)}.</p>`
243
+ <p class="hint explain-foot">Explained ${ago(ex.at)}. ${listenHtml(`explain:${a.id}`, { title: 'Read the explanation aloud' })}</p>`
225
244
  : ''}
226
245
  </div>`;
227
246
  return { button, box };
@@ -251,9 +270,18 @@ const INSIGHT_ICONS = { why: '🎯', what: '📦', how: '🛠', now: '📍' };
251
270
  const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI', diagrams: 'Diagrams' };
252
271
  // Optional extras that "Write all" leaves out.
253
272
  const DOC_EXTRAS = new Set(['diagrams']);
254
- const DOC_TITLES = { ...DOCS, plan: 'Implementation plan' };
273
+ const DOC_TITLES = { ...DOCS, plan: 'Plan', intent: 'Intent', spec: 'Spec' };
274
+ // The agent's intent, spec and plan files (read from the daemon when opened; kept here by agent and kind).
275
+ const SPEC_KINDS = { intent: 'Intent', spec: 'Spec', plan: 'Plan' };
276
+ const specCache = new Map(); // "<agent>:<kind>" -> { markdown, at }
277
+ const isSpecKind = (a, kind) => Boolean(SPEC_KINDS[kind]) && !(kind === 'plan' && a?.planReview);
278
+ async function loadSpec(agentId, kind) {
279
+ const r = await api('GET', `/agents/${agentId}/specs/${kind}`).catch(() => null);
280
+ specCache.set(`${agentId}:${kind}`, { markdown: r?.markdown ?? null, at: r?.updatedAt ?? null, loaded: true });
281
+ }
255
282
  /** What the document dialog shows: a stakeholder document, or the agent's plan under review. */
256
- const docOf = (a, kind) => (kind === 'plan' ? a?.planReview && { markdown: a.planReview.markdown, at: a.planReview.updatedAt } : a?.docs?.[kind]);
283
+ const docOf = (a, kind) =>
284
+ isSpecKind(a, kind) ? specCache.get(`${a?.id}:${kind}`) : kind === 'plan' ? a?.planReview && { markdown: a.planReview.markdown, at: a.planReview.updatedAt } : a?.docs?.[kind];
257
285
 
258
286
  const VERDICT = { agree: '✓ Agrees', partly: '≈ Partly agrees', disagree: '✗ Disagrees', keep: '✓ Keep', ai: '🤖 AI could answer', reword: '✎ Reword' };
259
287
  const CONFIDENCE = { high: 'High confidence', medium: 'Medium confidence', low: 'Low confidence' };
@@ -324,6 +352,17 @@ function docButtons(a) {
324
352
  return `<button class="small doc-btn ${d?.markdown ? 'has' : ''}" data-doc="${k}" ${d?.pending ? 'disabled' : ''}
325
353
  title="${k === 'diagrams' ? (d?.markdown ? `Open the diagrams${stale ? ' (the brief changed since)' : ''}` : 'Draw the problem and its solution for a quick grasp: architecture, design, data flow (animated) and the key interaction, diagrams only. Built from the documents already written and the agent\'s work') : d?.markdown ? `Open the ${label.toLowerCase()} document${stale ? ' (the brief changed since)' : ''}` : `Write a ${label.toLowerCase()} document for stakeholders`}">${d?.pending ? `${label}…` : label}${d?.markdown ? (stale ? ' •' : ' ✓') : ''}</button>`;
326
354
  })
355
+ .join('')}</span>${specButtons(a)}`;
356
+ }
357
+
358
+ /** Intent · Spec · Plan: the files kept for reviewers (and put in the pull request's description). */
359
+ function specButtons(a) {
360
+ if (!a.specDocs) return '';
361
+ return `<span class="doc-buttons spec-buttons" title="For reviewers, people and agents: what was asked, what done means, and the plan. Push & open PR puts them in the description">${Object.entries(SPEC_KINDS)
362
+ .map(([k, label]) => {
363
+ const at = a.specDocs[k] ?? (k === 'plan' ? a.planReview?.updatedAt : null);
364
+ return `<button class="small doc-btn ${at ? 'has' : ''}" data-doc="${k}" title="${at ? `Open ${label} (updated ${ago(at)})` : k === 'intent' ? 'Written when the agent is created' : `The agent writes it ${k === 'spec' ? 'before changing code' : 'and keeps it current'}`}">${label}${at ? ' ✓' : ''}</button>`;
365
+ })
327
366
  .join('')}</span>`;
328
367
  }
329
368
 
@@ -334,7 +373,9 @@ function renderDocDialog() {
334
373
  const a = agents.get(docView.agentId);
335
374
  const d = docOf(a, docView.kind);
336
375
  const label = DOC_TITLES[docView.kind];
337
- const plan = docView.kind === 'plan';
376
+ // The plan under review (approve / send back); otherwise a plain file to read.
377
+ const plan = docView.kind === 'plan' && Boolean(a?.planReview);
378
+ const specKind = isSpecKind(a, docView.kind);
338
379
  $('#doc-title').textContent = `${label} · ${a?.name ?? ''}`;
339
380
  const body = $('#doc-body');
340
381
  body.classList.toggle('diagrams-view', docView.kind === 'diagrams');
@@ -343,6 +384,8 @@ function renderDocDialog() {
343
384
  body.dataset.rendered = '';
344
385
  } else if (d?.error) body.innerHTML = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
345
386
  else if (plan && !d?.markdown) body.innerHTML = '<p class="hint">The agent is investigating and hasn\'t written its plan yet. You\'ll see it here (and in the inbox) when it has.</p>';
387
+ else if (specKind && !d?.loaded) body.innerHTML = '<p class="hint">Loading…</p>';
388
+ else if (specKind && !d?.markdown) body.innerHTML = `<p class="hint">${docView.kind === 'intent' ? 'No intent file for this agent (agents created before 1.9 have none).' : `The agent hasn't written its ${label.toLowerCase()} yet. It writes ${docView.kind === 'spec' ? 'the spec before changing code' : 'the plan and keeps it current'}.`}</p>`;
346
389
  else if (d?.markdown) {
347
390
  if (body.dataset.rendered !== d.at) {
348
391
  body.innerHTML = d.data ? humanAiHtml(d) : docHtml(d.markdown);
@@ -352,10 +395,13 @@ function renderDocDialog() {
352
395
  }
353
396
  const stale = !plan && d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
354
397
  const by = d?.writer ? ` by ${d.writer}${d.critique ? `, checked by ${d.critique.label}` : ''}${d.from ? ` from ${d.from.length ? d.from.join(', ') : "the agent's work"}` : ''}` : '';
355
- $('#doc-meta').textContent = d?.markdown ? `${plan ? `Plan · ${PLAN_LABEL[a.planReview.status]} · updated` : docView.kind === 'diagrams' ? 'Drawn' : 'Written'} ${ago(d.at)}${by}${stale ? ' · the brief has changed since; Regenerate for the latest' : ''}` : '';
398
+ $('#doc-meta').textContent = d?.markdown ? `${plan ? `Plan · ${PLAN_LABEL[a.planReview.status]} · updated` : specKind ? 'Updated' : docView.kind === 'diagrams' ? 'Drawn' : 'Written'} ${ago(d.at)}${by}${stale && !specKind ? ' · the brief has changed since; Regenerate for the latest' : ''}${specKind ? ' · goes into the pull request description' : ''}` : '';
356
399
  for (const id of ['doc-copy', 'doc-download', 'doc-print']) $(`#${id}`).disabled = !d?.markdown;
357
- $('#doc-print').hidden = plan;
358
- $('#doc-regen').hidden = plan;
400
+ // Diagrams: the whole document as one HTML file with every diagram (each also has Copy / PNG / SVG).
401
+ $('#doc-html').hidden = !/```mermaid/.test(d?.markdown ?? '');
402
+ $('#doc-listen').innerHTML = d?.markdown && docView.kind !== 'diagrams' ? listenHtml(`doc:${docView.agentId}:${docView.kind}`, { title: 'Read the document aloud' }) : '';
403
+ $('#doc-print').hidden = plan || specKind;
404
+ $('#doc-regen').hidden = plan || specKind;
359
405
  $('#doc-regen').disabled = Boolean(d?.pending);
360
406
  $('#doc-bg').hidden = !d?.pending;
361
407
  // Footer: send your answers (Human vs AI), or approve / send back the plan.
@@ -376,7 +422,10 @@ function openDoc(agentId, kind) {
376
422
  $('#doc-body').dataset.rendered = '';
377
423
  $('#doc-dialog').showModal();
378
424
  const a = agents.get(agentId);
379
- if (kind !== 'plan' && !a.docs?.[kind]?.markdown && !a.docs?.[kind]?.pending) generateDoc();
425
+ if (isSpecKind(a, kind)) {
426
+ specCache.delete(`${agentId}:${kind}`);
427
+ loadSpec(agentId, kind).then(() => docView?.agentId === agentId && docView.kind === kind && renderDocDialog());
428
+ } else if (kind !== 'plan' && !a.docs?.[kind]?.markdown && !a.docs?.[kind]?.pending) generateDoc();
380
429
  renderDocDialog();
381
430
  }
382
431
 
@@ -386,6 +435,7 @@ function generateDoc() {
386
435
  }
387
436
 
388
437
  $('#doc-close').onclick = () => {
438
+ if (speaking()?.id.startsWith('doc:')) stopSpeaking();
389
439
  docView = null;
390
440
  $('#doc-dialog').close();
391
441
  };
@@ -472,6 +522,25 @@ function planRow(a) {
472
522
  ${p.status !== 'approved' ? '<button class="small ghost" data-act="plan-skip" title="Stop waiting for review; the agent goes ahead">Skip review</button>' : ''}</div>`;
473
523
  }
474
524
  $('#doc-print').onclick = () => window.open(`/share.html#${docView.agentId}/${docView.kind}`, '_blank', 'noopener');
525
+ $('#doc-html').onclick = () => {
526
+ const a = agents.get(docView.agentId);
527
+ download(htmlFile($('#doc-body'), `${DOC_TITLES[docView.kind]}: ${a?.name ?? ''}`), `${a?.slug ?? 'agent'}-${docView.kind}.html`);
528
+ };
529
+
530
+ // Diagram export: files named after the agent they belong to.
531
+ initExport({ toast, base: (slot) => (slot.closest('#doc-body') && docView ? agents.get(docView.agentId)?.slug : agents.get(term.agentId)?.slug ?? agents.get(selectedId)?.slug) ?? '' });
532
+
533
+ // Listening: what each Listen control reads.
534
+ bindListen((id) => {
535
+ const [what, agentId, kind] = id.split(':');
536
+ const a = agents.get(agentId);
537
+ let parts = null;
538
+ if (what === 'explain') parts = [speakable(a?.explanation?.markdown)];
539
+ else if (what === 'doc') parts = [speakable(kind === 'explain' ? a?.explanation?.markdown : docOf(a, kind)?.markdown)];
540
+ else if (what === 'walk') parts = reviewUi.listenParts();
541
+ else if (what === 'sidewalk') parts = sidePane?.listenParts();
542
+ if (!parts?.length || !play(id, parts)) toast('Nothing to read aloud here (or this browser has no speech)');
543
+ });
475
544
 
476
545
  /** Why / What / How / Now as four cards. */
477
546
  function insightsBox(a) {
@@ -638,7 +707,7 @@ function agentItem(a) {
638
707
  const idle = !isActive(a);
639
708
  return `<li data-id="${a.id}" ${a.kind === 'general' || a.kind === 'assistant' ? '' : 'draggable="true" '}class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''} ${idle ? 'idle' : ''}">
640
709
  <div class="line1">${prioChip(a.priority ?? 2)}${a.results?.outcome === 'blocked' ? '<span class="outcome-mark" title="Blocked">⛔</span>' : a.results?.outcome === 'done' ? '<span class="outcome-mark" title="Done">✅</span>' : ''}<span class="name">${esc(a.name)}</span>${flags(a)}<span class="pill ${st}">${pillText(st)}</span></div>
641
- <div class="intent">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">🤖 by assistant</span> ' : a.trigger ? `<span class="tag assistant" title="Started from ${a.trigger.source === 'github' ? 'a labeled GitHub issue' : 'the webhook'}${a.trigger.url ? `: ${esc(a.trigger.url)}` : ''}">⚡ ${a.trigger.source === 'github' ? 'GitHub' : 'webhook'}</span> ` : a.playbook ? `<span class="tag assistant" title="Started by the playbook ${esc(a.playbook.name)}">▶ ${esc(a.playbook.name)}</span> ` : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(a.intent || (a.kind === 'assistant' ? 'Finds new work and starts agents for it' : 'General session'))}</div>
710
+ <div class="intent" title="${esc(describeTitle(a) ?? '')}">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">🤖 by assistant</span> ' : a.trigger ? `<span class="tag assistant" title="Started from ${a.trigger.source === 'github' ? 'a labeled GitHub issue' : 'the webhook'}${a.trigger.url ? `: ${esc(a.trigger.url)}` : ''}">⚡ ${a.trigger.source === 'github' ? 'GitHub' : 'webhook'}</span> ` : a.playbook ? `<span class="tag assistant" title="Started by the playbook ${esc(a.playbook.name)}">▶ ${esc(a.playbook.name)}</span> ` : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(describe(a) || (a.kind === 'assistant' ? 'Finds new work and starts agents for it' : 'General session'))}</div>
642
711
  ${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
643
712
  <div class="meta">
644
713
  <span class="mono">${a.id}</span>
@@ -1594,7 +1663,7 @@ function renderDetail() {
1594
1663
  ${[0, 1, 2, 3].map((p) => `<option value="${p}" ${p === (a.priority ?? 2) ? 'selected' : ''}>P${p}</option>`).join('')}
1595
1664
  </select>
1596
1665
  <span class="d-name">${esc(a.name)}</span> <span class="pill ${st}">${pillText(st)}</span>${a.kind === 'general' ? ' <span class="tag">general</span>' : ''}</h2>
1597
- <p class="intent">${esc(a.intent || (a.kind === 'general' ? 'General session: ask Claude anything in the terminal.' : a.kind === 'assistant' ? 'Your assistant: it checks for new work every few minutes and starts agents for it. Settings and activity: Assistant in the top bar.' : ''))}</p>
1666
+ <p class="intent">${a.synopsis?.text ? `<b>${esc(a.synopsis.text)}</b>${a.synopsis.source === 'jira' ? ` <span class="hint">· ${esc(a.synopsis.key)}</span>` : ''}<br><span class="hint">${esc(a.intent)}</span>` : esc(a.intent || (a.kind === 'general' ? 'General session: ask Claude anything in the terminal.' : a.kind === 'assistant' ? 'Your assistant: it checks for new work every few minutes and starts agents for it. Settings and activity: Assistant in the top bar.' : ''))}</p>
1598
1667
  </div>
1599
1668
  <div class="d-actions">
1600
1669
  ${a.status === 'error' && !a.workdir
@@ -2108,6 +2177,7 @@ sidePane = initSide({
2108
2177
  codeUi,
2109
2178
  docHtml,
2110
2179
  renderDiagrams,
2180
+ listenHtml,
2111
2181
  mainKind: () => term.kind,
2112
2182
  setMain: (kind) => term.agentId && term.open(term.agentId, kind),
2113
2183
  refit: () => requestAnimationFrame(() => term.refit()),
@@ -2897,6 +2967,10 @@ function upsert(a) {
2897
2967
  /** Views that show one agent's details outside the main render: keep them current. */
2898
2968
  function agentChanged(a) {
2899
2969
  if (a.id === term.agentId) term.renderTabs();
2970
+ // An open intent / spec / plan that the agent just updated: read it again.
2971
+ if (docView?.agentId === a.id && isSpecKind(a, docView.kind) && specCache.get(`${a.id}:${docView.kind}`)?.at !== (a.specDocs?.[docView.kind] ?? null)) {
2972
+ loadSpec(a.id, docView.kind).then(renderDocDialog);
2973
+ }
2900
2974
  reviewUi.agentChanged(a);
2901
2975
  sidePane?.agentChanged(a);
2902
2976
  }
@@ -3450,7 +3524,8 @@ $('#about-check').onclick = async () => {
3450
3524
  out.hidden = false;
3451
3525
  out.textContent = 'Checking…';
3452
3526
  try {
3453
- // On this computer the daemon can install it; from a phone, just say what is out.
3527
+ // The daemon checks and can install it (from this computer or a signed-in phone); an older
3528
+ // daemon without /update gets the manual steps.
3454
3529
  const local = await updateUi.check().catch(() => null);
3455
3530
  if (local) return void (out.innerHTML = local);
3456
3531
  const r = await api('GET', '/about/latest');
@@ -4070,6 +4145,10 @@ $('#login-form').addEventListener('submit', async (e) => {
4070
4145
  });
4071
4146
 
4072
4147
  // Engines this machine can run; the New agent form offers a choice once there's more than Claude.
4148
+ /** What an agent is about: the issue's summary when its request starts with an issue link, else the request. */
4149
+ const describe = (a) => a.synopsis?.text || a.intent;
4150
+ const describeTitle = (a) => (a.synopsis?.text ? `${a.synopsis.source === 'jira' ? `${a.synopsis.key} in Jira` : `${a.synopsis.key} (summary not fetched; your words after the link)`}. Request: ${a.intent}` : a.intent);
4151
+
4073
4152
  const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
4074
4153
  async function loadEngines() {
4075
4154
  try {
@@ -0,0 +1,110 @@
1
+ // Taking diagrams out of the page, for a pull request or Slack: each drawn diagram gets Copy image,
2
+ // PNG and SVG; a whole document of diagrams downloads as one self-contained HTML file. Diagrams are
3
+ // drawn with SVG text labels (not HTML in foreignObject), so they turn into PNGs without the
4
+ // browser refusing a "tainted" canvas.
5
+
6
+ const TOOLS = `<span class="dg-tools"><button type="button" class="small ghost" data-dg="copy" title="Copy as an image: paste into Slack, a GitHub comment or a doc">Copy image</button><button type="button" class="small ghost" data-dg="png" title="Download as a PNG">PNG</button><button type="button" class="small ghost" data-dg="svg" title="Download as an SVG (sharp at any size)">SVG</button></span>`;
7
+
8
+ /** Add the export buttons to a drawn diagram slot. */
9
+ export function addTools(slot) {
10
+ if (slot.querySelector('.dg-tools') || !slot.querySelector('svg')) return;
11
+ slot.insertAdjacentHTML('afterbegin', TOOLS);
12
+ }
13
+
14
+ const pageBg = () => getComputedStyle(document.body).backgroundColor || '#ffffff';
15
+
16
+ /** A standalone copy of a diagram's SVG: sized, namespaced, on the page's background. */
17
+ export function svgText(svg, { background = pageBg() } = {}) {
18
+ const copy = svg.cloneNode(true);
19
+ copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
20
+ copy.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
21
+ const vb = svg.viewBox?.baseVal;
22
+ const box = svg.getBoundingClientRect();
23
+ const w = Math.ceil(vb?.width || box.width || 800);
24
+ const h = Math.ceil(vb?.height || box.height || 600);
25
+ copy.setAttribute('width', w);
26
+ copy.setAttribute('height', h);
27
+ copy.removeAttribute('style'); // mermaid's max-width would shrink it outside the page
28
+ if (background) {
29
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
30
+ Object.entries({ x: vb?.x ?? 0, y: vb?.y ?? 0, width: '100%', height: '100%', fill: background }).forEach(([k, v]) => bg.setAttribute(k, v));
31
+ if (vb) {
32
+ bg.setAttribute('width', vb.width);
33
+ bg.setAttribute('height', vb.height);
34
+ }
35
+ copy.insertBefore(bg, copy.firstChild);
36
+ }
37
+ return { text: new XMLSerializer().serializeToString(copy), w, h };
38
+ }
39
+
40
+ /** The diagram as a PNG blob, at twice its size so it stays sharp on retina screens and in PRs. */
41
+ export function pngBlob(svg, scale = 2) {
42
+ const { text, w, h } = svgText(svg);
43
+ return new Promise((resolve, reject) => {
44
+ const img = new Image();
45
+ img.onload = () => {
46
+ const canvas = Object.assign(document.createElement('canvas'), { width: w * scale, height: h * scale });
47
+ const ctx = canvas.getContext('2d');
48
+ ctx.scale(scale, scale);
49
+ ctx.drawImage(img, 0, 0, w, h);
50
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Could not make the image'))), 'image/png');
51
+ };
52
+ img.onerror = () => reject(new Error('Could not draw the diagram as an image'));
53
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(text)}`;
54
+ });
55
+ }
56
+
57
+ export function download(blob, name) {
58
+ const link = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: name });
59
+ document.body.append(link);
60
+ link.click();
61
+ link.remove();
62
+ setTimeout(() => URL.revokeObjectURL(link.href), 2000);
63
+ }
64
+
65
+ const slugOf = (s) => String(s || 'diagram').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'diagram';
66
+
67
+ /** The diagram's name for files: its heading in the document, or "diagram". */
68
+ function nameOf(slot, base) {
69
+ let el = slot.previousElementSibling;
70
+ while (el && !/^H[1-4]$/.test(el.tagName)) el = el.previousElementSibling;
71
+ return `${base ? `${slugOf(base)}-` : ''}${slugOf(el?.textContent)}`;
72
+ }
73
+
74
+ /** One page with every diagram (and heading) of a rendered document, to keep or attach. */
75
+ export function htmlFile(article, title) {
76
+ const copy = article.cloneNode(true);
77
+ copy.querySelectorAll('.dg-tools').forEach((t) => t.remove());
78
+ copy.querySelectorAll('svg').forEach((svg, i) => {
79
+ const live = article.querySelectorAll('svg')[i];
80
+ if (live) svg.outerHTML = svgText(live, { background: null }).text;
81
+ });
82
+ const css = `body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;max-width:1000px;margin:32px auto;padding:0 16px;color:#1c2330;background:#fff}
83
+ h1{font-size:24px}h2{font-size:18px;margin-top:28px;border-top:1px solid #dde1e7;padding-top:14px}
84
+ .diagram{display:flex;justify-content:center;margin:12px 0;overflow-x:auto}svg{max-width:100%;height:auto}`;
85
+ const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title.replace(/</g, '&lt;')}</title><style>${css}</style></head><body>${copy.innerHTML}</body></html>`;
86
+ return new Blob([html], { type: 'text/html' });
87
+ }
88
+
89
+ /** Clicks on any diagram's buttons, wherever it is drawn. `base` names the files (the agent). */
90
+ export function initExport({ toast, base = () => '' }) {
91
+ document.addEventListener('click', async (e) => {
92
+ const b = e.target.closest('[data-dg]');
93
+ if (!b) return;
94
+ const slot = b.closest('.diagram');
95
+ const svg = slot?.querySelector('svg');
96
+ if (!svg) return;
97
+ const name = nameOf(slot, base(slot));
98
+ try {
99
+ if (b.dataset.dg === 'svg') download(new Blob([svgText(svg).text], { type: 'image/svg+xml' }), `${name}.svg`);
100
+ else if (b.dataset.dg === 'png') download(await pngBlob(svg), `${name}.png`);
101
+ else {
102
+ // Safari wants the promise handed over within the click.
103
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob(svg) })]);
104
+ toast('Diagram copied as an image: paste it into Slack, a PR comment or a doc');
105
+ }
106
+ } catch (err) {
107
+ toast(b.dataset.dg === 'copy' ? `Couldn't copy (${err.message}); use PNG instead` : `Couldn't export: ${err.message}`, 5000);
108
+ }
109
+ });
110
+ }
package/public/index.html CHANGED
@@ -5,6 +5,13 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <meta name="apple-mobile-web-app-capable" content="yes" />
7
7
  <title>AI Agent Manager</title>
8
+ <script>
9
+ // The theme chosen in the menu (Auto / Light / Dark), applied before anything draws.
10
+ try {
11
+ const t = localStorage.getItem('agentmgr.theme');
12
+ if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
13
+ } catch {}
14
+ </script>
8
15
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3E%3Cpath d=%22M8 1l7 7-7 7-7-7z%22 fill=%22%233b5bdb%22/%3E%3C/svg%3E" />
9
16
  <link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
10
17
  <meta name="theme-color" content="#151821" />
@@ -17,7 +24,7 @@
17
24
  <div class="brand">
18
25
  <a href="#" id="home-link" class="home-link" title="Home: all agents"><span class="logo" aria-hidden="true">◆</span><h1>AI Agent Manager</h1></a>
19
26
  <button id="about-btn" class="about-btn" type="button" title="About AI Agent Manager: version, release, links">About</button>
20
- <button id="update-btn" class="update-btn local-only" type="button" hidden></button>
27
+ <button id="update-btn" class="update-btn" type="button" hidden></button>
21
28
  <span id="conn" class="conn" title="Daemon connection">connecting…</span>
22
29
  </div>
23
30
  <div class="top-actions">
@@ -42,6 +49,8 @@
42
49
  <button role="menuitem" id="alerts-btn" class="local-only" title="Slack alerts when an agent needs you">Alerts…</button>
43
50
  <button role="menuitem" id="remote-btn" class="local-only" title="Open AI Agent Manager from your phone">Remote access…</button>
44
51
  <div class="menu-sep"></div>
52
+ <div class="menu-theme"><span>Theme</span><span class="seg" role="radiogroup" aria-label="Theme"><button type="button" data-theme-set="auto" title="Follow this device's setting">Auto</button><button type="button" data-theme-set="light">Light</button><button type="button" data-theme-set="dark">Dark</button></span></div>
53
+ <div class="menu-sep"></div>
45
54
  <button role="menuitem" id="menu-about">About and updates…</button>
46
55
  </div>
47
56
  </div>
@@ -369,7 +378,9 @@
369
378
  <button type="button" id="doc-bg" class="small" hidden title="Close this and keep writing; you'll be told when it's ready">Continue in background</button>
370
379
  <button type="button" id="doc-regen" class="small" title="Write it again from the agent's latest work">Regenerate</button>
371
380
  <button type="button" id="doc-copy" class="small" title="Copy as Markdown">Copy Markdown</button>
381
+ <span id="doc-listen"></span>
372
382
  <button type="button" id="doc-download" class="small">Download .md</button>
383
+ <button type="button" id="doc-html" class="small" hidden title="One HTML file with every diagram: open it anywhere, attach it, or print it">Download .html</button>
373
384
  <button type="button" id="doc-print" class="small primary" title="Open a print-ready page (Save as PDF)">Print / PDF</button>
374
385
  <button type="button" id="doc-close" class="small ghost">Close</button>
375
386
  </div>
@@ -691,9 +702,9 @@
691
702
  <dl id="about-list" class="about-list"></dl>
692
703
  <p id="about-update" class="hint" hidden></p>
693
704
  <div class="dialog-actions">
694
- <label class="about-auto local-only" title="Twice a day, ask for the newest release (GitHub for a git checkout, npm otherwise)"><input type="checkbox" id="about-auto" /> Check automatically</label>
705
+ <label class="about-auto" title="Twice a day, ask for the newest release (GitHub for a git checkout, npm otherwise)"><input type="checkbox" id="about-auto" /> Check automatically</label>
695
706
  <button type="button" id="about-check" class="ghost" title="Asks for the newest release">Check for updates</button>
696
- <button type="button" id="about-install" class="primary local-only" hidden>Update and restart</button>
707
+ <button type="button" id="about-install" class="primary" hidden>Update and restart</button>
697
708
  <button type="button" id="about-close" class="primary">Close</button>
698
709
  </div>
699
710
  </dialog>
@@ -712,6 +723,15 @@
712
723
  <p class="hint">After a restart or a crash, the daemon comes back by itself and running agents resume; without it, it starts only when you run <code>agentmgr</code>. <span id="settings-service-state"></span></p>
713
724
  </fieldset>
714
725
  <fieldset class="local-only">
726
+ <legend>Jira</legend>
727
+ <p class="hint">For an agent created from a Jira link, its description shows the issue's summary. Public issues on Atlassian Cloud need nothing here; for your company's site, add it with your email and an <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener">API token</a>. The token is kept on this computer and sent only to that site.</p>
728
+ <div class="row three">
729
+ <label>Site <input name="jiraSite" placeholder="https://yourcompany.atlassian.net" autocomplete="off" /></label>
730
+ <label>Email <input name="jiraEmail" type="email" autocomplete="off" /></label>
731
+ <label>API token <input name="jiraToken" type="password" autocomplete="new-password" /></label>
732
+ </div>
733
+ </fieldset>
734
+ <fieldset>
715
735
  <legend>Updates</legend>
716
736
  <label class="check"><input type="checkbox" name="updateCheck" /> Check for a new release twice a day</label>
717
737
  <p class="hint">Shows <b>Update to …</b> in the top bar; one click installs it and restarts, and running agents resume.</p>
package/public/menu.js CHANGED
@@ -22,7 +22,8 @@ export function initMenu({ api, esc, toast, $ }) {
22
22
  });
23
23
  // Any item closes the menu; its own handler does the rest.
24
24
  menu.addEventListener('click', (e) => {
25
- if (e.target.closest('button')) open(false);
25
+ // The theme buttons keep it open, to compare.
26
+ if (e.target.closest('button') && !e.target.closest('[data-theme-set]')) open(false);
26
27
  });
27
28
  $('#menu-about').onclick = () => $('#about-btn').click();
28
29
 
@@ -53,6 +54,7 @@ export function initMenu({ api, esc, toast, $ }) {
53
54
  const dialog = $('#settings-dialog');
54
55
  const form = $('#settings-form');
55
56
  let service = null; // the login service as the dialog opened
57
+ let jira = null; // the Jira settings as the dialog opened
56
58
  $('#settings-btn').onclick = async () => {
57
59
  $('#settings-error').hidden = true;
58
60
  try {
@@ -60,9 +62,14 @@ export function initMenu({ api, esc, toast, $ }) {
60
62
  form.maxRunning.value = c.maxRunning ?? '';
61
63
  $('#settings-running').textContent = `Open now: ${c.running}.`;
62
64
  service = null;
65
+ const u = await api('GET', '/update').catch(() => null);
66
+ form.updateCheck.checked = u?.check !== false;
63
67
  if (!remote()) {
64
- const u = await api('GET', '/update').catch(() => null);
65
- form.updateCheck.checked = u?.check !== false;
68
+ jira = await api('GET', '/jira').catch(() => null);
69
+ form.jiraSite.value = jira?.site ?? '';
70
+ form.jiraEmail.value = jira?.email ?? '';
71
+ form.jiraToken.value = '';
72
+ form.jiraToken.placeholder = jira?.hasToken ? 'saved (type to replace)' : '';
66
73
  service = await api('GET', '/service').catch(() => null);
67
74
  $('#settings-service').hidden = !service?.supported;
68
75
  form.startAtLogin.checked = Boolean(service?.installed);
@@ -84,7 +91,11 @@ export function initMenu({ api, esc, toast, $ }) {
84
91
  e.preventDefault();
85
92
  try {
86
93
  const c = await api('PUT', '/cleanup', { maxRunning: form.maxRunning.value.trim() || null });
87
- if (!remote()) await api('PUT', '/update', { check: form.updateCheck.checked }).catch(() => {});
94
+ await api('PUT', '/update', { check: form.updateCheck.checked }).catch(() => {});
95
+ if (!remote() && jira && (form.jiraSite.value.trim() !== (jira.site ?? '') || form.jiraEmail.value.trim() !== (jira.email ?? '') || form.jiraToken.value.trim())) {
96
+ await api('PUT', '/jira', { site: form.jiraSite.value.trim(), email: form.jiraEmail.value.trim(), token: form.jiraToken.value.trim() || undefined });
97
+ toast('Jira saved; summaries for agents from Jira links are being fetched', 4000);
98
+ }
88
99
  // Only when it changed: installing touches launchd / systemd.
89
100
  if (!remote() && service?.supported && form.startAtLogin.checked !== Boolean(service.installed)) {
90
101
  const s = await api('PUT', '/service', { enabled: form.startAtLogin.checked });
@@ -98,6 +109,31 @@ export function initMenu({ api, esc, toast, $ }) {
98
109
  }
99
110
  });
100
111
 
112
+ // ------------------------------------------------------------ Theme
113
+ const THEME_KEY = 'agentmgr.theme';
114
+ const theme = () => {
115
+ try {
116
+ return localStorage.getItem(THEME_KEY) || 'auto';
117
+ } catch {
118
+ return 'auto';
119
+ }
120
+ };
121
+ function applyTheme(t) {
122
+ if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
123
+ else delete document.documentElement.dataset.theme;
124
+ for (const b of menu.querySelectorAll('[data-theme-set]')) b.classList.toggle('on', b.dataset.themeSet === t);
125
+ window.dispatchEvent(new CustomEvent('agentmgr-theme'));
126
+ }
127
+ menu.addEventListener('click', (e) => {
128
+ const b = e.target.closest('[data-theme-set]');
129
+ if (!b) return;
130
+ try {
131
+ localStorage.setItem(THEME_KEY, b.dataset.themeSet);
132
+ } catch {}
133
+ applyTheme(b.dataset.themeSet);
134
+ });
135
+ applyTheme(theme());
136
+
101
137
  loadMe();
102
138
  return { loadMe };
103
139
  }