flow-agent-bridge 0.37.3 β†’ 0.37.5

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/bridge.js CHANGED
@@ -14,7 +14,7 @@ import path from 'node:path';
14
14
  import { createHash, randomUUID } from 'node:crypto';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { defaultVoiceConfig, mentionChainLimit, resolveWorkspace } from './config.js';
17
- import { accountLines, addressIn, FlowApi } from './api.js';
17
+ import { accountLines, addressIn, FlowApi, FlowApiError } from './api.js';
18
18
  import { attachmentFilename, formatAttachments } from './attachments.js';
19
19
  import { openFlowSocket } from './gateway.js';
20
20
  import { ChannelIndicatorLeases, ProgressReporter } from './progress.js';
@@ -23,6 +23,13 @@ import { SessionManager } from './session.js';
23
23
  import { EXIT_RESTART, EXIT_UPDATE } from './supervisor.js';
24
24
  import { currentVersion, isOutdated, latestPublishedVersion } from './version.js';
25
25
  import { HuddleVoiceManager } from './huddle-voice.js';
26
+ import { CATCH_UP_TYPES, catchUp, ensureSubscription, HandledMessages, jobsSpec, subscriptionName } from './catch-up.js';
27
+ /** How often the socket's latest cursor is acked to the bridge's subscription. */
28
+ const CURSOR_ACK_MS = 30_000;
29
+ /** A wake job's lease is renewed this often while its turn runs (the server's lease is 10 minutes). */
30
+ export const JOB_EXTEND_MS = 5 * 60_000;
31
+ /** A job whose turn never got to run (a `/reset` dropped it, say) is reported failed after this long. */
32
+ const JOB_WAIT_MAX_MS = 6 * 3_600_000;
26
33
  const THINKING_PREFIX = 'πŸ€– *thinking…*';
27
34
  /** The loop breaker's visible notice (#583). Never counts toward a chain, never wakes anyone. */
28
35
  const LOOP_BREAKER_PREFIX = '⚑ loop breaker:';
@@ -141,10 +148,33 @@ export class AgentBridge {
141
148
  taskSock = null;
142
149
  huddleVoice = null;
143
150
  logStream = null;
151
+ /** The messages answered lately, across restarts (catch-up.ts), so a catch-up never answers one twice. */
152
+ handled;
153
+ /** Messages answered since start: the catch-up's "answered M". */
154
+ answered = 0;
155
+ /** The bridge's subscription in this organization (catch-up.ts), and how far it is acked. */
156
+ subscription = null;
157
+ /** The latest `cursor` frame, to ack every 30 s and at shutdown. */
158
+ latestCursor = 0;
159
+ cursorTimer = null;
160
+ /** Wake jobs taken (platform.md Β§5.2, phase 2.4), across restarts, so a job delivered again or replayed runs once. */
161
+ handledJobs;
162
+ /** Jobs whose ack is in flight, so two copies of one `agent.job` arriving together are taken once. */
163
+ takingJobs = new Set();
164
+ /** The `bridge-jobs:` socket subscription, whose poke means an `agent.job` came; one read at a time. */
165
+ jobsSub = null;
166
+ jobsReading = false;
167
+ jobsAgain = false;
168
+ /** A job's turn, by its synthetic message's id: the client that marks its requests, and how the turn ended. */
169
+ jobTurns = new Map();
170
+ /** convKey β†’ the job whose turn is running there, which the MCP server asks for (`/job` on the task IPC). */
171
+ activeJobs = new Map();
144
172
  constructor(cfg) {
145
173
  this.cfg = cfg;
146
174
  this.api = new FlowApi(cfg.serverUrl, cfg.agentToken);
147
175
  this.sem = new Semaphore(cfg.concurrency);
176
+ this.handled = new HandledMessages(cfg.stateFile ?? null, (m) => this.log(m));
177
+ this.handledJobs = new HandledMessages(cfg.stateFile ?? null, (m) => this.log(m), undefined, 'handledJobIds');
148
178
  this.sessions = new SessionManager({
149
179
  cfg: cfg.runtime,
150
180
  idleMs: cfg.runtime.sessionIdleSec * 1000,
@@ -219,22 +249,227 @@ export class AgentBridge {
219
249
  `voice=${voiceState} cwd=${this.cfg.runtime.cwd}`);
220
250
  for (const line of accountLines(this.me, workspaces))
221
251
  this.log(line);
252
+ const since = await this.catchUp();
253
+ if (since !== null)
254
+ await this.ensureJobsSubscription();
222
255
  this.socket = openFlowSocket({
223
256
  serverUrl: this.cfg.serverUrl,
224
257
  token: this.cfg.agentToken,
225
258
  // one process, one workspace (#357) β€” so tell the server, or it lights
226
259
  // our presence dot in every workspace we belong to (#364)
227
260
  workspaces: [this.workspace.id],
228
- onEvent: (ev) => this.handleEvent(ev),
261
+ onEvent: (ev) => void this.handleEvent(ev),
229
262
  onOpen: () => { void this.huddleVoice?.refreshContext(); },
230
263
  log: (m) => this.log(m),
264
+ // What happened between the catch-up's read and the socket's hello comes as the socket's backlog.
265
+ ...(since !== null ? { since } : {}),
266
+ onCursor: (seq) => { this.latestCursor = Math.max(this.latestCursor, seq); },
267
+ onSubscription: (id) => void this.readJobs(id),
231
268
  });
232
269
  this.socket.connect();
270
+ if (this.subscription) {
271
+ this.cursorTimer = setInterval(() => void this.ackCursor(), CURSOR_ACK_MS);
272
+ this.cursorTimer.unref();
273
+ }
233
274
  this.startTaskIpc();
234
275
  // Best-effort staleness check β€” never blocks startup or crashes on failure.
235
276
  void this.checkVersion();
236
277
  void this.reportRelaunch();
237
278
  }
279
+ /**
280
+ * Before the socket opens: what this organization's log holds for the bridge's subscription since it last ran, fed
281
+ * through the path live events take. Returns the cursor reached (the socket's `since`), or null when the server has
282
+ * no subscriptions (an older server), which leaves the bridge on live events alone.
283
+ */
284
+ async catchUp() {
285
+ let sub;
286
+ try {
287
+ sub = await ensureSubscription(this.api, this.workspace.id, this.me.id);
288
+ }
289
+ catch (err) {
290
+ this.log(`no event subscription (${err.message}) β€” live events only`);
291
+ return null;
292
+ }
293
+ this.subscription = { id: sub.id, acked: sub.cursor };
294
+ this.latestCursor = sub.cursor;
295
+ const before = this.answered;
296
+ try {
297
+ const out = await catchUp(this.api, this.workspace.id, sub, (ev) => this.handleEvent(ev), (m) => this.log(m), (e) => this.handleJob(e));
298
+ this.subscription.acked = out.next;
299
+ this.latestCursor = Math.max(this.latestCursor, out.next);
300
+ this.log(`caught up: ${out.events} events, answered ${this.answered - before}`);
301
+ if (sub.outdated)
302
+ await this.replaceSubscription(sub.id);
303
+ return out.next;
304
+ }
305
+ catch (err) {
306
+ this.log(`catch-up failed (${err.message}) β€” continuing with live events`);
307
+ return this.subscription.acked;
308
+ }
309
+ }
310
+ /** An older bridge's subscription, caught up: replaced by one with today's types (`agent.job`), starting at the head. */
311
+ async replaceSubscription(oldId) {
312
+ try {
313
+ const fresh = await this.api.createSubscription(this.workspace.id, { name: subscriptionName(this.me.id), filter: { types: CATCH_UP_TYPES }, delivery: 'pull' });
314
+ await this.api.deleteSubscription(this.workspace.id, oldId).catch((err) => this.log(`old subscription not deleted: ${err.message}`));
315
+ this.subscription = { id: fresh.id, acked: fresh.cursor };
316
+ this.latestCursor = Math.max(this.latestCursor, fresh.cursor);
317
+ this.log('event subscription renewed with agent.job');
318
+ }
319
+ catch (err) {
320
+ this.log(`event subscription not renewed: ${err.message}`);
321
+ }
322
+ }
323
+ /** The socket subscription that pokes when a wake job comes (catch-up.ts `jobsSpec`). Without it jobs still come, late. */
324
+ async ensureJobsSubscription() {
325
+ try {
326
+ this.jobsSub = { id: (await ensureSubscription(this.api, this.workspace.id, this.me.id, jobsSpec(this.me.id))).id };
327
+ }
328
+ catch (err) {
329
+ this.log(`no job subscription (${err.message}) β€” wake jobs arrive with the next catch-up or delivery`);
330
+ }
331
+ }
332
+ /** A poke for the job subscription: read it until empty, taking each job, acking as it goes. */
333
+ async readJobs(id) {
334
+ if (!this.jobsSub || id !== this.jobsSub.id)
335
+ return;
336
+ if (this.jobsReading) {
337
+ this.jobsAgain = true;
338
+ return;
339
+ }
340
+ this.jobsReading = true;
341
+ try {
342
+ do {
343
+ this.jobsAgain = false;
344
+ await catchUp(this.api, this.workspace.id, { id, cursor: 0 }, () => { }, (m) => this.log(m), (e) => this.handleJob(e));
345
+ } while (this.jobsAgain);
346
+ }
347
+ catch (err) {
348
+ this.log(`job subscription read failed: ${err.message}`);
349
+ }
350
+ finally {
351
+ this.jobsReading = false;
352
+ }
353
+ }
354
+ /**
355
+ * An `agent.job` event (live or from a catch-up): acked as this agent, then run as a turn the way `start_task` runs
356
+ * one. Taken once: a job id already taken (state.json) or being taken is passed over. A job the server no longer
357
+ * offers (409) is remembered and dropped; an ack that failed otherwise is left for the server's next delivery.
358
+ */
359
+ async handleJob(e) {
360
+ const d = e.data;
361
+ const jobId = d?.jobId;
362
+ if (!jobId || !d.prompt)
363
+ return;
364
+ if (this.handledJobs.has(jobId) || this.takingJobs.has(jobId)) {
365
+ this.log(`job ${jobId} already taken β€” skipping the copy`);
366
+ return;
367
+ }
368
+ this.takingJobs.add(jobId);
369
+ try {
370
+ const api = this.api.withJob(jobId);
371
+ try {
372
+ await api.jobStep(this.workspace.id, jobId, 'ack');
373
+ }
374
+ catch (err) {
375
+ if (err instanceof FlowApiError && (err.status === 409 || err.status === 404))
376
+ this.handledJobs.add(jobId);
377
+ this.log(`job ${jobId}: ack failed (${err.message})`);
378
+ return;
379
+ }
380
+ this.handledJobs.add(jobId);
381
+ this.log(`job ${jobId}: acked, running`);
382
+ void this.runJob(jobId, d, api);
383
+ }
384
+ finally {
385
+ this.takingJobs.delete(jobId);
386
+ }
387
+ }
388
+ /**
389
+ * The job's turn: in its channel (joined first when public), or a new `job-<id>` channel of its own, as a task run
390
+ * there (`startTask`), every request of it marked with the job (`x-flow-job`). The lease is extended every 5 minutes
391
+ * while it runs; at the end the job is reported done or failed with a line about it.
392
+ */
393
+ async runJob(jobId, d, api) {
394
+ let outcome = { ok: false, note: '' };
395
+ const msgId = `job-${jobId}`;
396
+ const extend = setInterval(() => {
397
+ void api.jobStep(this.workspace.id, jobId, 'extend').catch((err) => this.log(`job ${jobId}: extend failed: ${err.message}`));
398
+ }, this.jobExtendMs);
399
+ extend.unref();
400
+ try {
401
+ const channelId = await this.jobChannel(jobId, d.channelId, api);
402
+ const ended = new Promise((settle) => {
403
+ let done = false;
404
+ const once = (r) => { if (!done) {
405
+ done = true;
406
+ settle(r);
407
+ } };
408
+ this.jobTurns.set(msgId, { jobId, api, settle: once });
409
+ setTimeout(() => once({ ok: false, note: 'the turn never ran' }), JOB_WAIT_MAX_MS).unref();
410
+ });
411
+ this.taskChannels.add(channelId);
412
+ this.enqueue(this.taskMessage(msgId, channelId, this.me.id, d.prompt));
413
+ outcome = await ended;
414
+ }
415
+ catch (err) {
416
+ outcome = { ok: false, note: err.message };
417
+ }
418
+ finally {
419
+ clearInterval(extend);
420
+ this.jobTurns.delete(msgId);
421
+ }
422
+ const note = oneLine(outcome.note) || (outcome.ok ? 'done' : 'failed');
423
+ await api.jobStep(this.workspace.id, jobId, 'done', { ok: outcome.ok, note })
424
+ .catch((err) => this.log(`job ${jobId}: done not reported: ${err.message}`));
425
+ this.log(`job ${jobId}: ${outcome.ok ? 'done' : 'failed'} β€” ${note}`);
426
+ return { ok: outcome.ok, note };
427
+ }
428
+ /** Where a job runs: its channel, which the agent must be in (a public one it joins), or a new one named for the job. */
429
+ async jobChannel(jobId, channelId, api) {
430
+ if (channelId) {
431
+ if (!this.channels.has(channelId))
432
+ await this.refreshDirectory().catch((err) => this.log(`directory refresh failed: ${err.message}`));
433
+ const chan = this.channels.get(channelId);
434
+ if (!chan)
435
+ throw new Error('no such channel');
436
+ if (!chan.isMember) {
437
+ if (chan.kind !== 'standard' || chan.isPrivate)
438
+ throw new Error('not a member of that channel');
439
+ await api.joinChannel(channelId);
440
+ this.channels.set(channelId, { ...chan, isMember: true });
441
+ }
442
+ return channelId;
443
+ }
444
+ const name = `job-${jobId.slice(0, 8)}`;
445
+ try {
446
+ const chan = await api.createChannel(this.workspace.id, { name, topic: `Wake job ${jobId}` });
447
+ this.channels.set(chan.id, chan);
448
+ return chan.id;
449
+ }
450
+ catch (err) {
451
+ if (!(err instanceof FlowApiError && err.status === 409))
452
+ throw err;
453
+ await this.refreshDirectory();
454
+ const found = [...this.channels.values()].find((c) => c.name === name);
455
+ if (!found)
456
+ throw err;
457
+ return found.id;
458
+ }
459
+ }
460
+ /** The socket's latest cursor, acked to the subscription: the next start replays only what came after. */
461
+ async ackCursor() {
462
+ const sub = this.subscription;
463
+ if (!sub || this.latestCursor <= sub.acked)
464
+ return;
465
+ const to = this.latestCursor;
466
+ try {
467
+ sub.acked = Math.max(sub.acked, await this.api.ackSubscription(this.workspace.id, sub.id, to));
468
+ }
469
+ catch (err) {
470
+ this.log(`subscription ack failed: ${err.message}`);
471
+ }
472
+ }
238
473
  /** After a `/update` / `/restart` relaunch, confirm we're back β€” in the
239
474
  * conversation that asked, with the version the update actually landed on. */
240
475
  async reportRelaunch() {
@@ -281,7 +516,7 @@ export class AgentBridge {
281
516
  fs.rmSync(sock, { force: true }); // stale socket from a previous run
282
517
  }
283
518
  const server = http.createServer((req, res) => {
284
- if (req.method !== 'POST' || req.url !== '/task') {
519
+ if (req.method !== 'POST' || (req.url !== '/task' && req.url !== '/job')) {
285
520
  res.writeHead(404).end();
286
521
  return;
287
522
  }
@@ -293,6 +528,18 @@ export class AgentBridge {
293
528
  });
294
529
  req.on('end', () => {
295
530
  void (async () => {
531
+ if (req.url === '/job') {
532
+ // The MCP server's question before each request: is a wake job's turn running in this conversation?
533
+ let jobId = null;
534
+ try {
535
+ const q = JSON.parse(body);
536
+ jobId = this.activeJobs.get(`${q.channelId ?? ''}|${q.threadRootId ?? ''}`) ?? null;
537
+ }
538
+ catch { /* no job */ }
539
+ res.writeHead(200, { 'content-type': 'application/json' });
540
+ res.end(JSON.stringify({ jobId }));
541
+ return;
542
+ }
296
543
  let out;
297
544
  try {
298
545
  out = await this.startTask(JSON.parse(body));
@@ -345,13 +592,18 @@ export class AgentBridge {
345
592
  `${requester !== this.me.id ? ` for <@${requester}>` : ''}.`)
346
593
  .catch((err) => this.log(`task kickoff post failed: ${err.message}`));
347
594
  }
348
- this.enqueue({
349
- id: `task-${randomUUID()}`,
595
+ this.enqueue(this.taskMessage(`task-${randomUUID()}`, channelId, requester, prompt));
596
+ return { ok: true, note: `task run queued in ${this.channelLabel(channelId)}` };
597
+ }
598
+ /** A run's opening message: what a task run (or a wake job) is asked, as if posted to its channel. */
599
+ taskMessage(id, channelId, userId, body) {
600
+ return {
601
+ id,
350
602
  channelId,
351
- userId: requester,
603
+ userId,
352
604
  threadRootId: null,
353
- clientMsgId: `task-${randomUUID()}`,
354
- body: prompt,
605
+ clientMsgId: id,
606
+ body,
355
607
  createdAt: new Date().toISOString(),
356
608
  editedAt: null,
357
609
  deletedAt: null,
@@ -365,8 +617,7 @@ export class AgentBridge {
365
617
  reactions: [],
366
618
  files: [],
367
619
  unfurls: [],
368
- });
369
- return { ok: true, note: `task run queued in ${this.channelLabel(channelId)}` };
620
+ };
370
621
  }
371
622
  /**
372
623
  * On startup, compare our installed version with the latest on npm. If we're
@@ -397,6 +648,10 @@ export class AgentBridge {
397
648
  }
398
649
  async stop() {
399
650
  this.socket?.close();
651
+ if (this.cursorTimer)
652
+ clearInterval(this.cursorTimer);
653
+ this.cursorTimer = null;
654
+ const acked = this.ackCursor();
400
655
  this.ipcServer?.close();
401
656
  this.ipcServer = null;
402
657
  if (this.taskSock && process.platform !== 'win32')
@@ -413,6 +668,7 @@ export class AgentBridge {
413
668
  await Promise.all([...new Set([...this.conversations.values()].map((c) => c.channelId))].map((id) => this.clearIndicator(id)));
414
669
  await this.huddleVoice?.stop();
415
670
  this.huddleVoice = null;
671
+ await acked;
416
672
  if (this.refreshTimer)
417
673
  clearTimeout(this.refreshTimer);
418
674
  this.logStream?.end();
@@ -476,6 +732,7 @@ export class AgentBridge {
476
732
  this.log(`removed from workspace ${this.workspace.name} β€” no longer serving it ` +
477
733
  `(ask an admin to re-invite this agent, then restart the bridge)`);
478
734
  }
735
+ /** Live events and the catch-up's alike; a message's promise settles once it is queued or passed over. */
479
736
  handleEvent(ev) {
480
737
  // One process, one workspace (#357). The socket carries every workspace the
481
738
  // agent belongs to, so anything from elsewhere belongs to a sibling process
@@ -528,7 +785,7 @@ export class AgentBridge {
528
785
  }
529
786
  if (ev.type !== 'message.created' && ev.type !== 'thread.reply')
530
787
  return;
531
- void this.handleMessage(ev.data);
788
+ return this.handleMessage(ev.data);
532
789
  }
533
790
  /**
534
791
  * The Interrupt button: πŸ›‘ on a thinking row stops the turn that row belongs
@@ -593,6 +850,13 @@ export class AgentBridge {
593
850
  }
594
851
  if (!(await this.inScope(msg)))
595
852
  return;
853
+ // Answered once, across restarts too: a catch-up, or a socket's backlog, can bring a message we already took.
854
+ // Marked before the commands, so a replayed `/restart` cannot restart us again.
855
+ if (!this.handled.add(msg.id)) {
856
+ this.log(`already answered ${msg.id} β€” skipping the replay`);
857
+ return;
858
+ }
859
+ this.answered++;
596
860
  // Bridge commands, not runtime turns. A leading mention of us is stripped
597
861
  // first, so "@Bot /update" works in a channel (where the mention is what
598
862
  // put the message in scope at all).
@@ -608,6 +872,8 @@ export class AgentBridge {
608
872
  return;
609
873
  this.enqueue(msg);
610
874
  }
875
+ /** How often a job's lease is extended; swappable in tests. */
876
+ jobExtendMs = JOB_EXTEND_MS;
611
877
  /** Swappable in tests β€” the real thing takes the process down. */
612
878
  exitProcess = (code) => process.exit(code);
613
879
  /** Swappable in tests β€” the real thing asks the npm registry. */
@@ -913,7 +1179,12 @@ export class AgentBridge {
913
1179
  // β€” targets the same thread (a new one when we're answering a top-level
914
1180
  // channel message).
915
1181
  const replyRoot = this.replyRoot(msg);
916
- const progress = new ProgressReporter(this.api, this.socket, this.cfg.progress, this.cfg.relayText, msg.channelId, replyRoot, (m) => this.log(m), this.indicatorLeases);
1182
+ // A wake job's turn: every request of it carries the job (x-flow-job), the MCP server's too (via `activeJobs`).
1183
+ const job = this.jobTurns.get(msg.id);
1184
+ const api = job?.api ?? this.api;
1185
+ if (job)
1186
+ this.activeJobs.set(key, job.jobId);
1187
+ const progress = new ProgressReporter(api, this.socket, this.cfg.progress, this.cfg.relayText, msg.channelId, replyRoot, (m) => this.log(m), this.indicatorLeases);
917
1188
  // The persistent session owns its own MCP config for as long as its process
918
1189
  // lives; only the one-shot path writes (and deletes) one per turn.
919
1190
  const persistent = this.cfg.runtime.kind === 'claude';
@@ -969,20 +1240,22 @@ export class AgentBridge {
969
1240
  this.log('run stopped by request');
970
1241
  if (result.sawSession)
971
1242
  conv.started = true;
972
- await this.api.sendMessage(msg.channelId, interruptReply(result, live.stoppedBy), replyRoot).catch(() => { });
1243
+ await api.sendMessage(msg.channelId, interruptReply(result, live.stoppedBy), replyRoot).catch(() => { });
973
1244
  if (replyRoot)
974
1245
  this.threadParticipation.set(replyRoot, true);
1246
+ job?.settle({ ok: false, note: 'stopped by request' });
975
1247
  return;
976
1248
  }
977
1249
  if (result.ok) {
978
1250
  conv.started = true;
979
1251
  const text = result.text.trim();
980
1252
  if (text.length > 0) {
981
- await this.api.sendMessage(msg.channelId, text, replyRoot);
1253
+ await api.sendMessage(msg.channelId, text, replyRoot);
982
1254
  // We've now spoken here β€” every later reply in this thread is ours to answer.
983
1255
  if (replyRoot)
984
1256
  this.threadParticipation.set(replyRoot, true);
985
1257
  }
1258
+ job?.settle({ ok: true, note: text || 'done' });
986
1259
  }
987
1260
  else {
988
1261
  this.log(`runtime error: ${result.error ?? 'unknown'}`);
@@ -1007,10 +1280,17 @@ export class AgentBridge {
1007
1280
  this.log(`codex resume of ${conv.codexSessionId} failed β€” next turn starts a fresh session`);
1008
1281
  conv.codexSessionId = undefined;
1009
1282
  }
1010
- await this.api.sendMessage(msg.channelId, failureReply(result), replyRoot).catch(() => { });
1283
+ await api.sendMessage(msg.channelId, failureReply(result), replyRoot).catch(() => { });
1284
+ job?.settle({ ok: false, note: result.error ?? 'runtime error' });
1011
1285
  }
1012
1286
  }
1287
+ catch (err) {
1288
+ job?.settle({ ok: false, note: err.message });
1289
+ throw err;
1290
+ }
1013
1291
  finally {
1292
+ if (job && this.activeJobs.get(key) === job.jobId)
1293
+ this.activeJobs.delete(key);
1014
1294
  if (this.liveRuns.get(key) === live)
1015
1295
  this.liveRuns.delete(key);
1016
1296
  await progress.finish().catch(() => { });
@@ -1264,4 +1544,9 @@ export class AgentBridge {
1264
1544
  return p;
1265
1545
  }
1266
1546
  }
1547
+ /** The first line of a turn's text or error, short enough for a job's note. */
1548
+ function oneLine(text) {
1549
+ const line = text.split('\n').map((l) => l.trim()).find(Boolean) ?? '';
1550
+ return line.length > 200 ? `${line.slice(0, 199)}…` : line;
1551
+ }
1267
1552
  //# sourceMappingURL=bridge.js.map