ai-agentmanager 1.3.0 → 1.3.1

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.1 (2026-10-31)
4
+
5
+ - **Restart comes back:** `agentmgr restart` waits for the old daemon to exit (it closes every agent first, which takes a while with many), instead of giving up after 20 seconds and reporting success with no daemon left. `serve` started while one is shutting down waits for it instead of exiting, and `shutdown` returns once the daemon has stopped. `/api/health` says `closing` while it shuts down.
6
+ - **Pull request status is current:** checking an agent's pull requests while a check is already running (after an update, a stop or a push) waits for it and checks again, instead of returning what that earlier check read, so a pull request ready to merge could still show as needing fixes.
7
+
3
8
  ## 1.3.0 (2026-10-31)
4
9
 
5
10
  - **Review changes:** each agent's changes in one screen: changed files, the diff file by file (new files included), line comments sent to the agent as one message, **Commit all**, and **Push & open PR** with `gh`. `agentmgr changes`, `comment`, `commit`, `pr`.
package/README.md CHANGED
@@ -83,13 +83,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
83
83
 
84
84
  ```sh
85
85
  npm install -g ai-agentmanager # the latest release
86
- npm install -g ai-agentmanager@1.3.0 # or a specific one
86
+ npm install -g ai-agentmanager@1.3.1 # or a specific one
87
87
  ```
88
88
 
89
89
  Or from the repository, at a release tag:
90
90
 
91
91
  ```sh
92
- git clone --branch v1.3.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
92
+ git clone --branch v1.3.1 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
93
93
  cd ai-agentmanager-core
94
94
  npm install
95
95
  npm link # optional: puts `agentmgr` on your PATH
@@ -109,7 +109,7 @@ npm link
109
109
  | Installed with | Update |
110
110
  | --- | --- |
111
111
  | npm | `npm install -g ai-agentmanager@latest` |
112
- | A clone at a release tag | `git fetch --tags && git checkout v1.3.0 && npm install` (the tag you want) |
112
+ | A clone at a release tag | `git fetch --tags && git checkout v1.3.1 && npm install` (the tag you want) |
113
113
  | A clone of `main` | `git pull && npm install` |
114
114
 
115
115
  **Check your setup.** `agentmgr doctor` checks Node.js, Claude Code and its sign-in, git, `gh`, the data folder, the daemon's port and Chrome, and says how to fix anything missing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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/src/cli.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import { Command, Option } from 'commander';
4
4
  import WebSocket from 'ws';
5
5
  import { HOST, PORT } from './config.js';
6
- import { api, BASE, ensureDaemon, isRunning } from './client.js';
6
+ import { api, BASE, ensureDaemon, health, isRunning, waitForExit } from './client.js';
7
7
  import { about, latestRelease } from './about.js';
8
8
 
9
9
  const tty = process.stdout.isTTY;
@@ -271,10 +271,16 @@ export async function main(argv) {
271
271
  .command('serve')
272
272
  .description('Run the agent daemon (API + web UI) in the foreground')
273
273
  .action(async () => {
274
- if (await isRunning()) {
274
+ const h = await health();
275
+ if (h && !h.closing) {
275
276
  // Clean exit, so a login service doesn't keep retrying while another daemon owns the port.
276
277
  return console.log(`[agentmgr] a daemon is already running at ${BASE}; not starting another`);
277
278
  }
279
+ if (h?.closing) {
280
+ // One that is shutting down closes its agents first; start once it has exited.
281
+ console.log(`[agentmgr] waiting for the previous daemon (pid ${h.pid}) to finish shutting down`);
282
+ if (!(await waitForExit(h.pid))) return console.log(`[agentmgr] the previous daemon (pid ${h.pid}) is still shutting down; not starting`);
283
+ }
278
284
  const { startServer } = await import('./server.js');
279
285
  const { url } = await startServer();
280
286
  console.log(`[agentmgr] listening on ${url}`);
@@ -1789,10 +1795,11 @@ export async function main(argv) {
1789
1795
  .description('Register the daemon with launchd (macOS) or systemd --user (Linux) and start it')
1790
1796
  .action(async () => {
1791
1797
  const svc = await import('./service.js');
1792
- if (await isRunning()) {
1798
+ const h = await health();
1799
+ if (h) {
1793
1800
  // Hand over to the service-managed daemon; running agents are resumed by it.
1794
- await api('POST', '/shutdown');
1795
- for (let i = 0; i < 50 && (await isRunning()); i++) await new Promise((r) => setTimeout(r, 200));
1801
+ if (!h.closing) await api('POST', '/shutdown');
1802
+ await waitForExit(h.pid, { onWait: (s) => console.log(dim(`closing agents… (${s}s)`)) });
1796
1803
  }
1797
1804
  const res = await svc.install();
1798
1805
  for (let i = 0; i < 100 && !(await isRunning()); i++) await new Promise((r) => setTimeout(r, 200));
@@ -2001,7 +2008,8 @@ export async function main(argv) {
2001
2008
  .description('Flush, stop the daemon, and start it again; running agents resume their sessions')
2002
2009
  .action(async () => {
2003
2010
  const svc = await import('./service.js');
2004
- if (await isRunning()) {
2011
+ const before = await health();
2012
+ if (before && !before.closing) {
2005
2013
  try {
2006
2014
  const r = await api('POST', '/flush');
2007
2015
  console.log(dim(`saved ${r.agents} agents, scrollback for ${r.scrollback}`));
@@ -2011,13 +2019,21 @@ export async function main(argv) {
2011
2019
  console.log(dim('running daemon is an older version (no flush); restarting it to upgrade'));
2012
2020
  }
2013
2021
  await api('POST', '/shutdown');
2014
- for (let i = 0; i < 100 && (await isRunning()); i++) await new Promise((res) => setTimeout(res, 200));
2022
+ }
2023
+ // Wait for the old process itself to exit: it answers until every agent is closed.
2024
+ if (before?.pid) {
2025
+ const gone = await waitForExit(before.pid, { onWait: (s) => console.log(dim(`closing agents… (${s}s)`)) });
2026
+ if (!gone) throw new Error(`The daemon (pid ${before.pid}) is still shutting down after 5 minutes; check the log`);
2015
2027
  }
2016
2028
  const st = await svc.status();
2017
2029
  if (st.loaded) await svc.kickstart();
2018
2030
  else await ensureDaemon({ quiet: true });
2019
- for (let i = 0; i < 100 && !(await isRunning()); i++) await new Promise((res) => setTimeout(res, 200));
2020
- if (!(await isRunning())) throw new Error('Daemon did not come back; check the log');
2031
+ const back = async () => {
2032
+ const h = await health();
2033
+ return Boolean(h && !h.closing && h.pid !== before?.pid);
2034
+ };
2035
+ for (let i = 0; i < 150 && !(await back()); i++) await new Promise((res) => setTimeout(res, 200));
2036
+ if (!(await back())) throw new Error('Daemon did not come back; check the log');
2021
2037
  const agents = await api('GET', '/agents');
2022
2038
  console.log(`${green('✓')} restarted${st.loaded ? ' (login service)' : ''}; ${agents.filter((a) => a.desired === 'running').length} agents set to resume`);
2023
2039
  });
@@ -2026,9 +2042,12 @@ export async function main(argv) {
2026
2042
  .command('shutdown')
2027
2043
  .description('Stop the daemon (running agents are closed; sessions are kept)')
2028
2044
  .action(async () => {
2029
- if (!(await isRunning())) return console.log('Daemon is not running');
2030
- await api('POST', '/shutdown');
2031
- console.log('Daemon stopped');
2045
+ const h = await health();
2046
+ if (!h) return console.log('Daemon is not running');
2047
+ if (!h.closing) await api('POST', '/shutdown');
2048
+ // It closes every agent before it exits; say stopped only once it has.
2049
+ const gone = await waitForExit(h.pid, { onWait: (s) => console.log(dim(`closing agents… (${s}s)`)) });
2050
+ console.log(gone ? 'Daemon stopped' : yellow(`The daemon (pid ${h.pid}) is still shutting down; check the log`));
2032
2051
  });
2033
2052
 
2034
2053
  try {
package/src/client.js CHANGED
@@ -19,18 +19,58 @@ export async function api(method, route, body) {
19
19
  return data;
20
20
  }
21
21
 
22
+ /** The daemon's /health answer ({ ok, pid, closing, … }), or null when nothing answers. */
23
+ export async function health() {
24
+ try {
25
+ return await api('GET', '/health');
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** A daemon that answers and isn't shutting down. */
22
32
  export async function isRunning() {
33
+ const h = await health();
34
+ return Boolean(h && !h.closing);
35
+ }
36
+
37
+ const alive = (pid) => {
23
38
  try {
24
- await api('GET', '/health');
39
+ process.kill(pid, 0);
25
40
  return true;
26
- } catch {
27
- return false;
41
+ } catch (e) {
42
+ return e.code === 'EPERM';
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Wait for a daemon process to exit. Shutting down closes every agent first (to save their
48
+ * sessions), which takes a while with many agents, and it answers /health until then.
49
+ * Returns true once it is gone, false after `timeoutMs`.
50
+ */
51
+ export async function waitForExit(pid, { timeoutMs = 300_000, onWait = null } = {}) {
52
+ if (!pid) return true;
53
+ const start = Date.now();
54
+ let told = 0;
55
+ while (alive(pid)) {
56
+ if (Date.now() - start > timeoutMs) return false;
57
+ if (onWait && Date.now() - told > 10_000) {
58
+ told = Date.now();
59
+ onWait(Math.round((Date.now() - start) / 1000));
60
+ }
61
+ await new Promise((r) => setTimeout(r, 500));
28
62
  }
63
+ return true;
29
64
  }
30
65
 
31
- /** Start the daemon in the background if it is not already listening. */
66
+ /** Start the daemon in the background if it is not already listening (waiting out one that is shutting down). */
32
67
  export async function ensureDaemon({ quiet = false } = {}) {
33
- if (await isRunning()) return false;
68
+ const h = await health();
69
+ if (h && !h.closing) return false;
70
+ if (h?.closing) {
71
+ if (!quiet) process.stderr.write(`agentmgr: waiting for the daemon (pid ${h.pid}) to finish shutting down…\n`);
72
+ if (!(await waitForExit(h.pid))) throw new Error(`The daemon (pid ${h.pid}) is still shutting down; try again in a minute`);
73
+ }
34
74
  ensureHome();
35
75
  const log = fs.openSync(paths.log, 'a');
36
76
  const child = spawn(process.execPath, [BIN, 'serve'], {
@@ -39,7 +79,7 @@ export async function ensureDaemon({ quiet = false } = {}) {
39
79
  env: process.env,
40
80
  });
41
81
  child.unref();
42
- for (let i = 0; i < 50; i++) {
82
+ for (let i = 0; i < 150; i++) {
43
83
  await new Promise((r) => setTimeout(r, 100));
44
84
  if (await isRunning()) {
45
85
  if (!quiet) process.stderr.write(`agentmgr: started daemon at ${BASE} (log: ${paths.log})\n`);
package/src/manager.js CHANGED
@@ -1991,18 +1991,19 @@ export class AgentManager extends EventEmitter {
1991
1991
  const id = this.mustGet(ref).id;
1992
1992
  const running = this.refreshing.get(id);
1993
1993
  if (running) {
1994
- // A pull request check asked for during a plain refresh runs right after it.
1995
- if (!withPrs || running.withPrs) return running.promise.then(() => this.store.get(id) && this.view(this.store.get(id)));
1996
- await running.promise.catch(() => {});
1997
- return this.refresh(id, { withPrs });
1998
- }
1999
- const promise = this.doRefresh(id, { withPrs });
2000
- this.refreshing.set(id, { promise, withPrs });
2001
- try {
2002
- return await promise;
2003
- } finally {
2004
- this.refreshing.delete(id);
2005
- }
1994
+ if (!withPrs) return running.promise.then(() => this.store.get(id) && this.view(this.store.get(id)));
1995
+ // A pull request check runs again after the one in progress, which may have read PRs from
1996
+ // before this request; callers arriving meanwhile share that next run.
1997
+ running.next ??= running.promise.catch(() => {}).then(() => this.refresh(id, { withPrs }));
1998
+ return running.next;
1999
+ }
2000
+ // Cleared before anyone waiting on it continues, so the next run starts afresh.
2001
+ const entry = { withPrs };
2002
+ entry.promise = this.doRefresh(id, { withPrs }).finally(() => {
2003
+ if (this.refreshing.get(id) === entry) this.refreshing.delete(id);
2004
+ });
2005
+ this.refreshing.set(id, entry);
2006
+ return entry.promise;
2006
2007
  }
2007
2008
 
2008
2009
  async doRefresh(id, { withPrs }) {
package/src/server.js CHANGED
@@ -193,7 +193,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
193
193
  };
194
194
  const fail = (status, message) => Object.assign(new Error(message), { status });
195
195
 
196
- api.get('/health', wrap(() => ({ ok: true, pid: process.pid, home: paths.home, claudeBin: config.claudeBin })));
196
+ // `closing` while shutting down: it still answers until every agent is closed, then exits.
197
+ api.get('/health', wrap(() => ({ ok: true, pid: process.pid, home: paths.home, claudeBin: config.claudeBin, closing })));
197
198
  api.get('/agents', wrap(() => manager.list()));
198
199
  api.get('/engines', wrap(() => listEngines(config)));
199
200
  api.post(