@koalaauto/agentchannel 3.3.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 +15 -0
- package/dist/src/attention.d.ts +34 -6
- package/dist/src/attention.js +157 -13
- package/dist/src/cli.js +19 -1
- package/dist/src/client.d.ts +9 -0
- package/dist/src/client.js +24 -1
- package/dist/src/desknotify.d.ts +3 -1
- package/dist/src/desknotify.js +2 -2
- package/dist/src/doctor.d.ts +2 -2
- package/dist/src/doctor.js +35 -38
- package/dist/src/mcp.d.ts +4 -0
- package/dist/src/mcp.js +12 -1
- package/dist/src/runner/runner.d.ts +10 -0
- package/dist/src/runner/runner.js +34 -2
- package/dist/src/selfupdate.d.ts +135 -0
- package/dist/src/selfupdate.js +287 -0
- package/dist/src/version.d.ts +9 -1
- package/dist/src/version.js +32 -1
- package/package.json +1 -1
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/attention.d.ts
CHANGED
|
@@ -46,31 +46,59 @@ export declare class AttentionNotifier {
|
|
|
46
46
|
private run;
|
|
47
47
|
stop(): void;
|
|
48
48
|
}
|
|
49
|
+
/** What ChangeFeed needs of a WebSocket (the WHATWG interface: undici's, a browser's, a test's fake). */
|
|
50
|
+
export interface FeedSocket {
|
|
51
|
+
addEventListener(type: 'open' | 'message' | 'close' | 'error', fn: (ev: any) => void): void;
|
|
52
|
+
close(code?: number, reason?: string): void;
|
|
53
|
+
}
|
|
49
54
|
export interface ChangeFeedOptions {
|
|
50
55
|
request: Request;
|
|
51
56
|
onChange: () => unknown;
|
|
57
|
+
/** Opens the hub's change stream (GET /api/ach/events; Client.eventsSocket). Absent or null: long poll only. */
|
|
58
|
+
socket?: () => FeedSocket | null;
|
|
59
|
+
/** How long a socket may take to open before the long poll takes over. */
|
|
60
|
+
wsOpenMs?: number;
|
|
61
|
+
/** While on the socket, check anyway this often (a change that published no event, a socket that died silently). */
|
|
62
|
+
wsCheckMs?: number;
|
|
52
63
|
/** Long-poll wait (s), ≤ 25. */
|
|
53
64
|
waitS?: number;
|
|
54
65
|
/** Fallback polling interval. */
|
|
55
66
|
pollMs?: number;
|
|
67
|
+
/** In polling mode, try the socket again after this many polls (a hub that was down at start). */
|
|
68
|
+
wsRetryPolls?: number;
|
|
56
69
|
sleep?: (ms: number) => Promise<void>;
|
|
57
70
|
log?: (s: string) => void;
|
|
58
71
|
}
|
|
59
72
|
/**
|
|
60
|
-
* Follows
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* the long poll
|
|
73
|
+
* Follows the hub's changes, with fallbacks, like the panel (hub/pb_public/realtime.js):
|
|
74
|
+
* 1. the WebSocket GET /api/ach/events (the Worker hub): "something changed", ids only; a socket that worked and
|
|
75
|
+
* then closed (a deploy, a network change) is opened again;
|
|
76
|
+
* 2. when it does not open, the long poll GET /api/ach/changes (the PocketBase hub);
|
|
77
|
+
* 3. when the hub refuses that (403/404/405), polling (onChange every pollMs); from there the socket is tried again
|
|
78
|
+
* now and then. An error on the long poll: one polling interval, then the long poll again.
|
|
79
|
+
* onChange calls never overlap: a change during one runs it once more afterwards.
|
|
64
80
|
*/
|
|
65
81
|
export declare class ChangeFeed {
|
|
66
82
|
private o;
|
|
67
|
-
mode: 'longpoll' | 'poll';
|
|
83
|
+
mode: 'ws' | 'longpoll' | 'poll';
|
|
68
84
|
private stopped;
|
|
69
85
|
private cursor;
|
|
86
|
+
private ws;
|
|
87
|
+
/** Ends the socket being followed now (stop() does not wait for the closing handshake). */
|
|
88
|
+
private endSocket;
|
|
89
|
+
private wsDue;
|
|
90
|
+
/** The socket opened at least once (a later failure retries it before giving up). */
|
|
91
|
+
private wsWorked;
|
|
92
|
+
private firing;
|
|
93
|
+
private again;
|
|
70
94
|
constructor(o: ChangeFeedOptions);
|
|
71
95
|
stop(): void;
|
|
72
96
|
private fire;
|
|
73
|
-
/**
|
|
97
|
+
/** One socket, until it ends. Resolves with how long it was open (ms), or -1 when it never opened. */
|
|
98
|
+
private oneSocket;
|
|
99
|
+
/** Stays on the socket while it works; returns when it cannot be used (or on stop). */
|
|
100
|
+
private followSocket;
|
|
101
|
+
/** Runs until stop(). `rounds` bounds the long-poll / polling loop (tests). */
|
|
74
102
|
run(rounds?: number): Promise<void>;
|
|
75
103
|
}
|
|
76
104
|
export type Exec = (cmd: string, args: string[]) => Promise<{
|
package/dist/src/attention.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Reminders, never content. Two pieces shared by `ach mcp` (an open Claude
|
|
3
3
|
* Code / Codex session) and `ach run --notify` (the background helper):
|
|
4
4
|
*
|
|
5
|
-
* - ChangeFeed: follows the hub's change stream
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
5
|
+
* - ChangeFeed: follows the hub's change stream: the GET /api/ach/events
|
|
6
|
+
* WebSocket (the Worker hub), else the GET /api/ach/changes long poll
|
|
7
|
+
* (wait ≈ 25 s; the PocketBase hub), else polling every 15 s.
|
|
8
8
|
* - AttentionNotifier: on a change, counts what needs the owner (questions,
|
|
9
9
|
* decisions, mentions...) and, when something new arrived, delivers ONE line
|
|
10
10
|
* ("AgentChannel: 2 items need you (1 question, 1 decision) — say 'check my
|
|
@@ -171,40 +171,183 @@ export class AttentionNotifier {
|
|
|
171
171
|
}
|
|
172
172
|
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
|
|
173
173
|
/**
|
|
174
|
-
* Follows
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* the long poll
|
|
174
|
+
* Follows the hub's changes, with fallbacks, like the panel (hub/pb_public/realtime.js):
|
|
175
|
+
* 1. the WebSocket GET /api/ach/events (the Worker hub): "something changed", ids only; a socket that worked and
|
|
176
|
+
* then closed (a deploy, a network change) is opened again;
|
|
177
|
+
* 2. when it does not open, the long poll GET /api/ach/changes (the PocketBase hub);
|
|
178
|
+
* 3. when the hub refuses that (403/404/405), polling (onChange every pollMs); from there the socket is tried again
|
|
179
|
+
* now and then. An error on the long poll: one polling interval, then the long poll again.
|
|
180
|
+
* onChange calls never overlap: a change during one runs it once more afterwards.
|
|
178
181
|
*/
|
|
179
182
|
export class ChangeFeed {
|
|
180
183
|
o;
|
|
181
184
|
mode = 'longpoll';
|
|
182
185
|
stopped = false;
|
|
183
186
|
cursor = '';
|
|
187
|
+
ws = null;
|
|
188
|
+
/** Ends the socket being followed now (stop() does not wait for the closing handshake). */
|
|
189
|
+
endSocket = null;
|
|
190
|
+
wsDue;
|
|
191
|
+
/** The socket opened at least once (a later failure retries it before giving up). */
|
|
192
|
+
wsWorked = false;
|
|
193
|
+
firing = null;
|
|
194
|
+
again = false;
|
|
184
195
|
constructor(o) {
|
|
185
196
|
this.o = o;
|
|
197
|
+
this.wsDue = !!o.socket;
|
|
186
198
|
}
|
|
187
199
|
stop() {
|
|
188
200
|
this.stopped = true;
|
|
189
|
-
|
|
190
|
-
|
|
201
|
+
const s = this.ws;
|
|
202
|
+
this.ws = null;
|
|
191
203
|
try {
|
|
192
|
-
|
|
204
|
+
s?.close(1000, 'bye');
|
|
193
205
|
}
|
|
194
206
|
catch {
|
|
195
|
-
/*
|
|
207
|
+
/* already closed */
|
|
196
208
|
}
|
|
209
|
+
this.endSocket?.();
|
|
197
210
|
}
|
|
198
|
-
|
|
211
|
+
fire() {
|
|
212
|
+
if (this.firing) {
|
|
213
|
+
this.again = true;
|
|
214
|
+
return this.firing;
|
|
215
|
+
}
|
|
216
|
+
this.firing = (async () => {
|
|
217
|
+
do {
|
|
218
|
+
this.again = false;
|
|
219
|
+
try {
|
|
220
|
+
await this.o.onChange();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
/* the consumer logs its own errors */
|
|
224
|
+
}
|
|
225
|
+
} while (this.again && !this.stopped);
|
|
226
|
+
})().finally(() => {
|
|
227
|
+
this.firing = null;
|
|
228
|
+
});
|
|
229
|
+
return this.firing;
|
|
230
|
+
}
|
|
231
|
+
/** One socket, until it ends. Resolves with how long it was open (ms), or -1 when it never opened. */
|
|
232
|
+
oneSocket() {
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
let s = null;
|
|
235
|
+
try {
|
|
236
|
+
s = this.o.socket();
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
this.o.log?.(`change stream: socket: ${String(e?.message ?? e).slice(0, 200)}`);
|
|
240
|
+
}
|
|
241
|
+
if (!s)
|
|
242
|
+
return resolve(-1);
|
|
243
|
+
const sock = s;
|
|
244
|
+
this.ws = sock;
|
|
245
|
+
let openedAt = 0;
|
|
246
|
+
let done = false;
|
|
247
|
+
let check = null;
|
|
248
|
+
const finish = () => {
|
|
249
|
+
if (done)
|
|
250
|
+
return;
|
|
251
|
+
done = true;
|
|
252
|
+
clearTimeout(openTimer);
|
|
253
|
+
if (check)
|
|
254
|
+
clearInterval(check);
|
|
255
|
+
if (this.ws === sock)
|
|
256
|
+
this.ws = null;
|
|
257
|
+
this.endSocket = null;
|
|
258
|
+
resolve(openedAt ? Date.now() - openedAt : -1);
|
|
259
|
+
};
|
|
260
|
+
this.endSocket = finish;
|
|
261
|
+
const openTimer = setTimeout(() => {
|
|
262
|
+
if (openedAt)
|
|
263
|
+
return;
|
|
264
|
+
try {
|
|
265
|
+
sock.close();
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* ignore */
|
|
269
|
+
}
|
|
270
|
+
finish();
|
|
271
|
+
}, this.o.wsOpenMs ?? 5000);
|
|
272
|
+
openTimer.unref?.();
|
|
273
|
+
sock.addEventListener('open', () => {
|
|
274
|
+
if (done)
|
|
275
|
+
return;
|
|
276
|
+
openedAt = Date.now();
|
|
277
|
+
clearTimeout(openTimer);
|
|
278
|
+
if (this.mode !== 'ws')
|
|
279
|
+
this.o.log?.('change stream: connected (websocket)');
|
|
280
|
+
this.mode = 'ws';
|
|
281
|
+
this.wsWorked = true;
|
|
282
|
+
void this.fire();
|
|
283
|
+
check = setInterval(() => void this.fire(), this.o.wsCheckMs ?? 5 * 60_000);
|
|
284
|
+
check.unref?.();
|
|
285
|
+
});
|
|
286
|
+
sock.addEventListener('message', () => {
|
|
287
|
+
if (!done && !this.stopped)
|
|
288
|
+
void this.fire();
|
|
289
|
+
});
|
|
290
|
+
sock.addEventListener('close', finish);
|
|
291
|
+
sock.addEventListener('error', () => {
|
|
292
|
+
try {
|
|
293
|
+
sock.close();
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
/* ignore */
|
|
297
|
+
}
|
|
298
|
+
finish();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/** Stays on the socket while it works; returns when it cannot be used (or on stop). */
|
|
303
|
+
async followSocket(sleep) {
|
|
304
|
+
let fails = 0;
|
|
305
|
+
while (!this.stopped) {
|
|
306
|
+
const lived = await this.oneSocket();
|
|
307
|
+
if (this.stopped)
|
|
308
|
+
return;
|
|
309
|
+
// a socket that stayed up a while worked: open it again at once; one that dropped quickly counts as a failure
|
|
310
|
+
if (lived >= 10_000)
|
|
311
|
+
fails = 0;
|
|
312
|
+
else
|
|
313
|
+
fails++;
|
|
314
|
+
if (!this.wsWorked || fails >= 4)
|
|
315
|
+
return;
|
|
316
|
+
if (fails > 0)
|
|
317
|
+
this.o.log?.('change stream: socket closed; reconnecting');
|
|
318
|
+
await sleep(Math.min(30_000, 1000 * 2 ** Math.max(0, fails - 1)));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Runs until stop(). `rounds` bounds the long-poll / polling loop (tests). */
|
|
199
322
|
async run(rounds = Infinity) {
|
|
200
323
|
const sleep = this.o.sleep ?? realSleep;
|
|
201
324
|
const poll = this.o.pollMs ?? 15_000;
|
|
202
325
|
const wait = Math.min(25, this.o.waitS ?? 25);
|
|
326
|
+
const retryPolls = this.o.wsRetryPolls ?? 20;
|
|
203
327
|
let errors = 0;
|
|
204
|
-
|
|
328
|
+
let polls = 0;
|
|
329
|
+
let longpollRefused = false;
|
|
330
|
+
for (let i = 0; i < rounds && !this.stopped;) {
|
|
331
|
+
if (this.wsDue) {
|
|
332
|
+
this.wsDue = false;
|
|
333
|
+
await this.followSocket(sleep);
|
|
334
|
+
if (this.stopped)
|
|
335
|
+
return;
|
|
336
|
+
const was = this.mode;
|
|
337
|
+
this.mode = longpollRefused ? 'poll' : 'longpoll';
|
|
338
|
+
this.cursor = '';
|
|
339
|
+
if (was === 'ws')
|
|
340
|
+
this.o.log?.(`change stream: websocket lost; ${this.mode === 'poll' ? `polling every ${Math.round(poll / 1000)} s` : 'long poll'}`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
i++;
|
|
205
344
|
if (this.mode === 'poll') {
|
|
206
345
|
await this.fire();
|
|
207
346
|
await sleep(poll);
|
|
347
|
+
if (this.o.socket && ++polls >= retryPolls) {
|
|
348
|
+
polls = 0;
|
|
349
|
+
this.wsDue = true;
|
|
350
|
+
}
|
|
208
351
|
continue;
|
|
209
352
|
}
|
|
210
353
|
const t0 = Date.now();
|
|
@@ -226,6 +369,7 @@ export class ChangeFeed {
|
|
|
226
369
|
catch (e) {
|
|
227
370
|
if (e instanceof HubError && (e.status === 403 || e.status === 404 || e.status === 405)) {
|
|
228
371
|
this.mode = 'poll';
|
|
372
|
+
longpollRefused = true;
|
|
229
373
|
this.o.log?.(`change stream not available (${e.status}); polling every ${Math.round(poll / 1000)} s`);
|
|
230
374
|
continue;
|
|
231
375
|
}
|
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);
|
|
@@ -1692,23 +1696,37 @@ async function runCommand(p, io) {
|
|
|
1692
1696
|
let desk = null;
|
|
1693
1697
|
if (v.notify && !v['no-notify']) {
|
|
1694
1698
|
const { DeskNotifier } = await import('./desknotify.js');
|
|
1695
|
-
const dn = new DeskNotifier({ request: (m, path) => c.request(m, path), home, hub: env.ACH_PUBLIC_URL || cfg.hub, env, log: io.err });
|
|
1699
|
+
const dn = new DeskNotifier({ request: (m, path) => c.request(m, path), socket: () => c.eventsSocket(), home, hub: env.ACH_PUBLIC_URL || cfg.hub, env, log: io.err });
|
|
1696
1700
|
dn.start();
|
|
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). */
|
package/dist/src/client.d.ts
CHANGED
|
@@ -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;
|
|
@@ -303,6 +305,13 @@ export declare class Client {
|
|
|
303
305
|
private cfSecret?;
|
|
304
306
|
private fetchFn;
|
|
305
307
|
constructor(o: ClientOptions);
|
|
308
|
+
private ownFetch;
|
|
309
|
+
/**
|
|
310
|
+
* The hub's change stream (GET /api/ach/events, a WebSocket that says "something changed", ids only). The token
|
|
311
|
+
* travels as the `ach.<token>` subprotocol; the Cloudflare Access service token as headers, like every request.
|
|
312
|
+
* null without a token, or when this client was given its own fetch (a socket would bypass it).
|
|
313
|
+
*/
|
|
314
|
+
eventsSocket(): WebSocket | null;
|
|
306
315
|
headers(json?: boolean): Record<string, string>;
|
|
307
316
|
/** `files`: local paths (already checked by resolveAttachments) sent as multipart with the JSON in field "json". */
|
|
308
317
|
request<T = any>(method: string, path: string, body?: unknown, files?: string[]): Promise<T>;
|
package/dist/src/client.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { openAsBlob, statSync } from 'node:fs';
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
|
+
import { WebSocket as UndiciWebSocket } from 'undici';
|
|
4
|
+
import { CLIENT_PROTO, VERSION } from './version.js';
|
|
3
5
|
import { attachmentForm, bigFiles, downloadFile, isLinkRef, linkRef, taskFiles, typeOf, uploadTarget } from './attachments.js';
|
|
4
6
|
export const TERMINAL = ['completed', 'failed', 'canceled'];
|
|
5
7
|
export function isTerminal(s) {
|
|
@@ -16,6 +18,8 @@ export function parseLinkRepo(v) {
|
|
|
16
18
|
throw new Error(`bad base in "${v}"`);
|
|
17
19
|
return base ? { repo, base } : { repo };
|
|
18
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) };
|
|
19
23
|
export class HubError extends Error {
|
|
20
24
|
status;
|
|
21
25
|
code;
|
|
@@ -69,9 +73,28 @@ export class Client {
|
|
|
69
73
|
this.cfId = o.cfId;
|
|
70
74
|
this.cfSecret = o.cfSecret;
|
|
71
75
|
this.fetchFn = o.fetch ?? ((u, i) => fetch(u, i));
|
|
76
|
+
this.ownFetch = !!o.fetch;
|
|
77
|
+
}
|
|
78
|
+
ownFetch;
|
|
79
|
+
/**
|
|
80
|
+
* The hub's change stream (GET /api/ach/events, a WebSocket that says "something changed", ids only). The token
|
|
81
|
+
* travels as the `ach.<token>` subprotocol; the Cloudflare Access service token as headers, like every request.
|
|
82
|
+
* null without a token, or when this client was given its own fetch (a socket would bypass it).
|
|
83
|
+
*/
|
|
84
|
+
eventsSocket() {
|
|
85
|
+
if (!this.token || this.ownFetch)
|
|
86
|
+
return null;
|
|
87
|
+
const url = new URL('/api/ach/events', `${this.hub}/`);
|
|
88
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
89
|
+
const headers = { 'user-agent': 'agentchannel-cli', ...CLIENT_HEADERS };
|
|
90
|
+
if (this.cfId && this.cfSecret) {
|
|
91
|
+
headers['CF-Access-Client-Id'] = this.cfId;
|
|
92
|
+
headers['CF-Access-Client-Secret'] = this.cfSecret;
|
|
93
|
+
}
|
|
94
|
+
return new UndiciWebSocket(url.href, { protocols: [`ach.${this.token}`], headers });
|
|
72
95
|
}
|
|
73
96
|
headers(json = true) {
|
|
74
|
-
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' }) };
|
|
75
98
|
if (this.token) {
|
|
76
99
|
// Agent keys and run tokens use Bearer; PocketBase user tokens are sent raw.
|
|
77
100
|
h.authorization = /^ach_(sk|rt)_/.test(this.token) ? `Bearer ${this.token}` : this.token;
|
package/dist/src/desknotify.d.ts
CHANGED
|
@@ -60,6 +60,8 @@ export interface DeskNotifierOptions {
|
|
|
60
60
|
/** Runs a notifier that waits for the click (alerter, notify-send --wait): long timeout. */
|
|
61
61
|
actionExec?: NotifyExec;
|
|
62
62
|
log?: (s: string) => void;
|
|
63
|
+
/** Opens the hub's change stream (Client.eventsSocket); absent: long poll, then polling. */
|
|
64
|
+
socket?: () => import('./attention.js').FeedSocket | null;
|
|
63
65
|
}
|
|
64
66
|
/** A decision notification waits up to an hour for the click. */
|
|
65
67
|
export declare const ACTION_TIMEOUT_S = 3600;
|
|
@@ -149,7 +151,7 @@ export declare class DeskNotifier {
|
|
|
149
151
|
* handled in the background: the notifier keeps polling meanwhile.
|
|
150
152
|
*/
|
|
151
153
|
private showActions;
|
|
152
|
-
/** Follows the hub's change stream (long poll
|
|
154
|
+
/** Follows the hub's change stream (WebSocket, else long poll, else 15 s polling). */
|
|
153
155
|
start(): void;
|
|
154
156
|
private stopped;
|
|
155
157
|
stop(): void;
|
package/dist/src/desknotify.js
CHANGED
|
@@ -411,12 +411,12 @@ export class DeskNotifier {
|
|
|
411
411
|
});
|
|
412
412
|
return true;
|
|
413
413
|
}
|
|
414
|
-
/** Follows the hub's change stream (long poll
|
|
414
|
+
/** Follows the hub's change stream (WebSocket, else long poll, else 15 s polling). */
|
|
415
415
|
start() {
|
|
416
416
|
void import('./attention.js').then(({ ChangeFeed }) => {
|
|
417
417
|
if (this.stopped)
|
|
418
418
|
return;
|
|
419
|
-
const feed = new ChangeFeed({ request: (m, p) => this.o.request(m, p), onChange: () => this.tick(), pollMs: this.o.intervalMs, log: this.o.log });
|
|
419
|
+
const feed = new ChangeFeed({ request: (m, p) => this.o.request(m, p), socket: this.o.socket, onChange: () => this.tick(), pollMs: this.o.intervalMs, log: this.o.log });
|
|
420
420
|
this.feed = feed;
|
|
421
421
|
void feed.run();
|
|
422
422
|
});
|
package/dist/src/doctor.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/src/doctor.js
CHANGED
|
@@ -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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -469,15 +470,25 @@ export function startSessionNotices(server, client, o = {}) {
|
|
|
469
470
|
gapMs: o.gapMs,
|
|
470
471
|
log: o.log,
|
|
471
472
|
});
|
|
472
|
-
const feed = new ChangeFeed({ request, onChange: () => notifier.check(), log: o.log, ...o.feed });
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
export declare const VERSION = "3.
|
|
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;
|
package/dist/src/version.js
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
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.
|
|
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",
|