ai-agentmanager 1.0.1 → 1.0.2

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2 (2026-10-28)
4
+
5
+ - **Resize and expand:** drag the edge of the agent list, or the line between the brief and the terminal; double-click a line to reset it. **Expand** (Ctrl+Shift+E) fills the window with the terminal, shell or browser tab, and **Full screen** fills the screen.
6
+ - **Active agents first:** agents that did real work or got your input in the last day sort to the top, and projects with active agents come before idle ones. Idle agents are dimmed and show how long they've been idle. A restart that resumes an agent doesn't count as activity.
7
+ - **`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 what's missing.
8
+ - **Clearer first run:** when Claude Code (or another engine) isn't installed or isn't on the daemon's PATH, the agent says so and how to fix it, instead of `spawn claude ENOENT`. Running from a git checkout without `npm install` says to run it, instead of `Cannot find package`.
9
+ - **AI judge:** no longer reads Claude Code's status bar ("accept edits on", "shift+tab to cycle", "update available") as a question, which raised false "waiting on you" items.
10
+ - **Install as an app:** Chrome and Edge can install AI Agent Manager as an app (the install icon in the address bar), so it opens in its own window with a Dock or taskbar icon.
11
+
3
12
  ## 1.0.1 (2026-10-26)
4
13
 
5
14
  - **Name:** the product is now called **AI Agent Manager**, matching the `ai-agentmanager` package. The command is still `agentmgr`.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Run one background [Claude Code](https://claude.com/claude-code) agent per inten
4
4
 
5
5
  ![Agents grouped by project, with an agent's brief: results, problem, solution, a flow diagram and checks](docs/images/agents.jpg)
6
6
 
7
- ![The conflict radar: two agents changed the same lines of README.md, with a suggested order to land them](docs/images/conflict-radar.jpg)
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 one intent or a list (one per line). Each becomes its own `claude` process.
10
10
  - **Repo or folder.** Give a git URL (cloned once, then each agent gets its own worktree and branch) or a local folder.
@@ -41,6 +41,8 @@ Run one background [Claude Code](https://claude.com/claude-code) agent per inten
41
41
  - **Live browser.** Browser tabs run a real Chrome on this machine and stream it into the tab: you see its screen and your clicks, scrolling, typing and paste go back to it, from the laptop or the phone. Because the page runs here, `localhost` is this machine, sites that refuse to be framed load normally, and popups (a sign-in window) open as tabs of their own and come back when they close. Chrome (or Chromium, Brave, Edge; `browserBin` in config.json picks one) runs headless with its own profile in `~/.agentmanager/browser-profile`, so sign-ins are remembered; it starts on the first view and stops after 15 minutes with nobody watching. Downloads go to `~/Downloads/agentmanager`. A site that still refuses it as a bot may accept `"browserHeadless": false`, which runs Chrome with a real window on this machine's screen. On Linux where Chrome's sandbox can't start (containers, Ubuntu 23.10+ user-namespace restrictions) the tab says so; `"browserNoSandbox": true` runs it without the sandbox, at your own risk. **Lite** on the bar switches a tab to the frame mode (the page loads in your own browser; quicker for a dev server).
42
42
  - **CLI sign-in links.** When a terminal prints a sign-in link that returns to a port on this machine (`railway login`, `gh auth login --web`, `gcloud auth login`...), a bar above the terminal offers **Open sign-in**: the link opens in a live browser tab here, so the provider's redirect to `http://127.0.0.1:PORT/callback` reaches the waiting CLI by itself, from any device. It opens on its own if you just typed the command. Without a live browser, it opens a window on your device; from another device you then paste the address you land on (`127.0.0.1:PORT/callback…`) into the bar and AI Agent Manager delivers it to the CLI, only on the port the link named.
43
43
  - **Attach files and screenshots.** Drop files on the terminal, paste a screenshot, or use **📎 Attach**. The file is saved on the daemon's machine and its path is pasted into the terminal, which is how Claude Code picks up images and files.
44
+ - **Your layout.** Drag the edge of the agent list, or the line between the brief and the terminal, to resize them; double-click a line to reset it. **Expand** (Ctrl+Shift+E) fills the window with the terminal, shell or browser tab; **Full screen** fills the screen. Agents active in the last day sort to the top; idle ones are dimmed.
45
+ - **As an app.** In Chrome or Edge, click the install icon in the address bar (or ⋮ → Cast, save and share → Install page as app). AI Agent Manager then opens in its own window with its own Dock or taskbar icon, and the daemon keeps running as before.
44
46
  - **About.** The version next to the title opens the About box: release date, author, license, links, and how this daemon runs (data folder, port, Node.js, which `claude`). **Check for updates** asks npm for the newest version. In a terminal: `agentmgr about` (`--check` for updates).
45
47
  - **General sessions.** **Ask Claude** starts a session with no repo, folder or intent, for questions. It is tracked and resumable like any other agent.
46
48
 
@@ -53,6 +55,18 @@ New in 0.2, for running many agents at once:
53
55
  - **Checkpoints and forks.** The code (uncommitted work included) and the conversation are saved at the end of every turn. **Fork** starts a new agent from now or from any checkpoint, in its own worktree, to try another direction without losing the first.
54
56
  - **Races.** Send one intent to several agents with different models or approaches. Compare their outcome, checks, size of change, cost and a test command run in each worktree, read their diffs side by side, then pick a winner and delete the rest.
55
57
 
58
+ ## When to use it
59
+
60
+ Claude Code on its own, including its built-in ways to run work in parallel, is enough for one repo in one sitting. AI Agent Manager is for when that stops being enough:
61
+
62
+ - **Many sessions across several repos,** running for hours, where you lose track of which one is waiting on you.
63
+ - **Agents that must survive** a closed terminal, a restart or a reboot, and pick up where they left off.
64
+ - **Several agents in one repo,** each on its own worktree and branch, with a warning when two touch the same code.
65
+ - **Keeping up without reading transcripts:** a brief per agent (result, problem, fix, checks) and one inbox for every prompt and question.
66
+ - **From anywhere:** your phone, another computer, or several machines through the hub.
67
+
68
+ **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 any time with `claude --resume`.
69
+
56
70
  ## Install
57
71
 
58
72
  Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the `claude` CLI, signed in (run `claude` once). Optional: `gh` (authenticated) for PR discovery by branch and PR state, Chrome (or Chromium, Brave, Edge) for live browser tabs, and Codex (`npm i -g @openai/codex`) as a second engine.
@@ -61,13 +75,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
61
75
 
62
76
  ```sh
63
77
  npm install -g ai-agentmanager # the latest release
64
- npm install -g ai-agentmanager@1.0.1 # or a specific one
78
+ npm install -g ai-agentmanager@1.0.2 # or a specific one
65
79
  ```
66
80
 
67
81
  Or from the repository, at a release tag:
68
82
 
69
83
  ```sh
70
- git clone --branch v1.0.1 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
84
+ git clone --branch v1.0.2 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
71
85
  cd ai-agentmanager-core
72
86
  npm install
73
87
  npm link # optional: puts `agentmgr` on your PATH
@@ -87,9 +101,11 @@ npm link
87
101
  | Installed with | Update |
88
102
  | --- | --- |
89
103
  | npm | `npm install -g ai-agentmanager@latest` |
90
- | A clone at a release tag | `git fetch --tags && git checkout v1.0.1 && npm install` (the tag you want) |
104
+ | A clone at a release tag | `git fetch --tags && git checkout v1.0.2 && npm install` (the tag you want) |
91
105
  | A clone of `main` | `git pull && npm install` |
92
106
 
107
+ **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.
108
+
93
109
  ## Quick start on a new computer
94
110
 
95
111
  **A computer that runs agents.** After installing:
@@ -126,6 +142,7 @@ The hub listens on port 4720, on this Mac and on its Tailscale address (`--host`
126
142
  ```sh
127
143
  agentmgr ui # start the daemon and open http://127.0.0.1:4717
128
144
  agentmgr service install # start the daemon at login (launchd / systemd --user)
145
+ agentmgr doctor # check Node.js, Claude Code, git, gh, the port; says how to fix
129
146
 
130
147
  agentmgr add "Fix the flaky login test" -d ~/src/app
131
148
  agentmgr add -r https://github.com/org/repo.git \
package/bin/agentmgr.js CHANGED
@@ -1,4 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { main } from '../src/cli.js';
3
-
4
- main(process.argv);
2
+ // Dependencies are loaded behind a check, so a git checkout without `npm install` says so.
3
+ try {
4
+ const { main } = await import('../src/cli.js');
5
+ await main(process.argv);
6
+ } catch (err) {
7
+ if (err?.code !== 'ERR_MODULE_NOT_FOUND' || !/Cannot find package/.test(err.message)) throw err;
8
+ const dir = new URL('..', import.meta.url).pathname;
9
+ console.error(`agentmgr: dependencies are missing (${err.message.match(/'([^']+)'/)?.[1] ?? 'a package'}).\nRun \`npm install\` in ${dir} and try again.`);
10
+ process.exit(1);
11
+ }
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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
@@ -556,6 +556,11 @@ const copyBtn = (value) => `<button class="link" data-copy="${esc(value)}" title
556
556
  /** Agents waiting on you first, then running ones, then the rest. */
557
557
  const rank = (a) => (a.runtime.attention ? 0 : a.runtime.running ? 1 : 2);
558
558
 
559
+ // Agents with no work and no input from you for a day sink below the active ones.
560
+ const ACTIVE_MS = 24 * 3600 * 1000;
561
+ const isActive = (a) =>
562
+ Boolean(a.runtime.attention || a.runtime.busy || a.status === 'preparing' || (a.activeAt && Date.now() - Date.parse(a.activeAt) < ACTIVE_MS));
563
+
559
564
  // Projects the user collapsed in the tree (kept per browser).
560
565
  const collapsed = new Set(
561
566
  (() => {
@@ -578,12 +583,14 @@ const prioChip = (p, { always = false } = {}) =>
578
583
 
579
584
  function agentItem(a) {
580
585
  const st = statusOf(a);
581
- return `<li data-id="${a.id}" class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''}">
586
+ const idle = !isActive(a);
587
+ return `<li data-id="${a.id}" class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''} ${idle ? 'idle' : ''}">
582
588
  <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>
583
589
  <div class="intent">${a.kind === 'general' ? '<span class="tag">general</span> ' : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(a.intent || 'General session')}</div>
584
590
  ${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
585
591
  <div class="meta">
586
592
  <span class="mono">${a.id}</span>
593
+ ${idle && a.activeAt ? `<span title="Idle: no work and no input from you for over a day">💤 ${ago(a.activeAt)}</span>` : ''}
587
594
  ${a.git?.branch ? `<span class="branch" title="${esc(a.git.branch)}">⎇ ${esc(a.git.branch)}</span>` : ''}
588
595
  ${a.prs.length ? `<span>${a.prs.length} PR</span>` : ''}
589
596
  ${a.bugs.length ? `<span>${a.bugs.length} bug</span>` : ''}
@@ -608,8 +615,15 @@ function projectGroups(list) {
608
615
  }
609
616
  const out = [...groups.values()];
610
617
  for (const g of out) {
611
- // Highest priority first, then agents needing you, then running ones, then newest.
612
- g.agents.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2) || rank(a) - rank(b) || b.createdAt.localeCompare(a.createdAt));
618
+ // Active in the last day first; then highest priority, agents needing you, running ones, most recent.
619
+ g.agents.sort(
620
+ (a, b) =>
621
+ isActive(b) - isActive(a) ||
622
+ (a.priority ?? 2) - (b.priority ?? 2) ||
623
+ rank(a) - rank(b) ||
624
+ (b.activeAt ?? b.createdAt).localeCompare(a.activeAt ?? a.createdAt),
625
+ );
626
+ g.active = g.agents.filter(isActive).length;
613
627
  g.top = Math.min(...g.agents.map((a) => a.priority ?? 2));
614
628
  g.running = g.agents.filter((a) => a.runtime.running).length;
615
629
  g.waiting = g.agents.filter((a) => a.runtime.attention).length;
@@ -623,7 +637,13 @@ function projectGroups(list) {
623
637
  }
624
638
  const special = (g) => (g.key === 'general' ? 1 : g.key === 'other' ? 2 : 0);
625
639
  return out.sort(
626
- (a, b) => special(a) - special(b) || a.top - b.top || b.waiting - a.waiting || b.running - a.running || a.label.localeCompare(b.label),
640
+ (a, b) =>
641
+ special(a) - special(b) ||
642
+ (b.active > 0) - (a.active > 0) ||
643
+ a.top - b.top ||
644
+ b.waiting - a.waiting ||
645
+ b.running - a.running ||
646
+ a.label.localeCompare(b.label),
627
647
  );
628
648
  }
629
649
 
@@ -1725,11 +1745,107 @@ const term = {
1725
1745
  this.agentId = null;
1726
1746
  $('#term-wrap').hidden = true;
1727
1747
  $('#detail').classList.remove('with-term');
1748
+ if (document.body.classList.contains('term-max')) setExpanded(false);
1749
+ if (document.fullscreenElement) document.exitFullscreen();
1728
1750
  },
1729
1751
  };
1730
1752
 
1731
1753
  $('#term-close').onclick = () => term.close();
1732
1754
  $('#term-fit').onclick = () => term.refit();
1755
+
1756
+ // ------------------------------------------------------------------ resizing, expanding, full screen
1757
+
1758
+ const layoutPref = {
1759
+ get(key) {
1760
+ try {
1761
+ return localStorage.getItem(`agentmgr.${key}`);
1762
+ } catch {
1763
+ return null;
1764
+ }
1765
+ },
1766
+ set(key, value) {
1767
+ try {
1768
+ value == null ? localStorage.removeItem(`agentmgr.${key}`) : localStorage.setItem(`agentmgr.${key}`, value);
1769
+ } catch {}
1770
+ },
1771
+ };
1772
+
1773
+ function applyLayout() {
1774
+ const side = layoutPref.get('sideWidth');
1775
+ const brief = layoutPref.get('briefHeight');
1776
+ document.documentElement.style.setProperty('--side-w', side ? `${side}px` : '340px');
1777
+ if (brief) document.documentElement.style.setProperty('--brief-h', `${brief}px`);
1778
+ else document.documentElement.style.removeProperty('--brief-h');
1779
+ $('#detail').classList.toggle('brief-sized', Boolean(brief));
1780
+ }
1781
+ applyLayout();
1782
+
1783
+ /** Drag a handle; `move` gets the pointer position and returns the value to keep. */
1784
+ function dragHandle(el, axis, move, key) {
1785
+ el.addEventListener('pointerdown', (e) => {
1786
+ e.preventDefault();
1787
+ el.setPointerCapture(e.pointerId);
1788
+ el.classList.add('dragging');
1789
+ document.body.classList.add('resizing', axis);
1790
+ let value = null;
1791
+ const onMove = (ev) => {
1792
+ value = move(ev);
1793
+ applyLayout();
1794
+ };
1795
+ const onUp = () => {
1796
+ el.classList.remove('dragging');
1797
+ document.body.classList.remove('resizing', axis);
1798
+ el.removeEventListener('pointermove', onMove);
1799
+ el.removeEventListener('pointerup', onUp);
1800
+ if (value != null) layoutPref.set(key, Math.round(value));
1801
+ term.refit();
1802
+ };
1803
+ el.addEventListener('pointermove', onMove);
1804
+ el.addEventListener('pointerup', onUp);
1805
+ });
1806
+ el.addEventListener('dblclick', () => {
1807
+ layoutPref.set(key, null);
1808
+ applyLayout();
1809
+ requestAnimationFrame(() => term.refit());
1810
+ });
1811
+ }
1812
+
1813
+ dragHandle($('#split-side'), 'cols', (ev) => {
1814
+ const w = Math.min(Math.max(ev.clientX, 220), window.innerWidth * 0.6);
1815
+ layoutPref.set('sideWidth', Math.round(w));
1816
+ return w;
1817
+ }, 'sideWidth');
1818
+
1819
+ dragHandle($('#split-term'), 'rows', (ev) => {
1820
+ const top = $('#detail').getBoundingClientRect().top;
1821
+ const h = Math.min(Math.max(ev.clientY - top, 60), $('#detail').clientHeight - 160);
1822
+ layoutPref.set('briefHeight', Math.round(h));
1823
+ return h;
1824
+ }, 'briefHeight');
1825
+
1826
+ function setExpanded(on) {
1827
+ document.body.classList.toggle('term-max', on);
1828
+ $('#term-max').classList.toggle('on', on);
1829
+ $('#term-max').textContent = on ? '⤡ Restore' : '⤢ Expand';
1830
+ requestAnimationFrame(() => term.refit());
1831
+ }
1832
+ $('#term-max').onclick = () => setExpanded(!document.body.classList.contains('term-max'));
1833
+ $('#term-full').onclick = () => {
1834
+ if (document.fullscreenElement) document.exitFullscreen();
1835
+ else $('#term-wrap').requestFullscreen?.().catch(() => setExpanded(true));
1836
+ };
1837
+ document.addEventListener('fullscreenchange', () => {
1838
+ const on = document.fullscreenElement === $('#term-wrap');
1839
+ $('#term-full').classList.toggle('on', on);
1840
+ $('#term-full').textContent = on ? '⛶ Exit full screen' : '⛶ Full screen';
1841
+ requestAnimationFrame(() => term.refit());
1842
+ });
1843
+ document.addEventListener('keydown', (e) => {
1844
+ if (e.ctrlKey && e.shiftKey && e.code === 'KeyE' && !$('#term-wrap').hidden) {
1845
+ e.preventDefault();
1846
+ setExpanded(!document.body.classList.contains('term-max'));
1847
+ }
1848
+ }, true);
1733
1849
  $('#term-resume').onclick = () => {
1734
1850
  const id = term.agentId;
1735
1851
  if (isShell(term.kind)) {
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#151821"/><path d="M256 76l180 180-180 180L76 256z" fill="#6d8bff"/><path d="M256 166l90 90-90 90-90-90z" fill="#151821"/></svg>
package/public/index.html CHANGED
@@ -6,6 +6,9 @@
6
6
  <meta name="apple-mobile-web-app-capable" content="yes" />
7
7
  <title>AI Agent Manager</title>
8
8
  <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
+ <link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
10
+ <meta name="theme-color" content="#151821" />
11
+ <link rel="apple-touch-icon" href="/icons/icon-192.png" />
9
12
  <link rel="stylesheet" href="/vendor/xterm/css/xterm.css" />
10
13
  <link rel="stylesheet" href="/style.css" />
11
14
  </head>
@@ -44,10 +47,12 @@
44
47
  <p>Add one or more intents and a repo or folder to get started.</p>
45
48
  </div>
46
49
  </aside>
50
+ <div class="splitter split-side" id="split-side" role="separator" aria-orientation="vertical" title="Drag to resize the list; double-click to reset"></div>
47
51
 
48
52
  <section class="detail" id="detail">
49
53
  <div class="placeholder" id="placeholder">Select an agent to see its session, folder, repo, PRs and bugs.</div>
50
54
  <div id="detail-body" hidden></div>
55
+ <div class="splitter split-term" id="split-term" role="separator" aria-orientation="horizontal" title="Drag to resize the terminal; double-click to reset"></div>
51
56
  <div class="term-wrap" id="term-wrap" hidden>
52
57
  <div class="term-bar">
53
58
  <div class="term-tabs" role="tablist">
@@ -62,6 +67,8 @@
62
67
  <button id="term-attach" class="ghost small" title="Attach files or images (you can also drop or paste them into the terminal)">📎 Attach</button>
63
68
  <input type="file" id="term-file" multiple hidden>
64
69
  <button id="term-fit" class="ghost small" title="Refit the terminal to the panel">Fit</button>
70
+ <button id="term-max" class="ghost small desk-only" title="Expand to fill the window (Ctrl+Shift+E)">⤢ Expand</button>
71
+ <button id="term-full" class="ghost small desk-only" title="Full screen (Esc to leave)">⛶ Full screen</button>
65
72
  <button id="term-close" class="ghost small" title="Hide the terminal (the agent keeps running)">Hide terminal</button>
66
73
  </div>
67
74
  <div id="signin-bar" class="signin-bar" hidden></div>
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "AI Agent Manager",
3
+ "short_name": "Agent Manager",
4
+ "description": "Run many coding agents in parallel without babysitting terminals.",
5
+ "id": "/",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "background_color": "#0d0f14",
10
+ "theme_color": "#151821",
11
+ "icons": [
12
+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
13
+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
14
+ { "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
15
+ ]
16
+ }
package/public/style.css CHANGED
@@ -88,7 +88,27 @@ input, select, textarea {
88
88
  textarea { resize: vertical; font-family: var(--mono); font-size: 13px; }
89
89
 
90
90
  /* ---------- layout */
91
- .layout { flex: 1; display: grid; grid-template-columns: 340px 1fr; min-height: 0; }
91
+ .layout { flex: 1; display: grid; grid-template-columns: var(--side-w, 340px) 0 1fr; min-height: 0; }
92
+
93
+ /* Drag handles: the list's width, and the brief above the terminal. */
94
+ .splitter { position: relative; z-index: 3; }
95
+ .splitter::after { content: ''; position: absolute; }
96
+ .split-side::after { top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; }
97
+ .split-term { display: none; flex: none; height: 0; }
98
+ .detail.with-term > #detail-body:not([hidden]) + .split-term { display: block; }
99
+ .split-term::after { left: 0; right: 0; top: -4px; height: 8px; cursor: row-resize; }
100
+ .splitter:hover::after, .splitter.dragging::after { background: var(--accent); opacity: .5; }
101
+ body.resizing { user-select: none; }
102
+ body.resizing iframe, body.resizing .terminal { pointer-events: none; }
103
+ body.resizing.cols { cursor: col-resize; }
104
+ body.resizing.rows { cursor: row-resize; }
105
+
106
+ /* Expanded: the terminal, shell or browser fills the window. */
107
+ body.term-max .topbar, body.term-max .sidebar, body.term-max .split-side,
108
+ body.term-max #detail-body, body.term-max .split-term, body.term-max #placeholder { display: none !important; }
109
+ body.term-max .layout { grid-template-columns: 1fr; }
110
+ .term-wrap:fullscreen { width: 100vw; height: 100vh; }
111
+ .term-bar button.on { background: #262c38; border-color: #58a6ff; }
92
112
  .sidebar { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); }
93
113
  .side-tabs { display: flex; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--panel); z-index: 2; }
94
114
  .side-tab { flex: 1; border: 0; border-radius: 0; background: transparent; padding: 9px; font-weight: 600; color: var(--muted); border-bottom: 2px solid transparent; }
@@ -172,7 +192,8 @@ textarea { resize: vertical; font-family: var(--mono); font-size: 13px; }
172
192
  .detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
173
193
  .placeholder { margin: auto; color: var(--muted); padding: 24px; text-align: center; }
174
194
  #detail-body { padding: 16px 20px; overflow-y: auto; flex: 0 1 auto; max-height: 100%; }
175
- .detail.with-term #detail-body { max-height: 42%; border-bottom: 1px solid var(--border); }
195
+ .detail.with-term #detail-body { max-height: var(--brief-h, 42%); border-bottom: 1px solid var(--border); }
196
+ .detail.with-term.brief-sized #detail-body { height: var(--brief-h); flex: none; }
176
197
  .d-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
177
198
  .d-title { flex: 1 1 420px; min-width: 0; }
178
199
  .d-title h2 { margin: 0 0 4px; font-size: 18px; display: flex; gap: 6px 10px; align-items: center; flex-wrap: wrap; }
@@ -399,6 +420,7 @@ dialog::backdrop { background: rgba(0, 0, 0, .45); }
399
420
  .term-bar { flex-wrap: wrap; row-gap: 6px; }
400
421
  .term-bar > .term-tabs { flex: 1 0 100%; }
401
422
  .layout { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
423
+ .splitter, .desk-only { display: none !important; }
402
424
  .sidebar { max-height: 38vh; border-right: 0; border-bottom: 1px solid var(--border); }
403
425
  .top-actions input { width: 130px; }
404
426
  .row { grid-template-columns: 1fr; }
@@ -548,3 +570,6 @@ pre.diff .meta { color: var(--muted); font-weight: 600; }
548
570
  #import-dialog .import-foot label { display: flex; flex-direction: row; align-items: center; gap: 6px; }
549
571
  #import-dialog .import-foot input { width: auto; }
550
572
  .judge-line { margin: 14px 12px; line-height: 1.5; }
573
+
574
+ .agent-list li.agent.idle { opacity: .62; }
575
+ .agent-list li.agent.idle:hover, .agent-list li.agent.idle.selected { opacity: 1; }
package/src/cli.js CHANGED
@@ -879,6 +879,23 @@ export async function main(argv) {
879
879
  }
880
880
  });
881
881
 
882
+ program
883
+ .command('doctor')
884
+ .description('Check what AI Agent Manager needs on this machine (Node.js, Claude Code, git, gh, the daemon) and how to fix it')
885
+ .action(async () => {
886
+ const { doctor } = await import('./doctor.js');
887
+ const checks = await doctor();
888
+ const mark = { ok: green('✓'), warn: yellow('!'), fail: red('✗'), info: dim('·') };
889
+ const width = Math.max(...checks.map((c) => c.name.length));
890
+ for (const c of checks) {
891
+ console.log(`${mark[c.status]} ${c.name.padEnd(width)} ${c.status === 'fail' ? red(c.detail) : c.detail}`);
892
+ if (c.fix) console.log(` ${' '.repeat(width)} ${dim('→')} ${c.fix}`);
893
+ }
894
+ const failed = checks.filter((c) => c.status === 'fail').length;
895
+ console.log(failed ? red(`\n${failed} problem${failed === 1 ? '' : 's'} to fix`) : green('\nReady: start with `agentmgr ui`'));
896
+ if (failed) process.exitCode = 1;
897
+ });
898
+
882
899
  const remoteCmd = program.command('remote').description('Open AI Agent Manager from a phone or another computer on the same network');
883
900
  const printRemote = async (st, { qr = true } = {}) => {
884
901
  if (!st.enabled || !st.running) return console.log(`remote access ${dim('off')} (turn on: agentmgr remote on)`);
package/src/doctor.js ADDED
@@ -0,0 +1,113 @@
1
+ // `agentmgr doctor`: checks what AI Agent Manager needs on this machine and says how to fix
2
+ // what's missing. Each check is { name, status: 'ok' | 'warn' | 'fail' | 'info', detail, fix }.
3
+ import { execFile } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import net from 'node:net';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { HOST, PORT, loadConfig, paths } from './config.js';
9
+ import { api, BASE, isRunning } from './client.js';
10
+ import { ENGINES, installed, notInstalled } from './engines/index.js';
11
+ import { findBrowser } from './livebrowser.js';
12
+
13
+ const run = (bin, args, timeout = 8000) =>
14
+ new Promise((resolve) => {
15
+ execFile(bin, args, { timeout }, (err, stdout, stderr) => resolve({ ok: !err, out: `${stdout}${stderr}`.trim(), err }));
16
+ });
17
+
18
+ const firstLine = (s) => s.split('\n')[0].trim();
19
+
20
+ function portFree(port) {
21
+ return new Promise((resolve) => {
22
+ const srv = net.createServer();
23
+ srv.once('error', () => resolve(false));
24
+ srv.listen(port, HOST, () => srv.close(() => resolve(true)));
25
+ });
26
+ }
27
+
28
+ /** Whether Claude Code has credentials, without reading them: an API key, or a signed-in account. */
29
+ function claudeSignedIn() {
30
+ if (process.env.ANTHROPIC_API_KEY) return 'an API key (ANTHROPIC_API_KEY)';
31
+ try {
32
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
33
+ if (cfg.oauthAccount) return 'a Claude account';
34
+ } catch {}
35
+ return null;
36
+ }
37
+
38
+ async function ptyWorks() {
39
+ try {
40
+ const pty = (await import('node-pty')).default;
41
+ return await new Promise((resolve) => {
42
+ const p = pty.spawn(process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', process.platform === 'win32' ? ['/c', 'exit 0'] : ['-c', 'exit 0'], {});
43
+ const t = setTimeout(() => resolve('started but did not finish'), 5000);
44
+ p.onExit(() => {
45
+ clearTimeout(t);
46
+ resolve(null);
47
+ });
48
+ });
49
+ } catch (err) {
50
+ return err.message;
51
+ }
52
+ }
53
+
54
+ export async function doctor() {
55
+ const config = loadConfig();
56
+ const checks = [];
57
+ const add = (name, status, detail, fix) => checks.push({ name, status, detail, ...(fix ? { fix } : {}) });
58
+
59
+ const major = Number(process.versions.node.split('.')[0]);
60
+ add('Node.js', major >= 20 ? 'ok' : 'fail', `${process.versions.node}`, major >= 20 ? null : 'Install Node.js 20 or newer (https://nodejs.org)');
61
+
62
+ const ptyErr = await ptyWorks();
63
+ add('Terminals (node-pty)', ptyErr ? 'fail' : 'ok', ptyErr ? `can't start a terminal: ${ptyErr}` : 'can start terminals',
64
+ ptyErr ? 'Reinstall: npm install -g ai-agentmanager (or npm rebuild node-pty in a git checkout)' : null);
65
+
66
+ // The daemon's view wins: a login service may have a different PATH than this shell.
67
+ const running = await isRunning();
68
+ const daemonEngines = running ? await api('GET', '/engines').catch(() => null) : null;
69
+ for (const engine of Object.values(ENGINES)) {
70
+ const has = daemonEngines ? daemonEngines.find((e) => e.id === engine.id)?.installed : installed(engine, config);
71
+ const main = engine.id === (config.engine ?? 'claude');
72
+ if (!has) {
73
+ add(engine.label, main ? 'fail' : 'info', main ? 'not found' : 'not installed (optional)', main ? notInstalled(engine, config) : null);
74
+ continue;
75
+ }
76
+ const v = await run(engine.bin(config), ['--version']);
77
+ add(engine.label, 'ok', v.ok ? firstLine(v.out) : 'installed');
78
+ }
79
+
80
+ const signedIn = claudeSignedIn();
81
+ add('Claude sign-in', signedIn ? 'ok' : 'warn', signedIn ? `uses ${signedIn}` : "couldn't tell whether Claude Code is signed in",
82
+ signedIn ? null : 'Run `claude` in a terminal once and sign in');
83
+
84
+ const git = await run('git', ['--version']);
85
+ add('git', git.ok ? 'ok' : 'fail', git.ok ? firstLine(git.out) : 'not found', git.ok ? null : 'Install git (agents work in git worktrees)');
86
+
87
+ const gh = await run('gh', ['auth', 'status']);
88
+ if (gh.err?.code === 'ENOENT') add('GitHub CLI (gh)', 'warn', 'not installed: PRs and checks are not tracked', 'Install it (https://cli.github.com) and run `gh auth login`');
89
+ else add('GitHub CLI (gh)', gh.ok ? 'ok' : 'warn', gh.ok ? 'signed in' : 'not signed in: PRs and checks are not tracked', gh.ok ? null : 'Run `gh auth login`');
90
+
91
+ try {
92
+ fs.mkdirSync(paths.home, { recursive: true });
93
+ fs.accessSync(paths.home, fs.constants.W_OK);
94
+ add('Data folder', 'ok', paths.home);
95
+ } catch (err) {
96
+ add('Data folder', 'fail', `${paths.home}: ${err.message}`, 'Make it writable, or point AGENTMGR_HOME at another folder');
97
+ }
98
+
99
+ if (running) {
100
+ const a = await api('GET', '/about').catch(() => null);
101
+ add('Daemon', 'ok', `running at ${BASE}${a ? ` (version ${a.version}, pid ${a.daemon.pid})` : ''}`);
102
+ } else if (await portFree(PORT)) {
103
+ add('Daemon', 'info', `not running; port ${PORT} is free`, 'Start it with `agentmgr ui`');
104
+ } else {
105
+ add('Daemon', 'fail', `port ${PORT} is used by another program`, 'Stop that program, or set AGENTMGR_PORT to another port');
106
+ }
107
+
108
+ const chrome = findBrowser(config);
109
+ add('Chrome (live browser tabs)', chrome ? 'ok' : 'warn', chrome ?? 'not found: live browser tabs are unavailable (Lite mode still works)',
110
+ chrome ? null : 'Install Chrome, Chromium, Brave or Edge, or set "browserBin" in config.json');
111
+
112
+ return checks;
113
+ }
@@ -84,6 +84,20 @@ export default {
84
84
  ],
85
85
  promptChoices: (screen) => promptChoices(screen),
86
86
 
87
+ /** Lines of Claude Code's own frame (input box, mode and status bar): never a question to the person. */
88
+ screenChrome: [
89
+ /\b(accept edits|bypass permissions|plan mode|auto mode|auto-accept edits) on\b/i,
90
+ /shift\+tab to cycle/i,
91
+ /\? for shortcuts/i,
92
+ /esc to interrupt/i,
93
+ /ctrl\+[a-z] to (expand|edit|show|hide)/i,
94
+ /update available/i,
95
+ /^[─━═-]{3,}/,
96
+ /^\/\w+$/,
97
+ /^[❯>]\s*$/,
98
+ ],
99
+ install: 'Install Claude Code (https://docs.claude.com/en/docs/claude-code) and run `claude` once to sign in',
100
+
87
101
  trust: (dir) => trustFolder(dir),
88
102
 
89
103
  /** A one-shot, tool-less call; the prompt goes on stdin and the answer comes back as text. */
@@ -23,6 +23,14 @@ export function installed(engine, config) {
23
23
  return (process.env.PATH ?? '').split(path.delimiter).some((d) => d && fs.existsSync(path.join(d, bin)));
24
24
  }
25
25
 
26
+ /** What to tell the person when an engine's command isn't on this machine. */
27
+ export function notInstalled(engine, config) {
28
+ const bin = engine.bin(config);
29
+ const how = engine.install ?? `Install ${engine.label}`;
30
+ const where = path.isAbsolute(bin) ? `${bin} doesn't exist` : `\`${bin}\` isn't on the daemon's PATH`;
31
+ return `${engine.label} not found: ${where}. ${how}, then run \`agentmgr restart\`. If it's installed somewhere else, set its path in config.json${engine.id === 'claude' ? ' ("claudeBin") or AGENTMGR_CLAUDE_BIN' : ` ("engines": {"${engine.id}": {"bin": "…"}})`}.`;
32
+ }
33
+
26
34
  /** Every engine, with whether it can run here, for pickers and the hub. */
27
35
  export function listEngines(config) {
28
36
  return Object.values(ENGINES).map((e) => ({ id: e.id, label: e.label, installed: installed(e, config), permissionModes: e.permissionModes, supports: e.supports }));
package/src/judge.js CHANGED
@@ -78,6 +78,16 @@ export function stuckPrompt(ctx, finding, events, past = []) {
78
78
  ].join('\n');
79
79
  }
80
80
 
81
+ /** The screen without the engine's own frame (input box, mode indicators, status bar). */
82
+ export function stripChrome(screen, patterns = []) {
83
+ if (!patterns.length) return screen;
84
+ return screen
85
+ .split('\n')
86
+ .filter((line) => !patterns.some((re) => re.test(line.trim())))
87
+ .join('\n')
88
+ .trim();
89
+ }
90
+
81
91
  export function screenPrompt(ctx, screen, past = []) {
82
92
  return [
83
93
  'You supervise an AI coding agent running in a terminal. It has stopped producing output.',
@@ -87,6 +97,8 @@ export function screenPrompt(ctx, screen, past = []) {
87
97
  '- "error": it crashed, hit an error it cannot recover from, or a limit (rate limit, auth, quota)',
88
98
  '- "idle": finished a step and is simply waiting for the next instruction, nothing specific to answer',
89
99
  'For "question", copy the question in plain words and list the choices it offered, if any.',
100
+ 'The tool\'s own input box, mode indicators and status bar (for example "accept edits on", "plan mode on",',
101
+ '"shift+tab to cycle", "? for shortcuts", "update available") are not questions: only the agent\'s own words count.',
90
102
  RULES,
91
103
  'Shape: {"state": "question|done|error|idle", "confidence": 0.0, "summary": "one line for an inbox", "question": "or empty", "choices": ["..."]}',
92
104
  '',
package/src/manager.js CHANGED
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import pty from 'node-pty';
6
6
  import { expandHome, paths } from './config.js';
7
- import { DEFAULT_ENGINE, ENGINES, engineOf, installed } from './engines/index.js';
7
+ import { DEFAULT_ENGINE, ENGINES, engineOf, installed, notInstalled } from './engines/index.js';
8
8
  import {
9
9
  DOC_KINDS,
10
10
  briefDir,
@@ -42,7 +42,7 @@ import * as git from './git.js';
42
42
  import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
43
43
  import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
44
44
  import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
45
- import { Judge, clashPrompt, finishMessage, hash, reviewPrompt, screenPrompt, stuckPrompt } from './judge.js';
45
+ import { Judge, clashPrompt, finishMessage, hash, reviewPrompt, screenPrompt, stripChrome, stuckPrompt } from './judge.js';
46
46
  import { findLocalUrls, findSignInLinks, parseCallback, parseUrl } from './preview.js';
47
47
  import { Plugins } from './plugins.js';
48
48
  import { runningClaude, scanClaudeSessions } from './sessions.js';
@@ -191,6 +191,7 @@ export class AgentManager extends EventEmitter {
191
191
  this.config = config;
192
192
  this.plugins = plugins; // policies before actions, listeners after (src/plugins.js)
193
193
  this.runtimes = new Map(); // agent id -> { pty, chunks, size, clients, lastOutputAt, busy, stopping, exited }
194
+ this.inputAt = new Map(); // agent id -> when you last typed into its session or a shell
194
195
  this.shells = new Map(); // agent id -> ancillary shell runtime in the agent's folder
195
196
  this.cursors = new Map(); // session id -> transcript scan cursor
196
197
  this.locks = new Map();
@@ -268,6 +269,7 @@ export class AgentManager extends EventEmitter {
268
269
  ...agent,
269
270
  results: this.results(agent, rt, prs),
270
271
  priority: agent.priority ?? 2,
272
+ activeAt: this.activeAt(agent, rt),
271
273
  projectInfo: this.projectInfo(agent),
272
274
  usage,
273
275
  // Claude Code's own cost figure from the transcripts (absent until it records one).
@@ -295,6 +297,19 @@ export class AgentManager extends EventEmitter {
295
297
  };
296
298
  }
297
299
 
300
+ /**
301
+ * When the agent last did real work or you last worked with it: its conversation,
302
+ * its brief, your typing, or output while busy (not the redraw after a restart).
303
+ */
304
+ activeAt(agent, rt) {
305
+ const times = [agent.createdAt, agent.brief?.updatedAt, ...(agent.sessions ?? []).map((s) => s.lastMessageAt ?? s.lastActivity)]
306
+ .map((t) => (t ? Date.parse(t) : 0));
307
+ times.push(this.inputAt.get(agent.id) ?? 0);
308
+ if (rt?.pty && rt.busy && rt.lastOutputAt - (rt.startedAt ?? 0) > 60_000) times.push(rt.lastOutputAt);
309
+ const t = Math.max(0, ...times.filter(Number.isFinite));
310
+ return t ? new Date(t).toISOString() : null;
311
+ }
312
+
298
313
  /**
299
314
  * The end result / status / blockers for an agent: what the agent wrote under
300
315
  * "## Results", plus what AI Agent Manager knows (PRs, checks, prompts, errors).
@@ -892,6 +907,11 @@ export class AgentManager extends EventEmitter {
892
907
  }
893
908
  }
894
909
 
910
+ if (!installed(engine, this.config)) {
911
+ const error = notInstalled(engine, this.config);
912
+ this.patch(agent.id, { status: 'error', error });
913
+ throw Object.assign(new Error(error), { status: 400 });
914
+ }
895
915
  let proc;
896
916
  try {
897
917
  proc = pty.spawn(engine.bin(this.config), args, {
@@ -1198,6 +1218,8 @@ export class AgentManager extends EventEmitter {
1198
1218
  }
1199
1219
 
1200
1220
  write(id, data, kind = 'claude') {
1221
+ // Focus reports and terminal replies are not you typing.
1222
+ if (!/^(\x1b\[[\d;?>]*[IOcRnt])+$/.test(data)) this.inputAt.set(id, Date.now());
1201
1223
  this.termRuntime(id, kind)?.pty?.write(data);
1202
1224
  }
1203
1225
 
@@ -1898,7 +1920,7 @@ export class AgentManager extends EventEmitter {
1898
1920
  session.usage = usage;
1899
1921
  changed = true;
1900
1922
  }
1901
- for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity']) {
1923
+ for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity', 'lastMessageAt']) {
1902
1924
  if (res[key] != null && JSON.stringify(session[key]) !== JSON.stringify(res[key])) {
1903
1925
  session[key] = res[key];
1904
1926
  changed = true;
@@ -2253,7 +2275,7 @@ export class AgentManager extends EventEmitter {
2253
2275
  async judgeScreen(id, rt) {
2254
2276
  const agent = this.store.get(id);
2255
2277
  if (!agent) return;
2256
- const screen = this.screenText(rt);
2278
+ const screen = stripChrome(this.screenText(rt), engineOf(agent).screenChrome);
2257
2279
  if (!screen) return;
2258
2280
  const h = hash(screen);
2259
2281
  if (rt.screenVerdict?.hash === h) return this.applyScreen(id, rt, rt.screenVerdict.data);
package/src/tracker.js CHANGED
@@ -132,7 +132,7 @@ function textOf(content) {
132
132
  * subsequent calls only parse newly appended lines.
133
133
  */
134
134
  export function scanTranscript(file, cursor, bugPatterns = []) {
135
- const result = { prs: [], bugs: [], title: null, lastPrompt: null, cost: null, lastActivity: null };
135
+ const result = { prs: [], bugs: [], title: null, lastPrompt: null, cost: null, lastActivity: null, lastMessageAt: null };
136
136
  // Token usage per API response, kept on the cursor so incremental scans accumulate.
137
137
  cursor.usage ??= new Map();
138
138
  let fd;
@@ -160,6 +160,10 @@ export function scanTranscript(file, cursor, bugPatterns = []) {
160
160
  } catch {
161
161
  continue;
162
162
  }
163
+ // The last real message: resuming a session appends bookkeeping lines, which have no timestamp.
164
+ if ((rec.type === 'user' || rec.type === 'assistant') && !rec.isMeta && rec.timestamp > (result.lastMessageAt ?? '')) {
165
+ result.lastMessageAt = rec.timestamp;
166
+ }
163
167
  switch (rec.type) {
164
168
  case 'pr-link':
165
169
  if (rec.prUrl) result.prs.push({ ...prFromUrl(rec.prUrl, 'session'), number: rec.prNumber ?? null });