@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.
- package/dist/src/attention.d.ts +34 -6
- package/dist/src/attention.js +157 -13
- package/dist/src/cli.js +1 -1
- package/dist/src/client.d.ts +7 -0
- package/dist/src/client.js +20 -0
- package/dist/src/desknotify.d.ts +3 -1
- package/dist/src/desknotify.js +2 -2
- package/dist/src/mcp.js +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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
|
@@ -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)');
|
package/dist/src/client.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/client.js
CHANGED
|
@@ -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' };
|
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/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,
|
package/dist/src/version.d.ts
CHANGED
package/dist/src/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koalaauto/agentchannel",
|
|
3
|
-
"version": "3.
|
|
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",
|