@koalaauto/agentchannel 3.4.0 → 3.5.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/README.md CHANGED
@@ -68,6 +68,21 @@ It reports three things, each problem with a one-line fix:
68
68
  It also checks this machine: which `ach` runs first on PATH, Claude Code and
69
69
  Codex login, and the `ach` MCP entry.
70
70
 
71
+ ## Staying current
72
+
73
+ Your hub names the newest `ach` release (`ach doctor` shows it), and the background helper keeps itself on it:
74
+
75
+ - **The helper (`ach run`, started by `--install-service`)** checks every few hours. When the hub is ahead it stops taking
76
+ new tasks, lets the runs in flight finish, installs the release (`npm install -g` for a global install; an `npx` start
77
+ fetches it itself) and exits with code 75, which systemd and launchd restart. It never crosses to a new major release
78
+ by itself, never moves a stable install onto a prerelease, leaves a source checkout alone, and tries a failed update
79
+ again only after a day. `ACH_AUTO_UPDATE=0` turns the installing off; it still says so in the log.
80
+ - **A session (`ach mcp`, started by Claude Code or Codex)** cannot be restarted from outside: it gets one line in the
81
+ session saying it is behind. Restart the session (`npx` installs pick up the new release); run
82
+ `npm install -g @koalaauto/agentchannel@latest` first if `ach` is installed globally.
83
+ - **A hub that sets a floor** (`ACH_MIN_CLIENT_PROTO`) refuses a CLI below it with `426 client_too_old` for new work; the
84
+ helper then checks for an update at once.
85
+
71
86
  ## Who decides
72
87
 
73
88
  `ach ask-human` asks your own agent's owner by default. When the call belongs
package/dist/src/cli.js CHANGED
@@ -14,6 +14,7 @@ import { isPerm } from './perm.js';
14
14
  import { AttachError, humanSize, isLinkRef, resolveAttachments, taskFiles } from './attachments.js';
15
15
  export { VERSION } from './version.js';
16
16
  import { VERSION } from './version.js';
17
+ import { SelfUpdater, UPDATE_EXIT_CODE } from './selfupdate.js';
17
18
  import { installProxy } from './netproxy.js';
18
19
  async function askTty(question) {
19
20
  const { createInterface } = await import('node:readline/promises');
@@ -1664,6 +1665,8 @@ async function runCommand(p, io) {
1664
1665
  const runtime = str(v.runtime) ?? (me.runtime === 'codex' ? 'codex' : 'claude');
1665
1666
  const { makeAdapter } = await import('./adapters/index.js');
1666
1667
  const adapter = makeAdapter(runtime, { model: str(v.model) });
1668
+ // keeps this runner on the hub's release: waits for the runs in flight, installs, exits UPDATE_EXIT_CODE so the service restarts
1669
+ let updater;
1667
1670
  const runner = new Runner({
1668
1671
  client: c,
1669
1672
  adapter,
@@ -1678,6 +1681,7 @@ async function runCommand(p, io) {
1678
1681
  cfId: cfg.cfId,
1679
1682
  cfSecret: cfg.cfSecret,
1680
1683
  publicUrl: env.ACH_PUBLIC_URL || undefined,
1684
+ onClientTooOld: () => void updater?.nudge(),
1681
1685
  passEnv: (Array.isArray(v['pass-env']) ? v['pass-env'] : []).flatMap(splitList),
1682
1686
  baseEnv: (() => {
1683
1687
  const shim = writeAchShim(home);
@@ -1697,18 +1701,32 @@ async function runCommand(p, io) {
1697
1701
  desk = dn;
1698
1702
  io.err('ach run: desktop notifications on (--no-notify to turn off)');
1699
1703
  }
1704
+ updater = new SelfUpdater({
1705
+ fetchVersions: () => c.request('GET', '/api/ach/auth/config'),
1706
+ runner,
1707
+ home,
1708
+ env,
1709
+ log: io.err,
1710
+ });
1711
+ updater.start();
1700
1712
  const shutdown = () => {
1701
1713
  io.err('stopping runner...');
1714
+ updater?.stop();
1702
1715
  desk?.stop();
1703
1716
  runner.stop().then(() => process.exit(0));
1704
1717
  };
1705
1718
  process.once('SIGINT', shutdown);
1706
1719
  process.once('SIGTERM', shutdown);
1707
1720
  const end = await runner.start();
1721
+ updater.stop();
1708
1722
  desk?.stop();
1709
1723
  // B7: the hub refused the agent key: rotated, agent disabled or owner deactivated.
1710
1724
  if (end === 'unauthorized')
1711
1725
  return authExit(io);
1726
+ if (updater.restarting) {
1727
+ io.err(`ach run: updated; exiting ${UPDATE_EXIT_CODE} so the service restarts on the new release (started by hand? run it again)`);
1728
+ return UPDATE_EXIT_CODE;
1729
+ }
1712
1730
  return 0;
1713
1731
  }
1714
1732
  /** B7: print the rejoin hint and return the auth exit code (ACH_AUTH_EXIT_CODE overrides it). */
@@ -265,6 +265,8 @@ export interface DecisionOption {
265
265
  detail?: string;
266
266
  money?: string;
267
267
  }
268
+ /** Who is calling, on every request: the hub records the release per agent and may refuse a protocol below its floor (426 client_too_old). */
269
+ export declare const CLIENT_HEADERS: Record<string, string>;
268
270
  export declare class HubError extends Error {
269
271
  status: number;
270
272
  code?: string | undefined;
@@ -1,6 +1,7 @@
1
1
  import { openAsBlob, statSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
3
  import { WebSocket as UndiciWebSocket } from 'undici';
4
+ import { CLIENT_PROTO, VERSION } from './version.js';
4
5
  import { attachmentForm, bigFiles, downloadFile, isLinkRef, linkRef, taskFiles, typeOf, uploadTarget } from './attachments.js';
5
6
  export const TERMINAL = ['completed', 'failed', 'canceled'];
6
7
  export function isTerminal(s) {
@@ -17,6 +18,8 @@ export function parseLinkRepo(v) {
17
18
  throw new Error(`bad base in "${v}"`);
18
19
  return base ? { repo, base } : { repo };
19
20
  }
21
+ /** Who is calling, on every request: the hub records the release per agent and may refuse a protocol below its floor (426 client_too_old). */
22
+ export const CLIENT_HEADERS = { 'x-ach-client': `ach-cli/${VERSION}`, 'x-ach-proto': String(CLIENT_PROTO) };
20
23
  export class HubError extends Error {
21
24
  status;
22
25
  code;
@@ -83,7 +86,7 @@ export class Client {
83
86
  return null;
84
87
  const url = new URL('/api/ach/events', `${this.hub}/`);
85
88
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
86
- const headers = { 'user-agent': 'agentchannel-cli' };
89
+ const headers = { 'user-agent': 'agentchannel-cli', ...CLIENT_HEADERS };
87
90
  if (this.cfId && this.cfSecret) {
88
91
  headers['CF-Access-Client-Id'] = this.cfId;
89
92
  headers['CF-Access-Client-Secret'] = this.cfSecret;
@@ -91,7 +94,7 @@ export class Client {
91
94
  return new UndiciWebSocket(url.href, { protocols: [`ach.${this.token}`], headers });
92
95
  }
93
96
  headers(json = true) {
94
- const h = json ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' };
97
+ const h = { ...CLIENT_HEADERS, ...(json ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' }) };
95
98
  if (this.token) {
96
99
  // Agent keys and run tokens use Bearer; PocketBase user tokens are sent raw.
97
100
  h.authorization = /^ach_(sk|rt)_/.test(this.token) ? `Bearer ${this.token}` : this.token;
@@ -1,4 +1,5 @@
1
1
  import { type FetchLike } from './client.js';
2
+ import { newerVersion } from './version.js';
2
3
  export type ExecResult = {
3
4
  code: number;
4
5
  stdout: string;
@@ -71,7 +72,6 @@ export declare function shadowFix(other: {
71
72
  }, ours: {
72
73
  path: string;
73
74
  } | undefined, self: string): string;
74
- /** True when version a is newer than b (x.y.z, a release beats its own prereleases; prerelease parts compared by number when numeric). */
75
- export declare function newerVersion(a: string, b: string): boolean;
75
+ export { newerVersion };
76
76
  /** Grouped report: hub, front door (only when there is one), key, this machine; each problem with a one-line fix. */
77
77
  export declare function formatDoctor(checks: DoctorCheck[]): string;
@@ -6,7 +6,7 @@ import { Client } from './client.js';
6
6
  import { achHome, loadAgentKey, loadConfig } from './config.js';
7
7
  import { hubTransportOk, identifyAch, isOurs, npmSpec, thisCliPath, userBin, whichAll } from './invite.js';
8
8
  import { openKeyStore } from './keystore.js';
9
- import { PKG_NAME, VERSION } from './version.js';
9
+ import { CLIENT_PROTO, newerVersion, PKG_NAME, VERSION } from './version.js';
10
10
  /** Ask-the-admin fix: a fresh join line carries a new key and whatever the hub needs to let you in. */
11
11
  export const NEW_LINE_FIX = 'ask your admin to run `ach admin invite <your agent>` again and paste the new line';
12
12
  /** Allowed clock difference with the hub before warning (login links and tokens are time-bound). */
@@ -103,6 +103,8 @@ export async function runDoctor(o) {
103
103
  const where = env.ACH_KEY ? 'ACH_KEY' : openKeyStore(home, env).kind === 'keychain' ? 'OS keychain' : 'agent-key file';
104
104
  add('agent key', key ? 'ok' : 'fail', key ? `present (${where}; not shown)` : `missing${keyErr ? ` (${keyErr})` : ''}`, 'key', key ? undefined : 'paste the join line from your admin again');
105
105
  // Hub: reachable over HTTPS; front door (if any); the key works; clock
106
+ let hubLatest = '';
107
+ let hubMinProto = 0;
106
108
  let runtime;
107
109
  let agentName;
108
110
  if (cfg.hub) {
@@ -181,26 +183,45 @@ export async function runDoctor(o) {
181
183
  const c = r.ok ? await r.json() : null;
182
184
  if (c && typeof c.version === 'string')
183
185
  add('hub version', 'info', `AgentChannel ${c.version}${c.commit ? ` (${c.commit})` : ''}`, 'hub');
186
+ if (c && typeof c.latest_client === 'string')
187
+ hubLatest = c.latest_client;
188
+ if (c && Number.isInteger(c.min_client_proto))
189
+ hubMinProto = c.min_client_proto;
184
190
  }
185
191
  catch {
186
192
  /* informational only */
187
193
  }
188
194
  }
189
195
  }
196
+ // the hub says what it needs: a floor this CLI is below, or a newer release than this one (hubs before 3.5 say nothing)
197
+ const line = VERSION.includes('-') ? 'next' : 'latest';
198
+ const upgrade = `run the join line from the panel again (Settings > Agents > New join command), or: npm install -g ${PKG_NAME}@${line}`;
199
+ let hubSaid = false;
200
+ if (hubMinProto > CLIENT_PROTO) {
201
+ hubSaid = true;
202
+ add('update', 'fail', `this hub no longer accepts ${VERSION} (protocol ${CLIENT_PROTO}, it needs ${hubMinProto} or newer)`, 'machine', upgrade);
203
+ }
204
+ else if (hubLatest && /^\d+\.\d+\.\d+/.test(hubLatest) && newerVersion(hubLatest, VERSION)) {
205
+ hubSaid = true;
206
+ // the exact release the hub names: always installable, whatever line (latest, next) this CLI is on
207
+ add('update', 'warn', `${PKG_NAME} ${hubLatest} is out (this is ${VERSION}; your hub announces it)`, 'machine', `run the join line from the panel again (Settings > Agents > New join command), or: npm install -g ${PKG_NAME}@${hubLatest}`);
208
+ }
190
209
  // a newer CLI on npm (the release line this CLI is on: `latest` for a release, `next` for a prerelease)
191
- try {
192
- const fetchFn = o.fetch ?? ((u, i) => fetch(u, i));
193
- const r = await fetchFn(`https://registry.npmjs.org/-/package/${PKG_NAME.replace('/', '%2f')}/dist-tags`, { method: 'GET', headers: { accept: 'application/json' } });
194
- const tags = r.ok ? await r.json() : null;
195
- const newest = tags && typeof tags[VERSION.includes('-') ? 'next' : 'latest'] === 'string' ? tags[VERSION.includes('-') ? 'next' : 'latest'] : '';
196
- if (newest && newerVersion(newest, VERSION)) {
197
- add('update', 'warn', `${PKG_NAME} ${newest} is out (this is ${VERSION})`, 'machine', `run the join line from the panel again (Settings > Agents > New join command), or: npm install -g ${PKG_NAME}@${VERSION.includes('-') ? 'next' : 'latest'}`);
210
+ if (!hubSaid) {
211
+ try {
212
+ const fetchFn = o.fetch ?? ((u, i) => fetch(u, i));
213
+ const r = await fetchFn(`https://registry.npmjs.org/-/package/${PKG_NAME.replace('/', '%2f')}/dist-tags`, { method: 'GET', headers: { accept: 'application/json' } });
214
+ const tags = r.ok ? await r.json() : null;
215
+ const newest = tags && typeof tags[line] === 'string' ? tags[line] : '';
216
+ if (newest && newerVersion(newest, VERSION)) {
217
+ add('update', 'warn', `${PKG_NAME} ${newest} is out (this is ${VERSION})`, 'machine', upgrade);
218
+ }
219
+ else if (newest)
220
+ add('update', 'ok', `${VERSION} is the newest ${PKG_NAME}`);
221
+ }
222
+ catch {
223
+ /* offline: nothing to say */
198
224
  }
199
- else if (newest)
200
- add('update', 'ok', `${VERSION} is the newest ${PKG_NAME}`);
201
- }
202
- catch {
203
- /* offline: nothing to say */
204
225
  }
205
226
  // Which `ach` runs
206
227
  const self = o.self ?? thisCliPath();
@@ -390,31 +411,7 @@ const SECTIONS = [
390
411
  { id: 'key', title: 'Key' },
391
412
  { id: 'machine', title: 'This machine' },
392
413
  ];
393
- /** True when version a is newer than b (x.y.z, a release beats its own prereleases; prerelease parts compared by number when numeric). */
394
- export function newerVersion(a, b) {
395
- const parse = (v) => {
396
- const [core, pre = ''] = v.split('-', 2);
397
- return { n: core.split('.').map((x) => Number(x) || 0), pre: pre ? pre.split('.') : [] };
398
- };
399
- const x = parse(a);
400
- const y = parse(b);
401
- for (let i = 0; i < 3; i++)
402
- if (x.n[i] !== y.n[i])
403
- return x.n[i] > y.n[i];
404
- if (!x.pre.length || !y.pre.length)
405
- return !x.pre.length && !!y.pre.length;
406
- for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
407
- const p = x.pre[i];
408
- const q = y.pre[i];
409
- if (p === undefined)
410
- return false;
411
- if (q === undefined)
412
- return true;
413
- if (p !== q)
414
- return /^\d+$/.test(p) && /^\d+$/.test(q) ? Number(p) > Number(q) : p > q;
415
- }
416
- return false;
417
- }
414
+ export { newerVersion };
418
415
  /** Grouped report: hub, front door (only when there is one), key, this machine; each problem with a one-line fix. */
419
416
  export function formatDoctor(checks) {
420
417
  const mark = { ok: '[ok] ', warn: '[warn]', fail: '[FAIL]', info: '[info]' };
package/dist/src/mcp.d.ts CHANGED
@@ -53,10 +53,14 @@ export declare function startSessionNotices(server: McpServer, client: Client, o
53
53
  log?: (s: string) => void;
54
54
  feed?: Partial<ChangeFeedOptions>;
55
55
  gapMs?: number;
56
+ staleFirstMs?: number;
56
57
  }): {
57
58
  notifier: AttentionNotifier;
58
59
  feed: ChangeFeed;
59
60
  done: Promise<void>;
61
+ stale: {
62
+ check(): Promise<boolean>;
63
+ };
60
64
  stop(): void;
61
65
  } | null;
62
66
  export declare function startMcp(client: Client): Promise<void>;
package/dist/src/mcp.js CHANGED
@@ -8,6 +8,7 @@ import { resolveAttachments, taskFiles } from './attachments.js';
8
8
  import { join } from 'node:path';
9
9
  import { AttentionNotifier, ChangeFeed, codexQueue, defaultExec, fetchAttention } from './attention.js';
10
10
  import { isZh } from './client.js';
11
+ import { startStaleNotice } from './selfupdate.js';
11
12
  import { decisionTo, explainToUser } from './names.js';
12
13
  export const HANDOFF_TEMPLATE = `## Goal
13
14
  <one or two sentences: what done looks like>
@@ -471,13 +472,23 @@ export function startSessionNotices(server, client, o = {}) {
471
472
  });
472
473
  const feed = new ChangeFeed({ request, socket: () => client.eventsSocket(), onChange: () => notifier.check(), log: o.log, ...o.feed });
473
474
  const done = feed.run();
475
+ // a session cannot be restarted from outside: when the hub names a newer ach, say so once (see selfupdate.ts)
476
+ const stale = startStaleNotice({
477
+ fetchVersions: () => client.request('GET', '/api/ach/auth/config'),
478
+ deliver: sessionDeliverer(server, env, o.exec),
479
+ lang: isZh(env) ? 'zh' : 'en',
480
+ firstMs: o.staleFirstMs,
481
+ log: o.log,
482
+ });
474
483
  return {
475
484
  notifier,
476
485
  feed,
477
486
  done,
487
+ stale,
478
488
  stop() {
479
489
  feed.stop();
480
490
  notifier.stop();
491
+ stale.stop();
481
492
  },
482
493
  };
483
494
  }
@@ -15,6 +15,8 @@ export interface RunnerOptions {
15
15
  max?: number;
16
16
  perm?: Perm;
17
17
  intervalMs?: number;
18
+ /** The hub answered 426 client_too_old: this release is below its floor (the updater checks at once). */
19
+ onClientTooOld?: () => void;
18
20
  heartbeatMs?: number;
19
21
  idleMs?: number;
20
22
  cfId?: string;
@@ -129,8 +131,11 @@ export declare class Runner {
129
131
  me?: Agent;
130
132
  private o;
131
133
  private stopped;
134
+ /** quiesce(): the runner takes nothing new (a CLI update is waiting for the runs in flight). */
135
+ private paused;
132
136
  private waiters;
133
137
  private lastPrune;
138
+ private lastTooOld;
134
139
  /** Last heartbeat sent for each parked run. */
135
140
  private parkedHb;
136
141
  /** B7: set when the hub answered 401 (key rotated, agent or owner deactivated): the runner stops. */
@@ -162,6 +167,11 @@ export declare class Runner {
162
167
  /** C8: drop runs.json bookkeeping for terminal (or vanished) tasks, every 10 minutes. */
163
168
  prune(force?: boolean): Promise<void>;
164
169
  pump(): void;
170
+ /** Stop taking new work and resolve when nothing is queued or running: what a CLI update waits for. Parked runs stay parked (the hub keeps their replies). */
171
+ quiesce(): Promise<void>;
172
+ unquiesce(): void;
173
+ /** Runs in flight plus queued. */
174
+ busy(): number;
165
175
  /** Resolves when nothing is queued or running (tests). */
166
176
  drain(): Promise<void>;
167
177
  private runJob;
@@ -197,8 +197,11 @@ export class Runner {
197
197
  me;
198
198
  o;
199
199
  stopped = false;
200
+ /** quiesce(): the runner takes nothing new (a CLI update is waiting for the runs in flight). */
201
+ paused = false;
200
202
  waiters = [];
201
203
  lastPrune = 0;
204
+ lastTooOld = 0;
202
205
  /** Last heartbeat sent for each parked run. */
203
206
  parkedHb = new Map();
204
207
  /** B7: set when the hub answered 401 (key rotated, agent or owner deactivated): the runner stops. */
@@ -280,6 +283,8 @@ export class Runner {
280
283
  }
281
284
  await this.checkActive();
282
285
  await this.prune();
286
+ if (this.paused)
287
+ return; // heartbeats of parked runs went out in checkActive; nothing new is taken (it waits in the hub's inbox)
283
288
  if (!this.me || inbox.tasks_to_me.length)
284
289
  this.me = await this.o.client.me();
285
290
  const me = this.me;
@@ -389,6 +394,8 @@ export class Runner {
389
394
  this.parkedHb.set(rec.taskId, now);
390
395
  await this.heartbeat(rec).catch((e) => this.log(`heartbeat ${rec.taskId}: ${e?.message ?? e}`));
391
396
  }
397
+ if (this.paused)
398
+ continue; // quiesced: a parked run resumes after the update (the hub keeps its replies)
392
399
  const me0 = await this.ensureMe();
393
400
  // A new handoff version reaches a parked worker at once, even while it
394
401
  // waits for an answer: the change may settle or replace its question.
@@ -477,6 +484,19 @@ export class Runner {
477
484
  });
478
485
  }
479
486
  }
487
+ /** Stop taking new work and resolve when nothing is queued or running: what a CLI update waits for. Parked runs stay parked (the hub keeps their replies). */
488
+ async quiesce() {
489
+ this.paused = true;
490
+ await this.drain();
491
+ }
492
+ unquiesce() {
493
+ this.paused = false;
494
+ this.wake?.();
495
+ }
496
+ /** Runs in flight plus queued. */
497
+ busy() {
498
+ return this.active.size + this.queue.length;
499
+ }
480
500
  /** Resolves when nothing is queued or running (tests). */
481
501
  async drain() {
482
502
  if (!this.active.size && !this.queue.length)
@@ -1089,16 +1109,27 @@ export class Runner {
1089
1109
  this.recover();
1090
1110
  this.pump();
1091
1111
  while (!this.stopped) {
1112
+ let wait = this.o.intervalMs;
1092
1113
  try {
1093
1114
  await this.tick();
1094
1115
  }
1095
1116
  catch (e) {
1096
- this.log(`poll: ${e?.message ?? e}`);
1117
+ if (e instanceof HubError && e.code === 'client_too_old') {
1118
+ // the hub refuses this release: poll slowly, say it now and then, and let the updater try at once
1119
+ wait = Math.max(wait, 30_000);
1120
+ if (Date.now() - this.lastTooOld > 10 * 60_000) {
1121
+ this.lastTooOld = Date.now();
1122
+ this.log(`the hub refuses this ach: ${e.message}`);
1123
+ }
1124
+ this.o.onClientTooOld?.();
1125
+ }
1126
+ else
1127
+ this.log(`poll: ${e?.message ?? e}`);
1097
1128
  }
1098
1129
  if (this.stopped)
1099
1130
  break;
1100
1131
  await new Promise((r) => {
1101
- const t = setTimeout(r, this.o.intervalMs);
1132
+ const t = setTimeout(r, wait);
1102
1133
  this.wake = () => {
1103
1134
  clearTimeout(t);
1104
1135
  r();
@@ -1114,6 +1145,7 @@ export class Runner {
1114
1145
  /** Stop polling; abort running jobs without failing their tasks (they resume on restart). */
1115
1146
  async stop() {
1116
1147
  this.stopped = true;
1148
+ this.wake?.(); // start() is asleep between polls: let it end now (a slow poll after a 426 would hold the exit)
1117
1149
  for (const a of this.active.values()) {
1118
1150
  a.reason = a.reason ?? 'stop';
1119
1151
  a.controller.abort();
@@ -0,0 +1,135 @@
1
+ /** A restart-worthy exit: not 0 (systemd on-failure and launchd restart only a failure) and not 78 (key refused: do not restart). */
2
+ export declare const UPDATE_EXIT_CODE = 75;
3
+ export interface HubVersions {
4
+ latest_client?: string;
5
+ min_client_proto?: number;
6
+ }
7
+ export type Verdict = {
8
+ action: 'none';
9
+ }
10
+ /** Safe to install by itself. */
11
+ | {
12
+ action: 'update';
13
+ target: string;
14
+ }
15
+ /** Needs a person: `why` says what to do. */
16
+ | {
17
+ action: 'manual';
18
+ target: string;
19
+ why: string;
20
+ };
21
+ /** What to do about the release the hub names. Pure: no I/O. */
22
+ export declare function judge(hub: HubVersions | null | undefined, current?: string, proto?: number): Verdict;
23
+ export type ExecFn = (file: string, args: string[], o?: {
24
+ timeoutMs?: number;
25
+ }) => Promise<{
26
+ code: number;
27
+ stdout: string;
28
+ stderr: string;
29
+ }>;
30
+ export declare const defaultInstallExec: ExecFn;
31
+ export type InstallKind =
32
+ /** Started from the npx cache with a range spec (`@koalaauto/agentchannel@3`): a restart fetches the newest release. */
33
+ {
34
+ kind: 'npx';
35
+ }
36
+ /** A global npm install: `npm install -g`. `dir` is where the package lives. */
37
+ | {
38
+ kind: 'global';
39
+ dir: string;
40
+ }
41
+ /** Source checkout, `npm link`, a project dependency, or anything else: not ours to replace. */
42
+ | {
43
+ kind: 'other';
44
+ why: string;
45
+ };
46
+ /** How this CLI was installed, so that the right update is used (or none). */
47
+ export declare function detectInstall(o?: {
48
+ self?: string;
49
+ exec?: ExecFn;
50
+ version?: string;
51
+ }): Promise<InstallKind>;
52
+ /**
53
+ * Before the runner stops taking work: the release exists on npm, and a global install can be written. Nothing has been paused or
54
+ * recorded yet, so a failure here costs nothing and is simply tried again at the next check.
55
+ */
56
+ export declare function preflight(kind: InstallKind, target: string, exec?: ExecFn): Promise<{
57
+ ok: boolean;
58
+ detail: string;
59
+ }>;
60
+ /** Install the release. npx needs nothing now (the restart fetches it). A global install is `npm install -g`, scripts off, then read back. */
61
+ export declare function installRelease(kind: InstallKind, target: string, exec?: ExecFn): Promise<{
62
+ ok: boolean;
63
+ detail: string;
64
+ }>;
65
+ interface UpdateState {
66
+ target: string;
67
+ /** ms since epoch of the last attempt. */
68
+ at: number;
69
+ attempts: number;
70
+ }
71
+ export declare function loadUpdateState(home: string): UpdateState | null;
72
+ /** The runner as the updater needs it. */
73
+ export interface Quiescable {
74
+ /** Stop taking new work and resolve when nothing is queued or running. */
75
+ quiesce(): Promise<void>;
76
+ /** Take work again (the update did not happen). */
77
+ unquiesce(): void;
78
+ /** Runs in flight + queued, for the log. */
79
+ busy(): number;
80
+ stop(): Promise<void>;
81
+ }
82
+ export type CheckResult = 'none' | 'manual' | 'off' | 'backoff' | 'busy' | 'failed' | 'restarting';
83
+ export interface SelfUpdaterOptions {
84
+ fetchVersions: () => Promise<HubVersions | null>;
85
+ runner: Quiescable;
86
+ home: string;
87
+ env?: NodeJS.ProcessEnv;
88
+ log?: (s: string) => void;
89
+ exec?: ExecFn;
90
+ self?: string;
91
+ version?: string;
92
+ proto?: number;
93
+ now?: () => number;
94
+ /** First check this long after start (default 20 s) and then every 6 h (±10 %). */
95
+ firstMs?: number;
96
+ everyMs?: number;
97
+ }
98
+ export declare class SelfUpdater {
99
+ private o;
100
+ /** True once the install is done and the runner is stopping: the process should exit UPDATE_EXIT_CODE. */
101
+ restarting: boolean;
102
+ private timer?;
103
+ private stopped;
104
+ private lastNudge;
105
+ private working;
106
+ private told;
107
+ constructor(o: SelfUpdaterOptions);
108
+ private log;
109
+ /** Say a thing once per process (a check every few hours must not repeat itself). */
110
+ private once;
111
+ start(): void;
112
+ stop(): void;
113
+ /** The hub just refused this release (426): check now, but not more than once in 10 minutes (the runner polls again every 30 s). */
114
+ nudge(): Promise<CheckResult> | undefined;
115
+ check(): Promise<CheckResult>;
116
+ }
117
+ export declare function staleText(latest: string, lang?: 'en' | 'zh', current?: string): string;
118
+ /**
119
+ * One line into the open session when the hub names a newer CLI (once per release per process, checked every 6 h). A session
120
+ * cannot be restarted from outside, so this is all there is to do for it. Returns stop().
121
+ */
122
+ export declare function startStaleNotice(o: {
123
+ fetchVersions: () => Promise<HubVersions | null>;
124
+ deliver: (text: string) => Promise<boolean>;
125
+ lang?: 'en' | 'zh';
126
+ version?: string;
127
+ proto?: number;
128
+ firstMs?: number;
129
+ everyMs?: number;
130
+ log?: (s: string) => void;
131
+ }): {
132
+ check(): Promise<boolean>;
133
+ stop(): void;
134
+ };
135
+ export {};
@@ -0,0 +1,287 @@
1
+ // Keeping a running `ach` current. Two jobs:
2
+ //
3
+ // - the runner (`ach run`, a long-lived daemon under systemd / launchd): SelfUpdater asks the hub which CLI release is the
4
+ // newest, and when this one is behind it stops taking new work, waits for the runs in flight, installs the release (a
5
+ // global install) or relies on npx to fetch it (a service started from the npx cache), and exits with UPDATE_EXIT_CODE.
6
+ // systemd (Restart=on-failure) and launchd (KeepAlive/SuccessfulExit=false) restart a non-zero exit, so the service comes
7
+ // back on the new release. Parked runs and queued answers live in the hub and the runner's state file: nothing is lost.
8
+ // - an MCP session (`ach mcp`, started by Claude Code or Codex): it cannot be restarted from outside, so startStaleNotice
9
+ // pushes one line into the session saying it is behind.
10
+ //
11
+ // What it never does: move across a major release by itself (the protocol may have changed: a person decides), move a stable
12
+ // install onto a prerelease, touch a CLI it cannot tell how it was installed, or retry a failed update more than once a day.
13
+ // ACH_AUTO_UPDATE=0 turns the installing off (the log and the session notice remain).
14
+ import { execFile } from 'node:child_process';
15
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
16
+ import { dirname, join, sep } from 'node:path';
17
+ import { thisCliPath } from './invite.js';
18
+ import { CLIENT_PROTO, newerVersion, PKG_NAME, VERSION } from './version.js';
19
+ /** A restart-worthy exit: not 0 (systemd on-failure and launchd restart only a failure) and not 78 (key refused: do not restart). */
20
+ export const UPDATE_EXIT_CODE = 75;
21
+ const DAY_MS = 24 * 3600_000;
22
+ const CHECK_EVERY_MS = 6 * 3600_000;
23
+ const isVersion = (v) => typeof v === 'string' && /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(v);
24
+ const major = (v) => Number(v.split('.')[0]);
25
+ /** What to do about the release the hub names. Pure: no I/O. */
26
+ export function judge(hub, current = VERSION, proto = CLIENT_PROTO) {
27
+ const target = hub?.latest_client;
28
+ const needs = Number(hub?.min_client_proto) > proto;
29
+ if (!isVersion(target) || !newerVersion(target, current)) {
30
+ return needs ? { action: 'manual', target: '', why: `the hub needs protocol ${hub.min_client_proto} or newer, but names no newer release: ask the hub's admin` } : { action: 'none' };
31
+ }
32
+ if (major(target) !== major(current))
33
+ return { action: 'manual', target, why: `${target} is a new major release: update it yourself (npm install -g ${PKG_NAME}@${target}), then restart` };
34
+ if (target.includes('-') && !current.includes('-')) {
35
+ // the hub runs a prerelease; a stable install stays on stable, unless the hub's floor leaves it no choice (then a person decides)
36
+ return needs ? { action: 'manual', target, why: `the hub needs protocol ${hub.min_client_proto} or newer and runs the prerelease ${target}: ask the hub's admin for a stable release, or install ${PKG_NAME}@${target} yourself` } : { action: 'none' };
37
+ }
38
+ return { action: 'update', target };
39
+ }
40
+ /** `npm` as a service can find it: the one beside this node (a version manager's bin dir is often not in a service's PATH), else PATH's. */
41
+ function resolveBin(file) {
42
+ if (file !== 'npm')
43
+ return file;
44
+ const beside = join(dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
45
+ return existsSync(beside) ? beside : file;
46
+ }
47
+ export const defaultInstallExec = (file, args, o) => new Promise((res) => {
48
+ execFile(resolveBin(file), args, { timeout: o?.timeoutMs ?? 30_000, encoding: 'utf8', maxBuffer: 4 << 20 }, (err, stdout, stderr) => {
49
+ res({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
50
+ });
51
+ });
52
+ const real = (p) => {
53
+ try {
54
+ return realpathSync(p);
55
+ }
56
+ catch {
57
+ return p;
58
+ }
59
+ };
60
+ /** How this CLI was installed, so that the right update is used (or none). */
61
+ export async function detectInstall(o = {}) {
62
+ const self = real(o.self ?? thisCliPath());
63
+ if (/[\\/]_npx[\\/]/.test(self)) {
64
+ // `...@3` re-resolves on a restart; an exact prerelease spec (`...@3.5.0-rc.1`) never moves
65
+ return (o.version ?? VERSION).includes('-') ? { kind: 'other', why: 'started with npx from an exact prerelease version: run the join line (or `ach run --install-service`) again with the new release' } : { kind: 'npx' };
66
+ }
67
+ const exec = o.exec ?? defaultInstallExec;
68
+ const root = await exec('npm', ['root', '-g']);
69
+ if (root.code !== 0 || !root.stdout.trim())
70
+ return { kind: 'other', why: 'npm is not available to update with' };
71
+ const dir = real(join(root.stdout.trim(), ...PKG_NAME.split('/')));
72
+ return self === dir || self.startsWith(dir + sep) ? { kind: 'global', dir } : { kind: 'other', why: `${self} is not a global npm install (a checkout, a link or a project dependency)` };
73
+ }
74
+ /**
75
+ * Before the runner stops taking work: the release exists on npm, and a global install can be written. Nothing has been paused or
76
+ * recorded yet, so a failure here costs nothing and is simply tried again at the next check.
77
+ */
78
+ export async function preflight(kind, target, exec = defaultInstallExec) {
79
+ if (kind.kind === 'other')
80
+ return { ok: false, detail: kind.why };
81
+ const v = await exec('npm', ['view', `${PKG_NAME}@${target}`, 'version'], { timeoutMs: 30_000 });
82
+ if (v.code !== 0 || !v.stdout.split(/\s+/).includes(target))
83
+ return { ok: false, detail: `${PKG_NAME}@${target} is not on npm (yet), or npm cannot reach it` };
84
+ if (kind.kind === 'global') {
85
+ try {
86
+ accessSync(dirname(kind.dir), constants.W_OK); // <npm root -g>/@koalaauto: where npm replaces the package
87
+ }
88
+ catch {
89
+ return { ok: false, detail: `no permission to write ${dirname(kind.dir)}: run npm install -g ${PKG_NAME}@${target} with the right permissions` };
90
+ }
91
+ }
92
+ return { ok: true, detail: 'ready' };
93
+ }
94
+ /** Install the release. npx needs nothing now (the restart fetches it). A global install is `npm install -g`, scripts off, then read back. */
95
+ export async function installRelease(kind, target, exec = defaultInstallExec) {
96
+ if (kind.kind === 'other')
97
+ return { ok: false, detail: kind.why };
98
+ if (kind.kind === 'npx')
99
+ return { ok: true, detail: 'npx fetches it on restart' };
100
+ const r = await exec('npm', ['install', '-g', '--ignore-scripts', '--no-audit', '--no-fund', `${PKG_NAME}@${target}`], { timeoutMs: 5 * 60_000 });
101
+ if (r.code !== 0)
102
+ return { ok: false, detail: `npm install -g ${PKG_NAME}@${target} failed (${(r.stderr || r.stdout).trim().split('\n').slice(-3).join(' | ').slice(0, 300)})` };
103
+ let got = '';
104
+ try {
105
+ got = JSON.parse(readFileSync(join(kind.dir, 'package.json'), 'utf8')).version;
106
+ }
107
+ catch {
108
+ /* read below */
109
+ }
110
+ return got === target ? { ok: true, detail: `installed ${target}` } : { ok: false, detail: `npm said it installed ${target}, but ${kind.dir} has ${got || 'no package.json'}` };
111
+ }
112
+ const stateFile = (home) => join(home, 'update.json');
113
+ export function loadUpdateState(home) {
114
+ try {
115
+ const s = JSON.parse(readFileSync(stateFile(home), 'utf8'));
116
+ return isVersion(s?.target) && Number.isFinite(s?.at) ? { target: s.target, at: s.at, attempts: Number(s.attempts) || 1 } : null;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ function saveUpdateState(home, s) {
123
+ try {
124
+ if (!existsSync(home))
125
+ mkdirSync(home, { recursive: true, mode: 0o700 });
126
+ writeFileSync(stateFile(home), s ? JSON.stringify(s) : '{}', { mode: 0o600 });
127
+ }
128
+ catch {
129
+ /* the back-off is a courtesy; a read-only home just means a retry sooner */
130
+ }
131
+ }
132
+ export class SelfUpdater {
133
+ o;
134
+ /** True once the install is done and the runner is stopping: the process should exit UPDATE_EXIT_CODE. */
135
+ restarting = false;
136
+ timer;
137
+ stopped = false;
138
+ lastNudge = 0;
139
+ working = false;
140
+ told = new Set();
141
+ constructor(o) {
142
+ this.o = o;
143
+ }
144
+ log(s) {
145
+ (this.o.log ?? ((x) => console.error(x)))(`[ach update] ${s}`);
146
+ }
147
+ /** Say a thing once per process (a check every few hours must not repeat itself). */
148
+ once(key, s) {
149
+ if (this.told.has(key))
150
+ return;
151
+ this.told.add(key);
152
+ this.log(s);
153
+ }
154
+ start() {
155
+ const every = this.o.everyMs ?? CHECK_EVERY_MS;
156
+ const next = (ms) => {
157
+ if (this.stopped)
158
+ return;
159
+ this.timer = setTimeout(() => void this.check().finally(() => next(every * (0.9 + Math.random() * 0.2))), ms);
160
+ this.timer.unref?.();
161
+ };
162
+ next(this.o.firstMs ?? 20_000);
163
+ }
164
+ stop() {
165
+ this.stopped = true;
166
+ if (this.timer)
167
+ clearTimeout(this.timer);
168
+ this.timer = undefined;
169
+ }
170
+ /** The hub just refused this release (426): check now, but not more than once in 10 minutes (the runner polls again every 30 s). */
171
+ nudge() {
172
+ const now = (this.o.now ?? Date.now)();
173
+ if (this.stopped || now - this.lastNudge < 10 * 60_000)
174
+ return undefined;
175
+ this.lastNudge = now;
176
+ return this.check();
177
+ }
178
+ async check() {
179
+ if (this.working || this.restarting)
180
+ return 'busy';
181
+ const version = this.o.version ?? VERSION;
182
+ const now = (this.o.now ?? Date.now)();
183
+ let hub = null;
184
+ try {
185
+ hub = await this.o.fetchVersions();
186
+ }
187
+ catch {
188
+ return 'none'; // offline or an old hub: nothing to act on
189
+ }
190
+ const prior = loadUpdateState(this.o.home);
191
+ if (prior && prior.target === version)
192
+ saveUpdateState(this.o.home, null); // it worked: forget the back-off
193
+ const v = judge(hub, version, this.o.proto);
194
+ if (v.action === 'none')
195
+ return 'none';
196
+ if (v.action === 'manual') {
197
+ this.once(`manual:${v.target}`, `${v.why}`);
198
+ return 'manual';
199
+ }
200
+ if ((this.o.env ?? process.env).ACH_AUTO_UPDATE === '0') {
201
+ this.once(`off:${v.target}`, `ach ${v.target} is out (this is ${version}); ACH_AUTO_UPDATE=0, so it is not installed: npm install -g ${PKG_NAME}@${v.target}`);
202
+ return 'off';
203
+ }
204
+ // an attempt that did not end on this release (npx served a cached one, npm refused) is not repeated for a day
205
+ const last = prior && prior.target === v.target ? prior : null;
206
+ if (last && now - last.at < DAY_MS) {
207
+ this.once(`backoff:${v.target}`, `ach ${v.target} is out but the last try ${new Date(last.at).toISOString()} did not end on it; trying again after a day`);
208
+ return 'backoff';
209
+ }
210
+ const kind = await detectInstall({ self: this.o.self, exec: this.o.exec, version });
211
+ if (kind.kind === 'other') {
212
+ this.once(`other:${v.target}`, `ach ${v.target} is out (this is ${version}) and cannot be installed by itself: ${kind.why}`);
213
+ return 'manual';
214
+ }
215
+ const ready = await preflight(kind, v.target, this.o.exec);
216
+ if (!ready.ok) {
217
+ this.once(`preflight:${v.target}`, `ach ${v.target} is out (this is ${version}) but cannot be installed now: ${ready.detail}; nothing was paused, trying again later`);
218
+ return 'failed';
219
+ }
220
+ this.working = true;
221
+ try {
222
+ this.log(`ach ${v.target} is out (this is ${version}): taking no new work, ${this.o.runner.busy()} run(s) to finish first`);
223
+ await this.o.runner.quiesce();
224
+ saveUpdateState(this.o.home, { target: v.target, at: now, attempts: (last?.attempts ?? 0) + 1 });
225
+ const r = await installRelease(kind, v.target, this.o.exec);
226
+ if (!r.ok) {
227
+ this.log(`update to ${v.target} failed: ${r.detail}; staying on ${version}`);
228
+ this.o.runner.unquiesce();
229
+ return 'failed';
230
+ }
231
+ this.log(`${r.detail}; restarting into ${v.target}`);
232
+ this.restarting = true;
233
+ await this.o.runner.stop();
234
+ return 'restarting';
235
+ }
236
+ finally {
237
+ this.working = false;
238
+ }
239
+ }
240
+ }
241
+ // ---- the MCP session -------------------------------------------------------------------------------------------------------
242
+ export function staleText(latest, lang = 'en', current = VERSION) {
243
+ return lang === 'zh'
244
+ ? `AgentChannel:本机的 ach ${current} 已过期(你的 hub 是 ${latest})。请告诉你的用户:重启这个会话;若 ach 是全局安装的,先运行 npm install -g ${PKG_NAME}@${latest}`
245
+ : `AgentChannel: the ach in this session is ${current}, out of date (your hub is on ${latest}). Tell your user to restart this session; if ach is installed globally, first run: npm install -g ${PKG_NAME}@${latest}`;
246
+ }
247
+ /**
248
+ * One line into the open session when the hub names a newer CLI (once per release per process, checked every 6 h). A session
249
+ * cannot be restarted from outside, so this is all there is to do for it. Returns stop().
250
+ */
251
+ export function startStaleNotice(o) {
252
+ const told = new Set();
253
+ let timer;
254
+ let stopped = false;
255
+ const check = async () => {
256
+ try {
257
+ const hub = await o.fetchVersions();
258
+ const v = judge(hub, o.version, o.proto);
259
+ const target = v.action === 'none' ? '' : v.target || (hub?.latest_client ?? '');
260
+ if (!target || told.has(target))
261
+ return false;
262
+ const ok = await o.deliver(staleText(target, o.lang, o.version));
263
+ if (ok)
264
+ told.add(target);
265
+ return ok;
266
+ }
267
+ catch (e) {
268
+ o.log?.(`stale check: ${e?.message ?? e}`);
269
+ return false;
270
+ }
271
+ };
272
+ const next = (ms) => {
273
+ if (stopped)
274
+ return;
275
+ timer = setTimeout(() => void check().finally(() => next((o.everyMs ?? CHECK_EVERY_MS) * (0.9 + Math.random() * 0.2))), ms);
276
+ timer.unref?.();
277
+ };
278
+ next(o.firstMs ?? 15_000);
279
+ return {
280
+ check,
281
+ stop() {
282
+ stopped = true;
283
+ if (timer)
284
+ clearTimeout(timer);
285
+ },
286
+ };
287
+ }
@@ -1,3 +1,11 @@
1
- export declare const VERSION = "3.4.0";
1
+ export declare const VERSION = "3.5.0";
2
2
  /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
3
  export declare const PKG_NAME = "@koalaauto/agentchannel";
4
+ /**
5
+ * What this CLI says about itself on every request (x-ach-proto). The hub's ACH_MIN_CLIENT_PROTO refuses a CLI below its
6
+ * floor. Raise this only when the CLI starts to need something an older hub does not have, and a hub only when it can no
7
+ * longer serve the older CLIs: docs/api/README.md, "Client compatibility". 1 = the CLI announces itself (3.5).
8
+ */
9
+ export declare const CLIENT_PROTO = 1;
10
+ /** True when version a is newer than b (x.y.z, a release beats its own prereleases; prerelease parts compared by number when numeric). */
11
+ export declare function newerVersion(a: string, b: string): boolean;
@@ -1,3 +1,34 @@
1
- export const VERSION = '3.4.0';
1
+ export const VERSION = '3.5.0';
2
2
  /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
3
  export const PKG_NAME = '@koalaauto/agentchannel';
4
+ /**
5
+ * What this CLI says about itself on every request (x-ach-proto). The hub's ACH_MIN_CLIENT_PROTO refuses a CLI below its
6
+ * floor. Raise this only when the CLI starts to need something an older hub does not have, and a hub only when it can no
7
+ * longer serve the older CLIs: docs/api/README.md, "Client compatibility". 1 = the CLI announces itself (3.5).
8
+ */
9
+ export const CLIENT_PROTO = 1;
10
+ /** True when version a is newer than b (x.y.z, a release beats its own prereleases; prerelease parts compared by number when numeric). */
11
+ export function newerVersion(a, b) {
12
+ const parse = (v) => {
13
+ const [core, pre = ''] = v.split('-', 2);
14
+ return { n: core.split('.').map((x) => Number(x) || 0), pre: pre ? pre.split('.') : [] };
15
+ };
16
+ const x = parse(a);
17
+ const y = parse(b);
18
+ for (let i = 0; i < 3; i++)
19
+ if (x.n[i] !== y.n[i])
20
+ return x.n[i] > y.n[i];
21
+ if (!x.pre.length || !y.pre.length)
22
+ return !x.pre.length && !!y.pre.length;
23
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
24
+ const p = x.pre[i];
25
+ const q = y.pre[i];
26
+ if (p === undefined)
27
+ return false;
28
+ if (q === undefined)
29
+ return true;
30
+ if (p !== q)
31
+ return /^\d+$/.test(p) && /^\d+$/.test(q) ? Number(p) > Number(q) : p > q;
32
+ }
33
+ return false;
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koalaauto/agentchannel",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Hand work between your team's agents (Claude Code, Codex) and follow it in one panel: the ach CLI, MCP server and runner for AgentChannel v2",
5
5
  "keywords": [
6
6
  "agentchannel",