foundr-companion 0.1.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.
Files changed (75) hide show
  1. package/dist/bridge/adapters.d.mts +33 -0
  2. package/dist/bridge/adapters.d.mts.map +1 -0
  3. package/dist/bridge/adapters.mjs +159 -0
  4. package/dist/bridge/adapters.mjs.map +1 -0
  5. package/dist/bridge/bridge-auth.d.mts +5 -0
  6. package/dist/bridge/bridge-auth.d.mts.map +1 -0
  7. package/dist/bridge/bridge-auth.mjs +24 -0
  8. package/dist/bridge/bridge-auth.mjs.map +1 -0
  9. package/dist/bridge/codex-app-server-adapter.d.mts +37 -0
  10. package/dist/bridge/codex-app-server-adapter.d.mts.map +1 -0
  11. package/dist/bridge/codex-app-server-adapter.mjs +401 -0
  12. package/dist/bridge/codex-app-server-adapter.mjs.map +1 -0
  13. package/dist/bridge/daemon.mjs +190 -0
  14. package/dist/bridge/foundr-codex-reply.d.mts +96 -0
  15. package/dist/bridge/foundr-codex-reply.d.mts.map +1 -0
  16. package/dist/bridge/foundr-codex-reply.mjs +542 -0
  17. package/dist/bridge/foundr-codex-reply.mjs.map +1 -0
  18. package/dist/bridge/listener-flags.d.mts +5 -0
  19. package/dist/bridge/listener-flags.d.mts.map +1 -0
  20. package/dist/bridge/listener-flags.mjs +18 -0
  21. package/dist/bridge/listener-flags.mjs.map +1 -0
  22. package/dist/bridge/listener-loop.d.mts +58 -0
  23. package/dist/bridge/listener-loop.d.mts.map +1 -0
  24. package/dist/bridge/listener-loop.mjs +424 -0
  25. package/dist/bridge/listener-loop.mjs.map +1 -0
  26. package/dist/bridge/logout.d.mts +8 -0
  27. package/dist/bridge/logout.d.mts.map +1 -0
  28. package/dist/bridge/logout.mjs +35 -0
  29. package/dist/bridge/logout.mjs.map +1 -0
  30. package/dist/bridge/mcp-client.d.mts +97 -0
  31. package/dist/bridge/mcp-client.d.mts.map +1 -0
  32. package/dist/bridge/mcp-client.mjs +290 -0
  33. package/dist/bridge/mcp-client.mjs.map +1 -0
  34. package/dist/bridge/oauth-provider.d.mts +32 -0
  35. package/dist/bridge/oauth-provider.d.mts.map +1 -0
  36. package/dist/bridge/oauth-provider.mjs +94 -0
  37. package/dist/bridge/oauth-provider.mjs.map +1 -0
  38. package/dist/bridge/public-room-mention-listener-loop.d.mts +120 -0
  39. package/dist/bridge/public-room-mention-listener-loop.d.mts.map +1 -0
  40. package/dist/bridge/public-room-mention-listener-loop.mjs +225 -0
  41. package/dist/bridge/public-room-mention-listener-loop.mjs.map +1 -0
  42. package/dist/bridge/realtime-wake.d.mts +11 -0
  43. package/dist/bridge/realtime-wake.d.mts.map +1 -0
  44. package/dist/bridge/realtime-wake.mjs +134 -0
  45. package/dist/bridge/realtime-wake.mjs.map +1 -0
  46. package/dist/bridge/work-mission-listener-loop.d.mts +100 -0
  47. package/dist/bridge/work-mission-listener-loop.d.mts.map +1 -0
  48. package/dist/bridge/work-mission-listener-loop.mjs +737 -0
  49. package/dist/bridge/work-mission-listener-loop.mjs.map +1 -0
  50. package/dist/cli-parse.d.ts +27 -0
  51. package/dist/cli-parse.d.ts.map +1 -0
  52. package/dist/cli-parse.js +47 -0
  53. package/dist/cli-parse.js.map +1 -0
  54. package/dist/cli.d.ts +3 -0
  55. package/dist/cli.d.ts.map +1 -0
  56. package/dist/cli.js +232 -0
  57. package/dist/cli.js.map +1 -0
  58. package/dist/codex-preflight.d.ts +44 -0
  59. package/dist/codex-preflight.d.ts.map +1 -0
  60. package/dist/codex-preflight.js +74 -0
  61. package/dist/codex-preflight.js.map +1 -0
  62. package/dist/index.d.ts +5 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +5 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/launchd.d.ts +78 -0
  67. package/dist/launchd.d.ts.map +1 -0
  68. package/dist/launchd.js +118 -0
  69. package/dist/launchd.js.map +1 -0
  70. package/dist/paths.d.ts +30 -0
  71. package/dist/paths.d.ts.map +1 -0
  72. package/dist/paths.js +26 -0
  73. package/dist/paths.js.map +1 -0
  74. package/install.sh +117 -0
  75. package/package.json +29 -0
@@ -0,0 +1,737 @@
1
+ import { createPrivateChatWakeSubscription } from './realtime-wake.mjs'
2
+ import {
3
+ isPublicRoomMentionWakePayload,
4
+ runOnePublicRoomMentionIteration,
5
+ runOnePublicRoomMentionWakeIteration,
6
+ } from './public-room-mention-listener-loop.mjs'
7
+
8
+ const CLIENT_NAME = 'foundr-local-bridge'
9
+ const CLIENT_VERSION = '0.1.0'
10
+ const HEARTBEAT_INTERVAL_MS = 20_000
11
+ const POLL_INTERVAL_MS = 1_000
12
+ const PUBLIC_ROOM_MENTION_POLL_INTERVAL_MS = 1_000
13
+ const WAKE_FALLBACK_POLL_INTERVAL_MS = 15_000
14
+ const PROGRESS_FLUSH_INTERVAL_MS = 120
15
+ const PROGRESS_MAX_BUFFER_CHARS = 180
16
+ const FINAL_PROGRESS_FLUSH_TIMEOUT_MS = 150
17
+
18
+ function nowMs() {
19
+ return performance.now()
20
+ }
21
+
22
+ function timingEnabled() {
23
+ return process.env.FOUNDR_WORK_MISSION_TIMING === '1' || process.env.FOUNDR_PRIVATE_CHAT_TIMING === '1'
24
+ }
25
+
26
+ function timingLog(startedAt, message) {
27
+ if (!timingEnabled()) return
28
+ console.error(`[foundr:work-missions:timing +${Math.round(nowMs() - startedAt)}ms] ${message}`)
29
+ }
30
+
31
+ function boundedError(error) {
32
+ const message = error instanceof Error ? error.message : String(error)
33
+ return message.replace(/\s+/g, ' ').trim().slice(0, 500)
34
+ }
35
+
36
+ function delay(ms) {
37
+ return new Promise((resolve) => {
38
+ const timer = setTimeout(resolve, ms)
39
+ timer.unref?.()
40
+ })
41
+ }
42
+
43
+ function timestampMs(value) {
44
+ const date = value instanceof Date ? value : new Date(value)
45
+ const time = date.getTime()
46
+ return Number.isNaN(time) ? 0 : time
47
+ }
48
+
49
+ function replyClientMsgId(messageId) {
50
+ return `work-mission-reply:${messageId}`.slice(0, 128)
51
+ }
52
+
53
+ function sortMessages(messages = []) {
54
+ return messages.slice().sort((a, b) => timestampMs(a.created_at) - timestampMs(b.created_at))
55
+ }
56
+
57
+ export function pendingWorkMissionFounderMessages(snapshot, {
58
+ ignoredMessageIds = new Set(),
59
+ } = {}) {
60
+ const messages = sortMessages(snapshot?.messages ?? [])
61
+ const replyClientIds = new Set(
62
+ messages
63
+ .filter(message => message.sender_kind === 'agent' && message.client_msg_id)
64
+ .map(message => message.client_msg_id),
65
+ )
66
+
67
+ return messages.filter((message) => (
68
+ message.sender_kind === 'founder' &&
69
+ !ignoredMessageIds.has(message.id) &&
70
+ !replyClientIds.has(replyClientMsgId(message.id))
71
+ ))
72
+ }
73
+
74
+ export function workMissionEnvelope(snapshot, message, agent, delivery = null) {
75
+ return {
76
+ kind: 'work_mission',
77
+ mission: snapshot.mission,
78
+ thread: {
79
+ id: `mission:${snapshot.mission.id}`,
80
+ kind: 'work_mission',
81
+ },
82
+ message,
83
+ delivery: {
84
+ id: delivery?.id ?? message.id,
85
+ agent_key_id: delivery?.agent_key_id ?? agent?.id ?? '',
86
+ requested_model: delivery?.requested_model ?? null,
87
+ },
88
+ agent: agent ?? null,
89
+ }
90
+ }
91
+
92
+ function isWorkMissionWakePayload(payload) {
93
+ return payload?.type === 'work_mission_delivery_ready' &&
94
+ typeof payload.delivery_id === 'string' &&
95
+ typeof payload.mission_id === 'string' &&
96
+ typeof payload.message_id === 'string' &&
97
+ typeof payload.body === 'string'
98
+ }
99
+
100
+ function workMissionWakeEnvelope(payload, agent) {
101
+ const message = {
102
+ id: payload.message_id,
103
+ mission_id: payload.mission_id,
104
+ sender_kind: 'founder',
105
+ sender_founder_id: null,
106
+ sender_agent_key_id: null,
107
+ body: payload.body,
108
+ client_msg_id: null,
109
+ created_at: payload.sent_at ?? new Date().toISOString(),
110
+ }
111
+
112
+ return workMissionEnvelope({
113
+ mission: {
114
+ id: payload.mission_id,
115
+ title: payload.mission_title ?? 'Mission',
116
+ status: 'running',
117
+ priority: 'medium',
118
+ },
119
+ tasks: [],
120
+ decisions: [],
121
+ artifacts: [],
122
+ messages: [message],
123
+ deliveries: [],
124
+ agent_status: { status: 'listening', assigned_count: 1 },
125
+ }, message, agent, {
126
+ id: payload.delivery_id,
127
+ agent_key_id: agent?.id ?? '',
128
+ requested_model: payload.requested_model ?? null,
129
+ })
130
+ }
131
+
132
+ function replyText(reply) {
133
+ return String(reply?.text ?? '').trim()
134
+ }
135
+
136
+ function isUnknownToolError(error) {
137
+ const message = boundedError(error).toLowerCase()
138
+ return message.includes('unknown tool') ||
139
+ message.includes('method not found') ||
140
+ message.includes('tool not found')
141
+ }
142
+
143
+ async function completeWorkMissionReply({
144
+ client,
145
+ deliveryId,
146
+ sessionId,
147
+ missionId,
148
+ messageId,
149
+ text,
150
+ }) {
151
+ const clientMsgId = replyClientMsgId(messageId)
152
+ try {
153
+ return await client.callTool('complete_work_mission_reply', {
154
+ delivery_id: deliveryId,
155
+ listener_session_id: sessionId,
156
+ body: text,
157
+ client_msg_id: clientMsgId,
158
+ })
159
+ } catch (error) {
160
+ if (!isUnknownToolError(error)) throw error
161
+ const sent = await client.callTool('append_work_mission_message', {
162
+ mission_id: missionId,
163
+ body: text,
164
+ client_msg_id: clientMsgId,
165
+ })
166
+ await client.callTool('ack_work_mission_message', {
167
+ delivery_id: deliveryId,
168
+ listener_session_id: sessionId,
169
+ status: 'replied',
170
+ response_message_id: sent.message.id,
171
+ })
172
+ return { message: sent.message }
173
+ }
174
+ }
175
+
176
+ function progressNumberFromEnv(name, fallback) {
177
+ const value = Number(process.env[name])
178
+ return Number.isFinite(value) && value > 0 ? value : fallback
179
+ }
180
+
181
+ function wakeFallbackPollMs(pollMs) {
182
+ const configured = Number(process.env.FOUNDR_WORK_MISSION_WAKE_FALLBACK_POLL_MS)
183
+ if (Number.isFinite(configured) && configured > 0) return configured
184
+ return Math.max(pollMs, WAKE_FALLBACK_POLL_INTERVAL_MS)
185
+ }
186
+
187
+ function createProgressReporter({ client, deliveryId, sessionId, iterationStartedAt }) {
188
+ const flushIntervalMs = progressNumberFromEnv('FOUNDR_WORK_MISSION_PROGRESS_FLUSH_MS', PROGRESS_FLUSH_INTERVAL_MS)
189
+ const maxBufferChars = progressNumberFromEnv('FOUNDR_WORK_MISSION_PROGRESS_MAX_CHARS', PROGRESS_MAX_BUFFER_CHARS)
190
+ const finalFlushTimeoutMs = progressNumberFromEnv(
191
+ 'FOUNDR_WORK_MISSION_FINAL_PROGRESS_TIMEOUT_MS',
192
+ FINAL_PROGRESS_FLUSH_TIMEOUT_MS,
193
+ )
194
+
195
+ let bufferedText = ''
196
+ let pendingStatus = null
197
+ let pendingError = null
198
+ let flushTimer = null
199
+ let sawFirstDelta = false
200
+ let progressChain = Promise.resolve()
201
+
202
+ const clearFlushTimer = () => {
203
+ if (!flushTimer) return
204
+ clearTimeout(flushTimer)
205
+ flushTimer = null
206
+ }
207
+
208
+ const callProgressTool = (args) => {
209
+ progressChain = progressChain
210
+ .then(() => client.callTool('stream_work_mission_reply', {
211
+ delivery_id: deliveryId,
212
+ listener_session_id: sessionId,
213
+ ...args,
214
+ }))
215
+ .catch(() => {})
216
+ return progressChain
217
+ }
218
+
219
+ const flush = () => {
220
+ clearFlushTimer()
221
+ const textDelta = bufferedText
222
+ const status = pendingStatus
223
+ const error = pendingError
224
+ bufferedText = ''
225
+ pendingStatus = null
226
+ pendingError = null
227
+
228
+ if (!textDelta && !status && !error) return progressChain
229
+ const nextStatus = textDelta && status !== 'failed' ? 'writing' : status
230
+
231
+ return callProgressTool({
232
+ ...(textDelta ? { text_delta: textDelta } : {}),
233
+ ...(nextStatus ? { status: nextStatus } : {}),
234
+ ...(error ? { error } : {}),
235
+ })
236
+ }
237
+
238
+ const scheduleFlush = () => {
239
+ if (flushTimer) return
240
+ flushTimer = setTimeout(() => {
241
+ flushTimer = null
242
+ void flush()
243
+ }, flushIntervalMs)
244
+ flushTimer.unref?.()
245
+ }
246
+
247
+ return {
248
+ push(event) {
249
+ if (event.type === 'status') {
250
+ if (event.status === 'thinking') return
251
+ pendingStatus = event.status
252
+ if (event.status === 'failed') {
253
+ void flush()
254
+ return
255
+ }
256
+ scheduleFlush()
257
+ return
258
+ }
259
+
260
+ if (event.type !== 'delta' || !event.text) return
261
+ if (!sawFirstDelta) {
262
+ sawFirstDelta = true
263
+ timingLog(iterationStartedAt, `first delta delivery=${deliveryId}`)
264
+ }
265
+ bufferedText += event.text
266
+ pendingStatus = 'writing'
267
+ if (bufferedText.length >= maxBufferChars) {
268
+ void flush()
269
+ } else {
270
+ scheduleFlush()
271
+ }
272
+ },
273
+
274
+ async flushBeforeFinal() {
275
+ const flushed = flush()
276
+ await Promise.race([flushed, delay(finalFlushTimeoutMs)])
277
+ },
278
+
279
+ async fail(error) {
280
+ pendingStatus = 'failed'
281
+ pendingError = boundedError(error)
282
+ await flush()
283
+ },
284
+
285
+ cancel() {
286
+ clearFlushTimer()
287
+ bufferedText = ''
288
+ pendingStatus = null
289
+ pendingError = null
290
+ },
291
+ }
292
+ }
293
+
294
+ export async function runOneWorkMissionIteration({
295
+ client,
296
+ adapter,
297
+ agent,
298
+ sessionId,
299
+ }) {
300
+ const iterationStartedAt = nowMs()
301
+ const inbound = await client.callTool('claim_work_mission_message', {
302
+ listener_session_id: sessionId,
303
+ include_snapshot: true,
304
+ })
305
+
306
+ if (inbound.timed_out) {
307
+ return { timed_out: true, replied: 0, skipped: 0, failed: 0 }
308
+ }
309
+
310
+ const deliveryId = inbound.delivery.id
311
+ timingLog(iterationStartedAt, `claimed delivery=${deliveryId}`)
312
+
313
+ const snapshotResult = inbound.snapshot
314
+ ? { snapshot: inbound.snapshot }
315
+ : await client.callTool('get_work_mission', {
316
+ mission_id: inbound.mission.id,
317
+ })
318
+ const snapshot = snapshotResult.snapshot ?? {
319
+ mission: inbound.mission,
320
+ tasks: [],
321
+ decisions: [],
322
+ artifacts: [],
323
+ messages: [inbound.message],
324
+ deliveries: [inbound.delivery],
325
+ agent_status: { status: 'listening', assigned_count: 1 },
326
+ }
327
+
328
+ const progress = createProgressReporter({
329
+ client,
330
+ deliveryId,
331
+ sessionId,
332
+ iterationStartedAt,
333
+ })
334
+
335
+ let text = ''
336
+ try {
337
+ text = replyText(await adapter.reply(
338
+ workMissionEnvelope(snapshot, inbound.message, agent, inbound.delivery),
339
+ {
340
+ onProgress: (event) => {
341
+ progress.push(event)
342
+ },
343
+ },
344
+ ))
345
+ await progress.flushBeforeFinal()
346
+ timingLog(iterationStartedAt, `adapter completed delivery=${deliveryId}`)
347
+ } catch (error) {
348
+ const errorText = boundedError(error)
349
+ await progress.fail(errorText).catch(() => {})
350
+ await client.callTool('ack_work_mission_message', {
351
+ delivery_id: deliveryId,
352
+ listener_session_id: sessionId,
353
+ status: 'failed',
354
+ error: errorText,
355
+ }).catch(() => {})
356
+ console.error(`[foundr:work-missions] reply failed mission=${inbound.mission.id} message=${inbound.message.id}: ${errorText}`)
357
+ return { replied: 0, skipped: 0, failed: 1 }
358
+ }
359
+
360
+ if (!text) {
361
+ await client.callTool('ack_work_mission_message', {
362
+ delivery_id: deliveryId,
363
+ listener_session_id: sessionId,
364
+ status: 'skipped',
365
+ })
366
+ return { replied: 0, skipped: 1, failed: 0 }
367
+ }
368
+
369
+ try {
370
+ const sent = await completeWorkMissionReply({
371
+ client,
372
+ deliveryId,
373
+ sessionId,
374
+ missionId: snapshot.mission.id,
375
+ messageId: inbound.message.id,
376
+ text,
377
+ })
378
+ const responseMessageId = sent.message.id
379
+ timingLog(iterationStartedAt, `reply sent delivery=${deliveryId}`)
380
+ return { replied: 1, skipped: 0, failed: 0, message_id: responseMessageId }
381
+ } catch (error) {
382
+ progress.cancel()
383
+ const errorText = boundedError(error)
384
+ await client.callTool('ack_work_mission_message', {
385
+ delivery_id: deliveryId,
386
+ listener_session_id: sessionId,
387
+ status: 'failed',
388
+ error: errorText,
389
+ }).catch(() => {})
390
+ return { replied: 0, skipped: 0, failed: 1, error: errorText }
391
+ }
392
+ }
393
+
394
+ export async function runOneWorkMissionWakeIteration({
395
+ client,
396
+ adapter,
397
+ agent,
398
+ sessionId,
399
+ wake,
400
+ }) {
401
+ if (!isWorkMissionWakePayload(wake)) {
402
+ return runOneWorkMissionIteration({ client, adapter, agent, sessionId })
403
+ }
404
+
405
+ const iterationStartedAt = nowMs()
406
+ const deliveryId = wake.delivery_id
407
+ const messageId = wake.message_id
408
+ timingLog(iterationStartedAt, `wake delivery=${deliveryId}`)
409
+
410
+ let progress = null
411
+ const bufferedProgress = []
412
+ let replyError = null
413
+ const replyPromise = adapter.reply(workMissionWakeEnvelope(wake, agent), {
414
+ onProgress: (event) => {
415
+ if (progress) {
416
+ progress.push(event)
417
+ } else {
418
+ bufferedProgress.push(event)
419
+ }
420
+ },
421
+ }).catch((error) => {
422
+ replyError = error
423
+ return { text: '' }
424
+ })
425
+
426
+ const inbound = await client.callTool('claim_work_mission_message', {
427
+ listener_session_id: sessionId,
428
+ delivery_id: deliveryId,
429
+ include_snapshot: false,
430
+ })
431
+
432
+ if (inbound.timed_out) {
433
+ await replyPromise.catch(() => {})
434
+ return { timed_out: true, replied: 0, skipped: 0, failed: 0 }
435
+ }
436
+
437
+ timingLog(iterationStartedAt, `claimed wake delivery=${deliveryId}`)
438
+
439
+ progress = createProgressReporter({
440
+ client,
441
+ deliveryId,
442
+ sessionId,
443
+ iterationStartedAt,
444
+ })
445
+ for (const event of bufferedProgress) progress.push(event)
446
+
447
+ let text = ''
448
+ try {
449
+ const reply = await replyPromise
450
+ if (replyError) throw replyError
451
+ text = replyText(reply)
452
+ await progress.flushBeforeFinal()
453
+ timingLog(iterationStartedAt, `wake adapter completed delivery=${deliveryId}`)
454
+ } catch (error) {
455
+ const errorText = boundedError(error)
456
+ await progress.fail(errorText).catch(() => {})
457
+ await client.callTool('ack_work_mission_message', {
458
+ delivery_id: deliveryId,
459
+ listener_session_id: sessionId,
460
+ status: 'failed',
461
+ error: errorText,
462
+ }).catch(() => {})
463
+ console.error(`[foundr:work-missions] wake reply failed mission=${wake.mission_id} message=${messageId}: ${errorText}`)
464
+ return { replied: 0, skipped: 0, failed: 1 }
465
+ }
466
+
467
+ if (!text) {
468
+ await client.callTool('ack_work_mission_message', {
469
+ delivery_id: deliveryId,
470
+ listener_session_id: sessionId,
471
+ status: 'skipped',
472
+ })
473
+ return { replied: 0, skipped: 1, failed: 0 }
474
+ }
475
+
476
+ try {
477
+ const sent = await completeWorkMissionReply({
478
+ client,
479
+ deliveryId,
480
+ sessionId,
481
+ missionId: wake.mission_id,
482
+ messageId,
483
+ text,
484
+ })
485
+ const responseMessageId = sent.message.id
486
+ timingLog(iterationStartedAt, `wake reply sent delivery=${deliveryId}`)
487
+ return { replied: 1, skipped: 0, failed: 0, message_id: responseMessageId }
488
+ } catch (error) {
489
+ progress.cancel()
490
+ const errorText = boundedError(error)
491
+ await client.callTool('ack_work_mission_message', {
492
+ delivery_id: deliveryId,
493
+ listener_session_id: sessionId,
494
+ status: 'failed',
495
+ error: errorText,
496
+ }).catch(() => {})
497
+ return { replied: 0, skipped: 0, failed: 1, error: errorText }
498
+ }
499
+ }
500
+
501
+ export async function runWorkMissionListener({
502
+ client,
503
+ adapter,
504
+ clientName = CLIENT_NAME,
505
+ clientVersion = CLIENT_VERSION,
506
+ expectedAgentId,
507
+ pollMs = POLL_INTERVAL_MS,
508
+ publicMentionPollMs = PUBLIC_ROOM_MENTION_POLL_INTERVAL_MS,
509
+ transport = 'wake',
510
+ wakeSubscriptionFactory = createPrivateChatWakeSubscription,
511
+ }) {
512
+ const adapterName = adapter.name ?? 'unknown'
513
+ const heartbeat = await client.callTool('heartbeat_private_chat_listener', {
514
+ client_name: clientName,
515
+ client_version: clientVersion,
516
+ status: 'listening',
517
+ metadata: { adapter: adapterName, channels: ['work_missions', 'public_room_mentions'] },
518
+ })
519
+ if (expectedAgentId && heartbeat.agent?.id !== expectedAgentId) {
520
+ throw new Error(
521
+ `Authenticated MCP token resolved to ${heartbeat.agent?.id ?? 'unknown'}; expected ${expectedAgentId}`,
522
+ )
523
+ }
524
+
525
+ const sessionId = heartbeat.session_id
526
+ const agent = heartbeat.agent ?? null
527
+ let stopping = false
528
+ let fatalError = null
529
+
530
+ const sendHeartbeat = (status) => client.callTool('heartbeat_private_chat_listener', {
531
+ session_id: sessionId,
532
+ client_name: clientName,
533
+ client_version: clientVersion,
534
+ status,
535
+ metadata: { adapter: adapterName, channels: ['work_missions', 'public_room_mentions'] },
536
+ })
537
+
538
+ const heartbeatTimer = setInterval(() => {
539
+ if (!stopping) void sendHeartbeat('listening').catch(() => {})
540
+ }, HEARTBEAT_INTERVAL_MS)
541
+
542
+ const stop = async () => {
543
+ if (stopping) return
544
+ stopping = true
545
+ clearInterval(heartbeatTimer)
546
+ adapter.close?.()
547
+ await sendHeartbeat('stopped').catch(() => {})
548
+ await client.close?.().catch(() => {})
549
+ }
550
+
551
+ let stopPromise = null
552
+ let resolveStopSignal
553
+ const stopSignal = new Promise((resolve) => {
554
+ resolveStopSignal = resolve
555
+ })
556
+ const requestStop = () => {
557
+ stopPromise ??= stop()
558
+ return stopPromise
559
+ }
560
+ const stopAndResolve = () => {
561
+ void requestStop().finally(() => {
562
+ resolveStopSignal()
563
+ })
564
+ }
565
+
566
+ const wakeQueue = []
567
+ const publicMentionWakeQueue = []
568
+ let wakeHealthy = false
569
+
570
+ const pollOnce = async ({
571
+ allowFallback = true,
572
+ allowPublicMentions = true,
573
+ publicMentionsOnly = false,
574
+ } = {}) => {
575
+ if (pollOnce.polling) {
576
+ pollOnce.needsPoll = pollOnce.needsPoll || !publicMentionsOnly
577
+ pollOnce.allowFallback = pollOnce.allowFallback || allowFallback
578
+ pollOnce.needsPublicMentions = pollOnce.needsPublicMentions || allowPublicMentions
579
+ return
580
+ }
581
+ pollOnce.polling = true
582
+ pollOnce.allowFallback = allowFallback
583
+ pollOnce.allowPublicMentions = allowPublicMentions
584
+ pollOnce.publicMentionsOnly = publicMentionsOnly
585
+ try {
586
+ do {
587
+ const shouldRunFallback = pollOnce.allowFallback
588
+ const shouldRunPublicMentions = pollOnce.allowPublicMentions || pollOnce.needsPublicMentions
589
+ const shouldRunWorkMissions = !pollOnce.publicMentionsOnly
590
+ pollOnce.needsPoll = false
591
+ pollOnce.allowFallback = false
592
+ pollOnce.allowPublicMentions = false
593
+ pollOnce.publicMentionsOnly = false
594
+ pollOnce.needsPublicMentions = false
595
+ if (shouldRunWorkMissions) {
596
+ while (!stopping && wakeQueue.length > 0) {
597
+ await runOneWorkMissionWakeIteration({
598
+ client,
599
+ adapter,
600
+ agent,
601
+ sessionId,
602
+ wake: wakeQueue.shift(),
603
+ })
604
+ }
605
+ if (shouldRunFallback) {
606
+ while (!stopping) {
607
+ const result = await runOneWorkMissionIteration({
608
+ client,
609
+ adapter,
610
+ agent,
611
+ sessionId,
612
+ })
613
+ if (wakeQueue.length > 0) break
614
+ if (result.timed_out && !pollOnce.needsPoll) break
615
+ }
616
+ }
617
+ }
618
+ if (shouldRunPublicMentions && !stopping) {
619
+ while (!stopping && publicMentionWakeQueue.length > 0) {
620
+ await runOnePublicRoomMentionWakeIteration({
621
+ client,
622
+ adapter,
623
+ agent,
624
+ wake: publicMentionWakeQueue.shift(),
625
+ })
626
+ }
627
+ const result = await runOnePublicRoomMentionIteration({
628
+ client,
629
+ adapter,
630
+ agent,
631
+ })
632
+ if (!result.timed_out) {
633
+ pollOnce.needsPublicMentions = true
634
+ }
635
+ }
636
+ } while (!stopping && (
637
+ pollOnce.needsPoll ||
638
+ pollOnce.allowFallback ||
639
+ pollOnce.needsPublicMentions ||
640
+ wakeQueue.length > 0 ||
641
+ publicMentionWakeQueue.length > 0
642
+ ))
643
+ } finally {
644
+ pollOnce.polling = false
645
+ }
646
+ }
647
+ pollOnce.polling = false
648
+ pollOnce.needsPoll = false
649
+ pollOnce.allowFallback = false
650
+ pollOnce.allowPublicMentions = false
651
+ pollOnce.needsPublicMentions = false
652
+ pollOnce.publicMentionsOnly = false
653
+
654
+ process.once('SIGINT', stopAndResolve)
655
+ process.once('SIGTERM', stopAndResolve)
656
+
657
+ const launchPoll = (options) => {
658
+ void pollOnce(options).catch((error) => {
659
+ if (stopping) return
660
+ fatalError = error
661
+ void requestStop().finally(resolveStopSignal)
662
+ })
663
+ }
664
+
665
+ const launchWake = (payload) => {
666
+ if (isWorkMissionWakePayload(payload)) wakeQueue.push(payload)
667
+ if (isPublicRoomMentionWakePayload(payload)) publicMentionWakeQueue.push(payload)
668
+ launchPoll({
669
+ allowFallback: false,
670
+ allowPublicMentions: isPublicRoomMentionWakePayload(payload),
671
+ publicMentionsOnly: isPublicRoomMentionWakePayload(payload),
672
+ })
673
+ }
674
+
675
+ let wakeSubscription = null
676
+ if (transport === 'wake' && heartbeat.wake) {
677
+ wakeSubscription = await wakeSubscriptionFactory({
678
+ wake: heartbeat.wake,
679
+ onWake: launchWake,
680
+ onStatus: (status) => {
681
+ if (status === 'SUBSCRIBED') {
682
+ wakeHealthy = true
683
+ }
684
+ if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
685
+ wakeHealthy = false
686
+ console.warn(`[foundr:work-missions] wake channel ${status}; fallback polling remains active`)
687
+ }
688
+ },
689
+ }).catch((error) => {
690
+ console.warn(`[foundr:work-missions] wake subscription unavailable: ${boundedError(error)}`)
691
+ return null
692
+ })
693
+ }
694
+
695
+ void adapter.start?.().catch((error) => {
696
+ if (!stopping) {
697
+ console.warn(`[foundr:work-missions] adapter warmup failed: ${boundedError(error)}`)
698
+ }
699
+ })
700
+
701
+ const fallbackPollMs = wakeSubscription ? wakeFallbackPollMs(pollMs) : pollMs
702
+ const pollTimer = setInterval(() => {
703
+ if (!stopping && (!wakeSubscription || !wakeHealthy)) launchPoll()
704
+ }, fallbackPollMs)
705
+ const publicMentionFallbackMs = wakeSubscription ? wakeFallbackPollMs(publicMentionPollMs) : publicMentionPollMs
706
+ const publicMentionTimer = setInterval(() => {
707
+ if (!stopping) {
708
+ launchPoll({
709
+ allowFallback: false,
710
+ allowPublicMentions: true,
711
+ publicMentionsOnly: true,
712
+ })
713
+ }
714
+ }, publicMentionFallbackMs)
715
+ publicMentionTimer.unref?.()
716
+ if (wakeSubscription) {
717
+ pollTimer.unref?.()
718
+ } else {
719
+ launchPoll()
720
+ }
721
+
722
+ try {
723
+ await stopSignal
724
+ if (fatalError) throw fatalError
725
+ } finally {
726
+ clearInterval(pollTimer)
727
+ clearInterval(publicMentionTimer)
728
+ await Promise.resolve(wakeSubscription?.close?.()).catch(() => {})
729
+ process.off('SIGINT', stopAndResolve)
730
+ process.off('SIGTERM', stopAndResolve)
731
+ if (stopPromise) {
732
+ await stopPromise
733
+ } else if (!stopping) {
734
+ await requestStop()
735
+ }
736
+ }
737
+ }