ai-agentmanager 1.16.0 → 1.17.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 +4 -0
- package/README.md +10 -4
- package/package.json +1 -1
- package/public/app.js +15 -4
- package/public/index.html +1 -1
- package/src/cli.js +11 -0
- package/src/manager.js +49 -4
- package/src/server.js +1 -0
- package/src/sessions.js +54 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.17.0 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
- **Sessions go both ways with the Claude desktop app.** **Open in Claude desktop** on a Claude agent (and `agentmgr desktop <agent>`) continues its conversation in the desktop app through Claude Code's `claude --desktop --resume`. The agent's session here closes first, messages for it wait until you resume it here, and **Resume** picks up what was said in the desktop app. **Import** now lists sessions started in the desktop app's Code tab (marked **Claude desktop**, with their title there) and in editors. Before, it only listed sessions started from a terminal. A session open in the desktop app is protected like one open in a terminal: close it there, or continue a copy.
|
|
6
|
+
|
|
3
7
|
## 1.16.0 (2026-11-03)
|
|
4
8
|
|
|
5
9
|
- **Faster typing and switching with many agents:** the page no longer redraws everything on every agent update. Updates are drawn once per frame, only the views they touch are redrawn (another agent's update leaves the open agent's details and terminal alone), and lists change only the cards that changed. With 40 busy agents, the page's redraw work went from about 870 ms to 35 ms per 10 seconds. Add `?perf` to the address to see how long redraws take.
|
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ Claude Code on its own, including its built-in ways to run work in parallel, is
|
|
|
75
75
|
- **Keeping up without reading transcripts:** a brief per agent (result, problem, fix, checks) and one inbox for every prompt and question.
|
|
76
76
|
- **From anywhere:** your phone, another computer, or several machines through the hub.
|
|
77
77
|
|
|
78
|
-
**Trying it costs little.** It runs the same `claude` you already use, with your plan and your repo's `CLAUDE.md` and skills. Sessions you started with `claude` in a terminal can be continued in it (**Import** above the agent list), and you can go back to a plain terminal
|
|
78
|
+
**Trying it costs little.** It runs the same `claude` you already use, with your plan and your repo's `CLAUDE.md` and skills. Sessions you started with `claude` in a terminal or in the Claude desktop app can be continued in it (**Import** above the agent list), and you can go back any time: to a plain terminal with `claude --resume`, or to the desktop app with **Open in Claude desktop**.
|
|
79
79
|
|
|
80
80
|
## Install
|
|
81
81
|
|
|
@@ -235,11 +235,17 @@ Every agent gets **its own worktree and its own branch** (`agent/<name>-<id>`) o
|
|
|
235
235
|
- **No upstream.** Agent branches are created without an upstream, so Claude's first `git push -u origin <branch>` sets the right one. They never accidentally track `origin/main`.
|
|
236
236
|
- **Where it shows.** The agent shows **Branched from** with the ref and commit it started at.
|
|
237
237
|
|
|
238
|
-
## Continue sessions started in a terminal
|
|
238
|
+
## Continue sessions started in a terminal or the Claude desktop app
|
|
239
239
|
|
|
240
|
-
**Import** (next to **Save now** in the agent list) lists the Claude Code sessions you started yourself
|
|
240
|
+
**Import** (next to **Save now** in the agent list) lists the Claude Code sessions you started yourself on this machine, from the last 7, 30 or 90 days: with `claude` in a terminal, in the Claude desktop app's Code tab (marked **Claude desktop**, with its title there), or in an editor. They're grouped by folder, with title, first and last ask, branch, size and last activity. Headless runs (`claude -p`, the SDK, built agents) and AI Agent Manager's own agents are left out. Each one you pick becomes an agent in the same folder that continues the same conversation (`--resume`), with the brief, inbox, PRs and usage tracked from then on.
|
|
241
241
|
|
|
242
|
-
A session still open in a terminal is marked **open in a terminal** (a running `claude` names it) or **maybe open** (a `claude` runs in that folder and the session was active minutes ago). Two places writing one conversation would mix it up, so those continue as a **copy** under a new session ID and the terminal keeps the original. For the same reason, an agent that follows a `/clear` to a new session never picks up a headless run or a session a terminal has open in its folder.
|
|
242
|
+
A session still open in a terminal or the desktop app is marked **open in a terminal** / **open in the desktop app** (a running `claude` names it) or **maybe open** (a `claude` runs in that folder and the session was active minutes ago). Two places writing one conversation would mix it up, so those continue as a **copy** under a new session ID and the terminal keeps the original. For the same reason, an agent that follows a `/clear` to a new session never picks up a headless run or a session a terminal has open in its folder.
|
|
243
|
+
|
|
244
|
+
### Open an agent in the Claude desktop app
|
|
245
|
+
|
|
246
|
+
**Open in Claude desktop** on a Claude agent (or `agentmgr desktop <agent>`) continues its conversation in the Claude desktop app, using Claude Code's own hand-off (`claude --desktop --resume`). Its session here closes first, and until you resume it here, messages for it (yours, Autopilot's, Landing's) wait instead of starting a second copy. **Resume** picks up what was said in the desktop app, since it's the same session; close it in the desktop app first. Needs macOS or Windows and Claude Code signed in with a Claude subscription; when Claude Code refuses, the reason is shown. Agents in a container, and agents moved to another machine, can't be opened there.
|
|
247
|
+
|
|
248
|
+
With Remote Control on (`"remoteControlAtStartup": true` in `~/.claude/settings.json`), running agents also show in the Claude apps while they run, as live sessions.
|
|
243
249
|
|
|
244
250
|
## Restarts and reboots
|
|
245
251
|
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -834,7 +834,7 @@ function renderList() {
|
|
|
834
834
|
$('#counts').innerHTML = agents.size
|
|
835
835
|
? `<span title="Projects">${projects} project${projects === 1 ? '' : 's'}</span><span>${agents.size} agents</span><span>${running} running</span>
|
|
836
836
|
${waiting ? `<span class="fail">${waiting} need${waiting === 1 ? 's' : ''} input</span>` : ''}
|
|
837
|
-
<span class="spacer"></span><button class="link" id="import-btn" title="Continue Claude sessions you started in a terminal on this machine">Import</button><button class="link" id="flush-btn" title="Save session IDs, terminal scrollback and state to disk now (also automatic every 30s)">Save now</button>`
|
|
837
|
+
<span class="spacer"></span><button class="link" id="import-btn" title="Continue Claude sessions you started in a terminal or the Claude desktop app on this machine">Import</button><button class="link" id="flush-btn" title="Save session IDs, terminal scrollback and state to disk now (also automatic every 30s)">Save now</button>`
|
|
838
838
|
: '';
|
|
839
839
|
|
|
840
840
|
// Every agent a project has, also the ones a filter hides.
|
|
@@ -1843,6 +1843,7 @@ function renderDetail() {
|
|
|
1843
1843
|
? ''
|
|
1844
1844
|
: `<button data-act="start" ${a.status === 'preparing' ? 'disabled' : ''}>Resume in background</button>`}
|
|
1845
1845
|
<button data-act="fresh" ${running || a.status === 'preparing' ? 'disabled' : ''} title="Start over with a new session ID (old ones are kept)">New session</button>
|
|
1846
|
+
${(a.engine ?? 'claude') === 'claude' && a.isolation !== 'container' && !a.movedTo && !REMOTE ? `<button data-act="desktop" ${a.sessionId && a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="Continue this conversation in the Claude desktop app. Its session here closes; Resume here later picks up what was said there">Open in Claude desktop</button>` : ''}
|
|
1846
1847
|
<button data-act="shell" ${a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="A separate terminal in this agent's folder for running commands by hand">Open shell</button>
|
|
1847
1848
|
${noRepo(a) ? '' : `<button data-act="changes" ${a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="Read its changes, comment on lines for the agent, commit and open a pull request">Review changes${a.git?.dirtyFiles ? ` <span class="count">${a.git.dirtyFiles}</span>` : ''}</button>`}
|
|
1848
1849
|
<button data-act="fork" ${a.workdir && a.git?.branch ? '' : 'disabled'} title="A new agent with this one's code and conversation, in its own worktree, to try another approach">Fork</button>
|
|
@@ -1859,6 +1860,7 @@ function renderDetail() {
|
|
|
1859
1860
|
${it.status === 'review' ? `<span class="spacer"></span><button class="small primary" data-backlog-accept="${it.id}">Accept</button> <button class="small" data-backlog-reopen="${it.id}">Reopen</button>` : ''}</div>`;
|
|
1860
1861
|
})()}
|
|
1861
1862
|
${a.error ? `<div class="error-box">${esc(a.error)}</div>` : ''}
|
|
1863
|
+
${a.desktop && !running ? `<p class="hint">Continued in the <b>Claude desktop app</b> ${ago(a.desktop.at)}. Messages for it wait here; <b>Resume</b> picks up what was said there (close it in the desktop app first).</p>` : ''}
|
|
1862
1864
|
${a.movedTo ? `<p class="hint">Moved to <b>${esc(a.movedTo.machine)}</b> ${ago(a.movedTo.at)}: it continues there with the same conversation and work. This copy stays closed; delete it when you no longer need its folder.</p>` : ''}
|
|
1863
1865
|
${a.movedFrom ? `<p class="hint">Moved here from <b>${esc(a.movedFrom.machine ?? 'another machine')}</b> ${ago(a.movedFrom.at)}, with its conversation and uncommitted work.</p>` : ''}
|
|
1864
1866
|
${!running && a.closedBy === 'cap' ? `<p class="hint">Closed ${ago(a.closedAt)} to stay under the limit on open agents (Settings), as the one idle longest. Resume it any time${a.keep ? '' : ', or mark it Keep'}.</p>` : ''}
|
|
@@ -2765,7 +2767,8 @@ function renderImport() {
|
|
|
2765
2767
|
(s) => `<label class="import-row ${s.exists ? '' : 'off'}">
|
|
2766
2768
|
<input type="checkbox" data-sid="${s.sessionId}" ${importPicked.has(s.sessionId) ? 'checked' : ''} ${s.exists ? '' : 'disabled'}>
|
|
2767
2769
|
<span class="import-main"><b>${esc(s.title || s.firstAsk.slice(0, 80))}</b>
|
|
2768
|
-
${s.
|
|
2770
|
+
${s.source === 'desktop' ? `<span class="tag" title="Started in the Claude desktop app${s.archived ? '; archived there' : ''}">Claude desktop${s.archived ? ' · archived' : ''}</span>` : s.source === 'editor' ? '<span class="tag" title="Started in an editor (VS Code, JetBrains)">editor</span>' : ''}
|
|
2771
|
+
${s.inUse ? `<span class="tag warn" title="${s.inUse === 'open' ? 'A claude process has this session open' : 'A claude process runs in this folder and the session was active minutes ago'}">${s.inUse === 'open' ? 'open' : 'maybe open'} ${s.source === 'desktop' ? 'in the desktop app' : 'in a terminal'}</span>` : ''}
|
|
2769
2772
|
<span class="hint">${s.title ? `${esc(s.firstAsk.slice(0, 120))}${s.lastAsk ? ` … last: ${esc(s.lastAsk.slice(0, 80))}` : ''}` : s.lastAsk ? `last: ${esc(s.lastAsk.slice(0, 100))}` : ''}</span>
|
|
2770
2773
|
<span class="hint">${ago(s.lastActivity)}${s.branch ? ` · ⎇ ${esc(s.branch)}` : ''} · ${Math.max(1, Math.round(s.bytes / 1024))} KB · <code>${s.sessionId.slice(0, 8)}</code></span>
|
|
2771
2774
|
</span></label>`,
|
|
@@ -2773,13 +2776,13 @@ function renderImport() {
|
|
|
2773
2776
|
.join('')}</div>`,
|
|
2774
2777
|
)
|
|
2775
2778
|
.join('')
|
|
2776
|
-
: `<p class="hint">${importSessions.length ? 'Nothing matches.' : 'No Claude sessions started in a terminal in this period (AI Agent Manager\'s own agents are left out).'}</p>`;
|
|
2779
|
+
: `<p class="hint">${importSessions.length ? 'Nothing matches.' : 'No Claude sessions started in a terminal or the Claude desktop app in this period (AI Agent Manager\'s own agents are left out).'}</p>`;
|
|
2777
2780
|
const picked = importSessions.filter((s) => importPicked.has(s.sessionId));
|
|
2778
2781
|
const open = picked.filter((s) => s.inUse).length;
|
|
2779
2782
|
Object.assign($('#import-go'), {
|
|
2780
2783
|
disabled: !picked.length,
|
|
2781
2784
|
textContent: picked.length ? `Continue ${picked.length} session${picked.length > 1 ? 's' : ''}${open ? ` (${open} as a copy)` : ''}` : 'Continue',
|
|
2782
|
-
title: open ? 'Sessions still open in a terminal continue as a copy, so the
|
|
2785
|
+
title: open ? 'Sessions still open in a terminal or the desktop app continue as a copy, so the original stays there' : '',
|
|
2783
2786
|
});
|
|
2784
2787
|
}
|
|
2785
2788
|
$('#import-list').addEventListener('change', (e) => {
|
|
@@ -3411,6 +3414,14 @@ $('#detail-body').addEventListener('click', async (e) => {
|
|
|
3411
3414
|
term.open(a.id);
|
|
3412
3415
|
});
|
|
3413
3416
|
break;
|
|
3417
|
+
case 'desktop':
|
|
3418
|
+
btn.disabled = true;
|
|
3419
|
+
btn.textContent = 'Opening…';
|
|
3420
|
+
act(async () => upsert(await api('POST', `/agents/${a.id}/desktop`)), 'Opened in the Claude desktop app; Resume here picks it back up').finally(() => {
|
|
3421
|
+
btn.disabled = false;
|
|
3422
|
+
btn.textContent = 'Open in Claude desktop';
|
|
3423
|
+
});
|
|
3424
|
+
break;
|
|
3414
3425
|
case 'stop':
|
|
3415
3426
|
btn.disabled = true;
|
|
3416
3427
|
act(async () => upsert(await api('POST', `/agents/${a.id}/stop`)), 'Session closed; the ID is kept for resuming');
|
package/public/index.html
CHANGED
|
@@ -429,7 +429,7 @@
|
|
|
429
429
|
<button type="button" id="import-close" class="small ghost">Close</button>
|
|
430
430
|
</div>
|
|
431
431
|
<div class="import-body">
|
|
432
|
-
<p class="hint">Sessions you started with <code>claude</code> in a terminal on this machine. Each one you pick becomes an agent in the same folder that continues the same conversation, with the brief, inbox, PRs and usage tracked here.</p>
|
|
432
|
+
<p class="hint">Sessions you started with <code>claude</code> in a terminal, or in the Claude desktop app, on this machine. Each one you pick becomes an agent in the same folder that continues the same conversation, with the brief, inbox, PRs and usage tracked here.</p>
|
|
433
433
|
<input id="import-filter" placeholder="Filter by folder, title or what you asked" autocomplete="off">
|
|
434
434
|
<div id="import-list" class="import-list"></div>
|
|
435
435
|
</div>
|
package/src/cli.js
CHANGED
|
@@ -968,6 +968,17 @@ export async function main(argv) {
|
|
|
968
968
|
console.log(`${green('✓')} sent ${a.name} its scorecard (${a.docs.scorecard.data.overall ?? '—'}/100) to improve; scored again when it updates its brief`);
|
|
969
969
|
});
|
|
970
970
|
|
|
971
|
+
program
|
|
972
|
+
.command('desktop')
|
|
973
|
+
.description('Continue a Claude agent in the Claude desktop app (its session here closes; resume it here to pick up what was said there)')
|
|
974
|
+
.argument('<agent>')
|
|
975
|
+
.action(async (ref) => {
|
|
976
|
+
await daemon();
|
|
977
|
+
const a = await resolveAgent(ref);
|
|
978
|
+
await api('POST', `/agents/${a.id}/desktop`);
|
|
979
|
+
console.log(`${green('✓')} ${a.name} opened in the Claude desktop app; continue it here with: agentmgr start ${a.id}`);
|
|
980
|
+
});
|
|
981
|
+
|
|
971
982
|
program
|
|
972
983
|
.command('tell')
|
|
973
984
|
.description("Send a message into an agent's session (typed in when it is idle; resumes it if closed)")
|
package/src/manager.js
CHANGED
|
@@ -955,6 +955,7 @@ export class AgentManager extends EventEmitter {
|
|
|
955
955
|
if (!agent.workdir || !fs.existsSync(agent.workdir)) {
|
|
956
956
|
throw Object.assign(new Error(`Working folder ${agent.workdir} no longer exists`), { status: 409 });
|
|
957
957
|
}
|
|
958
|
+
if (agent.desktop) this.patch(agent.id, { desktop: null }); // continued here again
|
|
958
959
|
|
|
959
960
|
if (sessionId) {
|
|
960
961
|
if (!agent.sessions.some((s) => s.id === sessionId)) {
|
|
@@ -1763,9 +1764,52 @@ export class AgentManager extends EventEmitter {
|
|
|
1763
1764
|
return this.view(this.store.get(agent.id));
|
|
1764
1765
|
}
|
|
1765
1766
|
|
|
1767
|
+
/**
|
|
1768
|
+
* Continue a Claude agent's conversation in the Claude desktop app (`claude --desktop --resume`):
|
|
1769
|
+
* its session here is closed first, and it waits until you resume it here, where it picks up
|
|
1770
|
+
* what was said in the desktop app (the same session).
|
|
1771
|
+
*/
|
|
1772
|
+
async openInDesktop(ref) {
|
|
1773
|
+
const agent = this.mustGet(ref);
|
|
1774
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
1775
|
+
if ((agent.engine ?? DEFAULT_ENGINE) !== 'claude') throw err(409, 'Only Claude Code sessions open in the Claude desktop app');
|
|
1776
|
+
if (agent.isolation === 'container') throw err(409, 'Its session runs in a container; the desktop app cannot reach it');
|
|
1777
|
+
if (agent.movedTo) throw err(409, `${agent.name} moved to ${agent.movedTo.machine}; open it in the desktop app there`);
|
|
1778
|
+
if (!agent.sessionId) throw err(409, 'It has no session yet: start it first');
|
|
1779
|
+
if (!agent.workdir || !fs.existsSync(agent.workdir)) throw err(409, `Working folder ${agent.workdir} no longer exists`);
|
|
1780
|
+
if (this.runtimes.get(agent.id)?.pty) await this.stop(agent.id);
|
|
1781
|
+
const args = [...(this.config.claudeArgs ?? []), '--desktop', '--resume', agent.sessionId];
|
|
1782
|
+
// In a terminal of its own: Claude Code refuses --desktop when its output isn't one.
|
|
1783
|
+
const out = await new Promise((resolve) => {
|
|
1784
|
+
let child;
|
|
1785
|
+
try {
|
|
1786
|
+
child = pty.spawn(this.config.claudeBin, args, { name: 'xterm-256color', cols: 120, rows: 30, cwd: agent.workdir, env: { ...process.env, FORCE_COLOR: '0' } });
|
|
1787
|
+
} catch (e) {
|
|
1788
|
+
return resolve({ code: -1, text: e.message });
|
|
1789
|
+
}
|
|
1790
|
+
let text = '';
|
|
1791
|
+
child.onData((d) => (text = (text + d).slice(-4000)));
|
|
1792
|
+
const timer = setTimeout(() => {
|
|
1793
|
+
child.kill();
|
|
1794
|
+
resolve({ code: null, text });
|
|
1795
|
+
}, 30_000);
|
|
1796
|
+
child.onExit(({ exitCode }) => {
|
|
1797
|
+
clearTimeout(timer);
|
|
1798
|
+
resolve({ code: exitCode, text });
|
|
1799
|
+
});
|
|
1800
|
+
});
|
|
1801
|
+
if (out.code !== 0) {
|
|
1802
|
+
const why = out.text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '').trim().split('\n').filter(Boolean).slice(-3).join(' ') || (out.code === null ? 'it did not finish in 30 seconds' : `exit code ${out.code}`);
|
|
1803
|
+
throw err(502, `Couldn't open it in the Claude desktop app: ${why}`);
|
|
1804
|
+
}
|
|
1805
|
+
this.patch(agent.id, { desktop: { at: now(), sessionId: agent.sessionId } });
|
|
1806
|
+
this.changed(this.store.get(agent.id));
|
|
1807
|
+
return this.view(this.store.get(agent.id));
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1766
1810
|
// ------------------------------------------------- sessions started elsewhere
|
|
1767
1811
|
|
|
1768
|
-
/** Claude Code sessions started outside AI Agent Manager (in a terminal), to continue here. */
|
|
1812
|
+
/** Claude Code sessions started outside AI Agent Manager (in a terminal or the Claude desktop app), to continue here. */
|
|
1769
1813
|
scanSessions({ days = 30 } = {}) {
|
|
1770
1814
|
const known = new Set(this.store.list().flatMap((a) => a.sessions.map((x) => x.id)));
|
|
1771
1815
|
return scanClaudeSessions({ days: Math.max(1, Math.min(365, Number(days) || 30)), known, excludeDir: paths.home });
|
|
@@ -1787,7 +1831,7 @@ export class AgentManager extends EventEmitter {
|
|
|
1787
1831
|
continue;
|
|
1788
1832
|
}
|
|
1789
1833
|
if (s.inUse && !copy) {
|
|
1790
|
-
out.push({ sessionId: s.sessionId, error:
|
|
1834
|
+
out.push({ sessionId: s.sessionId, error: `Still open in ${s.source === 'desktop' ? 'the Claude desktop app' : 'a terminal'}: close it there first, or continue a copy` });
|
|
1791
1835
|
continue;
|
|
1792
1836
|
}
|
|
1793
1837
|
let sessionId = s.sessionId;
|
|
@@ -1803,7 +1847,7 @@ export class AgentManager extends EventEmitter {
|
|
|
1803
1847
|
engine: 'claude',
|
|
1804
1848
|
start,
|
|
1805
1849
|
sessionId,
|
|
1806
|
-
imported: { from: 'terminal', sessionId: s.sessionId, copy: sessionId !== s.sessionId, at: now() },
|
|
1850
|
+
imported: { from: s.source === 'desktop' ? 'desktop' : 'terminal', sessionId: s.sessionId, copy: sessionId !== s.sessionId, at: now() },
|
|
1807
1851
|
});
|
|
1808
1852
|
out.push({ sessionId: s.sessionId, agentId: agent.id, name: agent.name });
|
|
1809
1853
|
}
|
|
@@ -2474,7 +2518,8 @@ export class AgentManager extends EventEmitter {
|
|
|
2474
2518
|
this.plugins.emit('agent.message', { agent: this.auditView(agent), text, source });
|
|
2475
2519
|
if (!this.outbox.has(agent.id)) this.outbox.set(agent.id, []);
|
|
2476
2520
|
this.outbox.get(agent.id).push({ text, source, at: now() });
|
|
2477
|
-
|
|
2521
|
+
// In the Claude desktop app: it waits for Resume here (two places writing one conversation mix it up).
|
|
2522
|
+
if (!this.runtimes.get(agent.id)?.pty && agent.workdir && agent.status !== 'preparing' && !agent.desktop) this.start(agent.id);
|
|
2478
2523
|
this.changed(agent);
|
|
2479
2524
|
return this.view(this.store.get(agent.id));
|
|
2480
2525
|
}
|
package/src/server.js
CHANGED
|
@@ -416,6 +416,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
416
416
|
);
|
|
417
417
|
api.post('/agents/:id/start', wrap((req) => manager.start(req.params.id, req.body ?? {})));
|
|
418
418
|
api.post('/agents/:id/stop', wrap((req) => manager.stop(req.params.id)));
|
|
419
|
+
api.post('/agents/:id/desktop', wrap((req) => manager.openInDesktop(req.params.id)));
|
|
419
420
|
api.post('/agents/:id/refresh', wrap((req) => manager.refresh(req.params.id, { withPrs: true })));
|
|
420
421
|
api.post('/agents/:id/links', wrap((req) => manager.addLink(req.params.id, req.body ?? {})));
|
|
421
422
|
api.delete('/agents/:id/links', wrap((req) => manager.removeLink(req.params.id, req.query)));
|
package/src/sessions.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Claude Code sessions started outside AI Agent Manager (a plain `claude` in a terminal
|
|
2
|
-
// can be continued here. Claude Code keeps each session as ~/.claude/projects/<cwd>/<id>.jsonl;
|
|
1
|
+
// Claude Code sessions started outside AI Agent Manager (a plain `claude` in a terminal, or the
|
|
2
|
+
// Claude desktop app's Code tab), so they can be continued here. Claude Code keeps each session as ~/.claude/projects/<cwd>/<id>.jsonl;
|
|
3
3
|
// the start and end of each file say where it ran, its title, branch and first ask.
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import fs from 'node:fs';
|
|
@@ -15,6 +15,46 @@ const encode = (p) => {
|
|
|
15
15
|
return real.replace(/[^a-zA-Z0-9]/g, '-');
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
/** Where the Claude desktop app keeps its data (its list of Code sessions is under it). */
|
|
19
|
+
const desktopDir = () =>
|
|
20
|
+
process.env.AGENTMGR_CLAUDE_DESKTOP_DIR ||
|
|
21
|
+
(process.platform === 'darwin'
|
|
22
|
+
? path.join(os.homedir(), 'Library', 'Application Support', 'Claude')
|
|
23
|
+
: process.platform === 'win32'
|
|
24
|
+
? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Claude')
|
|
25
|
+
: path.join(os.homedir(), '.config', 'Claude'));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The Claude desktop app's Code sessions on this computer, by Claude Code session ID: its title
|
|
29
|
+
* and whether it's archived. Read from the app's own records (claude-code-sessions/<account>/<org>/
|
|
30
|
+
* local_*.json, each naming its `cliSessionId`); an app that keeps them elsewhere gives none, and
|
|
31
|
+
* its sessions are still found by their transcripts.
|
|
32
|
+
*/
|
|
33
|
+
export function desktopSessions() {
|
|
34
|
+
const out = new Map();
|
|
35
|
+
const root = path.join(desktopDir(), 'claude-code-sessions');
|
|
36
|
+
const walk = (dir, depth) => {
|
|
37
|
+
let entries = [];
|
|
38
|
+
try {
|
|
39
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
const p = path.join(dir, e.name);
|
|
45
|
+
if (e.isDirectory() && depth < 3) walk(p, depth + 1);
|
|
46
|
+
else if (e.isFile() && /^local_.*\.json$/.test(e.name)) {
|
|
47
|
+
try {
|
|
48
|
+
const r = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
49
|
+
if (typeof r.cliSessionId === 'string') out.set(r.cliSessionId, { title: typeof r.title === 'string' ? r.title : null, archived: Boolean(r.isArchived) });
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(root, 0);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
18
58
|
/** The first and last `bytes` of a file as JSON records (partial lines dropped). */
|
|
19
59
|
function edges(file, size, bytes = 96 * 1024) {
|
|
20
60
|
const fd = fs.openSync(file, 'r');
|
|
@@ -84,7 +124,7 @@ export function runningClaude() {
|
|
|
84
124
|
}
|
|
85
125
|
|
|
86
126
|
/**
|
|
87
|
-
* Interactive Claude Code sessions from the last `days
|
|
127
|
+
* Interactive Claude Code sessions from the last `days` (terminal and Claude desktop app), newest first, leaving out `known`
|
|
88
128
|
* session IDs (AI Agent Manager's own) and anything run under `excludeDir` (its one-off calls and
|
|
89
129
|
* workspaces). inUse: "open" when a running claude names the session, "maybe" when one runs in
|
|
90
130
|
* its folder and the session was active in the last 10 minutes.
|
|
@@ -121,6 +161,7 @@ export function scanClaudeSessions({ days = 30, known = new Set(), excludeDir, l
|
|
|
121
161
|
}
|
|
122
162
|
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
123
163
|
const running = runningClaude();
|
|
164
|
+
const desktop = desktopSessions();
|
|
124
165
|
const out = [];
|
|
125
166
|
for (const f of files.slice(0, limit)) {
|
|
126
167
|
let recs;
|
|
@@ -132,10 +173,15 @@ export function scanClaudeSessions({ days = 30, known = new Set(), excludeDir, l
|
|
|
132
173
|
const all = [...recs.head, ...recs.tail];
|
|
133
174
|
const withCwd = all.find((r) => r.cwd);
|
|
134
175
|
if (!withCwd) continue;
|
|
135
|
-
// Headless runs (claude -p, the SDK) aren't conversations to continue.
|
|
136
|
-
if (all.some((r) => r.entrypoint && r.entrypoint !== 'cli')) continue;
|
|
137
176
|
const firstAsk = recs.head.map(typed).find(Boolean);
|
|
138
177
|
if (!firstAsk) continue;
|
|
178
|
+
// Where it was started: a terminal (`cli`), the Claude desktop app (in its list of sessions, or
|
|
179
|
+
// saying so), an editor, or a program (claude -p, the SDK, built agents), whose runs aren't
|
|
180
|
+
// conversations to continue.
|
|
181
|
+
const app = desktop.get(f.id);
|
|
182
|
+
const entry = all.find((r) => r.entrypoint)?.entrypoint ?? 'cli';
|
|
183
|
+
const source = app || /desktop/i.test(entry) ? 'desktop' : entry === 'cli' ? 'terminal' : /vscode|jetbrains|ide/i.test(entry) ? 'editor' : null;
|
|
184
|
+
if (!source) continue;
|
|
139
185
|
const cwd = withCwd.cwd;
|
|
140
186
|
if (excludeDir && (cwd === excludeDir || cwd.startsWith(`${excludeDir}/`))) continue;
|
|
141
187
|
const last = (type, key) => [...all].reverse().find((r) => r.type === type && r[key])?.[key];
|
|
@@ -147,7 +193,9 @@ export function scanClaudeSessions({ days = 30, known = new Set(), excludeDir, l
|
|
|
147
193
|
sessionId: f.id,
|
|
148
194
|
cwd,
|
|
149
195
|
exists: fs.existsSync(cwd),
|
|
150
|
-
|
|
196
|
+
source,
|
|
197
|
+
archived: app?.archived || undefined,
|
|
198
|
+
title: app?.title || last('custom-title', 'customTitle') || last('agent-name', 'agentName') || last('ai-title', 'aiTitle') || last('summary', 'summary') || null,
|
|
151
199
|
firstAsk: firstAsk.slice(0, 300),
|
|
152
200
|
lastAsk: lastAsk && lastAsk !== firstAsk ? lastAsk.slice(0, 300) : null,
|
|
153
201
|
branch: branch === 'HEAD' ? null : branch,
|