flow-agent-bridge 0.37.3 β 0.37.7
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/api.d.ts +39 -2
- package/dist/api.js +49 -6
- package/dist/api.js.map +1 -1
- package/dist/bridge.d.ts +68 -0
- package/dist/bridge.js +364 -15
- package/dist/bridge.js.map +1 -1
- package/dist/calendar-tools.d.ts +194 -0
- package/dist/calendar-tools.js +258 -0
- package/dist/calendar-tools.js.map +1 -0
- package/dist/catch-up.d.ts +50 -0
- package/dist/catch-up.js +137 -0
- package/dist/catch-up.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/gateway.d.ts +17 -1
- package/dist/gateway.js +41 -2
- package/dist/gateway.js.map +1 -1
- package/dist/mcp-server.js +28 -6
- package/dist/mcp-server.js.map +1 -1
- package/package.json +1 -1
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,14 @@ 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
|
+
import { invitationPrompt } from './calendar-tools.js';
|
|
28
|
+
/** How often the socket's latest cursor is acked to the bridge's subscription. */
|
|
29
|
+
const CURSOR_ACK_MS = 30_000;
|
|
30
|
+
/** A wake job's lease is renewed this often while its turn runs (the server's lease is 10 minutes). */
|
|
31
|
+
export const JOB_EXTEND_MS = 5 * 60_000;
|
|
32
|
+
/** A job whose turn never got to run (a `/reset` dropped it, say) is reported failed after this long. */
|
|
33
|
+
const JOB_WAIT_MAX_MS = 6 * 3_600_000;
|
|
26
34
|
const THINKING_PREFIX = 'π€ *thinkingβ¦*';
|
|
27
35
|
/** The loop breaker's visible notice (#583). Never counts toward a chain, never wakes anyone. */
|
|
28
36
|
const LOOP_BREAKER_PREFIX = 'β‘ loop breaker:';
|
|
@@ -141,10 +149,37 @@ export class AgentBridge {
|
|
|
141
149
|
taskSock = null;
|
|
142
150
|
huddleVoice = null;
|
|
143
151
|
logStream = null;
|
|
152
|
+
/** The messages answered lately, across restarts (catch-up.ts), so a catch-up never answers one twice. */
|
|
153
|
+
handled;
|
|
154
|
+
/** Messages answered since start: the catch-up's "answered M". */
|
|
155
|
+
answered = 0;
|
|
156
|
+
/** The bridge's subscription in this organization (catch-up.ts), and how far it is acked. */
|
|
157
|
+
subscription = null;
|
|
158
|
+
/** The latest `cursor` frame, to ack every 30 s and at shutdown. */
|
|
159
|
+
latestCursor = 0;
|
|
160
|
+
cursorTimer = null;
|
|
161
|
+
/** Wake jobs taken (platform.md Β§5.2, phase 2.4), across restarts, so a job delivered again or replayed runs once. */
|
|
162
|
+
handledJobs;
|
|
163
|
+
/** Invitations answered (phase 4.2), by event id, across restarts: each `calendar.invited` starts one turn. */
|
|
164
|
+
handledInvitations;
|
|
165
|
+
takingInvitations = new Set();
|
|
166
|
+
/** Jobs whose ack is in flight, so two copies of one `agent.job` arriving together are taken once. */
|
|
167
|
+
takingJobs = new Set();
|
|
168
|
+
/** The `bridge-jobs:` socket subscription, whose poke means an `agent.job` came; one read at a time. */
|
|
169
|
+
jobsSub = null;
|
|
170
|
+
jobsReading = false;
|
|
171
|
+
jobsAgain = false;
|
|
172
|
+
/** A job's turn, by its synthetic message's id: the client that marks its requests, and how the turn ended. */
|
|
173
|
+
jobTurns = new Map();
|
|
174
|
+
/** convKey β the job whose turn is running there, which the MCP server asks for (`/job` on the task IPC). */
|
|
175
|
+
activeJobs = new Map();
|
|
144
176
|
constructor(cfg) {
|
|
145
177
|
this.cfg = cfg;
|
|
146
178
|
this.api = new FlowApi(cfg.serverUrl, cfg.agentToken);
|
|
147
179
|
this.sem = new Semaphore(cfg.concurrency);
|
|
180
|
+
this.handled = new HandledMessages(cfg.stateFile ?? null, (m) => this.log(m));
|
|
181
|
+
this.handledJobs = new HandledMessages(cfg.stateFile ?? null, (m) => this.log(m), undefined, 'handledJobIds');
|
|
182
|
+
this.handledInvitations = new HandledMessages(cfg.stateFile ?? null, (m) => this.log(m), undefined, 'handledInvitationIds');
|
|
148
183
|
this.sessions = new SessionManager({
|
|
149
184
|
cfg: cfg.runtime,
|
|
150
185
|
idleMs: cfg.runtime.sessionIdleSec * 1000,
|
|
@@ -219,22 +254,286 @@ export class AgentBridge {
|
|
|
219
254
|
`voice=${voiceState} cwd=${this.cfg.runtime.cwd}`);
|
|
220
255
|
for (const line of accountLines(this.me, workspaces))
|
|
221
256
|
this.log(line);
|
|
257
|
+
const since = await this.catchUp();
|
|
258
|
+
if (since !== null)
|
|
259
|
+
await this.ensureJobsSubscription();
|
|
222
260
|
this.socket = openFlowSocket({
|
|
223
261
|
serverUrl: this.cfg.serverUrl,
|
|
224
262
|
token: this.cfg.agentToken,
|
|
225
263
|
// one process, one workspace (#357) β so tell the server, or it lights
|
|
226
264
|
// our presence dot in every workspace we belong to (#364)
|
|
227
265
|
workspaces: [this.workspace.id],
|
|
228
|
-
onEvent: (ev) => this.handleEvent(ev),
|
|
266
|
+
onEvent: (ev) => void this.handleEvent(ev),
|
|
229
267
|
onOpen: () => { void this.huddleVoice?.refreshContext(); },
|
|
230
268
|
log: (m) => this.log(m),
|
|
269
|
+
// What happened between the catch-up's read and the socket's hello comes as the socket's backlog.
|
|
270
|
+
...(since !== null ? { since } : {}),
|
|
271
|
+
onCursor: (seq) => { this.latestCursor = Math.max(this.latestCursor, seq); },
|
|
272
|
+
onSubscription: (id) => void this.readJobs(id),
|
|
231
273
|
});
|
|
232
274
|
this.socket.connect();
|
|
275
|
+
if (this.subscription) {
|
|
276
|
+
this.cursorTimer = setInterval(() => void this.ackCursor(), CURSOR_ACK_MS);
|
|
277
|
+
this.cursorTimer.unref();
|
|
278
|
+
}
|
|
233
279
|
this.startTaskIpc();
|
|
234
280
|
// Best-effort staleness check β never blocks startup or crashes on failure.
|
|
235
281
|
void this.checkVersion();
|
|
236
282
|
void this.reportRelaunch();
|
|
237
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Before the socket opens: what this organization's log holds for the bridge's subscription since it last ran, fed
|
|
286
|
+
* through the path live events take. Returns the cursor reached (the socket's `since`), or null when the server has
|
|
287
|
+
* no subscriptions (an older server), which leaves the bridge on live events alone.
|
|
288
|
+
*/
|
|
289
|
+
async catchUp() {
|
|
290
|
+
let sub;
|
|
291
|
+
try {
|
|
292
|
+
sub = await ensureSubscription(this.api, this.workspace.id, this.me.id);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
this.log(`no event subscription (${err.message}) β live events only`);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
this.subscription = { id: sub.id, acked: sub.cursor };
|
|
299
|
+
this.latestCursor = sub.cursor;
|
|
300
|
+
const before = this.answered;
|
|
301
|
+
try {
|
|
302
|
+
const out = await catchUp(this.api, this.workspace.id, sub, (ev) => this.handleEvent(ev), (m) => this.log(m), (e) => this.handleAccountEvent(e));
|
|
303
|
+
this.subscription.acked = out.next;
|
|
304
|
+
this.latestCursor = Math.max(this.latestCursor, out.next);
|
|
305
|
+
this.log(`caught up: ${out.events} events, answered ${this.answered - before}`);
|
|
306
|
+
if (sub.outdated)
|
|
307
|
+
await this.replaceSubscription(sub.id);
|
|
308
|
+
return out.next;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
this.log(`catch-up failed (${err.message}) β continuing with live events`);
|
|
312
|
+
return this.subscription.acked;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** An older bridge's subscription, caught up: replaced by one with today's types (`agent.job`, `calendar.invited`), starting at the head. */
|
|
316
|
+
async replaceSubscription(oldId) {
|
|
317
|
+
try {
|
|
318
|
+
const fresh = await this.api.createSubscription(this.workspace.id, { name: subscriptionName(this.me.id), filter: { types: CATCH_UP_TYPES }, delivery: 'pull' });
|
|
319
|
+
await this.api.deleteSubscription(this.workspace.id, oldId).catch((err) => this.log(`old subscription not deleted: ${err.message}`));
|
|
320
|
+
this.subscription = { id: fresh.id, acked: fresh.cursor };
|
|
321
|
+
this.latestCursor = Math.max(this.latestCursor, fresh.cursor);
|
|
322
|
+
this.log('event subscription renewed with today\'s types');
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
this.log(`event subscription not renewed: ${err.message}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** The socket subscription that pokes when a wake job comes (catch-up.ts `jobsSpec`). Without it jobs still come, late. */
|
|
329
|
+
async ensureJobsSubscription() {
|
|
330
|
+
try {
|
|
331
|
+
const spec = jobsSpec(this.me.id);
|
|
332
|
+
const sub = await ensureSubscription(this.api, this.workspace.id, this.me.id, spec);
|
|
333
|
+
this.jobsSub = { id: sub.id };
|
|
334
|
+
if (sub.outdated) {
|
|
335
|
+
// An older bridge's (agent.job alone): what it holds is read now, then it is replaced by one with today's types.
|
|
336
|
+
await this.readJobs(sub.id);
|
|
337
|
+
const fresh = await this.api.createSubscription(this.workspace.id, spec);
|
|
338
|
+
await this.api.deleteSubscription(this.workspace.id, sub.id).catch((err) => this.log(`old job subscription not deleted: ${err.message}`));
|
|
339
|
+
this.jobsSub = { id: fresh.id };
|
|
340
|
+
this.log('job subscription renewed with calendar.invited');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
this.log(`no job subscription (${err.message}) β wake jobs arrive with the next catch-up or delivery`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** A poke for the job subscription: read it until empty, taking each job, acking as it goes. */
|
|
348
|
+
async readJobs(id) {
|
|
349
|
+
if (!this.jobsSub || id !== this.jobsSub.id)
|
|
350
|
+
return;
|
|
351
|
+
if (this.jobsReading) {
|
|
352
|
+
this.jobsAgain = true;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.jobsReading = true;
|
|
356
|
+
try {
|
|
357
|
+
do {
|
|
358
|
+
this.jobsAgain = false;
|
|
359
|
+
await catchUp(this.api, this.workspace.id, { id, cursor: 0 }, () => { }, (m) => this.log(m), (e) => this.handleAccountEvent(e));
|
|
360
|
+
} while (this.jobsAgain);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
this.log(`job subscription read failed: ${err.message}`);
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
this.jobsReading = false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** An event on the agent's own account (catch-up.ts ACCOUNT_TYPES): a wake job, or an invitation. */
|
|
370
|
+
async handleAccountEvent(e) {
|
|
371
|
+
if (e.type === 'calendar.invited')
|
|
372
|
+
return this.handleInvitation(e);
|
|
373
|
+
if (e.type === 'agent.job')
|
|
374
|
+
return this.handleJob(e);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* A `calendar.invited` event (platform.md Β§7, phase 4.2): a turn in the DM with the organizer when they are a member of
|
|
378
|
+
* the organization, else with the agent's sponsor, asking the agent to answer with `respond_to_event` (or ask first).
|
|
379
|
+
* One turn per event id, across restarts (state.json); an invitation from another organization is a sibling process's.
|
|
380
|
+
*/
|
|
381
|
+
async handleInvitation(e) {
|
|
382
|
+
const d = e.data;
|
|
383
|
+
const eventId = d?.eventId;
|
|
384
|
+
if (!eventId || (e.organizationId && e.organizationId !== this.workspace.id))
|
|
385
|
+
return;
|
|
386
|
+
if (this.handledInvitations.has(eventId) || this.takingInvitations.has(eventId)) {
|
|
387
|
+
this.log('invitation already answered β skipping the copy');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
this.takingInvitations.add(eventId);
|
|
391
|
+
try {
|
|
392
|
+
const find = () => [...this.members.values()].find((m) => m.userId !== this.me.id && !!d.organizer &&
|
|
393
|
+
((!!d.organizer.accountId && m.accountId === d.organizer.accountId) || (!!d.organizer.email && [m.address, m.email].some((a) => a?.toLowerCase() === d.organizer.email.toLowerCase()))));
|
|
394
|
+
let organizer = find();
|
|
395
|
+
if (!organizer) {
|
|
396
|
+
await this.refreshDirectory().catch((err) => this.log(`directory refresh failed: ${err.message}`));
|
|
397
|
+
organizer = find();
|
|
398
|
+
}
|
|
399
|
+
const who = organizer?.userId ?? this.members.get(this.me.id)?.sponsorId ?? null;
|
|
400
|
+
if (!who) {
|
|
401
|
+
this.log('invitation: neither the organizer nor a sponsor to ask in a DM β not answered');
|
|
402
|
+
this.handledInvitations.add(eventId);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const dm = await this.api.createDm(this.workspace.id, [who]);
|
|
406
|
+
this.channels.set(dm.id, { ...dm, isMember: true });
|
|
407
|
+
this.handledInvitations.add(eventId);
|
|
408
|
+
this.log(`invitation: a turn in the DM with the ${organizer ? 'organizer' : 'sponsor'}`);
|
|
409
|
+
this.enqueue(this.taskMessage(`invite-${createHash('sha256').update(eventId).digest('hex').slice(0, 16)}`, dm.id, who, invitationPrompt(d)));
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
this.log(`invitation: not started (${err.message}) β not answered`);
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
this.takingInvitations.delete(eventId);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* An `agent.job` event (live or from a catch-up): acked as this agent, then run as a turn the way `start_task` runs
|
|
420
|
+
* one. Taken once: a job id already taken (state.json) or being taken is passed over. A job the server no longer
|
|
421
|
+
* offers (409) is remembered and dropped; an ack that failed otherwise is left for the server's next delivery.
|
|
422
|
+
*/
|
|
423
|
+
async handleJob(e) {
|
|
424
|
+
const d = e.data;
|
|
425
|
+
const jobId = d?.jobId;
|
|
426
|
+
if (!jobId || !d.prompt)
|
|
427
|
+
return;
|
|
428
|
+
if (this.handledJobs.has(jobId) || this.takingJobs.has(jobId)) {
|
|
429
|
+
this.log(`job ${jobId} already taken β skipping the copy`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
this.takingJobs.add(jobId);
|
|
433
|
+
try {
|
|
434
|
+
const api = this.api.withJob(jobId);
|
|
435
|
+
try {
|
|
436
|
+
await api.jobStep(this.workspace.id, jobId, 'ack');
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
if (err instanceof FlowApiError && (err.status === 409 || err.status === 404))
|
|
440
|
+
this.handledJobs.add(jobId);
|
|
441
|
+
this.log(`job ${jobId}: ack failed (${err.message})`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
this.handledJobs.add(jobId);
|
|
445
|
+
this.log(`job ${jobId}: acked, running`);
|
|
446
|
+
void this.runJob(jobId, d, api);
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
this.takingJobs.delete(jobId);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The job's turn: in its channel (joined first when public), or a new `job-<id>` channel of its own, as a task run
|
|
454
|
+
* there (`startTask`), every request of it marked with the job (`x-flow-job`). The lease is extended every 5 minutes
|
|
455
|
+
* while it runs; at the end the job is reported done or failed with a line about it.
|
|
456
|
+
*/
|
|
457
|
+
async runJob(jobId, d, api) {
|
|
458
|
+
let outcome = { ok: false, note: '' };
|
|
459
|
+
const msgId = `job-${jobId}`;
|
|
460
|
+
const extend = setInterval(() => {
|
|
461
|
+
void api.jobStep(this.workspace.id, jobId, 'extend').catch((err) => this.log(`job ${jobId}: extend failed: ${err.message}`));
|
|
462
|
+
}, this.jobExtendMs);
|
|
463
|
+
extend.unref();
|
|
464
|
+
try {
|
|
465
|
+
const channelId = await this.jobChannel(jobId, d.channelId, api);
|
|
466
|
+
const ended = new Promise((settle) => {
|
|
467
|
+
let done = false;
|
|
468
|
+
const once = (r) => { if (!done) {
|
|
469
|
+
done = true;
|
|
470
|
+
settle(r);
|
|
471
|
+
} };
|
|
472
|
+
this.jobTurns.set(msgId, { jobId, api, settle: once });
|
|
473
|
+
setTimeout(() => once({ ok: false, note: 'the turn never ran' }), JOB_WAIT_MAX_MS).unref();
|
|
474
|
+
});
|
|
475
|
+
this.taskChannels.add(channelId);
|
|
476
|
+
this.enqueue(this.taskMessage(msgId, channelId, this.me.id, d.prompt));
|
|
477
|
+
outcome = await ended;
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
outcome = { ok: false, note: err.message };
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
clearInterval(extend);
|
|
484
|
+
this.jobTurns.delete(msgId);
|
|
485
|
+
}
|
|
486
|
+
const note = oneLine(outcome.note) || (outcome.ok ? 'done' : 'failed');
|
|
487
|
+
await api.jobStep(this.workspace.id, jobId, 'done', { ok: outcome.ok, note })
|
|
488
|
+
.catch((err) => this.log(`job ${jobId}: done not reported: ${err.message}`));
|
|
489
|
+
this.log(`job ${jobId}: ${outcome.ok ? 'done' : 'failed'} β ${note}`);
|
|
490
|
+
return { ok: outcome.ok, note };
|
|
491
|
+
}
|
|
492
|
+
/** 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. */
|
|
493
|
+
async jobChannel(jobId, channelId, api) {
|
|
494
|
+
if (channelId) {
|
|
495
|
+
if (!this.channels.has(channelId))
|
|
496
|
+
await this.refreshDirectory().catch((err) => this.log(`directory refresh failed: ${err.message}`));
|
|
497
|
+
const chan = this.channels.get(channelId);
|
|
498
|
+
if (!chan)
|
|
499
|
+
throw new Error('no such channel');
|
|
500
|
+
if (!chan.isMember) {
|
|
501
|
+
if (chan.kind !== 'standard' || chan.isPrivate)
|
|
502
|
+
throw new Error('not a member of that channel');
|
|
503
|
+
await api.joinChannel(channelId);
|
|
504
|
+
this.channels.set(channelId, { ...chan, isMember: true });
|
|
505
|
+
}
|
|
506
|
+
return channelId;
|
|
507
|
+
}
|
|
508
|
+
const name = `job-${jobId.slice(0, 8)}`;
|
|
509
|
+
try {
|
|
510
|
+
const chan = await api.createChannel(this.workspace.id, { name, topic: `Wake job ${jobId}` });
|
|
511
|
+
this.channels.set(chan.id, chan);
|
|
512
|
+
return chan.id;
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
if (!(err instanceof FlowApiError && err.status === 409))
|
|
516
|
+
throw err;
|
|
517
|
+
await this.refreshDirectory();
|
|
518
|
+
const found = [...this.channels.values()].find((c) => c.name === name);
|
|
519
|
+
if (!found)
|
|
520
|
+
throw err;
|
|
521
|
+
return found.id;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** The socket's latest cursor, acked to the subscription: the next start replays only what came after. */
|
|
525
|
+
async ackCursor() {
|
|
526
|
+
const sub = this.subscription;
|
|
527
|
+
if (!sub || this.latestCursor <= sub.acked)
|
|
528
|
+
return;
|
|
529
|
+
const to = this.latestCursor;
|
|
530
|
+
try {
|
|
531
|
+
sub.acked = Math.max(sub.acked, await this.api.ackSubscription(this.workspace.id, sub.id, to));
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
this.log(`subscription ack failed: ${err.message}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
238
537
|
/** After a `/update` / `/restart` relaunch, confirm we're back β in the
|
|
239
538
|
* conversation that asked, with the version the update actually landed on. */
|
|
240
539
|
async reportRelaunch() {
|
|
@@ -281,7 +580,7 @@ export class AgentBridge {
|
|
|
281
580
|
fs.rmSync(sock, { force: true }); // stale socket from a previous run
|
|
282
581
|
}
|
|
283
582
|
const server = http.createServer((req, res) => {
|
|
284
|
-
if (req.method !== 'POST' || req.url !== '/task') {
|
|
583
|
+
if (req.method !== 'POST' || (req.url !== '/task' && req.url !== '/job')) {
|
|
285
584
|
res.writeHead(404).end();
|
|
286
585
|
return;
|
|
287
586
|
}
|
|
@@ -293,6 +592,18 @@ export class AgentBridge {
|
|
|
293
592
|
});
|
|
294
593
|
req.on('end', () => {
|
|
295
594
|
void (async () => {
|
|
595
|
+
if (req.url === '/job') {
|
|
596
|
+
// The MCP server's question before each request: is a wake job's turn running in this conversation?
|
|
597
|
+
let jobId = null;
|
|
598
|
+
try {
|
|
599
|
+
const q = JSON.parse(body);
|
|
600
|
+
jobId = this.activeJobs.get(`${q.channelId ?? ''}|${q.threadRootId ?? ''}`) ?? null;
|
|
601
|
+
}
|
|
602
|
+
catch { /* no job */ }
|
|
603
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
604
|
+
res.end(JSON.stringify({ jobId }));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
296
607
|
let out;
|
|
297
608
|
try {
|
|
298
609
|
out = await this.startTask(JSON.parse(body));
|
|
@@ -345,13 +656,18 @@ export class AgentBridge {
|
|
|
345
656
|
`${requester !== this.me.id ? ` for <@${requester}>` : ''}.`)
|
|
346
657
|
.catch((err) => this.log(`task kickoff post failed: ${err.message}`));
|
|
347
658
|
}
|
|
348
|
-
this.enqueue({
|
|
349
|
-
|
|
659
|
+
this.enqueue(this.taskMessage(`task-${randomUUID()}`, channelId, requester, prompt));
|
|
660
|
+
return { ok: true, note: `task run queued in ${this.channelLabel(channelId)}` };
|
|
661
|
+
}
|
|
662
|
+
/** A run's opening message: what a task run (or a wake job) is asked, as if posted to its channel. */
|
|
663
|
+
taskMessage(id, channelId, userId, body) {
|
|
664
|
+
return {
|
|
665
|
+
id,
|
|
350
666
|
channelId,
|
|
351
|
-
userId
|
|
667
|
+
userId,
|
|
352
668
|
threadRootId: null,
|
|
353
|
-
clientMsgId:
|
|
354
|
-
body
|
|
669
|
+
clientMsgId: id,
|
|
670
|
+
body,
|
|
355
671
|
createdAt: new Date().toISOString(),
|
|
356
672
|
editedAt: null,
|
|
357
673
|
deletedAt: null,
|
|
@@ -365,8 +681,7 @@ export class AgentBridge {
|
|
|
365
681
|
reactions: [],
|
|
366
682
|
files: [],
|
|
367
683
|
unfurls: [],
|
|
368
|
-
}
|
|
369
|
-
return { ok: true, note: `task run queued in ${this.channelLabel(channelId)}` };
|
|
684
|
+
};
|
|
370
685
|
}
|
|
371
686
|
/**
|
|
372
687
|
* On startup, compare our installed version with the latest on npm. If we're
|
|
@@ -397,6 +712,10 @@ export class AgentBridge {
|
|
|
397
712
|
}
|
|
398
713
|
async stop() {
|
|
399
714
|
this.socket?.close();
|
|
715
|
+
if (this.cursorTimer)
|
|
716
|
+
clearInterval(this.cursorTimer);
|
|
717
|
+
this.cursorTimer = null;
|
|
718
|
+
const acked = this.ackCursor();
|
|
400
719
|
this.ipcServer?.close();
|
|
401
720
|
this.ipcServer = null;
|
|
402
721
|
if (this.taskSock && process.platform !== 'win32')
|
|
@@ -413,6 +732,7 @@ export class AgentBridge {
|
|
|
413
732
|
await Promise.all([...new Set([...this.conversations.values()].map((c) => c.channelId))].map((id) => this.clearIndicator(id)));
|
|
414
733
|
await this.huddleVoice?.stop();
|
|
415
734
|
this.huddleVoice = null;
|
|
735
|
+
await acked;
|
|
416
736
|
if (this.refreshTimer)
|
|
417
737
|
clearTimeout(this.refreshTimer);
|
|
418
738
|
this.logStream?.end();
|
|
@@ -476,6 +796,7 @@ export class AgentBridge {
|
|
|
476
796
|
this.log(`removed from workspace ${this.workspace.name} β no longer serving it ` +
|
|
477
797
|
`(ask an admin to re-invite this agent, then restart the bridge)`);
|
|
478
798
|
}
|
|
799
|
+
/** Live events and the catch-up's alike; a message's promise settles once it is queued or passed over. */
|
|
479
800
|
handleEvent(ev) {
|
|
480
801
|
// One process, one workspace (#357). The socket carries every workspace the
|
|
481
802
|
// agent belongs to, so anything from elsewhere belongs to a sibling process
|
|
@@ -528,7 +849,7 @@ export class AgentBridge {
|
|
|
528
849
|
}
|
|
529
850
|
if (ev.type !== 'message.created' && ev.type !== 'thread.reply')
|
|
530
851
|
return;
|
|
531
|
-
|
|
852
|
+
return this.handleMessage(ev.data);
|
|
532
853
|
}
|
|
533
854
|
/**
|
|
534
855
|
* The Interrupt button: π on a thinking row stops the turn that row belongs
|
|
@@ -593,6 +914,13 @@ export class AgentBridge {
|
|
|
593
914
|
}
|
|
594
915
|
if (!(await this.inScope(msg)))
|
|
595
916
|
return;
|
|
917
|
+
// Answered once, across restarts too: a catch-up, or a socket's backlog, can bring a message we already took.
|
|
918
|
+
// Marked before the commands, so a replayed `/restart` cannot restart us again.
|
|
919
|
+
if (!this.handled.add(msg.id)) {
|
|
920
|
+
this.log(`already answered ${msg.id} β skipping the replay`);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
this.answered++;
|
|
596
924
|
// Bridge commands, not runtime turns. A leading mention of us is stripped
|
|
597
925
|
// first, so "@Bot /update" works in a channel (where the mention is what
|
|
598
926
|
// put the message in scope at all).
|
|
@@ -608,6 +936,8 @@ export class AgentBridge {
|
|
|
608
936
|
return;
|
|
609
937
|
this.enqueue(msg);
|
|
610
938
|
}
|
|
939
|
+
/** How often a job's lease is extended; swappable in tests. */
|
|
940
|
+
jobExtendMs = JOB_EXTEND_MS;
|
|
611
941
|
/** Swappable in tests β the real thing takes the process down. */
|
|
612
942
|
exitProcess = (code) => process.exit(code);
|
|
613
943
|
/** Swappable in tests β the real thing asks the npm registry. */
|
|
@@ -913,7 +1243,12 @@ export class AgentBridge {
|
|
|
913
1243
|
// β targets the same thread (a new one when we're answering a top-level
|
|
914
1244
|
// channel message).
|
|
915
1245
|
const replyRoot = this.replyRoot(msg);
|
|
916
|
-
|
|
1246
|
+
// A wake job's turn: every request of it carries the job (x-flow-job), the MCP server's too (via `activeJobs`).
|
|
1247
|
+
const job = this.jobTurns.get(msg.id);
|
|
1248
|
+
const api = job?.api ?? this.api;
|
|
1249
|
+
if (job)
|
|
1250
|
+
this.activeJobs.set(key, job.jobId);
|
|
1251
|
+
const progress = new ProgressReporter(api, this.socket, this.cfg.progress, this.cfg.relayText, msg.channelId, replyRoot, (m) => this.log(m), this.indicatorLeases);
|
|
917
1252
|
// The persistent session owns its own MCP config for as long as its process
|
|
918
1253
|
// lives; only the one-shot path writes (and deletes) one per turn.
|
|
919
1254
|
const persistent = this.cfg.runtime.kind === 'claude';
|
|
@@ -969,20 +1304,22 @@ export class AgentBridge {
|
|
|
969
1304
|
this.log('run stopped by request');
|
|
970
1305
|
if (result.sawSession)
|
|
971
1306
|
conv.started = true;
|
|
972
|
-
await
|
|
1307
|
+
await api.sendMessage(msg.channelId, interruptReply(result, live.stoppedBy), replyRoot).catch(() => { });
|
|
973
1308
|
if (replyRoot)
|
|
974
1309
|
this.threadParticipation.set(replyRoot, true);
|
|
1310
|
+
job?.settle({ ok: false, note: 'stopped by request' });
|
|
975
1311
|
return;
|
|
976
1312
|
}
|
|
977
1313
|
if (result.ok) {
|
|
978
1314
|
conv.started = true;
|
|
979
1315
|
const text = result.text.trim();
|
|
980
1316
|
if (text.length > 0) {
|
|
981
|
-
await
|
|
1317
|
+
await api.sendMessage(msg.channelId, text, replyRoot);
|
|
982
1318
|
// We've now spoken here β every later reply in this thread is ours to answer.
|
|
983
1319
|
if (replyRoot)
|
|
984
1320
|
this.threadParticipation.set(replyRoot, true);
|
|
985
1321
|
}
|
|
1322
|
+
job?.settle({ ok: true, note: text || 'done' });
|
|
986
1323
|
}
|
|
987
1324
|
else {
|
|
988
1325
|
this.log(`runtime error: ${result.error ?? 'unknown'}`);
|
|
@@ -1007,10 +1344,17 @@ export class AgentBridge {
|
|
|
1007
1344
|
this.log(`codex resume of ${conv.codexSessionId} failed β next turn starts a fresh session`);
|
|
1008
1345
|
conv.codexSessionId = undefined;
|
|
1009
1346
|
}
|
|
1010
|
-
await
|
|
1347
|
+
await api.sendMessage(msg.channelId, failureReply(result), replyRoot).catch(() => { });
|
|
1348
|
+
job?.settle({ ok: false, note: result.error ?? 'runtime error' });
|
|
1011
1349
|
}
|
|
1012
1350
|
}
|
|
1351
|
+
catch (err) {
|
|
1352
|
+
job?.settle({ ok: false, note: err.message });
|
|
1353
|
+
throw err;
|
|
1354
|
+
}
|
|
1013
1355
|
finally {
|
|
1356
|
+
if (job && this.activeJobs.get(key) === job.jobId)
|
|
1357
|
+
this.activeJobs.delete(key);
|
|
1014
1358
|
if (this.liveRuns.get(key) === live)
|
|
1015
1359
|
this.liveRuns.delete(key);
|
|
1016
1360
|
await progress.finish().catch(() => { });
|
|
@@ -1264,4 +1608,9 @@ export class AgentBridge {
|
|
|
1264
1608
|
return p;
|
|
1265
1609
|
}
|
|
1266
1610
|
}
|
|
1611
|
+
/** The first line of a turn's text or error, short enough for a job's note. */
|
|
1612
|
+
function oneLine(text) {
|
|
1613
|
+
const line = text.split('\n').map((l) => l.trim()).find(Boolean) ?? '';
|
|
1614
|
+
return line.length > 200 ? `${line.slice(0, 199)}β¦` : line;
|
|
1615
|
+
}
|
|
1267
1616
|
//# sourceMappingURL=bridge.js.map
|