teamshare-bridge 0.11.2 → 0.14.1

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 (68) hide show
  1. package/dist/bridge/daemon.js +339 -48
  2. package/dist/bridge/daemon.js.map +1 -1
  3. package/dist/bridge/index.js +38 -0
  4. package/dist/bridge/index.js.map +1 -1
  5. package/dist/bridge/local-server.d.ts +1 -1
  6. package/dist/bridge/local-server.js +20 -5
  7. package/dist/bridge/local-server.js.map +1 -1
  8. package/dist/bridge/service.d.ts +11 -0
  9. package/dist/bridge/service.js +137 -7
  10. package/dist/bridge/service.js.map +1 -1
  11. package/dist/bridge/spawn.d.ts +3 -1
  12. package/dist/bridge/spawn.js +6 -2
  13. package/dist/bridge/spawn.js.map +1 -1
  14. package/dist/cli/commands/build.js +1 -1
  15. package/dist/cli/commands/build.js.map +1 -1
  16. package/dist/cli/commands/chat.js +2 -1
  17. package/dist/cli/commands/chat.js.map +1 -1
  18. package/dist/cli/commands/connect.js +21 -0
  19. package/dist/cli/commands/connect.js.map +1 -1
  20. package/dist/cli/commands/doc.js +5 -1
  21. package/dist/cli/commands/doc.js.map +1 -1
  22. package/dist/cli/commands/harness.js +5 -9
  23. package/dist/cli/commands/harness.js.map +1 -1
  24. package/dist/cli/commands/link.js +1 -1
  25. package/dist/cli/commands/link.js.map +1 -1
  26. package/dist/cli/commands/run.js +1 -0
  27. package/dist/cli/commands/run.js.map +1 -1
  28. package/dist/cli/commands/worker.js +1 -0
  29. package/dist/cli/commands/worker.js.map +1 -1
  30. package/dist/cli/index.js +1 -1
  31. package/dist/cli/index.js.map +1 -1
  32. package/dist/cli/utils.d.ts +5 -0
  33. package/dist/cli/utils.js +19 -1
  34. package/dist/cli/utils.js.map +1 -1
  35. package/dist/lib/agy-pty.js +20 -7
  36. package/dist/lib/agy-pty.js.map +1 -1
  37. package/dist/lib/api.d.ts +1 -0
  38. package/dist/lib/api.js.map +1 -1
  39. package/dist/lib/chat-reply.d.ts +4 -0
  40. package/dist/lib/chat-reply.js +71 -12
  41. package/dist/lib/chat-reply.js.map +1 -1
  42. package/dist/lib/doc-reply.d.ts +8 -0
  43. package/dist/lib/doc-reply.js +104 -16
  44. package/dist/lib/doc-reply.js.map +1 -1
  45. package/dist/lib/git-auto.d.ts +6 -9
  46. package/dist/lib/git-auto.js +9 -23
  47. package/dist/lib/git-auto.js.map +1 -1
  48. package/dist/lib/git-pipeline.js +6 -23
  49. package/dist/lib/git-pipeline.js.map +1 -1
  50. package/dist/lib/harness-adapter.d.ts +5 -1
  51. package/dist/lib/harness-adapter.js +360 -35
  52. package/dist/lib/harness-adapter.js.map +1 -1
  53. package/dist/lib/harness-models.d.ts +17 -0
  54. package/dist/lib/harness-models.js +146 -0
  55. package/dist/lib/harness-models.js.map +1 -0
  56. package/dist/lib/harness-output.d.ts +19 -0
  57. package/dist/lib/harness-output.js +114 -0
  58. package/dist/lib/harness-output.js.map +1 -0
  59. package/dist/lib/harness.d.ts +13 -2
  60. package/dist/lib/harness.js +103 -19
  61. package/dist/lib/harness.js.map +1 -1
  62. package/dist/lib/llm.d.ts +11 -0
  63. package/dist/lib/llm.js +34 -4
  64. package/dist/lib/llm.js.map +1 -1
  65. package/dist/lib/opencode-model-seed.d.ts +7 -0
  66. package/dist/lib/opencode-model-seed.js +97 -0
  67. package/dist/lib/opencode-model-seed.js.map +1 -0
  68. package/package.json +47 -50
@@ -16,6 +16,7 @@ const cache_cleaner_1 = require("../lib/cache-cleaner");
16
16
  const lock_1 = require("../lib/lock");
17
17
  const wake_state_1 = require("../lib/wake-state");
18
18
  const rate_gate_1 = require("../lib/rate-gate");
19
+ const harness_models_1 = require("../lib/harness-models");
19
20
  /** IMP-917: wakes already deferred once for a rate-limit pause (bounded). */
20
21
  const rateDeferred = new Set();
21
22
  const bus_1 = require("../lib/workspace/bus");
@@ -143,6 +144,40 @@ function inQuietHours(quietHours) {
143
144
  return start <= end ? mins >= start && mins < end : mins >= start || mins < end;
144
145
  }
145
146
  async function runDaemon(portOverride) {
147
+ // IMP-9xx crash resilience: an unhandled rejection killed the daemon
148
+ // silently (Node >= 15 default) and nothing revived it. Rejections now
149
+ // log and stay alive; hard exceptions log and exit(1) on purpose so the
150
+ // platform watchdog (scheduled-task restart / LaunchAgent / systemd)
151
+ // brings a fresh instance up. Signals log the shutdown reason so future
152
+ // log gaps are diagnosable.
153
+ process.on('unhandledRejection', (reason) => {
154
+ log(`[fatal] unhandled rejection (continuing): ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
155
+ });
156
+ process.on('uncaughtException', (err) => {
157
+ log(`[fatal] uncaught exception - exiting for watchdog restart: ${err.stack ?? err.message}`);
158
+ process.exit(1);
159
+ });
160
+ for (const sig of ['SIGTERM', 'SIGINT']) {
161
+ process.on(sig, () => {
162
+ log(`shutting down (${sig})`);
163
+ process.exit(0);
164
+ });
165
+ }
166
+ // Hidden drill hooks (IMP-9xx verification):
167
+ // TS_DAEMON_TEST_CRASH=1 -> async REJECTION after 5s: must LOG and
168
+ // SURVIVE (the silent-killer case)
169
+ // TS_DAEMON_TEST_CRASH=throw -> sync THROW after 5s: logs then exits(1),
170
+ // platform watchdog revives
171
+ if (process.env.TS_DAEMON_TEST_CRASH === '1') {
172
+ setTimeout(() => {
173
+ void Promise.reject(new Error('TS_DAEMON_TEST_CRASH rejection drill'));
174
+ }, 5000);
175
+ }
176
+ else if (process.env.TS_DAEMON_TEST_CRASH === 'throw') {
177
+ setTimeout(() => {
178
+ throw new Error('TS_DAEMON_TEST_CRASH throw drill');
179
+ }, 5000);
180
+ }
146
181
  // Idempotency guard: a second `daemon` invocation (manual or autostart)
147
182
  // must never fight the live instance for ports - it would crash with
148
183
  // "no free probe port found" while the real daemon keeps running.
@@ -156,16 +191,44 @@ async function runDaemon(portOverride) {
156
191
  const port = portOverride ?? config.port;
157
192
  const apiBase = process.env.TEAMSHARE_API_URL ?? api_1.DEFAULT_API_URL;
158
193
  const wsBase = (process.env.TEAMSHARE_WS_URL ?? WS_DEFAULT).replace(/\/$/, '');
194
+ // IMP-9xx model catalog discovery: push each installed harness's native
195
+ // list to the backend so the web picker stays current. Best-effort -
196
+ // failures never gate sessions.
197
+ void (async () => {
198
+ const apiKey = config.agents[0]?.apiKey;
199
+ if (!apiKey)
200
+ return;
201
+ for (const harness of ['opencode', 'antigravity', 'codex', 'cursor']) {
202
+ try {
203
+ const batch = (0, harness_models_1.discoverHarnessModels)(harness);
204
+ if (!batch?.models.length)
205
+ continue;
206
+ const stored = await (0, harness_models_1.pushHarnessCatalog)(apiBase, apiKey, harness, batch);
207
+ log(`[models] pushed ${stored} ${harness} model(s) (${batch.source})`);
208
+ }
209
+ catch (err) {
210
+ log(`[models] ${harness} discovery failed: ${(0, config_1.msg)(err)}`);
211
+ }
212
+ }
213
+ })();
159
214
  /** agentId -> queued wakes waiting for the session lock to free up. */
160
215
  const wakeQueue = new Map();
161
216
  /** dedupeKey -> last spawn timestamp (usage guard against wake storms). */
162
217
  const recentSpawns = new Map();
163
218
  /** agentId -> ISO timestamp of the last wake event (probe/status). */
164
219
  const lastWakeByAgent = new Map();
220
+ const handles = new Map();
221
+ /** Fresh config snapshot per wake - project links/wake rules apply live. */
222
+ const getConfig = () => (0, config_1.loadConfig)();
223
+ const credsOf = (h) => ({
224
+ agentId: h.agentId,
225
+ apiKey: h.apiKey,
226
+ });
165
227
  const getStatus = () => ({
166
228
  configPath: (0, config_1.configPath)(),
167
- agents: config.agents.map((a) => {
168
- const lock = (0, lock_1.readLock)(a.agentId);
229
+ agents: [...handles.values()].map((h) => {
230
+ const a = { agentId: h.agentId };
231
+ const lock = (0, lock_1.readLock)(h.agentId);
169
232
  const base = {
170
233
  agentId: a.agentId,
171
234
  lastWake: lastWakeByAgent.get(a.agentId),
@@ -187,7 +250,7 @@ async function runDaemon(portOverride) {
187
250
  registeredSchemes: false,
188
251
  projects: config.projects,
189
252
  });
190
- const { server, port: boundPort } = await (0, local_server_1.startProbeServer)(port, getStatus);
253
+ const { server, port: boundPort } = await (0, local_server_1.startProbeServer)(port, getStatus, () => syncAgents());
191
254
  log(`probe server listening on http://localhost:${boundPort}/ping`);
192
255
  // Track this instance for stop/restart/status even when started in the
193
256
  // foreground (only detached `start` used to leave a pid file behind).
@@ -235,8 +298,17 @@ async function runDaemon(portOverride) {
235
298
  ? new orchestrator_1.OrchestratorClient(config.agents[0].apiKey, apiBase)
236
299
  : null;
237
300
  orchestratorClient?.connect();
238
- for (const agent of config.agents) {
301
+ function startAgentLoop(agent) {
302
+ if (handles.has(agent.agentId))
303
+ return handles.get(agent.agentId);
304
+ log(`[agents] starting loop for ${agent.agentId}`);
239
305
  const api = api_1.TeamshareApi.forAgent(apiBase, agent);
306
+ // Per-wake fresh config: handleWake accepts a getter, so project link /
307
+ // wake-rule edits apply to the NEXT wake without a restart. Shadowing
308
+ // the outer snapshot keeps every existing `config,` call-site argument
309
+ // below valid and always current.
310
+ const config = getConfig;
311
+ const agentTimers = [];
240
312
  /** Keeps the agent marked online; errors are surfaced, never swallowed. */
241
313
  const heartbeat = () => {
242
314
  api
@@ -299,14 +371,15 @@ async function runDaemon(portOverride) {
299
371
  joinUntilReady(0);
300
372
  heartbeat();
301
373
  pushState();
302
- timers.push(setInterval(heartbeat, 60_000));
303
- timers.push(setInterval(pushState, 60_000));
374
+ agentTimers.push(setInterval(heartbeat, 60_000));
375
+ agentTimers.push(setInterval(pushState, 60_000));
304
376
  // Periodic mention poll (catches wakes lost while connected — WS
305
377
  // emits are best-effort; a silent hiccup loses the wake until the
306
378
  // next reconnect). Runs every 10 minutes and processes only
307
- // mentions (never re-wakes open tasks to avoid repeated work).
379
+ // mentions + stranded task assignments (never re-wakes claimed or
380
+ // recently-spawned tasks to avoid repeated work).
308
381
  const MENTION_POLL_MS = 10 * 60_000;
309
- timers.push(setInterval(async () => {
382
+ agentTimers.push(setInterval(async () => {
310
383
  try {
311
384
  const inbox = await api.inbox(agent.agentId);
312
385
  for (const mention of inbox.mentions ?? []) {
@@ -318,13 +391,15 @@ async function runDaemon(portOverride) {
318
391
  if (!projectId)
319
392
  continue;
320
393
  log(`agent ${agent.agentId}: periodic poll wake chat.mention id=${mention.id}`);
321
- await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
394
+ const pollChatOutcome = await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
322
395
  type: 'chat.mention',
323
396
  projectId,
324
397
  messageId: String(mention.id ?? ''),
325
398
  mention: String(mention.mention ?? ''),
326
399
  ...(mention.channelId ? { channelId: String(mention.channelId) } : {}),
327
400
  }, config, { settings: settingsByAgent.get(agent.agentId), wakeQueue, recentSpawns });
401
+ if (pollChatOutcome === 'queued' || pollChatOutcome === 'deferred' || pollChatOutcome === 'blocked')
402
+ continue;
328
403
  (0, wake_state_1.recordProcessed)(agent.agentId, 'chat.mention', key);
329
404
  }
330
405
  else if (String(mention.type) === 'comment') {
@@ -333,28 +408,65 @@ async function runDaemon(portOverride) {
333
408
  if (!projectId)
334
409
  continue;
335
410
  log(`agent ${agent.agentId}: periodic poll wake document.mention id=${mention.id}`);
336
- await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
411
+ const pollDocOutcome = await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
337
412
  type: 'document.mention',
338
413
  documentId: String(mention.documentId),
339
414
  projectId,
340
415
  commentId: String(mention.id ?? ''),
341
416
  mention: String(mention.mention ?? ''),
342
417
  }, config, { settings: settingsByAgent.get(agent.agentId), wakeQueue, recentSpawns });
418
+ if (pollDocOutcome === 'queued' || pollDocOutcome === 'deferred' || pollDocOutcome === 'blocked')
419
+ continue;
343
420
  (0, wake_state_1.recordProcessed)(agent.agentId, 'document.mention', key);
344
421
  }
345
422
  else if (mention.taskId) {
346
423
  log(`agent ${agent.agentId}: periodic poll wake comment.mention id=${mention.id}`);
347
- await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
424
+ const pollCommentOutcome = await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
348
425
  type: 'comment.mention',
349
426
  taskId: String(mention.taskId),
350
427
  ...(mention.projectId ? { projectId: String(mention.projectId) } : {}),
351
428
  commentId: String(mention.id ?? ''),
352
429
  mention: String(mention.mention ?? ''),
353
430
  }, config, { settings: settingsByAgent.get(agent.agentId), wakeQueue, recentSpawns });
431
+ if (pollCommentOutcome === 'queued' || pollCommentOutcome === 'deferred' || pollCommentOutcome === 'blocked')
432
+ continue;
354
433
  (0, wake_state_1.recordProcessed)(agent.agentId, 'comment.mention', key);
355
434
  }
356
435
  }
357
436
  }
437
+ // --- Stranded-assignment recovery (IMP-9xx): a task.assigned
438
+ // wake that was BLOCKED (e.g. no linked folder) or lost mid-blip
439
+ // used to strand forever - the backend only emits on assignment
440
+ // and re-sync only runs on reconnect. Sweep open assigned tasks
441
+ // that no session has claimed yet; 'blocked' outcomes stay
442
+ // unmarked so the next cycle retries them automatically.
443
+ const stranded = (inbox.tasks ?? []).filter((task) => String(task.status ?? '') === 'open' &&
444
+ !task.claimedAt);
445
+ let sweptCount = 0;
446
+ for (const task of stranded) {
447
+ if (sweptCount >= 3)
448
+ break;
449
+ const taskId = String(task.id ?? '');
450
+ if (!taskId)
451
+ continue;
452
+ if ((0, wake_state_1.isRecentlyProcessed)(agent.agentId, 'task.assigned', taskId))
453
+ continue;
454
+ const lastSpawn = recentSpawns.get(`task:${taskId}`);
455
+ if (lastSpawn && Date.now() - lastSpawn < TASK_WAKE_COOLDOWN_MS)
456
+ continue;
457
+ log(`agent ${agent.agentId}: stranded-task sweep wake ${taskId} (${String(task.title ?? '')})`);
458
+ sweptCount++;
459
+ const sweepOutcome = await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
460
+ type: 'task.assigned',
461
+ taskId,
462
+ projectId: task.projectId ?? undefined,
463
+ }, config, { settings: settingsByAgent.get(agent.agentId), wakeQueue, recentSpawns });
464
+ if (sweepOutcome !== 'queued' &&
465
+ sweepOutcome !== 'deferred' &&
466
+ sweepOutcome !== 'blocked') {
467
+ (0, wake_state_1.recordProcessed)(agent.agentId, 'task.assigned', taskId);
468
+ }
469
+ }
358
470
  }
359
471
  catch {
360
472
  // best-effort: don't crash the daemon on periodic poll failure
@@ -396,7 +508,7 @@ async function runDaemon(portOverride) {
396
508
  if ((0, wake_state_1.isRecentlyProcessed)(agent.agentId, 'task.assigned', taskId))
397
509
  continue;
398
510
  log(`agent ${agent.agentId}: re-sync wake task=${taskId} (${String(task.title ?? '')})`);
399
- await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
511
+ const resyncTaskOutcome = await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
400
512
  type: 'task.assigned',
401
513
  taskId,
402
514
  projectId: task.projectId ?? undefined,
@@ -406,7 +518,11 @@ async function runDaemon(portOverride) {
406
518
  wakeQueue,
407
519
  recentSpawns,
408
520
  });
409
- (0, wake_state_1.recordProcessed)(agent.agentId, 'task.assigned', taskId);
521
+ if (resyncTaskOutcome !== 'queued' &&
522
+ resyncTaskOutcome !== 'deferred' &&
523
+ resyncTaskOutcome !== 'blocked') {
524
+ (0, wake_state_1.recordProcessed)(agent.agentId, 'task.assigned', taskId);
525
+ }
410
526
  totalTasks++;
411
527
  }
412
528
  // --- 2. Process inbox.mentions (server-authoritative, replaces
@@ -423,7 +539,7 @@ async function runDaemon(portOverride) {
423
539
  continue;
424
540
  }
425
541
  log(`agent ${agent.agentId}: re-sync wake chat.mention id=${mention.id} project=${projectId}`);
426
- await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
542
+ const resyncChatOutcome = await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
427
543
  type: 'chat.mention',
428
544
  projectId,
429
545
  messageId: String(mention.id ?? ''),
@@ -434,7 +550,11 @@ async function runDaemon(portOverride) {
434
550
  wakeQueue,
435
551
  recentSpawns,
436
552
  });
437
- (0, wake_state_1.recordProcessed)(agent.agentId, 'chat.mention', key);
553
+ if (resyncChatOutcome !== 'queued' &&
554
+ resyncChatOutcome !== 'deferred' &&
555
+ resyncChatOutcome !== 'blocked') {
556
+ (0, wake_state_1.recordProcessed)(agent.agentId, 'chat.mention', key);
557
+ }
438
558
  totalChatMentions++;
439
559
  }
440
560
  else if (String(mention.type) === 'comment') {
@@ -446,7 +566,7 @@ async function runDaemon(portOverride) {
446
566
  continue;
447
567
  }
448
568
  log(`agent ${agent.agentId}: re-sync wake document.mention id=${mention.id} doc=${mention.documentId}`);
449
- await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
569
+ const resyncDocOutcome = await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
450
570
  type: 'document.mention',
451
571
  documentId: String(mention.documentId),
452
572
  projectId,
@@ -457,13 +577,17 @@ async function runDaemon(portOverride) {
457
577
  wakeQueue,
458
578
  recentSpawns,
459
579
  });
460
- (0, wake_state_1.recordProcessed)(agent.agentId, 'document.mention', key);
580
+ if (resyncDocOutcome !== 'queued' &&
581
+ resyncDocOutcome !== 'deferred' &&
582
+ resyncDocOutcome !== 'blocked') {
583
+ (0, wake_state_1.recordProcessed)(agent.agentId, 'document.mention', key);
584
+ }
461
585
  totalDocMentions++;
462
586
  }
463
587
  else if (mention.taskId) {
464
588
  const projectId = String(mention.projectId ?? '');
465
589
  log(`agent ${agent.agentId}: re-sync wake comment.mention id=${mention.id} task=${mention.taskId}`);
466
- await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
590
+ const resyncCommentOutcome = await handleWake(api_1.TeamshareApi.forAgent(apiBase, agent), agent.agentId, agent.apiKey, {
467
591
  type: 'comment.mention',
468
592
  taskId: String(mention.taskId),
469
593
  ...(projectId ? { projectId } : {}),
@@ -474,7 +598,11 @@ async function runDaemon(portOverride) {
474
598
  wakeQueue,
475
599
  recentSpawns,
476
600
  });
477
- (0, wake_state_1.recordProcessed)(agent.agentId, 'comment.mention', key);
601
+ if (resyncCommentOutcome !== 'queued' &&
602
+ resyncCommentOutcome !== 'deferred' &&
603
+ resyncCommentOutcome !== 'blocked') {
604
+ (0, wake_state_1.recordProcessed)(agent.agentId, 'comment.mention', key);
605
+ }
478
606
  totalCommentMentions++;
479
607
  }
480
608
  else {
@@ -515,7 +643,127 @@ async function runDaemon(portOverride) {
515
643
  });
516
644
  sockets.push(socket);
517
645
  wakeQueue.set(agent.agentId, []);
646
+ const handle = {
647
+ agentId: agent.agentId,
648
+ apiKey: agent.apiKey,
649
+ api,
650
+ socket,
651
+ timers: agentTimers,
652
+ };
653
+ handles.set(agent.agentId, handle);
654
+ return handle;
518
655
  }
656
+ /** Stops one agent loop. Caller must have verified lock/queue are drained. */
657
+ function stopAgentLoop(handle) {
658
+ log(`[agents] stopping loop for ${handle.agentId}`);
659
+ for (const t of handle.timers)
660
+ clearInterval(t);
661
+ try {
662
+ handle.socket.close();
663
+ }
664
+ catch {
665
+ /* already closed */
666
+ }
667
+ wakeQueue.delete(handle.agentId);
668
+ settingsByAgent.delete(handle.agentId);
669
+ handles.delete(handle.agentId);
670
+ }
671
+ // Start every configured agent, then keep the set in sync with config.json.
672
+ for (const agent of (0, config_1.loadConfig)().agents)
673
+ startAgentLoop(agent);
674
+ let removalPending = false;
675
+ /**
676
+ * Diff the live loops against config.json:
677
+ * - added agents -> startAgentLoop immediately (zero impact on running
678
+ * sessions of other agents)
679
+ * - removed agents -> deferred while their lock is held / wakes queued;
680
+ * a 30s sweeper finishes the removal once drained.
681
+ */
682
+ function syncAgents() {
683
+ try {
684
+ // Guard against corrupt / mid-write reads: loadConfig() falls back to
685
+ // an EMPTY default config when JSON.parse fails (BOM, partial write),
686
+ // which would otherwise be misread as "remove every agent". A strict
687
+ // re-parse keeps the current loops alive until the file is readable.
688
+ if (!(0, node_fs_1.existsSync)((0, config_1.configPath)()))
689
+ return;
690
+ JSON.parse((0, node_fs_1.readFileSync)((0, config_1.configPath)(), 'utf8'));
691
+ JSON.parse(JSON.stringify((0, config_1.loadConfig)()));
692
+ }
693
+ catch {
694
+ return; // unreadable - keep current loops, next event retries
695
+ }
696
+ const fresh = (0, config_1.loadConfig)();
697
+ const wanted = new Map(fresh.agents.map((a) => [a.agentId, a]));
698
+ for (const [id, a] of wanted) {
699
+ if (!handles.has(id)) {
700
+ log(`[agents] hot-add ${id}`);
701
+ try {
702
+ startAgentLoop(a);
703
+ }
704
+ catch (err) {
705
+ log(`[agents] hot-add ${id} failed: ${(0, config_1.msg)(err)}`);
706
+ }
707
+ }
708
+ }
709
+ let deferred = false;
710
+ for (const [id, h] of [...handles.entries()]) {
711
+ if (wanted.has(id))
712
+ continue;
713
+ const busy = (0, lock_1.isLockBusy)(id);
714
+ const queued = wakeQueue.get(id)?.length ?? 0;
715
+ if (busy || queued > 0) {
716
+ log(`[agents] hot-remove ${id} deferred (busy=${busy}, queued=${queued})`);
717
+ deferred = true;
718
+ continue;
719
+ }
720
+ stopAgentLoop(h);
721
+ }
722
+ removalPending = deferred;
723
+ }
724
+ // Config watcher (debounced - saveConfig writes are not atomic): react to
725
+ // agent-set changes only; model/path edits already apply per-wake.
726
+ let lastAgentSig = '';
727
+ try {
728
+ lastAgentSig = JSON.stringify((0, config_1.loadConfig)().agents.map((a) => a.agentId).sort());
729
+ }
730
+ catch {
731
+ lastAgentSig = '';
732
+ }
733
+ let watchDebounce = null;
734
+ let cfgWatcher = null;
735
+ try {
736
+ cfgWatcher = (0, node_fs_1.watch)((0, config_1.configPath)(), () => {
737
+ if (watchDebounce)
738
+ clearTimeout(watchDebounce);
739
+ watchDebounce = setTimeout(() => {
740
+ watchDebounce = null;
741
+ let next;
742
+ try {
743
+ // Strict parse - a corrupt read must never produce a "[]" signature
744
+ // that wipes the live agent set (see syncAgents guard).
745
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, config_1.configPath)(), 'utf8'));
746
+ if (!parsed || !Array.isArray(parsed.agents))
747
+ return;
748
+ next = JSON.stringify(parsed.agents.map((a) => a.agentId).sort());
749
+ }
750
+ catch {
751
+ return;
752
+ }
753
+ if (!next || next === lastAgentSig)
754
+ return;
755
+ log(`[agents] config change detected (${lastAgentSig} -> ${next})`);
756
+ lastAgentSig = next;
757
+ syncAgents();
758
+ }, 2_000);
759
+ });
760
+ }
761
+ catch (err) {
762
+ log(`[agents] config watcher unavailable: ${(0, config_1.msg)(err)} - connect pings will still reload`);
763
+ }
764
+ // Finishes removals that were deferred behind a busy session.
765
+ timers.push(setInterval(() => { if (removalPending)
766
+ syncAgents(); }, 30_000));
519
767
  // Queue flush: spawn queued wakes once the agent's session lock frees up
520
768
  // (staleness-aware - a crashed session's dead-pid lock is treated as free).
521
769
  const flushInterval = setInterval(() => {
@@ -524,13 +772,18 @@ async function runDaemon(portOverride) {
524
772
  continue;
525
773
  if ((0, lock_1.isLockBusy)(agentId))
526
774
  continue; // still busy - keep waiting
527
- const agent = config.agents.find((a) => a.agentId === agentId);
775
+ const h = handles.get(agentId);
528
776
  const event = queue.shift();
529
- if (!agent || !event)
777
+ if (!h || !event) {
778
+ // Agent removed while queued - drop the stale wake.
779
+ if (!h)
780
+ wakeQueue.delete(agentId);
530
781
  continue;
782
+ }
783
+ const agent = credsOf(h);
531
784
  void (async () => {
532
785
  const settings = await freshSettings(api_1.TeamshareApi.forAgent(apiBase, agent), agentId);
533
- return spawnWake(api_1.TeamshareApi.forAgent(apiBase, agent), agentId, agent.apiKey, event, config, { settings, recentSpawns });
786
+ return spawnWake(api_1.TeamshareApi.forAgent(apiBase, agent), agentId, agent.apiKey, event, getConfig(), { settings, recentSpawns });
534
787
  })()
535
788
  .then(() => {
536
789
  log(`agent ${agentId}: spawned queued wake ${event.type}`);
@@ -554,6 +807,14 @@ async function runDaemon(portOverride) {
554
807
  timers.push(flushInterval);
555
808
  const shutdown = () => {
556
809
  log('shutting down');
810
+ if (watchDebounce)
811
+ clearTimeout(watchDebounce);
812
+ try {
813
+ cfgWatcher?.close();
814
+ }
815
+ catch {
816
+ /* watcher already gone */
817
+ }
557
818
  for (const t of timers)
558
819
  clearInterval(t);
559
820
  for (const s of sockets)
@@ -574,7 +835,11 @@ async function runDaemon(portOverride) {
574
835
  .filter((p) => typeof p === 'string' && p.length > 0);
575
836
  const sweep = () => {
576
837
  const fresh = (0, config_1.loadConfig)();
577
- void (0, cache_cleaner_1.runCleanup)(fresh.cleanup, { projectPaths, agents: config.agents, log })
838
+ void (0, cache_cleaner_1.runCleanup)(fresh.cleanup, {
839
+ projectPaths,
840
+ agents: [...handles.values()].map(credsOf),
841
+ log,
842
+ })
578
843
  .catch((err) => log(`[cleanup] sweep failed: ${(0, config_1.msg)(err)}`));
579
844
  };
580
845
  try {
@@ -591,7 +856,8 @@ async function runDaemon(portOverride) {
591
856
  return typeof raw?.intervalHours === 'number' && raw.intervalHours >= 1 ? raw.intervalHours : 6;
592
857
  }
593
858
  }
594
- async function handleWake(api, agentId, apiKey, event, config, extra) {
859
+ async function handleWake(api, agentId, apiKey, event, configInput, extra) {
860
+ const config = typeof configInput === 'function' ? configInput() : configInput;
595
861
  const { wakeQueue, settings, recentSpawns, orchestratorClient } = extra;
596
862
  // IMP-917: user rate-limited - defer ONCE until the shared gate lifts
597
863
  // (bounded single re-fire; the second pass proceeds and individual API
@@ -608,7 +874,7 @@ async function handleWake(api, agentId, apiKey, event, config, extra) {
608
874
  rateDeferred.delete(deferKey);
609
875
  void handleWake(api, agentId, apiKey, event, config, extra);
610
876
  }, wait);
611
- return;
877
+ return 'deferred';
612
878
  }
613
879
  }
614
880
  }
@@ -629,21 +895,21 @@ async function handleWake(api, agentId, apiKey, event, config, extra) {
629
895
  const now = Date.now();
630
896
  if (last && now - last < cooldownMs) {
631
897
  log(`agent ${agentId}: wake ${event.type} skipped (dedupe - spawned ${Math.round((now - last) / 1000)}s ago)`);
632
- return;
898
+ return 'skipped';
633
899
  }
634
900
  }
635
901
  // Quiet hours: the per-agent setting wins; the bridge config is the
636
902
  // machine-wide fallback for agents without one.
637
903
  if (inQuietHours(settings?.quietHours ?? config.quietHours)) {
638
904
  log(`agent ${agentId}: wake ${event.type} skipped (quiet hours)`);
639
- return;
905
+ return 'skipped';
640
906
  }
641
907
  if (event.taskId && config.wakeRules?.priorities?.length) {
642
908
  try {
643
909
  const task = await api.getTask(event.taskId);
644
910
  if (!config.wakeRules.priorities.includes(task.priority)) {
645
911
  log(`agent ${agentId}: wake ${event.type} skipped (priority ${task.priority})`);
646
- return;
912
+ return 'skipped';
647
913
  }
648
914
  }
649
915
  catch {
@@ -659,9 +925,9 @@ async function handleWake(api, agentId, apiKey, event, config, extra) {
659
925
  queue.push(event);
660
926
  wakeQueue.set(agentId, queue);
661
927
  log(`agent ${agentId}: wake ${event.type} queued (busy since ${lock.startedAt}, ${lock.kind}) - ${queue.length} waiting`);
662
- return;
928
+ return 'queued';
663
929
  }
664
- await spawnWake(api, agentId, apiKey, event, config, { settings, recentSpawns, orchestratorClient });
930
+ return await spawnWake(api, agentId, apiKey, event, config, { settings, recentSpawns, orchestratorClient });
665
931
  }
666
932
  async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
667
933
  log(`agent ${agentId}: wake ${event.type}${event.taskId ? ` task=${event.taskId}` : ''}`);
@@ -709,6 +975,26 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
709
975
  ? `project ${projectId} has no linked folder`
710
976
  : `could not determine the project (guard requires one)`;
711
977
  log(`agent ${agentId}: wake ${event.type} BLOCKED - ${why}`);
978
+ // IMP-9xx visible feedback: the error terminal is easy to miss, so tell
979
+ // people IN the project chat (once per event-type+project window) what
980
+ // blocked the session and exactly how to fix it.
981
+ if (projectId) {
982
+ const fbKey = `${event.type}:${projectId}`;
983
+ try {
984
+ if (!(0, wake_state_1.isRecentlyProcessed)(agentId, 'blocked', fbKey)) {
985
+ const who = event.by?.name?.trim();
986
+ await api.postChatMessage(projectId, `${who ? `${who} ` : 'Hi! '}I can't work here yet - this project has no linked folder on this machine.` +
987
+ ` Set one in TeamShare (Edit project > Linked folder) or run:` +
988
+ ` teamshare-agent set-folder --project ${projectId} --path C:\\path\\to\\folder` +
989
+ ` - then mention or assign me again.`);
990
+ (0, wake_state_1.recordProcessed)(agentId, 'blocked', fbKey);
991
+ log(`agent ${agentId}: blocked-feedback posted to project chat (${fbKey})`);
992
+ }
993
+ }
994
+ catch (err) {
995
+ log(`agent ${agentId}: blocked-feedback chat failed: ${(0, config_1.msg)(err)}`);
996
+ }
997
+ }
712
998
  (0, spawn_1.openErrorTerminal)(`TeamShare agent ${agentId} cannot work: ${why}.\n\n` +
713
999
  `The project guard blocks sessions until a folder is linked.\n` +
714
1000
  `Set one in the TeamShare UI (Edit agent / Edit project > Linked folder)\n` +
@@ -717,7 +1003,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
717
1003
  ? ` teamshare-agent set-folder --project ${projectId} --path C:\\path\\to\\your\\project-folder\n`
718
1004
  : ` teamshare-agent set-folder --project <projectId> --path C:\\path\\to\\your\\project-folder\n`) +
719
1005
  `\nThen mention or assign the agent again.`);
720
- return;
1006
+ return 'blocked';
721
1007
  }
722
1008
  log(`agent ${agentId}: wake ${event.type} -> working folder ${cwd}`);
723
1009
  // IMP-950 auto-worktree escalation (opt-in per project link): when another
@@ -768,35 +1054,38 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
768
1054
  if (event.type === 'chat.mention') {
769
1055
  if (!event.projectId) {
770
1056
  log(`agent ${agentId}: chat.mention without projectId - skipping`);
771
- return;
1057
+ return 'skipped';
772
1058
  }
773
1059
  // Chat mentions spawn a chat-reply loop (no task context).
774
1060
  // Pass messageId so the loop replies to only that specific message.
775
1061
  (0, spawn_1.spawnChatSession)(agentId, apiKey, event.projectId, event.mention ?? '', extra.settings, undefined, cwd, event.messageId);
776
- return;
1062
+ return 'spawned';
777
1063
  }
778
1064
  if (event.type === 'document.mention') {
779
1065
  if (!event.documentId) {
780
1066
  log(`agent ${agentId}: document.mention without documentId - skipping`);
781
- return;
1067
+ return 'skipped';
782
1068
  }
783
1069
  // Document mentions spawn the document assistant loop (Phase F).
784
- (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, cwd);
785
- return;
1070
+ // IMP-9xx: pass the waking comment id so the loop answers ONLY that
1071
+ // comment (answer-once guard still applies) instead of rescanning
1072
+ // every historical mention in the thread.
1073
+ (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, cwd, event.commentId);
1074
+ return 'spawned';
786
1075
  }
787
1076
  if (event.type === 'build.trigger') {
788
1077
  if (!event.projectId) {
789
1078
  log(`agent ${agentId}: build.trigger without projectId - skipping`);
790
- return;
1079
+ return 'skipped';
791
1080
  }
792
1081
  // Build mode: start (or restart) the project's build run.
793
1082
  (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
794
- return;
1083
+ return 'spawned';
795
1084
  }
796
1085
  if (event.type === 'build.stop') {
797
1086
  if (!event.projectId) {
798
1087
  log(`agent ${agentId}: build.stop without projectId - skipping`);
799
- return;
1088
+ return 'skipped';
800
1089
  }
801
1090
  // Write the stop file - the running build loop honors it (wraps up the
802
1091
  // current task and exits). Best-effort: if no build session is running,
@@ -809,7 +1098,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
809
1098
  catch (err) {
810
1099
  log(`agent ${agentId}: build.stop failed to write stop file: ${(0, config_1.msg)(err)}`);
811
1100
  }
812
- return;
1101
+ return 'spawned';
813
1102
  }
814
1103
  if (event.type === 'question.answered') {
815
1104
  // Resume-on-answer: spawn a build session when a build is actually
@@ -818,7 +1107,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
818
1107
  // and resumes it. A stale answer for a finished build does nothing.
819
1108
  if (!event.projectId) {
820
1109
  log(`agent ${agentId}: question.answered without projectId - skipping`);
821
- return;
1110
+ return 'skipped';
822
1111
  }
823
1112
  const stateFile = (0, node_path_1.join)(BUILD_STATE_DIR, `${agentId}.${event.projectId}.json`);
824
1113
  const hasBuildState = (0, node_fs_1.existsSync)(stateFile);
@@ -841,7 +1130,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
841
1130
  }
842
1131
  (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
843
1132
  }
844
- return;
1133
+ return 'spawned';
845
1134
  }
846
1135
  if (event.type === 'question.expired') {
847
1136
  // Expired-on-timeout: same wake path as question.answered — the agent
@@ -850,7 +1139,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
850
1139
  // just wake the agent loop so it picks up the new state.
851
1140
  if (!event.projectId) {
852
1141
  log(`agent ${agentId}: question.expired without projectId - skipping`);
853
- return;
1142
+ return 'skipped';
854
1143
  }
855
1144
  const stateFile = (0, node_path_1.join)(BUILD_STATE_DIR, `${agentId}.${event.projectId}.json`);
856
1145
  const hasBuildState = (0, node_fs_1.existsSync)(stateFile);
@@ -868,30 +1157,32 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
868
1157
  }
869
1158
  (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
870
1159
  }
871
- return;
1160
+ return 'spawned';
872
1161
  }
873
1162
  if (event.type === 'comment.mention') {
874
1163
  // Comment mentions on tasks spawn a task session (agent responds in
875
1164
  // the task thread). Document comment mentions spawn the doc assistant.
876
1165
  if (event.documentId) {
877
1166
  if (projectId && cwd) {
878
- (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, cwd);
1167
+ (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, cwd, event.commentId);
1168
+ return 'spawned';
879
1169
  }
880
- return;
1170
+ return 'skipped';
881
1171
  }
882
1172
  // Fall through to the task-id check below for taskId-carrying comment mentions.
883
1173
  }
884
1174
  const taskId = event.taskId ?? '';
885
1175
  if (!UUID_RE.test(taskId)) {
886
1176
  log(`agent ${agentId}: wake ${event.type} skipped (no/malformed task id "${taskId}")`);
887
- return;
1177
+ return 'skipped';
888
1178
  }
889
1179
  // IMP-810: Worker mode — spawn the worker loop for orchestrator subtasks
890
1180
  if (event.role === 'worker') {
891
1181
  log(`agent ${agentId}: spawning worker session for task ${taskId}`);
892
1182
  (0, spawn_1.spawnWorkerSession)(agentId, apiKey, taskId, event.orchestratorTaskId, extra.settings, undefined, cwd);
893
- return;
1183
+ return 'spawned';
894
1184
  }
895
1185
  (0, spawn_1.spawnAgentSession)(agentId, apiKey, taskId, extra.settings, undefined, cwd);
1186
+ return 'spawned';
896
1187
  }
897
1188
  //# sourceMappingURL=daemon.js.map