@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.6

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.
@@ -23,12 +23,9 @@ import { isBusy } from '../hub/core/ticks.mjs';
23
23
  import { handleHook } from '../hub/hooks/hub-bridge.mjs';
24
24
  import { defaultDrivers, diagnoseClaude } from '../hub/runners/drivers.mjs';
25
25
  import { createRunner } from '../hub/workers/runner.mjs';
26
- import { readTaskBrief } from '../hub/lib/brief.mjs';
27
26
  import { decisionPost, parsePosts } from '../hub/lib/channels.mjs';
28
27
  import { STEP_IDS, auditPost, movePost, overrideHolds, overrideKey, overrideText, statusRequest, stepHolds, withOverride, workMoved } from '../hub/lib/status.mjs';
29
- import { askDetail, channelParticipant, hasChatInbox, listChannels, participantPost, participantThread, postToProject, projectMessages, taskMessages } from '../hub/lib/chat.mjs';
30
- import { findAgentQuery } from '../hub/lib/transcripts.mjs';
31
- import { watchTranscripts } from '../hub/lib/transcript-feed.mjs';
28
+ import { channelParticipant, hasChatInbox, participantPost, postToProject } from '../hub/lib/chat.mjs';
32
29
  import { HUB, acceptDelivery, keepAwakeStatus, readSettings, validSettings, writeSettings } from '../hub/lib/store.mjs';
33
30
  import { createManager } from '../hub/workers/manager.mjs';
34
31
  import { NOBODY, aliasesFor } from '../hub/lib/me.mjs';
@@ -37,8 +34,12 @@ import { projectDeliveries } from '../hub/projections/deliveries.mjs';
37
34
  import { projectGraph } from '../hub/projections/graph.mjs';
38
35
  import { execFileBounded } from '../lib/subprocess.js';
39
36
  import { createBusIdentity, routedMessaging } from './busIdentity.js';
37
+ import { channelFileFor as channelFileIn } from './channelFiles.js';
40
38
  import { createChannelDiscipline } from './channelDiscipline.js';
41
39
  import { createDeliveryStates } from './deliveryStates.js';
40
+ import { createFileReads } from './fileReads.js';
41
+ import { UNMONITORED } from './loopMonitor.js';
42
+ import { serial } from './serial.js';
42
43
  import { statusPage } from './statusPage.js';
43
44
 
44
45
  const WORKSPACE = path.resolve(HUB, '..');
@@ -46,6 +47,8 @@ const CHANNEL_ROOTS = ['AgentPulseApps', 'Google_Cloud'].map((d) => path.join(WO
46
47
  const HOURS = [1, 3, 6, 12, 24, 72, 168];
47
48
  const CACHE_MS = 4000;
48
49
  const CHANGE_DEBOUNCE_MS = 600;
50
+ /** A change feed news of transcripts alone starts a new discovery pass at most this often. */
51
+ const TRANSCRIPT_RESCAN_MS = 15e3;
49
52
  /** Steady writes (heartbeats every few hundred ms) keep restarting the debounce; a change still goes out this soon. */
50
53
  const CHANGE_MAX_WAIT_MS = 2000;
51
54
  /** A graph read re-registers the user at most this often: often enough to stay online, rarely enough not to wake the change feed on every read. */
@@ -64,10 +67,12 @@ const LOCAL_HOST = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
64
67
  const LOCAL_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/;
65
68
  const NO_FRAME = { 'x-frame-options': 'DENY', 'content-security-policy': "frame-ancestors 'none'", 'x-content-type-options': 'nosniff' };
66
69
 
67
- const sendJson = (res, body, code = 200, extra = {}) => {
70
+ /** sendJson for a body already serialized (by the file reads worker): the same headers and bytes. */
71
+ const sendJsonText = (res, text, code = 200, extra = {}) => {
68
72
  res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store', ...NO_FRAME, ...extra });
69
- res.end(JSON.stringify(body));
73
+ res.end(text);
70
74
  };
75
+ const sendJson = (res, body, code = 200, extra = {}) => sendJsonText(res, JSON.stringify(body), code, extra);
71
76
 
72
77
  function trustedWrite(req) {
73
78
  const origin = req.headers.origin;
@@ -133,40 +138,11 @@ export function plainCapture(db) {
133
138
  tx: (fn, { guard = null, label = 'write' } = {}) => withTx(db, () => { const out = fn(); if (guard) guard(); return out; }, label),
134
139
  applying: (fn) => fn(),
135
140
  pump: () => 0,
141
+ chunks: async (steps, { label = 'write' } = {}) => { withTx(db, () => { for (const step of steps) step(); }, label); return 1; },
136
142
  };
137
143
  }
138
144
 
139
- const BRIEF_CACHE_MS = 30e3;
140
- const briefCache = new Map();
141
-
142
- export function channelFileFor(key, roots = CHANNEL_ROOTS) {
143
- const m = String(key || '').match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
144
- if (!m || m[1].startsWith('.') || m[2].startsWith('.')) return null;
145
- for (const root of roots) {
146
- const file = path.join(root, m[1], '.agents', m[2], 'channel.md');
147
- try {
148
- const st = fs.lstatSync(file);
149
- if (!st.isFile()) continue;
150
- const realRoot = fs.realpathSync(root);
151
- if (fs.realpathSync(file) !== path.join(realRoot, m[1], '.agents', m[2], 'channel.md')) continue;
152
- return file;
153
- } catch { /* not here */ }
154
- }
155
- return null;
156
- }
157
-
158
- function taskBriefFor(key, roots) {
159
- const hit = briefCache.get(key);
160
- if (hit && Date.now() - hit.at < BRIEF_CACHE_MS) return hit.body;
161
- const file = channelFileFor(key, roots);
162
- if (!file) return null;
163
- const fromFiles = readTaskBrief(file);
164
- const thin = !fromFiles || !fromFiles.sections.some((s) => s.body && s.body.trim());
165
- const body = thin ? (readTaskBrief(file, { query: findAgentQuery(key) }) || fromFiles || { title: null, source: null, sections: [] }) : fromFiles;
166
- if (briefCache.size > 200) briefCache.clear();
167
- briefCache.set(key, { at: Date.now(), body });
168
- return body;
169
- }
145
+ export const channelFileFor = (key, roots = CHANNEL_ROOTS) => channelFileIn(key, roots);
170
146
 
171
147
  /** How many posts a channel holds now: the position of the one just appended. */
172
148
  const postsIn = (file) => parsePosts(fs.readFileSync(file, 'utf8'), Date.now()).length;
@@ -182,14 +158,15 @@ export function appendNoFollow(file, text) {
182
158
  } finally { fs.closeSync(fd); }
183
159
  }
184
160
 
185
- function collectWorker() {
186
- const worker = new Worker(new URL('../hub/lib/collect-worker.mjs', import.meta.url));
161
+ function collectWorker({ onDiscovered = () => {} } = {}) {
162
+ const worker = new Worker(new URL('./collectWorker.js', import.meta.url));
187
163
  const waiting = new Map();
188
164
  let seq = 0;
189
- worker.on('message', ({ id, body, error }) => {
165
+ worker.on('message', ({ type, id, text, error }) => {
166
+ if (type === 'discovered') { onDiscovered(); return; }
190
167
  const w = waiting.get(id);
191
168
  waiting.delete(id);
192
- if (w) { if (error) w.reject(new Error(error)); else w.resolve(body); }
169
+ if (w) { if (error) w.reject(new Error(error)); else w.resolve(text); }
193
170
  });
194
171
  worker.on('error', (e) => { console.error(`collector crashed: ${e && e.stack ? e.stack : e}`); process.exit(1); });
195
172
  return (hours, managerView, aliases, { discoveryEpoch } = {}) => {
@@ -200,12 +177,23 @@ function collectWorker() {
200
177
  };
201
178
  }
202
179
 
203
- function collector(run) {
180
+ function collector(run, { rescanMs = TRANSCRIPT_RESCAN_MS } = {}) {
204
181
  const cache = new Map();
205
182
  /** Bumped by clear() and reproject(): a collect started before a change must not cache its older body. */
206
183
  let generation = 0;
207
184
  /** Bumped by clear() only: the collector may reuse a discovery pass from the same epoch (workers/collector.mjs). */
208
185
  let discoveryEpoch = 0;
186
+ let epochAt = -Infinity;
187
+ let rescan = null;
188
+ const clear = () => {
189
+ clearTimeout(rescan);
190
+ rescan = null;
191
+ generation++;
192
+ discoveryEpoch++;
193
+ epochAt = Date.now();
194
+ cache.clear();
195
+ };
196
+ const reproject = () => { generation++; cache.clear(); };
209
197
  return {
210
198
  graph(hours, managerView, aliases = []) {
211
199
  const hit = cache.get(hours);
@@ -218,9 +206,20 @@ function collector(run) {
218
206
  cache.set(hours, { ...(hit || {}), running });
219
207
  return running;
220
208
  },
221
- clear() { generation++; discoveryEpoch++; cache.clear(); },
209
+ clear,
222
210
  /** Presence moved and nothing else: refresh the bodies without a new discovery pass. */
223
- reproject() { generation++; cache.clear(); },
211
+ reproject,
212
+ /**
213
+ * Only Cursor transcripts moved, which they do every second while agents work: a new discovery pass at most every
214
+ * `rescanMs`, the last change still picked up by a pass at the end of the wait; reads meanwhile reproject.
215
+ */
216
+ transcriptsMoved() {
217
+ const wait = epochAt + rescanMs - Date.now();
218
+ if (wait <= 0) return clear();
219
+ reproject();
220
+ if (!rescan) { rescan = setTimeout(clear, wait); rescan.unref(); }
221
+ return undefined;
222
+ },
224
223
  };
225
224
  }
226
225
 
@@ -231,7 +230,7 @@ function watchRecursive(dir, onFile, onError) {
231
230
  return fs.watch(dir, { recursive: true }, (e, f) => { if (f) onFile(String(f)); }).on('error', onError);
232
231
  }
233
232
 
234
- function changeFeed(onChange, { stateDir, dbFile, channelRoots, transcriptsDir, hubChanged, watchDir }) {
233
+ function changeFeed(onChange, { stateDir, dbFile, channelRoots, hubChanged, watchDir }) {
235
234
  const clients = new Set();
236
235
  const pending = new Set();
237
236
  let timer = null;
@@ -264,7 +263,6 @@ function changeFeed(onChange, { stateDir, dbFile, channelRoots, transcriptsDir,
264
263
  for (const root of channelRoots) watch(root, (f) => (/\.agents[\\/][^\\/]+[\\/](channel|brief)\.md$/.test(f) ? 'channels' : null));
265
264
  watch(stateDir, (f) => (/(events\.jsonl|requests|chat[\\/]|inbox[\\/]|bus[\\/]|accepted\.json|personas\.json)/.test(f) ? 'hub' : null));
266
265
  if (dbFile) watch(path.dirname(dbFile), (f) => (f.startsWith(dbName) ? DB_WRITE : null));
267
- if (transcriptsDir) watchTranscripts(transcriptsDir, () => notify('agents'));
268
266
  const keepAlive = setInterval(() => { for (const res of clients) res.write(': keep-alive\n\n'); }, 25e3);
269
267
  keepAlive.unref();
270
268
  return {
@@ -292,23 +290,53 @@ const hasTable = (db, name) => Boolean(db.prepare("SELECT 1 AS found FROM sqlite
292
290
  /** Without a cloud lease (tests, a bare hub) this runner is the only one, as the local hub was. */
293
291
  const SOLE_LEASE = Object.freeze({ held: () => true, view: () => ({ holder: 'local', here: true }), onChange: () => {} });
294
292
 
293
+ /**
294
+ * A lease that lapses here only while this process stalled (a long runner.db transaction holds the event loop) comes back
295
+ * as soon as the queued report is read. The manager pauses meanwhile and is stopped, killing its runs, only past this.
296
+ */
297
+ export const MANAGER_STOP_GRACE_MS = 60e3;
298
+ const MANAGER_LOG_MAX_BYTES = 1024 * 1024;
299
+
300
+ /** state/manager.log: what the manager did wrong, with its time, also on stderr; one older file is kept. */
301
+ export function managerLog(file, { echo = console.error } = {}) {
302
+ return (line) => {
303
+ echo(line);
304
+ try {
305
+ if ((fs.statSync(file, { throwIfNoEntry: false })?.size || 0) > MANAGER_LOG_MAX_BYTES) fs.renameSync(file, `${file}.1`);
306
+ fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
307
+ } catch { /* stderr has it */ }
308
+ };
309
+ }
310
+
295
311
  export function serve({
296
312
  hub, capture = plainCapture(hub.db), port = 8890, listenHost = '127.0.0.1', stateDir = path.join(HUB, 'state'), dbFile = null,
297
313
  enableCollector = true, enableManager = true, collect = null, channelRoots = CHANNEL_ROOTS,
298
314
  transcriptsDir = path.join(os.homedir(), '.cursor', 'projects'), watchDir = watchRecursive, identity = null,
299
315
  lease = SOLE_LEASE, pulseUrl = null, health = () => ({ state: 'online' }), metrics = () => ({}), drivers = null,
300
316
  hookBudgetMs = HOOK_BUDGET_MS, writeBudgetMs = WRITE_BUDGET_MS, onWrite = () => {}, managerOptions = {}, workspaceRoot = WORKSPACE,
301
- discipline: disciplineOptions = {},
317
+ discipline: disciplineOptions = {}, loop = UNMONITORED,
302
318
  } = {}) {
303
319
  const me = () => {
304
320
  const m = identity ? identity.current() : NOBODY;
305
321
  return m === NOBODY ? NOBODY : { ...m, aliases: aliasesFor(m, personaUsernames()) };
306
322
  };
307
- const graphs = enableCollector ? collector(collect || collectWorker()) : { graph: async () => ({ nodes: [], links: [], events: [], deliveries: [], agents: [], totals: {} }), clear() {}, reproject() {} };
323
+ // A discovery pass the worker saved after answering: later reads project its rows, and change-feed clients read again.
324
+ const graphs = enableCollector ? collector(collect || collectWorker({ onDiscovered: () => { graphs.reproject(); feed.notify('presence'); } })) : { graph: async () => ({ nodes: [], links: [], events: [], deliveries: [], agents: [], totals: {} }), clear() {}, reproject() {}, transcriptsMoved() {} };
308
325
  const caches = { clear: () => graphs.clear() };
326
+ const onFeedChange = (kinds) => {
327
+ if (kinds.every((k) => k === 'presence')) return graphs.reproject();
328
+ if (kinds.every((k) => k === 'presence' || k === 'agents')) return graphs.transcriptsMoved();
329
+ return graphs.clear();
330
+ };
309
331
  const feed = enableCollector
310
- ? changeFeed((kinds) => (kinds.every((k) => k === 'presence') ? graphs.reproject() : graphs.clear()), { stateDir, dbFile, channelRoots, transcriptsDir, hubChanged: changeTracker(hub.db), watchDir })
332
+ ? changeFeed(onFeedChange, { stateDir, dbFile, channelRoots, hubChanged: changeTracker(hub.db), watchDir })
311
333
  : { attach(req, res) { res.writeHead(200); res.end(); }, dbWrite() {}, notify() {}, close() {} };
334
+ // Channel, brief and transcript files are read and parsed off the event loop (fileReadsWorker.js).
335
+ const fileReads = createFileReads({ roots: channelRoots, transcriptsDir: enableCollector ? transcriptsDir : null, onTranscripts: () => feed.notify('agents') });
336
+ /** Answers a route from the file reads worker; `fallback` answers what the worker leaves to the hub (null). */
337
+ const fromFiles = (res, op, args, fallback = null) => fileReads.read(op, args, { text: true })
338
+ .then((answer) => (answer ? sendJsonText(res, ...answer) : fallback()))
339
+ .catch((e) => { console.error(`file read failed: ${op}: ${e.message}`); if (!res.headersSent) sendJson(res, { error: 'server error' }, 500); });
312
340
  if (identity) {
313
341
  identity.listen(() => { graphs.clear(); feed.notify('hub'); });
314
342
  identity.refresh();
@@ -320,8 +348,9 @@ export function serve({
320
348
  channelRoots, workspaceRoot, append: appendNoFollow, ...thresholds,
321
349
  publish: (message) => hub.messaging.publish(message, hub.registry.directory()),
322
350
  });
351
+ const logManager = managerOptions.log || managerLog(path.join(stateDir, 'manager.log'));
323
352
  const manager = enableManager
324
- ? createManager({ hub, state: stateDir, onChange: () => graphs.clear(), held: () => lease.held(), fyi: (m) => delivery.managerFyi(m), ...managerOptions })
353
+ ? createManager({ hub, state: stateDir, onChange: () => graphs.clear(), held: () => lease.held(), fyi: (m) => delivery.managerFyi(m), ...managerOptions, log: logManager })
325
354
  : { start() {}, stop() {}, view() { return { state: 'offline', online: false, pending: 0 }; } };
326
355
  /** The manager's status, with the DONE and STATUS posts it has read (F2). */
327
356
  const managerView = () => ({ ...manager.view(), reports: delivery.reports() });
@@ -334,30 +363,65 @@ export function serve({
334
363
  }
335
364
  if (serverLease.error) console.error(`another hub server holds the lease (${serverLease.holder}); taking over`);
336
365
  const timers = [];
337
- const every = (ms, fn) => { const t = setInterval(fn, ms); t.unref(); timers.push(t); };
366
+ const every = (ms, fn, name = 'timer') => { const t = setInterval(() => loop.tick(name, fn), ms); t.unref(); timers.push(t); };
338
367
  if (serverLease.token) {
339
- every(10e3, () => { try { hub.leases.renew('server', serverLease.token, { ttlMs: 30e3 }); } catch (e) { console.error(`server lease renewal failed: ${e.message}`); } });
368
+ every(10e3, () => { try { hub.leases.renew('server', serverLease.token, { ttlMs: 30e3 }); } catch (e) { console.error(`server lease renewal failed: ${e.message}`); } }, 'server lease');
340
369
  }
341
370
  const releaseLease = () => { if (serverLease.token) { try { hub.leases.release('server', serverLease.token); } catch { /* closing */ } } };
342
371
  const wrote = () => { feed.dbWrite(); onWrite(); };
343
372
  /** The manager, the wake sweep and the scheduled nudge belong to the runner holding the user's manager lease (Q9). */
344
373
  const leaseHeld = () => lease.held();
374
+ const deliverySweep = serial(async () => {
375
+ try {
376
+ const settled = capture.tx(() => delivery.settleNudges(), { label: 'delivery sweep' });
377
+ const bounced = [];
378
+ await capture.chunks(delivery.bounceSteps({ sent: bounced }), { label: 'delivery bounce' });
379
+ if (settled + bounced.length) { caches.clear(); wrote(); }
380
+ } catch (e) { console.error(`delivery sweep failed: ${e.message}`); }
381
+ });
382
+ const staleSweep = serial(async () => {
383
+ try {
384
+ const seen = await discipline.scan();
385
+ if (leaseHeld() && capture.tx(() => discipline.sweep({ seen }).length, { label: 'stale sweep' })) { caches.clear(); wrote(); }
386
+ } catch (e) { console.error(`stale sweep failed: ${e.message}`); }
387
+ });
345
388
  if (enableManager) {
346
389
  if (leaseHeld()) manager.start();
347
- lease.onChange((held) => { if (held) manager.start(); else manager.stop(); });
390
+ let lapsedAt = null;
391
+ let stopTimer = null;
392
+ lease.onChange((held) => {
393
+ const { fence } = lease.view();
394
+ if (held) {
395
+ clearTimeout(stopTimer);
396
+ stopTimer = null;
397
+ if (lapsedAt) {
398
+ logManager(`manager lease held again (fence ${fence})${fence !== lapsedAt.fence ? `; it moved from fence ${lapsedAt.fence}, so runs from before are stopped` : ''}`);
399
+ if (fence !== lapsedAt.fence) manager.stop();
400
+ }
401
+ lapsedAt = null;
402
+ manager.start();
403
+ return;
404
+ }
405
+ if (lapsedAt) return;
406
+ lapsedAt = { fence };
407
+ logManager(`manager lease lapsed here (fence ${fence}); runs in flight keep going, and are stopped if it is not back in ${MANAGER_STOP_GRACE_MS / 1000} s`);
408
+ stopTimer = setTimeout(() => {
409
+ stopTimer = null;
410
+ if (lease.held()) return;
411
+ logManager('manager lease still not held: the manager is stopped');
412
+ manager.stop();
413
+ }, MANAGER_STOP_GRACE_MS);
414
+ stopTimer.unref();
415
+ });
348
416
  every(60e3, () => {
349
417
  if (!leaseHeld()) return;
350
418
  try { if (capture.tx(() => sweepWakes(hub), { label: 'wake sweep' }).length) { caches.clear(); wrote(); } } catch (e) { console.error(`wake sweep failed: ${e.message}`); }
351
- try {
352
- if (capture.tx(() => delivery.settleNudges() + delivery.bounce().length, { label: 'delivery sweep' })) { caches.clear(); wrote(); }
353
- } catch (e) { console.error(`delivery sweep failed: ${e.message}`); }
354
- });
419
+ return deliverySweep();
420
+ }, 'wake and delivery sweep');
355
421
  every(disciplineSweepMs, () => {
356
422
  if (!leaseHeld()) return;
357
- try {
358
- if (capture.tx(() => discipline.sweep().length, { label: 'stale sweep' })) { caches.clear(); wrote(); }
359
- } catch (e) { console.error(`stale sweep failed: ${e.message}`); }
360
- });
423
+ return staleSweep();
424
+ }, 'stale sweep');
361
425
  }
362
426
  const runner = createRunner({ hub, drivers: drivers || defaultDrivers(), workspaceRoot: WORKSPACE, onChange: () => { graphs.clear(); wrote(); } });
363
427
  if (enableManager) runner.begin();
@@ -369,7 +433,7 @@ export function serve({
369
433
  });
370
434
  if (capture.tx(nudge, { label: 'scheduled nudge' })) { caches.clear(); wrote(); }
371
435
  } catch (e) { console.error(`scheduled nudge failed: ${e.message}`); }
372
- });
436
+ }, 'scheduled nudge');
373
437
 
374
438
  const registeredAt = hasTable(hub.db, 'pulse_registered')
375
439
  ? hub.db.prepare('INSERT INTO pulse_registered (id, at) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET at = excluded.at')
@@ -469,7 +533,12 @@ export function serve({
469
533
  capture.tx(() => hub.registry.register({ id: 'user', role: 'USER', name: 'You', topics: ['user'], node_type: 'user' }), { label: 'graph user' });
470
534
  wrote();
471
535
  }
472
- return graphs.graph(hours, managerView(), me().aliases).then((body) => sendJson(res, { ...body, settings: { ...settingsOf(hub), me: me() } }))
536
+ return graphs.graph(hours, managerView(), me().aliases).then((body) => {
537
+ const settings = { ...settingsOf(hub), me: me() };
538
+ // The collect worker answers JSON text of an object without `settings`; appended last, the bytes are those of the spread.
539
+ if (typeof body === 'string' && body.length > 2 && body.endsWith('}')) return sendJsonText(res, `${body.slice(0, -1)},"settings":${JSON.stringify(settings)}}`);
540
+ return sendJson(res, { ...(typeof body === 'string' ? JSON.parse(body) : body), settings });
541
+ })
473
542
  .catch((e) => { console.error(`collect failed: ${e.message}`); if (!res.headersSent) sendJson(res, { error: 'server error' }, 500); });
474
543
  }
475
544
  if (p === '/api/settings' && req.method === 'GET') return sendJson(res, { ...settingsOf(hub), me: me() });
@@ -495,8 +564,7 @@ export function serve({
495
564
  if (p === '/api/task/brief' && req.method === 'GET') {
496
565
  const key = String(url.searchParams.get('key') || '');
497
566
  if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(key)) return sendJson(res, { error: 'no such channel' }, 404);
498
- const brief = taskBriefFor(key, channelRoots);
499
- return brief ? sendJson(res, brief) : sendJson(res, { error: 'no such channel' }, 404);
567
+ return fromFiles(res, 'brief', { key });
500
568
  }
501
569
  if (p === '/api/runs' && req.method === 'GET') return sendJson(res, { runs: runner.list(), drivers: runner.drivers() });
502
570
  if (p === '/api/runs/diagnose' && req.method === 'GET') {
@@ -505,12 +573,10 @@ export function serve({
505
573
  }
506
574
  if (p === '/api/task/ask' && req.method === 'GET') {
507
575
  const key = String(url.searchParams.get('key') || '');
508
- const file = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(key) ? channelFileFor(key, channelRoots) : null;
509
- if (!file) return sendJson(res, { error: 'no such channel' }, 404);
576
+ if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(key)) return sendJson(res, { error: 'no such channel' }, 404);
510
577
  const ref = String(url.searchParams.get('ref') || '');
511
578
  const at = Number(url.searchParams.get('at')) || null;
512
- const detail = askDetail(file, { ref: /^[A-Z]?\d+$/.test(ref) ? ref : null, at, aliases: me().aliases });
513
- return detail ? sendJson(res, detail) : sendJson(res, { error: 'no open ask in this channel' }, 404);
579
+ return fromFiles(res, 'ask', { key, ref: /^[A-Z]?\d+$/.test(ref) ? ref : null, at, aliases: me().aliases });
514
580
  }
515
581
  if (p === '/api/inbox' && req.method === 'GET') {
516
582
  const since = Math.max(Date.now() - 24 * 3600e3, Number(url.searchParams.get('since')) || 0);
@@ -520,29 +586,16 @@ export function serve({
520
586
  return sendJson(res, { messages, at: Date.now() });
521
587
  }
522
588
  if (p === '/api/chat/channels' && req.method === 'GET') {
523
- const dms = hub.conversations.dmPartners();
524
- return sendJson(res, { ...listChannels(channelRoots, Date.now() - CHAT_CHANNELS_MS), dms });
589
+ return fileReads.read('channels', { sinceMs: Date.now() - CHAT_CHANNELS_MS })
590
+ .then(([listed]) => sendJson(res, { ...listed, dms: hub.conversations.dmPartners() }))
591
+ .catch((e) => { console.error(`file read failed: channels: ${e.message}`); if (!res.headersSent) sendJson(res, { error: 'server error' }, 500); });
525
592
  }
526
593
  if (p === '/api/chat/messages' && req.method === 'GET') {
527
594
  const ch = String(url.searchParams.get('ch') || '');
528
- if (ch.startsWith('task:')) {
529
- const file = channelFileFor(ch.slice(5), channelRoots);
530
- return file ? sendJson(res, { messages: taskMessages(file) }) : sendJson(res, { error: 'no such channel' }, 404);
531
- }
532
- if (ch.startsWith('project:')) {
533
- const messages = projectMessages(channelRoots, ch.slice(8), Date.now() - CHAT_HISTORY_MS);
534
- return messages ? sendJson(res, { messages }) : sendJson(res, { error: 'no such channel' }, 404);
535
- }
536
- if (ch.startsWith('dm:')) {
537
- const part = channelParticipant(ch.slice(3));
538
- if (part) {
539
- const file = channelFileFor(part.key, channelRoots);
540
- return file ? sendJson(res, { messages: participantThread(file, part.role) }) : sendJson(res, { error: 'no such person' }, 404);
541
- }
595
+ return fromFiles(res, 'messages', { ch, historyMs: CHAT_HISTORY_MS }, () => {
542
596
  const dm = hub.conversations.thread(ch.slice(3));
543
597
  return dm ? sendJson(res, dm) : sendJson(res, { error: 'no such person' }, 404);
544
- }
545
- return sendJson(res, { error: 'no such channel' }, 404);
598
+ });
546
599
  }
547
600
  if (p === '/api/deliveries' && req.method === 'GET') return sendJson(res, projectDeliveries(hub));
548
601
  if (p === '/api/legacy/map' && req.method === 'GET') {
@@ -579,9 +632,17 @@ export function serve({
579
632
  const pollStarted = Date.now();
580
633
  let beat = pollStarted;
581
634
  const inbox = routedMessaging(hub.messaging, box.addresses);
635
+ const claimsFor = box.addresses.length > 1 && box.addresses[0] === bound.principal ? box.addresses : [bound.principal];
582
636
  // Re-polls run from a timer, outside readBody's try/catch: a busy database waits for the next poll, anything else answers 503.
583
637
  const poll = () => {
584
638
  if (!connected(req, res)) return undefined;
639
+ let idle = false;
640
+ try { idle = !(body.heartbeat === true && Date.now() - beat >= LISTEN_HEARTBEAT_MS) && !bus.claimable(claimsFor); } catch { /* the claim below says why */ }
641
+ if (idle) {
642
+ if (Date.now() - pollStarted >= timeout) return sendJson(res, { messages: [], at: Date.now() });
643
+ setTimeout(poll, 250).unref();
644
+ return undefined;
645
+ }
585
646
  let messages = [];
586
647
  try {
587
648
  messages = commit(req, res, pollStarted, Infinity, () => {
@@ -867,6 +928,7 @@ export function serve({
867
928
  });
868
929
  server.on('close', () => {
869
930
  feed.close();
931
+ fileReads.close();
870
932
  for (const t of timers) clearInterval(t);
871
933
  manager.stop();
872
934
  releaseLease();
@@ -0,0 +1,64 @@
1
+ import { Worker } from 'node:worker_threads';
2
+
3
+ /** A worker that died is started again after this long, so a crash can't turn into a busy loop. */
4
+ const RESTART_MS = 1000;
5
+
6
+ /**
7
+ * The facade's side of fileReadsWorker.js. `read(op, args)` resolves to the route's [body, status], or null for a
8
+ * `dm:` that is the hub's own thread; with `{ text: true }` the body comes as its JSON text. The worker also runs the transcript feed when given `transcriptsDir`, calling
9
+ * `onTranscripts` once per pass that saw a transcript move. A worker that dies fails the reads it had in flight and is
10
+ * started again; `close()` stops it.
11
+ */
12
+ export function createFileReads({ roots, transcriptsDir = null, onTranscripts = () => {} }) {
13
+ const waiting = new Map();
14
+ let seq = 0;
15
+ let worker = null;
16
+ let closed = false;
17
+ let restart = null;
18
+
19
+ const start = () => {
20
+ const w = new Worker(new URL('./fileReadsWorker.js', import.meta.url), { workerData: { roots, transcriptsDir } });
21
+ w.unref();
22
+ w.on('message', (m) => {
23
+ if (m.type === 'agents') { onTranscripts(); return; }
24
+ const pending = waiting.get(m.id);
25
+ if (!pending) return;
26
+ waiting.delete(m.id);
27
+ if (m.error) pending.reject(new Error(m.error)); else pending.resolve(m.answer);
28
+ });
29
+ const failed = (error) => {
30
+ if (worker !== w) return;
31
+ worker = null;
32
+ for (const pending of waiting.values()) pending.reject(error);
33
+ waiting.clear();
34
+ if (transcriptsDir && !closed && !restart) {
35
+ restart = setTimeout(() => { restart = null; if (!closed && !worker) worker = start(); }, RESTART_MS);
36
+ restart.unref();
37
+ }
38
+ };
39
+ w.on('error', (e) => { console.error(`file reads worker failed: ${e && e.stack ? e.stack : e}`); failed(e); });
40
+ w.on('exit', (code) => failed(new Error(`the file reads worker exited (${code})`)));
41
+ return w;
42
+ };
43
+ if (transcriptsDir) worker = start();
44
+
45
+ return {
46
+ read(op, args, { text = false } = {}) {
47
+ if (closed) return Promise.reject(new Error('the file reads worker is closed'));
48
+ worker ??= start();
49
+ const id = ++seq;
50
+ const answer = new Promise((resolve, reject) => waiting.set(id, { resolve, reject }));
51
+ worker.postMessage({ id, op, args, text });
52
+ return answer;
53
+ },
54
+ close() {
55
+ closed = true;
56
+ clearTimeout(restart);
57
+ const w = worker;
58
+ worker = null;
59
+ for (const pending of waiting.values()) pending.reject(new Error('the file reads worker is closed'));
60
+ waiting.clear();
61
+ if (w) w.terminate();
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,61 @@
1
+ // The facade's file reads, off the runner's event loop: channel and brief files and Cursor transcripts are read and
2
+ // parsed here with the same hub functions the routes called inline, so each answer is the same, byte for byte.
3
+ import { parentPort, workerData } from 'node:worker_threads';
4
+ import { askDetail, channelParticipant, listChannels, participantThread, projectMessages, taskMessages } from '../hub/lib/chat.mjs';
5
+ import { channelFileFor, taskBriefFor } from './channelFiles.js';
6
+ import { watchTranscripts } from './transcriptFeed.js';
7
+
8
+ const { roots, transcriptsDir } = workerData;
9
+ const NOT_FOUND = (error) => [{ error }, 404];
10
+
11
+ /** Each op answers [body, status]; a dm: that isn't a channel participant is the hub's own (null: the caller reads it). */
12
+ const OPS = {
13
+ brief: ({ key }) => {
14
+ const brief = taskBriefFor(key, roots);
15
+ return brief ? [brief, 200] : NOT_FOUND('no such channel');
16
+ },
17
+ ask: ({ key, ref, at, aliases }) => {
18
+ const file = channelFileFor(key, roots);
19
+ if (!file) return NOT_FOUND('no such channel');
20
+ const detail = askDetail(file, { ref, at, aliases });
21
+ return detail ? [detail, 200] : NOT_FOUND('no open ask in this channel');
22
+ },
23
+ channels: ({ sinceMs }) => [listChannels(roots, sinceMs), 200],
24
+ messages: ({ ch, historyMs }) => {
25
+ if (ch.startsWith('task:')) {
26
+ const file = channelFileFor(ch.slice(5), roots);
27
+ return file ? [{ messages: taskMessages(file) }, 200] : NOT_FOUND('no such channel');
28
+ }
29
+ if (ch.startsWith('project:')) {
30
+ const messages = projectMessages(roots, ch.slice(8), Date.now() - historyMs);
31
+ return messages ? [{ messages }, 200] : NOT_FOUND('no such channel');
32
+ }
33
+ if (ch.startsWith('dm:')) {
34
+ const part = channelParticipant(ch.slice(3));
35
+ if (!part) return null;
36
+ const file = channelFileFor(part.key, roots);
37
+ return file ? [{ messages: participantThread(file, part.role) }, 200] : NOT_FOUND('no such person');
38
+ }
39
+ return NOT_FOUND('no such channel');
40
+ },
41
+ };
42
+
43
+ parentPort.on('message', ({ id, op, args, text = false }) => {
44
+ try {
45
+ const answer = OPS[op](args);
46
+ // As JSON text the answer crosses to the main thread as one string, not an object graph it must clone and stringify.
47
+ parentPort.postMessage({ id, answer: text && answer ? [JSON.stringify(answer[0]), answer[1]] : answer });
48
+ } catch (e) {
49
+ parentPort.postMessage({ id, error: String((e && e.stack) || e) });
50
+ }
51
+ });
52
+
53
+ if (transcriptsDir) {
54
+ // One message per pass of the feed, however many transcript files it saw move.
55
+ let queued = false;
56
+ watchTranscripts(transcriptsDir, () => {
57
+ if (queued) return;
58
+ queued = true;
59
+ queueMicrotask(() => { queued = false; parentPort.postMessage({ type: 'agents' }); });
60
+ });
61
+ }