flow-agent-bridge 0.37.2 β 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/api.d.ts +44 -3
- package/dist/api.js +50 -6
- package/dist/api.js.map +1 -1
- package/dist/app-guard-cli.js.map +1 -1
- package/dist/app-token.js.map +1 -1
- package/dist/artifact-delivery.d.ts +1 -1
- package/dist/bridge.d.ts +57 -0
- package/dist/bridge.js +308 -19
- package/dist/bridge.js.map +1 -1
- package/dist/catch-up.d.ts +47 -0
- package/dist/catch-up.js +133 -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 +26 -1
- package/dist/gateway.js +96 -2
- package/dist/gateway.js.map +1 -1
- package/dist/huddle-context.js.map +1 -1
- package/dist/huddle-replies.js.map +1 -1
- package/dist/huddle-voice.js.map +1 -1
- package/dist/mcp-init.js.map +1 -1
- package/dist/mcp-server.js +20 -5
- package/dist/mcp-server.js.map +1 -1
- package/dist/progress.d.ts +1 -1
- package/dist/progress.js.map +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/session.js.map +1 -1
- package/dist/setup.js.map +1 -1
- package/dist/shared-file-worker.js.map +1 -1
- package/dist/shared-files.js +1 -1
- package/dist/shared-files.js.map +1 -1
- package/dist/supervisor.js.map +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +4 -4
package/dist/bridge.js
CHANGED
|
@@ -14,15 +14,22 @@ 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 { FlowApi } from './api.js';
|
|
17
|
+
import { accountLines, addressIn, FlowApi, FlowApiError } from './api.js';
|
|
18
18
|
import { attachmentFilename, formatAttachments } from './attachments.js';
|
|
19
|
-
import {
|
|
19
|
+
import { openFlowSocket } from './gateway.js';
|
|
20
20
|
import { ChannelIndicatorLeases, ProgressReporter } from './progress.js';
|
|
21
21
|
import { killAllRuntimes, runRuntime } from './runtime.js';
|
|
22
22
|
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,
|
|
@@ -180,7 +210,8 @@ export class AgentBridge {
|
|
|
180
210
|
if (!this.me.isAgent)
|
|
181
211
|
this.log('warning: token belongs to a non-agent user');
|
|
182
212
|
// #357: an agent can belong to several workspaces; this process serves one.
|
|
183
|
-
|
|
213
|
+
const workspaces = await this.api.myWorkspaces();
|
|
214
|
+
this.workspace = resolveWorkspace(workspaces, this.cfg.workspace);
|
|
184
215
|
await this.refreshDirectory();
|
|
185
216
|
this.clearLeftoverIndicators();
|
|
186
217
|
const voiceConfig = this.cfg.voice ?? defaultVoiceConfig();
|
|
@@ -212,25 +243,233 @@ export class AgentBridge {
|
|
|
212
243
|
const voiceState = !voiceConfig.enabled
|
|
213
244
|
? 'off'
|
|
214
245
|
: `ready via bridge runtime (${voiceConfig.sttModel} β ${voiceConfig.ttsModel}/${voiceConfig.ttsVoice})`;
|
|
215
|
-
|
|
246
|
+
const address = addressIn(this.me, this.workspace.id);
|
|
247
|
+
this.log(`${this.me.displayName} <@${this.me.id}>${address ? ` ${address}` : ''} online in "${this.workspace.name}" β ` +
|
|
216
248
|
`scope=${this.cfg.eventScope}+DMs progress=${this.cfg.progress} runtime=${this.cfg.runtime.kind} ` +
|
|
217
249
|
`voice=${voiceState} cwd=${this.cfg.runtime.cwd}`);
|
|
218
|
-
|
|
250
|
+
for (const line of accountLines(this.me, workspaces))
|
|
251
|
+
this.log(line);
|
|
252
|
+
const since = await this.catchUp();
|
|
253
|
+
if (since !== null)
|
|
254
|
+
await this.ensureJobsSubscription();
|
|
255
|
+
this.socket = openFlowSocket({
|
|
219
256
|
serverUrl: this.cfg.serverUrl,
|
|
220
257
|
token: this.cfg.agentToken,
|
|
221
258
|
// one process, one workspace (#357) β so tell the server, or it lights
|
|
222
259
|
// our presence dot in every workspace we belong to (#364)
|
|
223
260
|
workspaces: [this.workspace.id],
|
|
224
|
-
onEvent: (ev) => this.handleEvent(ev),
|
|
261
|
+
onEvent: (ev) => void this.handleEvent(ev),
|
|
225
262
|
onOpen: () => { void this.huddleVoice?.refreshContext(); },
|
|
226
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),
|
|
227
268
|
});
|
|
228
269
|
this.socket.connect();
|
|
270
|
+
if (this.subscription) {
|
|
271
|
+
this.cursorTimer = setInterval(() => void this.ackCursor(), CURSOR_ACK_MS);
|
|
272
|
+
this.cursorTimer.unref();
|
|
273
|
+
}
|
|
229
274
|
this.startTaskIpc();
|
|
230
275
|
// Best-effort staleness check β never blocks startup or crashes on failure.
|
|
231
276
|
void this.checkVersion();
|
|
232
277
|
void this.reportRelaunch();
|
|
233
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
|
+
}
|
|
234
473
|
/** After a `/update` / `/restart` relaunch, confirm we're back β in the
|
|
235
474
|
* conversation that asked, with the version the update actually landed on. */
|
|
236
475
|
async reportRelaunch() {
|
|
@@ -277,7 +516,7 @@ export class AgentBridge {
|
|
|
277
516
|
fs.rmSync(sock, { force: true }); // stale socket from a previous run
|
|
278
517
|
}
|
|
279
518
|
const server = http.createServer((req, res) => {
|
|
280
|
-
if (req.method !== 'POST' || req.url !== '/task') {
|
|
519
|
+
if (req.method !== 'POST' || (req.url !== '/task' && req.url !== '/job')) {
|
|
281
520
|
res.writeHead(404).end();
|
|
282
521
|
return;
|
|
283
522
|
}
|
|
@@ -289,6 +528,18 @@ export class AgentBridge {
|
|
|
289
528
|
});
|
|
290
529
|
req.on('end', () => {
|
|
291
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
|
+
}
|
|
292
543
|
let out;
|
|
293
544
|
try {
|
|
294
545
|
out = await this.startTask(JSON.parse(body));
|
|
@@ -341,13 +592,18 @@ export class AgentBridge {
|
|
|
341
592
|
`${requester !== this.me.id ? ` for <@${requester}>` : ''}.`)
|
|
342
593
|
.catch((err) => this.log(`task kickoff post failed: ${err.message}`));
|
|
343
594
|
}
|
|
344
|
-
this.enqueue({
|
|
345
|
-
|
|
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,
|
|
346
602
|
channelId,
|
|
347
|
-
userId
|
|
603
|
+
userId,
|
|
348
604
|
threadRootId: null,
|
|
349
|
-
clientMsgId:
|
|
350
|
-
body
|
|
605
|
+
clientMsgId: id,
|
|
606
|
+
body,
|
|
351
607
|
createdAt: new Date().toISOString(),
|
|
352
608
|
editedAt: null,
|
|
353
609
|
deletedAt: null,
|
|
@@ -361,8 +617,7 @@ export class AgentBridge {
|
|
|
361
617
|
reactions: [],
|
|
362
618
|
files: [],
|
|
363
619
|
unfurls: [],
|
|
364
|
-
}
|
|
365
|
-
return { ok: true, note: `task run queued in ${this.channelLabel(channelId)}` };
|
|
620
|
+
};
|
|
366
621
|
}
|
|
367
622
|
/**
|
|
368
623
|
* On startup, compare our installed version with the latest on npm. If we're
|
|
@@ -393,6 +648,10 @@ export class AgentBridge {
|
|
|
393
648
|
}
|
|
394
649
|
async stop() {
|
|
395
650
|
this.socket?.close();
|
|
651
|
+
if (this.cursorTimer)
|
|
652
|
+
clearInterval(this.cursorTimer);
|
|
653
|
+
this.cursorTimer = null;
|
|
654
|
+
const acked = this.ackCursor();
|
|
396
655
|
this.ipcServer?.close();
|
|
397
656
|
this.ipcServer = null;
|
|
398
657
|
if (this.taskSock && process.platform !== 'win32')
|
|
@@ -409,6 +668,7 @@ export class AgentBridge {
|
|
|
409
668
|
await Promise.all([...new Set([...this.conversations.values()].map((c) => c.channelId))].map((id) => this.clearIndicator(id)));
|
|
410
669
|
await this.huddleVoice?.stop();
|
|
411
670
|
this.huddleVoice = null;
|
|
671
|
+
await acked;
|
|
412
672
|
if (this.refreshTimer)
|
|
413
673
|
clearTimeout(this.refreshTimer);
|
|
414
674
|
this.logStream?.end();
|
|
@@ -472,6 +732,7 @@ export class AgentBridge {
|
|
|
472
732
|
this.log(`removed from workspace ${this.workspace.name} β no longer serving it ` +
|
|
473
733
|
`(ask an admin to re-invite this agent, then restart the bridge)`);
|
|
474
734
|
}
|
|
735
|
+
/** Live events and the catch-up's alike; a message's promise settles once it is queued or passed over. */
|
|
475
736
|
handleEvent(ev) {
|
|
476
737
|
// One process, one workspace (#357). The socket carries every workspace the
|
|
477
738
|
// agent belongs to, so anything from elsewhere belongs to a sibling process
|
|
@@ -524,7 +785,7 @@ export class AgentBridge {
|
|
|
524
785
|
}
|
|
525
786
|
if (ev.type !== 'message.created' && ev.type !== 'thread.reply')
|
|
526
787
|
return;
|
|
527
|
-
|
|
788
|
+
return this.handleMessage(ev.data);
|
|
528
789
|
}
|
|
529
790
|
/**
|
|
530
791
|
* The Interrupt button: π on a thinking row stops the turn that row belongs
|
|
@@ -589,6 +850,13 @@ export class AgentBridge {
|
|
|
589
850
|
}
|
|
590
851
|
if (!(await this.inScope(msg)))
|
|
591
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++;
|
|
592
860
|
// Bridge commands, not runtime turns. A leading mention of us is stripped
|
|
593
861
|
// first, so "@Bot /update" works in a channel (where the mention is what
|
|
594
862
|
// put the message in scope at all).
|
|
@@ -604,6 +872,8 @@ export class AgentBridge {
|
|
|
604
872
|
return;
|
|
605
873
|
this.enqueue(msg);
|
|
606
874
|
}
|
|
875
|
+
/** How often a job's lease is extended; swappable in tests. */
|
|
876
|
+
jobExtendMs = JOB_EXTEND_MS;
|
|
607
877
|
/** Swappable in tests β the real thing takes the process down. */
|
|
608
878
|
exitProcess = (code) => process.exit(code);
|
|
609
879
|
/** Swappable in tests β the real thing asks the npm registry. */
|
|
@@ -909,7 +1179,12 @@ export class AgentBridge {
|
|
|
909
1179
|
// β targets the same thread (a new one when we're answering a top-level
|
|
910
1180
|
// channel message).
|
|
911
1181
|
const replyRoot = this.replyRoot(msg);
|
|
912
|
-
|
|
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);
|
|
913
1188
|
// The persistent session owns its own MCP config for as long as its process
|
|
914
1189
|
// lives; only the one-shot path writes (and deletes) one per turn.
|
|
915
1190
|
const persistent = this.cfg.runtime.kind === 'claude';
|
|
@@ -965,20 +1240,22 @@ export class AgentBridge {
|
|
|
965
1240
|
this.log('run stopped by request');
|
|
966
1241
|
if (result.sawSession)
|
|
967
1242
|
conv.started = true;
|
|
968
|
-
await
|
|
1243
|
+
await api.sendMessage(msg.channelId, interruptReply(result, live.stoppedBy), replyRoot).catch(() => { });
|
|
969
1244
|
if (replyRoot)
|
|
970
1245
|
this.threadParticipation.set(replyRoot, true);
|
|
1246
|
+
job?.settle({ ok: false, note: 'stopped by request' });
|
|
971
1247
|
return;
|
|
972
1248
|
}
|
|
973
1249
|
if (result.ok) {
|
|
974
1250
|
conv.started = true;
|
|
975
1251
|
const text = result.text.trim();
|
|
976
1252
|
if (text.length > 0) {
|
|
977
|
-
await
|
|
1253
|
+
await api.sendMessage(msg.channelId, text, replyRoot);
|
|
978
1254
|
// We've now spoken here β every later reply in this thread is ours to answer.
|
|
979
1255
|
if (replyRoot)
|
|
980
1256
|
this.threadParticipation.set(replyRoot, true);
|
|
981
1257
|
}
|
|
1258
|
+
job?.settle({ ok: true, note: text || 'done' });
|
|
982
1259
|
}
|
|
983
1260
|
else {
|
|
984
1261
|
this.log(`runtime error: ${result.error ?? 'unknown'}`);
|
|
@@ -1003,10 +1280,17 @@ export class AgentBridge {
|
|
|
1003
1280
|
this.log(`codex resume of ${conv.codexSessionId} failed β next turn starts a fresh session`);
|
|
1004
1281
|
conv.codexSessionId = undefined;
|
|
1005
1282
|
}
|
|
1006
|
-
await
|
|
1283
|
+
await api.sendMessage(msg.channelId, failureReply(result), replyRoot).catch(() => { });
|
|
1284
|
+
job?.settle({ ok: false, note: result.error ?? 'runtime error' });
|
|
1007
1285
|
}
|
|
1008
1286
|
}
|
|
1287
|
+
catch (err) {
|
|
1288
|
+
job?.settle({ ok: false, note: err.message });
|
|
1289
|
+
throw err;
|
|
1290
|
+
}
|
|
1009
1291
|
finally {
|
|
1292
|
+
if (job && this.activeJobs.get(key) === job.jobId)
|
|
1293
|
+
this.activeJobs.delete(key);
|
|
1010
1294
|
if (this.liveRuns.get(key) === live)
|
|
1011
1295
|
this.liveRuns.delete(key);
|
|
1012
1296
|
await progress.finish().catch(() => { });
|
|
@@ -1260,4 +1544,9 @@ export class AgentBridge {
|
|
|
1260
1544
|
return p;
|
|
1261
1545
|
}
|
|
1262
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
|
+
}
|
|
1263
1552
|
//# sourceMappingURL=bridge.js.map
|