ai-agentmanager 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0 (2026-10-31)
4
+
5
+ - **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.
6
+ - **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.
7
+ - **Theme:** Auto / Light / Dark in the ☰ menu, remembered in the browser; diagrams redraw in the theme's colors.
8
+
9
+ ## 1.7.0 (2026-10-31)
10
+
11
+ - **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.
12
+ - **Review: changes explained one by one:** **Explain changes** in Review changes puts an explanation above each hunk (what, why, risk, what to check) and **◀ / ▶** or **[ / ]** step through them in reading order, skipping hunks that moved since. A notice and **Explain again** when the code changed after.
13
+
3
14
  ## 1.6.0 (2026-10-31)
4
15
 
5
16
  - **Changes explained one by one:** `agentmgr doc <agent> walkthrough` explains each hunk of an agent's changes (what, why, risk, what to check) in the order a reviewer should read them, from the same diff the review screen shows. The review screen and a split view for it come next.
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.6.0 # or a specific one
88
+ npm install -g ai-agentmanager@1.8.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.6.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
94
+ git clone --branch v1.8.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.6.0 && npm install` (the tag you want) |
114
+ | A clone at a release tag | `git fetch --tags && git checkout v1.8.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.
@@ -384,6 +385,7 @@ Each card has at most **three points of about 12 words each**, so they're easy t
384
385
 
385
386
  - **Files and diff.** The changed files on the left with lines added and removed, and the diff file by file on the right. Click a file header to fold it; very large files start folded.
386
387
  - **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.
388
+ - **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`.
387
389
  - **Commit all** commits everything in the worktree, new files included, with your git name and email.
388
390
  - **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.
389
391
 
@@ -909,6 +911,7 @@ Usage comes from the session transcripts and is refreshed every few seconds whil
909
911
  ## Studio: code and terminals
910
912
 
911
913
  - **Code** (a tab next to **Claude** in the terminal pane) opens the agent's folder: a file tree, tabs, and Monaco, the editor inside VS Code, with syntax highlighting and search within a file. **Save** (Ctrl/Cmd+S) writes into the agent's folder. If the agent changed the file since you opened it, you're asked before anything is overwritten. Files you haven't edited pick up the agent's changes as it makes them. **Search** finds text across the folder (git grep, so ignored files are skipped). **Diff** compares the file with where the agent started, side by side, and you can edit on the right. **+ / ▣ / ✎ / 🗑** create, rename and delete. Every path stays inside the agent's folder: `..`, and links that point outside, are refused.
914
+ - **◫ Split** (in the terminal bar) keeps the terminal, a shell or a browser tab on the left and opens a pane on the right with **Code**, **Docs** (Diagrams by default; Design, Flow, Analysis, Proposal or Explain from the menu, written there if missing) or **Changes** (the change-by-change explanations, each with its diff). Drag the divider to resize; the pane, its tab and its width are remembered in this browser. Desktop only.
912
915
  - **VS Code / Cursor** opens the agent's folder (and the open file) in the editor installed on this computer. The buttons show when its `code` or `cursor` command is found.
913
916
  - Monaco loads from the jsDelivr CDN the first time you open **Code**. Offline, a plain text editor takes its place.
914
917
  - **Terminal** in the top bar opens a shell on this computer in your home folder, not tied to any agent. It works from any device you're signed in on, so you can run any command from your phone. **+ Terminal** opens more. They keep running when hidden; **×** ends one. Plugins can refuse it with the `terminal.open` policy (`kind: "machine"`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.6.0",
3
+ "version": "1.8.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
@@ -5,6 +5,7 @@ import DOMPurify from '/vendor/dompurify/purify.es.mjs';
5
5
  import { touchScroll } from '/touchscroll.js';
6
6
  import { openStream, post, REMOTE } from '/transport.js';
7
7
  import { attachBox } from '/attach.js';
8
+ import { initSide } from '/side.js';
8
9
  import { initReview } from '/review.js';
9
10
  import { initLand, prChips } from '/land.js';
10
11
  import { initTriggers } from '/triggers.js';
@@ -111,7 +112,8 @@ initSetup({ api, esc, $ });
111
112
  const updateUi = initUpdate({ api, esc, toast, $ });
112
113
  initMenu({ api, esc, toast, $ });
113
114
  initMachine({ api, esc, toast, $, Terminal, FitAddon });
114
- const codeUi = initCode({ api, esc, toast, $, agents, isOpen: () => term.kind === 'code' });
115
+ let sidePane = null; // the split view's right pane (set up once the terminal is)
116
+ const codeUi = initCode({ api, esc, toast, $, agents, isOpen: () => term.kind === 'code' || Boolean(sidePane?.showing('code')) });
115
117
  const playbooksUi = initPlaybooks({ api, esc, toast, $, select: (id) => (showTab('agents'), select(id)) });
116
118
  $('#new-playbook').onclick = () => {
117
119
  const f = $('#new-form');
@@ -162,6 +164,18 @@ function checksSummary(b) {
162
164
  }
163
165
 
164
166
  const diagrams = new Map(); // mermaid source -> rendered SVG (or an error)
167
+ /** Whether the page is dark: the menu's choice, else this device's setting. */
168
+ const isDark = () => {
169
+ const t = document.documentElement.dataset.theme;
170
+ return t ? t === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
171
+ };
172
+ // Diagrams are drawn in the theme's colors: redraw them when it changes.
173
+ window.addEventListener('agentmgr-theme', () => {
174
+ diagrams.clear();
175
+ mermaidLib?.initialize({ startOnLoad: false, securityLevel: 'strict', theme: isDark() ? 'dark' : 'default' });
176
+ for (const el of document.querySelectorAll('[data-mermaid][data-filled]')) delete el.dataset.filled;
177
+ renderDiagrams(document);
178
+ });
165
179
  const expanded = new Set(); // "<agent id>:<section>" the user opened with Show more
166
180
 
167
181
  /** Long sections are clamped; Show more toggles them. */
@@ -187,7 +201,7 @@ async function renderDiagrams(root) {
187
201
  try {
188
202
  if (!mermaidLib) {
189
203
  mermaidLib = (await import('/vendor/mermaid/mermaid.esm.min.mjs')).default;
190
- const dark = matchMedia('(prefers-color-scheme: dark)').matches;
204
+ const dark = isDark();
191
205
  mermaidLib.initialize({ startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' });
192
206
  }
193
207
  const { svg } = await mermaidLib.render(`mmd-${diagrams.size}-${Date.now()}`, src);
@@ -636,7 +650,7 @@ function agentItem(a) {
636
650
  const idle = !isActive(a);
637
651
  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' : ''}">
638
652
  <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>
639
- <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>
653
+ <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>
640
654
  ${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
641
655
  <div class="meta">
642
656
  <span class="mono">${a.id}</span>
@@ -1592,7 +1606,7 @@ function renderDetail() {
1592
1606
  ${[0, 1, 2, 3].map((p) => `<option value="${p}" ${p === (a.priority ?? 2) ? 'selected' : ''}>P${p}</option>`).join('')}
1593
1607
  </select>
1594
1608
  <span class="d-name">${esc(a.name)}</span> <span class="pill ${st}">${pillText(st)}</span>${a.kind === 'general' ? ' <span class="tag">general</span>' : ''}</h2>
1595
- <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>
1609
+ <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>
1596
1610
  </div>
1597
1611
  <div class="d-actions">
1598
1612
  ${a.status === 'error' && !a.workdir
@@ -1899,6 +1913,7 @@ const term = {
1899
1913
  const a = this.agentId && agents.get(this.agentId);
1900
1914
  // The agent's own session tab is named after its engine (Codex, Muse Code, …).
1901
1915
  $('.term-tab[data-kind="claude"]').textContent = ENGINE_LABEL[a?.engine] ?? 'Claude';
1916
+ sidePane?.follow(this.agentId);
1902
1917
  const nums = new Set(a?.runtime.shells ?? []);
1903
1918
  if (isShell(this.kind)) nums.add(shellNum(this.kind));
1904
1919
  $('#shell-tabs').innerHTML = [...nums]
@@ -1930,6 +1945,7 @@ const term = {
1930
1945
  $('#term-wrap').hidden = false;
1931
1946
  $('#detail').classList.add('with-term');
1932
1947
  $('#term-wrap').classList.remove('browsing');
1948
+ sidePane?.mainShowsCode();
1933
1949
  $('#term-wrap').classList.add('coding');
1934
1950
  this.ensure();
1935
1951
  this.disconnect();
@@ -2095,6 +2111,20 @@ $('#term-fit').onclick = () => term.refit();
2095
2111
 
2096
2112
  // ------------------------------------------------------------------ resizing, expanding, full screen
2097
2113
 
2114
+ sidePane = initSide({
2115
+ api,
2116
+ esc,
2117
+ toast,
2118
+ $,
2119
+ agents,
2120
+ codeUi,
2121
+ docHtml,
2122
+ renderDiagrams,
2123
+ mainKind: () => term.kind,
2124
+ setMain: (kind) => term.agentId && term.open(term.agentId, kind),
2125
+ refit: () => requestAnimationFrame(() => term.refit()),
2126
+ });
2127
+
2098
2128
  const layoutPref = {
2099
2129
  get(key) {
2100
2130
  try {
@@ -2114,6 +2144,8 @@ function applyLayout() {
2114
2144
  const side = layoutPref.get('sideWidth');
2115
2145
  const brief = layoutPref.get('briefHeight');
2116
2146
  document.documentElement.style.setProperty('--side-w', side ? `${side}px` : '340px');
2147
+ const pane = layoutPref.get('paneWidth');
2148
+ document.documentElement.style.setProperty('--pane-w', pane ? `${pane}%` : '45%');
2117
2149
  if (brief) document.documentElement.style.setProperty('--brief-h', `${brief}px`);
2118
2150
  else document.documentElement.style.removeProperty('--brief-h');
2119
2151
  $('#detail').classList.toggle('brief-sized', Boolean(brief));
@@ -2156,6 +2188,14 @@ dragHandle($('#split-side'), 'cols', (ev) => {
2156
2188
  return w;
2157
2189
  }, 'sideWidth');
2158
2190
 
2191
+ // The split view's divider: the right pane's share of the panel, in percent.
2192
+ dragHandle($('#split-pane'), 'cols', (ev) => {
2193
+ const r = $('#term-split').getBoundingClientRect();
2194
+ const pct = Math.min(Math.max(((r.right - ev.clientX) / r.width) * 100, 20), 75);
2195
+ layoutPref.set('paneWidth', Math.round(pct));
2196
+ return pct;
2197
+ }, 'paneWidth');
2198
+
2159
2199
  dragHandle($('#split-term'), 'rows', (ev) => {
2160
2200
  const top = $('#detail').getBoundingClientRect().top;
2161
2201
  const h = Math.min(Math.max(ev.clientY - top, 60), $('#detail').clientHeight - 160);
@@ -2863,7 +2903,14 @@ $('#term-file').onchange = (e) => {
2863
2903
  function upsert(a) {
2864
2904
  agents.set(a.id, a);
2865
2905
  render();
2906
+ agentChanged(a);
2907
+ }
2908
+
2909
+ /** Views that show one agent's details outside the main render: keep them current. */
2910
+ function agentChanged(a) {
2866
2911
  if (a.id === term.agentId) term.renderTabs();
2912
+ reviewUi.agentChanged(a);
2913
+ sidePane?.agentChanged(a);
2867
2914
  }
2868
2915
 
2869
2916
  $('#counts').addEventListener('click', (e) => {
@@ -3415,7 +3462,8 @@ $('#about-check').onclick = async () => {
3415
3462
  out.hidden = false;
3416
3463
  out.textContent = 'Checking…';
3417
3464
  try {
3418
- // On this computer the daemon can install it; from a phone, just say what is out.
3465
+ // The daemon checks and can install it (from this computer or a signed-in phone); an older
3466
+ // daemon without /update gets the manual steps.
3419
3467
  const local = await updateUi.check().catch(() => null);
3420
3468
  if (local) return void (out.innerHTML = local);
3421
3469
  const r = await api('GET', '/about/latest');
@@ -3498,7 +3546,7 @@ function handleEvent(msg) {
3498
3546
  } else if (msg.t === 'agent') {
3499
3547
  agents.set(msg.agent.id, msg.agent);
3500
3548
  watchDocs(msg.agent);
3501
- if (msg.agent.id === term.agentId) term.renderTabs();
3549
+ agentChanged(msg.agent);
3502
3550
  } else if (msg.t === 'removed') {
3503
3551
  agents.delete(msg.id);
3504
3552
  if (selectedId === msg.id) select(null);
@@ -4035,6 +4083,10 @@ $('#login-form').addEventListener('submit', async (e) => {
4035
4083
  });
4036
4084
 
4037
4085
  // Engines this machine can run; the New agent form offers a choice once there's more than Claude.
4086
+ /** What an agent is about: the issue's summary when its request starts with an issue link, else the request. */
4087
+ const describe = (a) => a.synopsis?.text || a.intent;
4088
+ 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);
4089
+
4038
4090
  const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
4039
4091
  async function loadEngines() {
4040
4092
  try {
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>
@@ -96,41 +105,59 @@
96
105
  <button id="term-attach" class="ghost small" title="Attach files or images (you can also drop or paste them into the terminal)">📎 Attach</button>
97
106
  <input type="file" id="term-file" multiple hidden>
98
107
  <button id="term-fit" class="ghost small" title="Refit the terminal to the panel">Fit</button>
108
+ <button id="term-split-btn" class="ghost small desk-only" title="Split: the terminal on the left, and Code, Docs (diagrams) or Changes on the right">◫ Split</button>
99
109
  <button id="term-max" class="ghost small desk-only" title="Expand to fill the window (Ctrl+Shift+E)">⤢ Expand</button>
100
110
  <button id="term-full" class="ghost small desk-only" title="Full screen (Esc to leave)">⛶ Full screen</button>
101
111
  <button id="term-close" class="ghost small" title="Hide the terminal (the agent keeps running)">Hide terminal</button>
102
112
  </div>
103
113
  <div id="signin-bar" class="signin-bar" hidden></div>
104
- <div id="terminal" class="terminal"></div>
105
- <div id="code" class="code"></div>
106
- <div id="browser" class="browser">
107
- <form id="browser-bar" class="browser-bar" autocomplete="off">
108
- <button type="button" id="browser-back" class="ghost small live-only" title="Back">◀</button>
109
- <button type="button" id="browser-fwd" class="ghost small live-only" title="Forward">▶</button>
110
- <button type="button" id="browser-reload" class="ghost small" title="Reload the page">⟳</button>
111
- <input id="browser-url" placeholder="localhost:5173, a port, or any address" spellcheck="false" autocapitalize="off" autocorrect="off" enterkeyhint="go">
112
- <button class="small">Go</button>
113
- <button type="button" id="browser-sound" class="ghost small live-only" title="Hear the page: sound from videos and audio playing on that machine">🔇</button>
114
- <button type="button" id="browser-keys" class="ghost small live-only touch-only" title="Show the keyboard to type into the page">⌨</button>
115
- <button type="button" id="browser-mode" class="ghost small" hidden></button>
116
- <a id="browser-pop" class="ghost small button" target="_blank" rel="noopener" title="Open in a new browser tab on this device" hidden>↗</a>
117
- </form>
118
- <div id="browser-hint" class="browser-hint" hidden>Blank? Many sites refuse to show inside another page. Open it with ↗ instead.</div>
119
- <div id="browser-cert" class="browser-start" hidden>
120
- <p><b>One more step on this device.</b> Each browser tab goes through its own port, and this device hasn't accepted AI Agent Manager's certificate for this one yet.</p>
121
- <p>Open it, accept the warning the same way you did for AI Agent Manager (Safari: Show Details → visit this website), then come back here. You only do this once per site; it keeps its port.</p>
122
- <div><a id="browser-cert-open" class="button primary small" target="_blank" rel="noopener">Open and accept</a> <button type="button" id="browser-cert-retry" class="small">Done, load it</button></div>
123
- </div>
124
- <div id="browser-start" class="browser-start" hidden>
125
- <p>Open a page as this machine sees it: the agent's dev server, a site on its network or VPN, or any address. It works from your phone or laptop too: AI Agent Manager relays it over its own secure connection, for signed-in devices only.</p>
126
- <div id="browser-suggest"></div>
114
+ <div class="term-split" id="term-split">
115
+ <div class="term-main" id="term-main">
116
+ <div id="terminal" class="terminal"></div>
117
+ <div id="code" class="code"></div>
118
+ <div id="browser" class="browser">
119
+ <form id="browser-bar" class="browser-bar" autocomplete="off">
120
+ <button type="button" id="browser-back" class="ghost small live-only" title="Back">◀</button>
121
+ <button type="button" id="browser-fwd" class="ghost small live-only" title="Forward">▶</button>
122
+ <button type="button" id="browser-reload" class="ghost small" title="Reload the page">⟳</button>
123
+ <input id="browser-url" placeholder="localhost:5173, a port, or any address" spellcheck="false" autocapitalize="off" autocorrect="off" enterkeyhint="go">
124
+ <button class="small">Go</button>
125
+ <button type="button" id="browser-sound" class="ghost small live-only" title="Hear the page: sound from videos and audio playing on that machine">🔇</button>
126
+ <button type="button" id="browser-keys" class="ghost small live-only touch-only" title="Show the keyboard to type into the page">⌨</button>
127
+ <button type="button" id="browser-mode" class="ghost small" hidden></button>
128
+ <a id="browser-pop" class="ghost small button" target="_blank" rel="noopener" title="Open in a new browser tab on this device" hidden>↗</a>
129
+ </form>
130
+ <div id="browser-hint" class="browser-hint" hidden>Blank? Many sites refuse to show inside another page. Open it with ↗ instead.</div>
131
+ <div id="browser-cert" class="browser-start" hidden>
132
+ <p><b>One more step on this device.</b> Each browser tab goes through its own port, and this device hasn't accepted AI Agent Manager's certificate for this one yet.</p>
133
+ <p>Open it, accept the warning the same way you did for AI Agent Manager (Safari: Show Details → visit this website), then come back here. You only do this once per site; it keeps its port.</p>
134
+ <div><a id="browser-cert-open" class="button primary small" target="_blank" rel="noopener">Open and accept</a> <button type="button" id="browser-cert-retry" class="small">Done, load it</button></div>
135
+ </div>
136
+ <div id="browser-start" class="browser-start" hidden>
137
+ <p>Open a page as this machine sees it: the agent's dev server, a site on its network or VPN, or any address. It works from your phone or laptop too: AI Agent Manager relays it over its own secure connection, for signed-in devices only.</p>
138
+ <div id="browser-suggest"></div>
139
+ </div>
140
+ <div id="browser-frames" class="browser-frames"></div>
141
+ <div id="browser-live" class="browser-live" hidden>
142
+ <img id="browser-live-img" alt="" draggable="false">
143
+ <textarea id="browser-live-keys" class="browser-live-keys" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Type into the page"></textarea>
144
+ <div id="browser-live-msg" class="browser-live-msg" hidden></div>
145
+ </div>
127
146
  </div>
128
- <div id="browser-frames" class="browser-frames"></div>
129
- <div id="browser-live" class="browser-live" hidden>
130
- <img id="browser-live-img" alt="" draggable="false">
131
- <textarea id="browser-live-keys" class="browser-live-keys" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Type into the page"></textarea>
132
- <div id="browser-live-msg" class="browser-live-msg" hidden></div>
133
147
  </div>
148
+ <div class="splitter split-pane" id="split-pane" role="separator" aria-orientation="vertical" title="Drag to resize; double-click to reset" hidden></div>
149
+ <aside class="side-pane" id="side-pane" hidden>
150
+ <div class="side-bar">
151
+ <div class="term-tabs" role="tablist">
152
+ <button class="term-tab" data-side="code" role="tab" title="The agent's files">Code</button>
153
+ <button class="term-tab" data-side="docs" role="tab" title="Diagrams and the other documents">Docs</button>
154
+ <button class="term-tab" data-side="changes" role="tab" title="Its changes, explained one by one">Changes</button>
155
+ </div>
156
+ <span class="spacer"></span>
157
+ <button type="button" class="ghost small" id="side-close" title="Close the split">✕</button>
158
+ </div>
159
+ <div class="side-body" id="side-body"></div>
160
+ </aside>
134
161
  </div>
135
162
  <div id="term-drop" class="term-drop" hidden>Drop files to attach</div>
136
163
  <div class="keybar" id="keybar" aria-label="Terminal keys">
@@ -673,9 +700,9 @@
673
700
  <dl id="about-list" class="about-list"></dl>
674
701
  <p id="about-update" class="hint" hidden></p>
675
702
  <div class="dialog-actions">
676
- <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>
703
+ <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>
677
704
  <button type="button" id="about-check" class="ghost" title="Asks for the newest release">Check for updates</button>
678
- <button type="button" id="about-install" class="primary local-only" hidden>Update and restart</button>
705
+ <button type="button" id="about-install" class="primary" hidden>Update and restart</button>
679
706
  <button type="button" id="about-close" class="primary">Close</button>
680
707
  </div>
681
708
  </dialog>
@@ -694,6 +721,15 @@
694
721
  <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>
695
722
  </fieldset>
696
723
  <fieldset class="local-only">
724
+ <legend>Jira</legend>
725
+ <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>
726
+ <div class="row three">
727
+ <label>Site <input name="jiraSite" placeholder="https://yourcompany.atlassian.net" autocomplete="off" /></label>
728
+ <label>Email <input name="jiraEmail" type="email" autocomplete="off" /></label>
729
+ <label>API token <input name="jiraToken" type="password" autocomplete="new-password" /></label>
730
+ </div>
731
+ </fieldset>
732
+ <fieldset>
697
733
  <legend>Updates</legend>
698
734
  <label class="check"><input type="checkbox" name="updateCheck" /> Check for a new release twice a day</label>
699
735
  <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
  }
package/public/review.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Review screen: an agent's changes file by file, line comments sent back to the agent as one
2
2
  // message, and Commit / Push & open PR. Comments not sent yet are kept per agent in this browser.
3
+ // With a walkthrough, each hunk shows its explanation and ◀ / ▶ step through them in reading order.
4
+ import { cardHtml, explainButton, stepOf, walkthroughOf } from './walkthrough.js';
3
5
 
4
6
  /** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
5
7
  export function parseDiff(text) {
@@ -56,7 +58,8 @@ const BIG = 600; // files with more changed lines start collapsed
56
58
  export function initReview({ api, esc, toast, agents, $ }) {
57
59
  const dlg = $('#review-dialog');
58
60
  const body = $('#review-body');
59
- const st = { id: null, data: null, files: [], comments: [], collapsed: new Set(), editing: null, loading: false };
61
+ const st = { id: null, data: null, files: [], comments: [], collapsed: new Set(), editing: null, loading: false, step: null };
62
+ const walk = () => walkthroughOf(agents.get(st.id), st.data?.diffHash);
60
63
 
61
64
  const key = (id) => `agentmgr.review.${id}`;
62
65
  const loadDrafts = (id) => {
@@ -80,6 +83,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
80
83
  st.collapsed = new Set();
81
84
  st.editing = null;
82
85
  st.data = null;
86
+ st.step = null;
83
87
  $('#review-title').textContent = `Review: ${agents.get(id)?.name ?? ''}`;
84
88
  body.innerHTML = '<p class="hint">Loading changes…</p>';
85
89
  if (!dlg.open) dlg.showModal();
@@ -129,7 +133,10 @@ export function initReview({ api, esc, toast, agents, $ }) {
129
133
  if (collapsed) return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}</div>`;
130
134
  if (f.binary) return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}<p class="hint rv-pad">Binary file</p></div>`;
131
135
  const rows = [];
136
+ const w = walk();
132
137
  for (const h of f.hunks) {
138
+ const i = w.doc ? stepOf(w.doc, f.path, h.head) : -1;
139
+ if (i >= 0) rows.push(`<tr class="rv-explain ${st.step === i ? 'current' : ''}" id="rv-step-${i}"><td colspan="3">${cardHtml(w.doc.data.changes[i], i, w.doc.data.changes.length, esc)}</td></tr>`);
133
140
  rows.push(`<tr class="rv-hunk"><td colspan="3">${esc(h.head)}</td></tr>`);
134
141
  for (const l of h.lines) {
135
142
  const side = l.t === 'del' ? 'old' : 'new';
@@ -149,6 +156,48 @@ export function initReview({ api, esc, toast, agents, $ }) {
149
156
  return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}<table class="rv-table">${rows.join('')}</table></div>`;
150
157
  }
151
158
 
159
+ /** Explain changes, then ◀ n of N ▶ once there is a walkthrough. */
160
+ function walkNav() {
161
+ const w = walk();
162
+ const n = w.doc?.data.changes.length ?? 0;
163
+ const nav = n
164
+ ? `<span class="rv-walk"><button type="button" class="small ghost" data-rv-step="-1" title="Previous change ([)">◀</button><span class="hint">${st.step == null ? `${n} changes` : `${st.step + 1} of ${n}`}</span><button type="button" class="small ghost" data-rv-step="1" title="Next change (])">▶</button></span>`
165
+ : '';
166
+ return `${nav}${explainButton(w, 'data-rv-explain')}`;
167
+ }
168
+
169
+ function walkNote() {
170
+ const w = walk();
171
+ if (w.error && !w.pending) return `<p class="error rv-pad">Couldn't explain the changes: ${esc(w.error)}</p>`;
172
+ if (!w.doc) return '';
173
+ const notes = [esc(w.doc.data.summary)];
174
+ if (w.stale) notes.push('<b>The changes moved since this was written</b>; explanations for hunks that changed are missing. Explain again for the latest.');
175
+ if (w.doc.cut) notes.push('The diff was too long to explain all of it; the rest has no explanation.');
176
+ return `<p class="hint rv-walk-note">${notes.join(' ')}</p>`;
177
+ }
178
+
179
+ /** Explanations whose hunk is still in the diff (after more edits, some may be gone). */
180
+ const present = (w) => (w.doc?.data.changes ?? []).map((c, i) => (st.files.some((f) => f.path === c.file && f.hunks.some((h) => h.head === c.head)) ? i : -1)).filter((i) => i >= 0);
181
+
182
+ /** Step `dir` (+1 / -1) from the current explanation: open its file, mark it and scroll to it. */
183
+ function step(dir) {
184
+ const w = walk();
185
+ const ok = present(w);
186
+ if (!ok.length) return toast('These explanations no longer match the changes; Explain again');
187
+ const at = st.step == null ? -1 : ok.indexOf(st.step);
188
+ const next = at < 0 ? (dir > 0 ? ok[0] : ok.at(-1)) : ok[(at + dir + ok.length) % ok.length];
189
+ gotoStep(next);
190
+ }
191
+
192
+ function gotoStep(i) {
193
+ const w = walk();
194
+ st.step = i;
195
+ st.collapsed.delete(w.doc.data.changes[st.step].file);
196
+ keepInputs();
197
+ render();
198
+ body.querySelector(`#rv-step-${st.step}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' });
199
+ }
200
+
152
201
  function render() {
153
202
  const d = st.data;
154
203
  if (!d) return;
@@ -168,8 +217,10 @@ export function initReview({ api, esc, toast, agents, $ }) {
168
217
  ${d.unpushed ? `<span class="tag">${d.unpushed} not pushed</span>` : ''}
169
218
  ${openPr ? `<a href="${esc(openPr.url)}" target="_blank" rel="noopener">PR #${esc(openPr.number ?? '')}</a>` : ''}
170
219
  <span class="spacer"></span>
220
+ ${d.files.length ? walkNav() : ''}
171
221
  <button type="button" class="small ghost" data-rv-reload>${st.loading ? 'Loading…' : 'Reload'}</button>
172
222
  </div>
223
+ ${walkNote()}
173
224
  ${d.busy ? '<p class="hint rv-warn">The agent is working, so the changes may still move. Reload when it is idle.</p>' : ''}
174
225
  ${d.truncated ? '<p class="hint rv-warn">The diff is large and was cut short; open a shell for the rest.</p>' : ''}
175
226
  <div class="rv-main">
@@ -235,6 +286,20 @@ export function initReview({ api, esc, toast, agents, $ }) {
235
286
  body.addEventListener('click', async (e) => {
236
287
  const t = e.target;
237
288
  if (t.closest('[data-rv-reload]')) return keepInputs(), reload();
289
+ const stepBtn = t.closest('[data-rv-step]');
290
+ if (stepBtn) return step(Number(stepBtn.dataset.rvStep));
291
+ if (t.closest('[data-rv-explain]')) {
292
+ keepInputs();
293
+ try {
294
+ // In the background: the agent's record says when it's done (agentChanged re-renders).
295
+ agents.set(st.id, await api('POST', `/agents/${st.id}/docs/walkthrough?background=1`));
296
+ st.step = null;
297
+ render();
298
+ } catch (err) {
299
+ toast(`Error: ${err.message}`, 5000);
300
+ }
301
+ return;
302
+ }
238
303
  const go = t.closest('[data-rv-goto]');
239
304
  if (go) {
240
305
  e.preventDefault();
@@ -339,6 +404,13 @@ export function initReview({ api, esc, toast, agents, $ }) {
339
404
  }
340
405
  });
341
406
 
407
+ // [ and ] step through the explained changes (not while typing).
408
+ dlg.addEventListener('keydown', (e) => {
409
+ if ((e.key !== '[' && e.key !== ']') || e.target.closest?.('textarea, input') || !walk().doc) return;
410
+ e.preventDefault();
411
+ step(e.key === ']' ? 1 : -1);
412
+ });
413
+
342
414
  body.addEventListener('keydown', (e) => {
343
415
  const form = e.target.closest?.('.rv-form');
344
416
  if (form && e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
@@ -364,5 +436,16 @@ export function initReview({ api, esc, toast, agents, $ }) {
364
436
  st.note = st.msg = st.prTitle = '';
365
437
  });
366
438
 
367
- return { open, get openId() { return dlg.open ? st.id : null; } };
439
+ /** The agent changed (its walkthrough finished, say): redraw if it's the one under review. */
440
+ function agentChanged(a) {
441
+ if (!dlg.open || a.id !== st.id || !st.data) return;
442
+ const w = a.docs?.walkthrough;
443
+ const sig = `${w?.at}|${w?.pending}|${w?.error}`;
444
+ if (sig === st.walkSig) return;
445
+ st.walkSig = sig;
446
+ keepInputs();
447
+ render();
448
+ }
449
+
450
+ return { open, agentChanged, get openId() { return dlg.open ? st.id : null; } };
368
451
  }
package/public/side.js ADDED
@@ -0,0 +1,191 @@
1
+ // The split view: the terminal (Claude, a shell or a browser tab) stays on the left, and the right
2
+ // pane shows the same agent's Code, its Docs (Diagrams by default) or its Changes explained one by
3
+ // one. On, the tab and the document are remembered in this browser.
4
+ import { parseDiff } from '/review.js';
5
+ import { cardHtml, explainButton, walkthroughOf } from '/walkthrough.js';
6
+
7
+ const DOCS = [
8
+ ['diagrams', 'Diagrams'],
9
+ ['design', 'Design'],
10
+ ['flow', 'Flow'],
11
+ ['analysis', 'Analysis'],
12
+ ['proposal', 'Proposal'],
13
+ ['explain', 'Explain'],
14
+ ];
15
+ const pref = {
16
+ get: (k, d) => {
17
+ try {
18
+ return localStorage.getItem(`agentmgr.side.${k}`) ?? d;
19
+ } catch {
20
+ return d;
21
+ }
22
+ },
23
+ set: (k, v) => {
24
+ try {
25
+ localStorage.setItem(`agentmgr.side.${k}`, v);
26
+ } catch {}
27
+ },
28
+ };
29
+
30
+ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDiagrams, mainKind, setMain, refit }) {
31
+ const pane = $('#side-pane');
32
+ const body = $('#side-body');
33
+ const code = $('#code');
34
+ const codeHome = $('#term-main');
35
+ const st = { on: pref.get('on', '0') === '1', tab: pref.get('tab', 'docs'), doc: pref.get('doc', 'diagrams'), agentId: null, changes: null, sig: '', loading: false };
36
+
37
+ const docOf = (a, kind) => (kind === 'explain' ? a?.explanation : a?.docs?.[kind]);
38
+
39
+ function show() {
40
+ const on = st.on && Boolean(st.agentId);
41
+ pane.hidden = !on;
42
+ $('#split-pane').hidden = !on;
43
+ $('#term-split-btn').classList.toggle('on', on);
44
+ for (const t of pane.querySelectorAll('[data-side]')) t.classList.toggle('active', t.dataset.side === st.tab);
45
+ // Code lives in one place at a time: here when this tab shows it, else back in the main panel.
46
+ const want = on && st.tab === 'code' ? body : codeHome;
47
+ if (code.parentElement !== want) want === body ? body.replaceChildren(code) : codeHome.insertBefore(code, $('#browser'));
48
+ pane.classList.toggle('with-code', on && st.tab === 'code');
49
+ if (!on) return refit();
50
+ if (st.tab === 'code') codeUi.open(st.agentId);
51
+ else if (st.tab === 'docs') renderDocs(true);
52
+ else renderChanges(true);
53
+ refit();
54
+ }
55
+
56
+ // ------------------------------------------------------------ Docs
57
+ function renderDocs(force) {
58
+ const a = agents.get(st.agentId);
59
+ const d = docOf(a, st.doc);
60
+ const sig = `docs|${st.agentId}|${st.doc}|${d?.at}|${d?.pending}|${d?.error}`;
61
+ if (!force && sig === st.sig) return;
62
+ st.sig = sig;
63
+ const label = DOCS.find(([k]) => k === st.doc)?.[1] ?? st.doc;
64
+ const picker = `<select data-side-doc>${DOCS.map(([k, l]) => `<option value="${k}" ${k === st.doc ? 'selected' : ''}>${l}${docOf(a, k)?.markdown ? ' ✓' : ''}</option>`).join('')}</select>`;
65
+ const stale = d?.markdown && a?.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
66
+ const action = d?.pending
67
+ ? `<span class="hint">Writing…</span>`
68
+ : `<button type="button" class="small ${d?.markdown ? 'ghost' : 'primary'}" data-side-write>${d?.markdown ? 'Again' : st.doc === 'diagrams' ? 'Draw' : 'Write'}</button>`;
69
+ let content;
70
+ if (d?.pending && !d?.markdown) content = `<p class="hint">Writing the ${esc(label.toLowerCase())} from the agent's work so far…</p>`;
71
+ else if (d?.error && !d?.markdown) content = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
72
+ else if (d?.markdown) content = `<div class="md ${st.doc === 'diagrams' ? 'diagrams-view' : ''}">${docHtml(d.markdown)}</div>`;
73
+ else content = `<p class="hint">No ${esc(label.toLowerCase())} for this agent yet.</p>`;
74
+ body.innerHTML = `<div class="side-tools">${picker}<span class="spacer"></span>${stale ? '<span class="hint">brief changed since</span>' : ''}${action}</div><div class="side-scroll">${content}</div>`;
75
+ renderDiagrams(body);
76
+ }
77
+
78
+ // ------------------------------------------------------------ Changes
79
+ async function loadChanges() {
80
+ st.loading = true;
81
+ try {
82
+ const data = await api('GET', `/agents/${st.agentId}/changes`);
83
+ st.changes = { id: st.agentId, diffHash: data.diffHash, files: parseDiff(data.diff) };
84
+ } catch (err) {
85
+ st.changes = { id: st.agentId, error: err.message };
86
+ }
87
+ st.loading = false;
88
+ renderChanges(true);
89
+ }
90
+
91
+ function hunkHtml(file, head) {
92
+ const h = st.changes?.files?.find((f) => f.path === file)?.hunks.find((x) => x.head === head);
93
+ if (!h) return '<p class="hint">This hunk has changed since; Explain again for the latest.</p>';
94
+ const lines = h.lines.slice(0, 40).map((l) => `<div class="sd-${l.t}">${l.t === 'add' ? '+' : l.t === 'del' ? '−' : ' '}${esc(l.text)}</div>`);
95
+ if (h.lines.length > 40) lines.push(`<div class="hint">… ${h.lines.length - 40} more lines (Review changes shows all)</div>`);
96
+ return `<div class="sd-hunk"><div class="sd-head">${esc(head)}</div>${lines.join('')}</div>`;
97
+ }
98
+
99
+ function renderChanges(force) {
100
+ if (!st.changes || st.changes.id !== st.agentId) {
101
+ body.innerHTML = '<p class="hint side-pad">Loading the changes…</p>';
102
+ if (!st.loading) loadChanges();
103
+ return;
104
+ }
105
+ const a = agents.get(st.agentId);
106
+ const w = walkthroughOf(a, st.changes.diffHash);
107
+ const sig = `changes|${st.agentId}|${a?.docs?.walkthrough?.at}|${w.pending}|${w.error}|${st.changes.diffHash}`;
108
+ if (!force && sig === st.sig) return;
109
+ st.sig = sig;
110
+ const tools = `<div class="side-tools">${explainButton(w, 'data-side-explain')}<span class="spacer"></span><button type="button" class="small ghost" data-side-reload title="Load the changes again">⟳</button></div>`;
111
+ let content;
112
+ if (st.changes.error) content = `<p class="error">${esc(st.changes.error)}</p>`;
113
+ else if (!st.changes.files.length) content = '<p class="hint">No changes yet.</p>';
114
+ else if (!w.doc) content = `${w.error && !w.pending ? `<p class="error">Couldn't explain them: ${esc(w.error)}</p>` : ''}<p class="hint">${st.changes.files.length} file${st.changes.files.length === 1 ? '' : 's'} changed. <b>Explain changes</b> goes through them one by one: what, why, the risk and what to check.</p>`;
115
+ else {
116
+ const list = w.doc.data.changes;
117
+ content = `<p class="hint">${esc(w.doc.data.summary)}${w.stale ? ' <b>The changes moved since this was written.</b>' : ''}</p>${list
118
+ .map((c, i) => `<section class="sd-step"><div class="sd-file"><code>${esc(c.file)}</code></div>${cardHtml(c, i, list.length, esc)}${hunkHtml(c.file, c.head)}</section>`)
119
+ .join('')}`;
120
+ }
121
+ body.innerHTML = `${tools}<div class="side-scroll">${content}</div>`;
122
+ }
123
+
124
+ // ------------------------------------------------------------ events
125
+ pane.addEventListener('click', async (e) => {
126
+ const tab = e.target.closest('[data-side]');
127
+ if (tab) return setTab(tab.dataset.side);
128
+ if (e.target.closest('#side-close')) return toggle(false);
129
+ if (e.target.closest('[data-side-reload]')) return loadChanges();
130
+ try {
131
+ if (e.target.closest('[data-side-explain]')) {
132
+ agents.set(st.agentId, await api('POST', `/agents/${st.agentId}/docs/walkthrough?background=1`));
133
+ return renderChanges(true);
134
+ }
135
+ if (e.target.closest('[data-side-write]')) {
136
+ const route = st.doc === 'explain' ? `/agents/${st.agentId}/explain?background=1` : `/agents/${st.agentId}/docs/${st.doc}?background=1`;
137
+ agents.set(st.agentId, await api('POST', route));
138
+ return renderDocs(true);
139
+ }
140
+ } catch (err) {
141
+ toast(`Error: ${err.message}`, 5000);
142
+ }
143
+ });
144
+ pane.addEventListener('change', (e) => {
145
+ if (!e.target.matches('[data-side-doc]')) return;
146
+ st.doc = e.target.value;
147
+ pref.set('doc', st.doc);
148
+ renderDocs(true);
149
+ });
150
+
151
+ function setTab(tab) {
152
+ st.tab = tab;
153
+ pref.set('tab', tab);
154
+ // Code can't be on both sides: the main panel goes back to the agent's session.
155
+ if (tab === 'code' && mainKind() === 'code') setMain('claude');
156
+ show();
157
+ }
158
+
159
+ function toggle(on = !st.on) {
160
+ st.on = on;
161
+ pref.set('on', on ? '1' : '0');
162
+ if (on && st.tab === 'code' && mainKind() === 'code') st.tab = 'docs';
163
+ show();
164
+ }
165
+
166
+ $('#term-split-btn').onclick = () => toggle();
167
+
168
+ return {
169
+ /** The terminal now shows this agent (or none): follow it. */
170
+ follow(id) {
171
+ if (id === st.agentId) return;
172
+ st.agentId = id ?? null;
173
+ st.changes = null;
174
+ st.sig = '';
175
+ show();
176
+ },
177
+ /** The main panel is about to show Code: move this pane off it. */
178
+ mainShowsCode() {
179
+ if (st.on && st.tab === 'code') {
180
+ st.tab = 'docs';
181
+ show();
182
+ }
183
+ },
184
+ showing: (tab) => st.on && Boolean(st.agentId) && st.tab === tab,
185
+ agentChanged(a) {
186
+ if (!st.on || a.id !== st.agentId) return;
187
+ if (st.tab === 'docs') renderDocs(false);
188
+ else if (st.tab === 'changes') renderChanges(false);
189
+ },
190
+ };
191
+ }
package/public/style.css CHANGED
@@ -15,8 +15,9 @@
15
15
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
16
16
  color-scheme: light;
17
17
  }
18
+ /* Dark: the system's setting unless the page is set to Light, or always when set to Dark (☰ menu). */
18
19
  @media (prefers-color-scheme: dark) {
19
- :root {
20
+ :root:not([data-theme="light"]) {
20
21
  --bg: #0d0f14;
21
22
  --panel: #151821;
22
23
  --panel-2: #1c202b;
@@ -31,6 +32,20 @@
31
32
  color-scheme: dark;
32
33
  }
33
34
  }
35
+ :root[data-theme="dark"] {
36
+ --bg: #0d0f14;
37
+ --panel: #151821;
38
+ --panel-2: #1c202b;
39
+ --border: #2a2f3c;
40
+ --text: #e5e8ef;
41
+ --muted: #8b93a7;
42
+ --accent: #6d8bff;
43
+ --accent-text: #0d0f14;
44
+ --green: #3ecf8e;
45
+ --yellow: #e6b450;
46
+ --red: #ff6b6b;
47
+ color-scheme: dark;
48
+ }
34
49
 
35
50
  * { box-sizing: border-box; }
36
51
  [hidden] { display: none !important; }
@@ -837,3 +852,45 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
837
852
  .attach-chip.fail { border-color: var(--red); }
838
853
  .attach-chip .link { padding: 0 4px; }
839
854
  form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px; }
855
+
856
+ /* Walkthrough: an explanation card above each hunk; the current one is marked. */
857
+ .wt-card { display: flex; flex-direction: column; gap: 3px; padding: 8px 10px; font: 13px/1.45 var(--sans, system-ui); white-space: normal; color: var(--text); }
858
+ .wt-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
859
+ .wt-n { font: 11px var(--mono, ui-monospace); color: var(--muted); }
860
+ .wt-k { display: inline-block; min-width: 44px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
861
+ .rv-explain td { background: var(--panel); border-top: 1px solid var(--border); }
862
+ .rv-explain.current td { box-shadow: inset 3px 0 0 var(--accent, #6b8cff); background: var(--panel-2); }
863
+ .rv-walk { display: inline-flex; align-items: center; gap: 4px; }
864
+ .rv-walk-note { margin: 4px 12px 8px; }
865
+
866
+ /* Split view: the terminal (or a browser tab, or Code) on the left, a pane on the right. */
867
+ .term-split { flex: 1; display: flex; min-height: 0; min-width: 0; }
868
+ .term-main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
869
+ .split-pane { flex: none; width: 0; }
870
+ .split-pane::after { top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; }
871
+ .split-pane[hidden], .side-pane[hidden] { display: none !important; }
872
+ .side-pane { flex: none; width: var(--pane-w, 45%); display: flex; flex-direction: column; min-height: 0; border-left: 1px solid #30363d; background: var(--bg); color: var(--text); }
873
+ .side-bar { display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: #161a22; color: #c9d1d9; font-size: 12.5px; }
874
+ .side-bar button { color: #c9d1d9; border-color: #30363d; }
875
+ .side-body { flex: 1; display: flex; flex-direction: column; min-height: 0; }
876
+ .side-pane .code { display: flex; flex: 1; }
877
+ .side-tools { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); }
878
+ .side-tools select { width: auto; }
879
+ .side-scroll { flex: 1; overflow: auto; padding: 10px 12px; }
880
+ .side-pad { padding: 10px 12px; }
881
+ .side-scroll .diagrams-view .diagram, .side-scroll .diagram { display: flex; justify-content: center; overflow-x: auto; }
882
+ .side-scroll .diagram svg { max-width: 100%; height: auto; }
883
+ .sd-step { margin-bottom: 14px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
884
+ .sd-file { padding: 4px 10px; background: var(--panel-2); font-size: 12px; }
885
+ .sd-hunk { font: 12px/1.45 var(--mono, ui-monospace, monospace); white-space: pre; overflow-x: auto; border-top: 1px solid var(--border); }
886
+ .sd-hunk > div { padding: 0 10px; }
887
+ .sd-head { color: var(--muted); background: var(--panel); }
888
+ .sd-add { background: rgba(46, 160, 67, .15); }
889
+ .sd-del { background: rgba(248, 81, 73, .15); }
890
+ #term-split-btn.on { background: #262c38; }
891
+
892
+ /* Theme switch in the menu: Auto / Light / Dark. */
893
+ .menu-theme { display: flex; align-items: center; gap: 6px; padding: 6px 12px; }
894
+ .menu-theme .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-left: auto; }
895
+ .menu-theme .seg button { border: 0; border-radius: 0; padding: 3px 9px; font-size: 12px; background: transparent; }
896
+ .menu-theme .seg button.on { background: var(--accent); color: var(--accent-text); }
package/public/update.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // Updating from the app: a button in the top bar when a newer release is out, and "Update and
2
2
  // restart" in About. The daemon installs it (git checkout of the release tag, or npm install -g)
3
- // and restarts onto it; this page then waits for it to come back and reloads.
3
+ // and restarts onto it; this page then waits for it to come back and reloads. It works the same
4
+ // from a phone over remote access: the install runs on the computer.
4
5
 
5
6
  export function initUpdate({ api, esc, toast, $ }) {
6
- const remote = () => document.body.classList.contains('remote');
7
7
  let state = null;
8
8
 
9
9
  function show(s) {
@@ -20,7 +20,6 @@ export function initUpdate({ api, esc, toast, $ }) {
20
20
  }
21
21
 
22
22
  async function load() {
23
- if (remote()) return;
24
23
  try {
25
24
  show(await api('GET', '/update'));
26
25
  } catch {
@@ -62,9 +61,8 @@ export function initUpdate({ api, esc, toast, $ }) {
62
61
  }
63
62
  }
64
63
 
65
- /** "Check for updates" in About: the daemon's check where it can update itself. */
64
+ /** "Check for updates" in About: the daemon's check, and whether it can update itself. */
66
65
  async function check() {
67
- if (remote()) return null;
68
66
  const s = await api('POST', '/update/check');
69
67
  show(s);
70
68
  if (!s.newer) return `You have the newest release (${esc(s.current)}).`;
@@ -0,0 +1,36 @@
1
+ // An agent's changes explained one by one (the "walkthrough" document): shared by the review
2
+ // screen, which shows each explanation above its hunk, and the Changes pane of the split view.
3
+
4
+ const RISK = { high: 'fail', medium: '', low: 'open' };
5
+
6
+ /** The walkthrough of an agent and whether it still matches the changes (diffHash from /changes). */
7
+ export function walkthroughOf(agent, diffHash) {
8
+ const w = agent?.docs?.walkthrough;
9
+ return {
10
+ doc: w?.data ? w : null,
11
+ pending: Boolean(w?.pending),
12
+ error: w?.error ?? null,
13
+ stale: Boolean(w?.data && diffHash && w.diffHash !== diffHash),
14
+ };
15
+ }
16
+
17
+ /** Index of the explanation for a hunk (file + header), or -1. */
18
+ export const stepOf = (doc, file, head) => doc?.data.changes.findIndex((c) => c.file === file && c.head === head) ?? -1;
19
+
20
+ /** One explanation: title, risk, what / why / check. */
21
+ export function cardHtml(c, i, total, esc) {
22
+ if (c.unexplained) return `<div class="wt-card"><div class="wt-head"><span class="wt-n">${i + 1}/${total}</span><span class="hint">Not explained (the model skipped it)</span></div></div>`;
23
+ return `<div class="wt-card">
24
+ <div class="wt-head"><span class="wt-n">${i + 1}/${total}</span><b>${esc(c.title)}</b><span class="tag ${RISK[c.risk] ?? ''}">${esc(c.risk)} risk</span></div>
25
+ ${c.what ? `<div><span class="wt-k">What</span> ${esc(c.what)}</div>` : ''}
26
+ ${c.why ? `<div><span class="wt-k">Why</span> ${esc(c.why)}</div>` : ''}
27
+ ${c.check ? `<div><span class="wt-k">Check</span> ${esc(c.check)}</div>` : ''}
28
+ </div>`;
29
+ }
30
+
31
+ /** The button that writes (or rewrites) the walkthrough, with its state. */
32
+ export function explainButton(w, attrs = 'data-wt-explain') {
33
+ const label = w.pending ? 'Explaining…' : w.doc ? (w.stale ? 'Explain again (changed)' : 'Explain again') : '✨ Explain changes';
34
+ return `<button type="button" class="small ${w.doc && !w.stale ? 'ghost' : 'primary'}" ${attrs} ${w.pending ? 'disabled' : ''}
35
+ title="Explain each change one by one: what, why, the risk and what to check, in the order to read them (a separate one-off call; the agent isn't interrupted)">${label}</button>`;
36
+ }
package/src/jira.js ADDED
@@ -0,0 +1,100 @@
1
+ // The summary of the Jira issue a request starts with, so an agent created from a link shows what
2
+ // the issue is about instead of the link. Fetched from Jira Cloud's REST API in the background;
3
+ // when it can't be fetched, the words after the link stand in for it.
4
+ //
5
+ // Only Atlassian Cloud sites (*.atlassian.net) and the site set here are contacted, and the email
6
+ // and API token go only to that site: requests can come from outside (GitHub issues, the
7
+ // assistant), and a link in one must not receive your token or reach internal addresses.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { paths } from './config.js';
11
+ import { leadingJiraKey } from './tracker.js';
12
+
13
+ const file = () => path.join(paths.home, 'jira.json');
14
+
15
+ /** Saved settings ({ site, email, token }), with JIRA_SITE / JIRA_EMAIL / JIRA_API_TOKEN as fallbacks. */
16
+ export function loadJira(env = process.env) {
17
+ let saved = {};
18
+ try {
19
+ saved = JSON.parse(fs.readFileSync(file(), 'utf8'));
20
+ } catch {}
21
+ return {
22
+ site: normSite(saved.site || env.JIRA_SITE || ''),
23
+ email: saved.email || env.JIRA_EMAIL || '',
24
+ token: saved.token || env.JIRA_API_TOKEN || '',
25
+ };
26
+ }
27
+
28
+ /** What the page shows: never the token itself. */
29
+ export const jiraView = (j = loadJira()) => ({ site: j.site, email: j.email, hasToken: Boolean(j.token) });
30
+
31
+ /** Save settings; an empty token keeps the saved one unless `clearToken`. */
32
+ export function saveJira({ site, email, token, clearToken = false } = {}) {
33
+ const cur = (() => {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(file(), 'utf8'));
36
+ } catch {
37
+ return {};
38
+ }
39
+ })();
40
+ const next = {
41
+ site: site === undefined ? cur.site ?? '' : normSite(site),
42
+ email: email === undefined ? cur.email ?? '' : String(email).trim(),
43
+ token: clearToken ? '' : token ? String(token).trim() : cur.token ?? '',
44
+ };
45
+ if (next.site && !/^https:\/\/[\w.-]+$/.test(next.site)) throw Object.assign(new Error('The Jira site is an https address, like https://yourcompany.atlassian.net'), { status: 400 });
46
+ fs.mkdirSync(paths.home, { recursive: true });
47
+ fs.writeFileSync(file(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
48
+ return jiraView(loadJira());
49
+ }
50
+
51
+ function normSite(s) {
52
+ const v = String(s ?? '').trim().replace(/\/+$/, '');
53
+ if (!v) return '';
54
+ return /^https?:\/\//.test(v) ? v : `https://${v}`;
55
+ }
56
+
57
+ /** The issue a request starts with: { key, site } from its leading link, or null. */
58
+ export function leadingIssue(text) {
59
+ const key = leadingJiraKey(text);
60
+ if (!key) return null;
61
+ const url = String(text).trimStart().match(/^<?(https?:\/\/\S+)/)[1];
62
+ try {
63
+ return { key, site: new URL(url).origin };
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** The person's own words after the leading link ("…/browse/ABC-1 fix the timeout" → "fix the timeout"). */
70
+ export function wordsAfterLink(text) {
71
+ const rest = String(text ?? '').trimStart().replace(/^<?https?:\/\/\S+>?/, '').trim();
72
+ return rest.replace(/^[-:–—,\s]+/, '').split('\n')[0].trim().slice(0, 200) || null;
73
+ }
74
+
75
+ /**
76
+ * The issue's summary, or null when it can't be had (not a site we contact, no access, offline).
77
+ * `fetchImpl` and `allow` are for tests.
78
+ */
79
+ export async function issueSummary({ key, site }, { jira = loadJira(), fetchImpl = fetch, timeoutMs = 6000, allow = null } = {}) {
80
+ const host = (() => {
81
+ try {
82
+ return new URL(site).host;
83
+ } catch {
84
+ return '';
85
+ }
86
+ })();
87
+ const configured = jira.site && new URL(jira.site).host === host;
88
+ const allowed = configured || /\.atlassian\.net$/.test(host) || allow?.(site);
89
+ if (!allowed) return null;
90
+ const headers = { accept: 'application/json' };
91
+ if (configured && jira.email && jira.token) headers.authorization = `Basic ${Buffer.from(`${jira.email}:${jira.token}`).toString('base64')}`;
92
+ try {
93
+ const res = await fetchImpl(`${site}/rest/api/3/issue/${encodeURIComponent(key)}?fields=summary`, { headers, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
94
+ if (!res.ok) return null;
95
+ const summary = (await res.json())?.fields?.summary;
96
+ return typeof summary === 'string' && summary.trim() ? summary.trim().slice(0, 300) : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
package/src/manager.js CHANGED
@@ -93,6 +93,7 @@ async function repoLayout(dir) {
93
93
  import { spawn } from 'node:child_process';
94
94
  import * as git from './git.js';
95
95
  import { parseDiff } from '../public/review.js';
96
+ import { issueSummary, leadingIssue, wordsAfterLink } from './jira.js';
96
97
  import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
97
98
  import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
98
99
  import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
@@ -667,9 +668,14 @@ export class AgentManager extends EventEmitter {
667
668
  };
668
669
  for (const pr of refs.prs) agent.prs[pr.url] = { ...pr, source: 'intent' };
669
670
  for (const bug of refs.bugs) agent.bugs[bug.key] = { ...bug, source: 'intent' };
671
+ // A request that starts with an issue link: describe it by the issue's summary (fetched below),
672
+ // and until then by the words after the link.
673
+ const issue = leadingIssue(intent);
674
+ if (issue) agent.synopsis = { text: wordsAfterLink(intent), source: 'request', key: issue.key };
670
675
  this.store.put(agent);
671
676
  this.registerProject(agent);
672
677
  this.changed(agent);
678
+ if (issue) this.fetchSynopsis(agent.id).catch(() => {});
673
679
 
674
680
  this.plugins.emit('agent.created', { agent: this.auditView(agent) });
675
681
  this.prepare(agent, { start, workspace }).catch(() => {});
@@ -682,6 +688,32 @@ export class AgentManager extends EventEmitter {
682
688
  return this.view(agent);
683
689
  }
684
690
 
691
+ /** Fetch the summary of the issue an agent's request starts with; it becomes its description. */
692
+ async fetchSynopsis(ref) {
693
+ const agent = this.store.get(this.store.resolve(ref)?.id ?? ref);
694
+ const issue = agent && leadingIssue(agent.intent);
695
+ if (!issue) return null;
696
+ const summary = await issueSummary(issue, this.jiraOptions ?? {});
697
+ const cur = this.store.get(agent.id);
698
+ if (!summary || !cur) return null;
699
+ const bug = cur.bugs?.[issue.key];
700
+ this.patch(cur.id, {
701
+ synopsis: { text: summary, source: 'jira', key: issue.key },
702
+ ...(bug && !bug.title ? { bugs: { ...cur.bugs, [issue.key]: { ...bug, title: summary } } } : {}),
703
+ });
704
+ return summary;
705
+ }
706
+
707
+ /** Fill in summaries still missing (after Jira settings change); returns how many it found. */
708
+ async refreshSynopses() {
709
+ let found = 0;
710
+ for (const a of this.store.list()) {
711
+ if (a.synopsis?.source === 'jira' || !leadingIssue(a.intent)) continue;
712
+ if (await this.fetchSynopsis(a.id).catch(() => null)) found++;
713
+ }
714
+ return found;
715
+ }
716
+
685
717
  prepare(agent, { start, workspace }) {
686
718
  const controller = new AbortController();
687
719
  const { signal } = controller;
package/src/server.js CHANGED
@@ -33,6 +33,7 @@ import { Previews, parseUrl } from './preview.js';
33
33
  import { LiveBrowser } from './livebrowser.js';
34
34
  import * as remote from './remote.js';
35
35
  import * as service from './service.js';
36
+ import { jiraView, saveJira } from './jira.js';
36
37
  import { Store } from './store.js';
37
38
  import { about, latestRelease } from './about.js';
38
39
 
@@ -60,7 +61,9 @@ function isAllowedOrigin(origin, port) {
60
61
  }
61
62
 
62
63
  const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
63
- const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|update(\/|$)|service$|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
64
+ // Updates are not here: a signed-in device (a phone) can install the newest release too. It only
65
+ // ever installs an official release from where this copy came from (git tag or npm) and restarts.
66
+ const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|service$|jira$|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
64
67
  const HOOK_PATH = '/api/hooks/agents';
65
68
  const NO_REPO_KINDS = new Set(['general', 'assistant']);
66
69
 
@@ -508,6 +511,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
508
511
  api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
509
512
  api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
510
513
  api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
514
+ // Jira: the site and API token used to show an issue's summary for agents created from its link.
515
+ api.get('/jira', wrap(() => jiraView()));
516
+ api.put(
517
+ '/jira',
518
+ wrap(async (req) => {
519
+ const view = saveJira(req.body ?? {});
520
+ // Agents already created from links get their summaries now (in the background).
521
+ manager.refreshSynopses().catch(() => {});
522
+ return view;
523
+ }),
524
+ );
511
525
  // Start at login: the login service (launchd or systemd) that brings the daemon back after a reboot.
512
526
  const serviceSupported = ['darwin', 'linux'].includes(process.platform);
513
527
  const serviceView = async () => ({ supported: serviceSupported, ...(serviceSupported ? await service.status().catch(() => ({ installed: false, loaded: false })) : { installed: false, loaded: false }) });