@koalaauto/agentchannel 3.3.0 → 3.4.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.
@@ -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 GET /api/ach/changes (long poll). Falls back to polling (onChange
61
- * every pollMs) when the hub refuses the route (403/404: an older hub, or
62
- * person sessions only) — permanently — or after an error (one interval, then
63
- * the long poll again).
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
- /** Runs until stop(). `rounds` bounds the loop (tests). */
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<{
@@ -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. GET /api/ach/changes long-poll
6
- * (wait ≈ 25 s); when the hub refuses it (older hub: person sessions only) or
7
- * it errors, falls back to polling every 15 s.
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 GET /api/ach/changes (long poll). Falls back to polling (onChange
175
- * every pollMs) when the hub refuses the route (403/404: an older hub, or
176
- * person sessions only) — permanently — or after an error (one interval, then
177
- * the long poll again).
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
- async fire() {
201
+ const s = this.ws;
202
+ this.ws = null;
191
203
  try {
192
- await this.o.onChange();
204
+ s?.close(1000, 'bye');
193
205
  }
194
206
  catch {
195
- /* the consumer logs its own errors */
207
+ /* already closed */
196
208
  }
209
+ this.endSocket?.();
197
210
  }
198
- /** Runs until stop(). `rounds` bounds the loop (tests). */
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
- for (let i = 0; i < rounds && !this.stopped; i++) {
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
@@ -1692,7 +1692,7 @@ async function runCommand(p, io) {
1692
1692
  let desk = null;
1693
1693
  if (v.notify && !v['no-notify']) {
1694
1694
  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 });
1695
+ 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
1696
  dn.start();
1697
1697
  desk = dn;
1698
1698
  io.err('ach run: desktop notifications on (--no-notify to turn off)');
@@ -303,6 +303,13 @@ export declare class Client {
303
303
  private cfSecret?;
304
304
  private fetchFn;
305
305
  constructor(o: ClientOptions);
306
+ private ownFetch;
307
+ /**
308
+ * The hub's change stream (GET /api/ach/events, a WebSocket that says "something changed", ids only). The token
309
+ * travels as the `ach.<token>` subprotocol; the Cloudflare Access service token as headers, like every request.
310
+ * null without a token, or when this client was given its own fetch (a socket would bypass it).
311
+ */
312
+ eventsSocket(): WebSocket | null;
306
313
  headers(json?: boolean): Record<string, string>;
307
314
  /** `files`: local paths (already checked by resolveAttachments) sent as multipart with the JSON in field "json". */
308
315
  request<T = any>(method: string, path: string, body?: unknown, files?: string[]): Promise<T>;
@@ -1,5 +1,6 @@
1
1
  import { openAsBlob, statSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
+ import { WebSocket as UndiciWebSocket } from 'undici';
3
4
  import { attachmentForm, bigFiles, downloadFile, isLinkRef, linkRef, taskFiles, typeOf, uploadTarget } from './attachments.js';
4
5
  export const TERMINAL = ['completed', 'failed', 'canceled'];
5
6
  export function isTerminal(s) {
@@ -69,6 +70,25 @@ export class Client {
69
70
  this.cfId = o.cfId;
70
71
  this.cfSecret = o.cfSecret;
71
72
  this.fetchFn = o.fetch ?? ((u, i) => fetch(u, i));
73
+ this.ownFetch = !!o.fetch;
74
+ }
75
+ ownFetch;
76
+ /**
77
+ * The hub's change stream (GET /api/ach/events, a WebSocket that says "something changed", ids only). The token
78
+ * travels as the `ach.<token>` subprotocol; the Cloudflare Access service token as headers, like every request.
79
+ * null without a token, or when this client was given its own fetch (a socket would bypass it).
80
+ */
81
+ eventsSocket() {
82
+ if (!this.token || this.ownFetch)
83
+ return null;
84
+ const url = new URL('/api/ach/events', `${this.hub}/`);
85
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
86
+ const headers = { 'user-agent': 'agentchannel-cli' };
87
+ if (this.cfId && this.cfSecret) {
88
+ headers['CF-Access-Client-Id'] = this.cfId;
89
+ headers['CF-Access-Client-Secret'] = this.cfSecret;
90
+ }
91
+ return new UndiciWebSocket(url.href, { protocols: [`ach.${this.token}`], headers });
72
92
  }
73
93
  headers(json = true) {
74
94
  const h = json ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' };
@@ -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; 15 s polling fallback). */
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;
@@ -411,12 +411,12 @@ export class DeskNotifier {
411
411
  });
412
412
  return true;
413
413
  }
414
- /** Follows the hub's change stream (long poll; 15 s polling fallback). */
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/mcp.js CHANGED
@@ -469,7 +469,7 @@ export function startSessionNotices(server, client, o = {}) {
469
469
  gapMs: o.gapMs,
470
470
  log: o.log,
471
471
  });
472
- const feed = new ChangeFeed({ request, onChange: () => notifier.check(), log: o.log, ...o.feed });
472
+ const feed = new ChangeFeed({ request, socket: () => client.eventsSocket(), onChange: () => notifier.check(), log: o.log, ...o.feed });
473
473
  const done = feed.run();
474
474
  return {
475
475
  notifier,
@@ -1,3 +1,3 @@
1
- export declare const VERSION = "3.3.0";
1
+ export declare const VERSION = "3.4.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";
@@ -1,3 +1,3 @@
1
- export const VERSION = '3.3.0';
1
+ export const VERSION = '3.4.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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koalaauto/agentchannel",
3
- "version": "3.3.0",
3
+ "version": "3.4.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",