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 +6 -0
- package/README.md +5 -4
- package/package.json +1 -1
- package/public/app.js +21 -4
- package/public/index.html +21 -3
- package/public/menu.js +40 -4
- package/public/style.css +22 -1
- package/public/update.js +3 -5
- package/src/jira.js +100 -0
- package/src/manager.js +32 -0
- package/src/server.js +15 -1
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
|

|
|
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.
|
|
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.
|
|
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.
|
|
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
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 =
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
form.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 }) });
|