ai-agentmanager 1.7.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,11 @@
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
+
3
9
  ## 1.7.0 (2026-10-31)
4
10
 
5
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.
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.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.7.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.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.7.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
@@ -164,6 +164,18 @@ function checksSummary(b) {
164
164
  }
165
165
 
166
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
+ });
167
179
  const expanded = new Set(); // "<agent id>:<section>" the user opened with Show more
168
180
 
169
181
  /** Long sections are clamped; Show more toggles them. */
@@ -189,7 +201,7 @@ async function renderDiagrams(root) {
189
201
  try {
190
202
  if (!mermaidLib) {
191
203
  mermaidLib = (await import('/vendor/mermaid/mermaid.esm.min.mjs')).default;
192
- const dark = matchMedia('(prefers-color-scheme: dark)').matches;
204
+ const dark = isDark();
193
205
  mermaidLib.initialize({ startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' });
194
206
  }
195
207
  const { svg } = await mermaidLib.render(`mmd-${diagrams.size}-${Date.now()}`, src);
@@ -638,7 +650,7 @@ function agentItem(a) {
638
650
  const idle = !isActive(a);
639
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' : ''}">
640
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>
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>
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>
642
654
  ${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
643
655
  <div class="meta">
644
656
  <span class="mono">${a.id}</span>
@@ -1594,7 +1606,7 @@ function renderDetail() {
1594
1606
  ${[0, 1, 2, 3].map((p) => `<option value="${p}" ${p === (a.priority ?? 2) ? 'selected' : ''}>P${p}</option>`).join('')}
1595
1607
  </select>
1596
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>
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>
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>
1598
1610
  </div>
1599
1611
  <div class="d-actions">
1600
1612
  ${a.status === 'error' && !a.workdir
@@ -3450,7 +3462,8 @@ $('#about-check').onclick = async () => {
3450
3462
  out.hidden = false;
3451
3463
  out.textContent = 'Checking…';
3452
3464
  try {
3453
- // 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.
3454
3467
  const local = await updateUi.check().catch(() => null);
3455
3468
  if (local) return void (out.innerHTML = local);
3456
3469
  const r = await api('GET', '/about/latest');
@@ -4070,6 +4083,10 @@ $('#login-form').addEventListener('submit', async (e) => {
4070
4083
  });
4071
4084
 
4072
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
+
4073
4090
  const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
4074
4091
  async function loadEngines() {
4075
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>
@@ -691,9 +700,9 @@
691
700
  <dl id="about-list" class="about-list"></dl>
692
701
  <p id="about-update" class="hint" hidden></p>
693
702
  <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>
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>
695
704
  <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>
705
+ <button type="button" id="about-install" class="primary" hidden>Update and restart</button>
697
706
  <button type="button" id="about-close" class="primary">Close</button>
698
707
  </div>
699
708
  </dialog>
@@ -712,6 +721,15 @@
712
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>
713
722
  </fieldset>
714
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>
715
733
  <legend>Updates</legend>
716
734
  <label class="check"><input type="checkbox" name="updateCheck" /> Check for a new release twice a day</label>
717
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/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; }
@@ -873,3 +888,9 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
873
888
  .sd-add { background: rgba(46, 160, 67, .15); }
874
889
  .sd-del { background: rgba(248, 81, 73, .15); }
875
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)}).`;
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 }) });