@holon-run/agentinbox 0.2.0 → 0.4.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.
@@ -7,6 +7,7 @@ const source_schema_1 = require("./source_schema");
7
7
  const source_resolution_1 = require("./source_resolution");
8
8
  const filter_1 = require("./filter");
9
9
  const terminal_1 = require("./terminal");
10
+ const runtime_gate_1 = require("./runtime_gate");
10
11
  const DEFAULT_SUBSCRIPTION_POLL_LIMIT = 100;
11
12
  const DEFAULT_ACTIVATION_WINDOW_MS = 3_000;
12
13
  const DEFAULT_ACTIVATION_MAX_ITEMS = 20;
@@ -17,6 +18,12 @@ const DEFAULT_OFFLINE_AGENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
17
18
  const DEFAULT_GC_INTERVAL_MS = 60 * 1000;
18
19
  const DEFAULT_SYNC_INTERVAL_MS = 2_000;
19
20
  const DEFAULT_IDLE_SOURCE_GRACE_MS = 5 * 60 * 1000;
21
+ const DIRECT_INBOX_SOURCE_ID = "__agentinbox_direct_text__";
22
+ const DIRECT_INBOX_EVENT_VARIANT = "agentinbox.direct_text_message";
23
+ const TIMER_INBOX_SOURCE_ID = "__agentinbox_timer__";
24
+ const TIMER_INBOX_EVENT_VARIANT = "agentinbox.timer_fired";
25
+ const DEFAULT_TIMER_SENDER = "timer";
26
+ const MIN_TIMER_INTERVAL_MS = 60 * 1000;
20
27
  const WEBHOOK_ACTIVATION_MODES = new Set(["activation_only", "activation_with_items"]);
21
28
  const SUBSCRIPTION_START_POLICIES = new Set(["latest", "earliest", "at_offset", "at_time"]);
22
29
  class AgentInboxService {
@@ -30,11 +37,12 @@ class AgentInboxService {
30
37
  notificationBuffers = new Map();
31
38
  inboxWatchers = new Map();
32
39
  syncInterval = null;
40
+ timerSyncTimeout = null;
33
41
  stopping = false;
34
42
  lastAckedInboxGcAt = 0;
35
43
  lastLifecycleCleanupAt = 0;
36
44
  lastOfflineAgentGcAt = 0;
37
- constructor(store, adapters, activationDispatcher = new ActivationDispatcher(), backend, activationPolicy, terminalDispatcher = new terminal_1.TerminalDispatcher()) {
45
+ constructor(store, adapters, activationDispatcher = new ActivationDispatcher(), backend, activationPolicy, terminalDispatcher = new terminal_1.TerminalDispatcher(), activationGate = new runtime_gate_1.DefaultActivationGate()) {
38
46
  this.store = store;
39
47
  this.adapters = adapters;
40
48
  this.activationDispatcher = activationDispatcher;
@@ -43,9 +51,11 @@ class AgentInboxService {
43
51
  this.activationMaxItems = activationPolicy?.maxItems ?? DEFAULT_ACTIVATION_MAX_ITEMS;
44
52
  this.ackedRetentionMs = DEFAULT_ACKED_RETENTION_MS;
45
53
  this.terminalDispatcher = terminalDispatcher;
54
+ this.activationGate = activationGate;
46
55
  }
47
56
  activationDispatcher;
48
57
  terminalDispatcher;
58
+ activationGate;
49
59
  async start() {
50
60
  if (this.syncInterval) {
51
61
  return;
@@ -57,10 +67,12 @@ class AgentInboxService {
57
67
  void this.runAckedInboxGcIfDue();
58
68
  void this.syncLifecycleGc();
59
69
  }, DEFAULT_SYNC_INTERVAL_MS);
70
+ this.refreshTimersOnStart();
60
71
  await this.syncAllSubscriptions();
61
72
  await this.syncActivationDispatchStates();
62
73
  await this.runAckedInboxGcIfDue(true);
63
74
  await this.syncLifecycleGc();
75
+ await this.syncTimers();
64
76
  }
65
77
  async stop() {
66
78
  this.stopping = true;
@@ -74,7 +86,12 @@ class AgentInboxService {
74
86
  clearInterval(this.syncInterval);
75
87
  this.syncInterval = null;
76
88
  }
89
+ if (this.timerSyncTimeout) {
90
+ clearTimeout(this.timerSyncTimeout);
91
+ this.timerSyncTimeout = null;
92
+ }
77
93
  await this.flushAllPendingNotifications();
94
+ await this.awaitInFlightNotificationBuffers();
78
95
  }
79
96
  async registerSource(input) {
80
97
  const existing = this.store.getSourceByKey(input.sourceType, input.sourceKey);
@@ -319,6 +336,7 @@ class AgentInboxService {
319
336
  return this.registerAgent({
320
337
  runtimeKind: detected.runtimeKind,
321
338
  runtimeSessionId: detected.runtimeSessionId ?? null,
339
+ runtimePid: detected.runtimePid ?? null,
322
340
  backend: detected.backend,
323
341
  mode: "agent_prompt",
324
342
  tmuxPaneId: detected.tmuxPaneId ?? null,
@@ -365,8 +383,106 @@ class AgentInboxService {
365
383
  }
366
384
  });
367
385
  this.inboxWatchers.delete(agentId);
386
+ this.rescheduleTimerSync();
368
387
  return { removed: true };
369
388
  }
389
+ registerTimer(input) {
390
+ this.getAgent(input.agentId);
391
+ const normalized = normalizeTimerInput(input);
392
+ const timezone = normalized.timezone ?? detectHostTimezone();
393
+ assertValidTimeZone(timezone);
394
+ const now = (0, util_1.nowIso)();
395
+ const nextFireAt = computeNextTimerFire({
396
+ mode: normalized.mode,
397
+ at: normalized.at ?? null,
398
+ intervalMs: normalized.every ?? null,
399
+ cronExpr: normalized.cron ?? null,
400
+ timezone,
401
+ fromIso: now,
402
+ lastFiredAt: null,
403
+ restartRecovery: false,
404
+ });
405
+ const timer = {
406
+ scheduleId: (0, util_1.generateId)("sched"),
407
+ agentId: input.agentId,
408
+ status: nextFireAt ? "active" : "paused",
409
+ mode: normalized.mode,
410
+ at: normalized.at ?? null,
411
+ intervalMs: normalized.every ?? null,
412
+ cronExpr: normalized.cron ?? null,
413
+ timezone,
414
+ message: normalizeDirectInboxMessage(input.message),
415
+ sender: normalizeDirectInboxSender(input.sender) ?? DEFAULT_TIMER_SENDER,
416
+ nextFireAt,
417
+ lastFiredAt: null,
418
+ createdAt: now,
419
+ updatedAt: now,
420
+ };
421
+ this.store.insertTimer(timer);
422
+ this.rescheduleTimerSync();
423
+ return timer;
424
+ }
425
+ listTimers(agentId) {
426
+ if (agentId) {
427
+ this.getAgent(agentId);
428
+ return this.store.listTimersForAgent(agentId);
429
+ }
430
+ return this.store.listTimers();
431
+ }
432
+ getTimer(scheduleId) {
433
+ const timer = this.store.getTimer(scheduleId);
434
+ if (!timer) {
435
+ throw new Error(`unknown timer: ${scheduleId}`);
436
+ }
437
+ return timer;
438
+ }
439
+ pauseTimer(scheduleId) {
440
+ const timer = this.getTimer(scheduleId);
441
+ const updated = this.store.updateTimer(scheduleId, {
442
+ status: "paused",
443
+ nextFireAt: null,
444
+ updatedAt: (0, util_1.nowIso)(),
445
+ });
446
+ this.rescheduleTimerSync();
447
+ return {
448
+ updated: timer.status !== "paused",
449
+ timer: updated,
450
+ };
451
+ }
452
+ resumeTimer(scheduleId) {
453
+ const timer = this.getTimer(scheduleId);
454
+ if (timer.mode === "at" && timer.lastFiredAt) {
455
+ throw new Error(`timer ${scheduleId} already fired`);
456
+ }
457
+ const now = (0, util_1.nowIso)();
458
+ const nextFireAt = computeNextTimerFire({
459
+ mode: timer.mode,
460
+ at: timer.at ?? null,
461
+ intervalMs: timer.intervalMs ?? null,
462
+ cronExpr: timer.cronExpr ?? null,
463
+ timezone: timer.timezone,
464
+ fromIso: now,
465
+ lastFiredAt: timer.lastFiredAt ?? null,
466
+ restartRecovery: false,
467
+ });
468
+ const status = nextFireAt ? "active" : "paused";
469
+ const updated = this.store.updateTimer(scheduleId, {
470
+ status,
471
+ nextFireAt,
472
+ updatedAt: now,
473
+ });
474
+ this.rescheduleTimerSync();
475
+ return {
476
+ updated: timer.status !== status,
477
+ timer: updated,
478
+ };
479
+ }
480
+ removeTimer(scheduleId) {
481
+ this.getTimer(scheduleId);
482
+ this.store.deleteTimer(scheduleId);
483
+ this.rescheduleTimerSync();
484
+ return { removed: true, scheduleId };
485
+ }
370
486
  addWebhookActivationTarget(agentId, input) {
371
487
  this.getAgent(agentId);
372
488
  validateNotifyLeaseMs(input.notifyLeaseMs);
@@ -415,6 +531,57 @@ class AgentInboxService {
415
531
  this.reconcileAgentStatus(agentId);
416
532
  return { removed: true };
417
533
  }
534
+ async resumeAgent(agentId) {
535
+ this.getAgent(agentId);
536
+ const targets = this.store.listActivationTargetsForAgent(agentId);
537
+ const results = [];
538
+ for (const target of targets) {
539
+ results.push(await this.resumeActivationTarget(agentId, target.targetId));
540
+ }
541
+ this.reconcileAgentStatus(agentId);
542
+ return {
543
+ resumed: results.some((result) => result.resumed),
544
+ agent: this.getAgent(agentId),
545
+ targets: results,
546
+ };
547
+ }
548
+ async resumeActivationTarget(agentId, targetId) {
549
+ const target = this.getActivationTarget(targetId);
550
+ if (target.agentId !== agentId) {
551
+ throw new Error(`activation target ${targetId} does not belong to agent ${agentId}`);
552
+ }
553
+ if (target.status === "active") {
554
+ return {
555
+ targetId: target.targetId,
556
+ resumed: false,
557
+ status: target.status,
558
+ reason: "already_active",
559
+ };
560
+ }
561
+ let result;
562
+ if (target.kind === "terminal") {
563
+ result = await this.resumeTerminalActivationTarget(target);
564
+ }
565
+ else {
566
+ const now = (0, util_1.nowIso)();
567
+ this.store.updateActivationTargetRuntime(target.targetId, {
568
+ status: "active",
569
+ offlineSince: null,
570
+ consecutiveFailures: 0,
571
+ lastError: null,
572
+ updatedAt: now,
573
+ lastSeenAt: now,
574
+ });
575
+ result = {
576
+ targetId: target.targetId,
577
+ resumed: true,
578
+ status: "active",
579
+ reason: "webhook_resumed",
580
+ };
581
+ }
582
+ this.reconcileAgentStatus(agentId);
583
+ return result;
584
+ }
418
585
  async registerSubscription(input) {
419
586
  if (input.shortcut) {
420
587
  const source = this.store.getSource(input.sourceId);
@@ -578,6 +745,109 @@ class AgentInboxService {
578
745
  listInboxItems(agentId, options) {
579
746
  return this.store.listInboxItems(this.ensureInboxForAgent(agentId).inboxId, options);
580
747
  }
748
+ async addDirectInboxTextMessage(agentId, input, env = process.env) {
749
+ this.getAgent(agentId);
750
+ const message = normalizeDirectInboxMessage(input.message);
751
+ const sender = normalizeDirectInboxSender(input.sender) ?? this.detectDirectInboxSender(env);
752
+ return this.materializeAgentInboxItem(agentId, {
753
+ sourceId: DIRECT_INBOX_SOURCE_ID,
754
+ sourceNativeId: (0, util_1.generateId)("direct"),
755
+ eventVariant: DIRECT_INBOX_EVENT_VARIANT,
756
+ summary: summarizeDirectInboxMessage(sender),
757
+ rawPayload: {
758
+ type: "direct_text_message",
759
+ message,
760
+ sender,
761
+ },
762
+ });
763
+ }
764
+ async fireTimer(timer, now) {
765
+ const scheduledFireAt = timer.nextFireAt ?? now;
766
+ this.materializeAgentInboxItem(timer.agentId, {
767
+ sourceId: TIMER_INBOX_SOURCE_ID,
768
+ sourceNativeId: `${timer.scheduleId}:${scheduledFireAt}`,
769
+ eventVariant: TIMER_INBOX_EVENT_VARIANT,
770
+ summary: summarizeTimerMessage(timer.scheduleId),
771
+ rawPayload: {
772
+ type: "timer_fired",
773
+ scheduleId: timer.scheduleId,
774
+ message: timer.message,
775
+ sender: timer.sender ?? DEFAULT_TIMER_SENDER,
776
+ },
777
+ occurredAt: scheduledFireAt,
778
+ allowDuplicate: true,
779
+ });
780
+ const nextFireAt = computeNextTimerFire({
781
+ mode: timer.mode,
782
+ at: timer.at ?? null,
783
+ intervalMs: timer.intervalMs ?? null,
784
+ cronExpr: timer.cronExpr ?? null,
785
+ timezone: timer.timezone,
786
+ fromIso: scheduledFireAt,
787
+ lastFiredAt: scheduledFireAt,
788
+ restartRecovery: false,
789
+ });
790
+ this.store.updateTimer(timer.scheduleId, {
791
+ status: nextFireAt ? "active" : "paused",
792
+ nextFireAt,
793
+ lastFiredAt: now,
794
+ updatedAt: now,
795
+ });
796
+ }
797
+ materializeAgentInboxItem(agentId, input) {
798
+ const inbox = this.ensureInboxForAgent(agentId);
799
+ const occurredAt = input.occurredAt ?? (0, util_1.nowIso)();
800
+ const item = {
801
+ itemId: (0, util_1.generateId)("item"),
802
+ sourceId: input.sourceId,
803
+ sourceNativeId: input.sourceNativeId,
804
+ eventVariant: input.eventVariant,
805
+ inboxId: inbox.inboxId,
806
+ occurredAt,
807
+ metadata: {},
808
+ rawPayload: input.rawPayload,
809
+ deliveryHandle: null,
810
+ ackedAt: null,
811
+ };
812
+ const inserted = this.store.insertInboxItem(item);
813
+ if (!inserted && !input.allowDuplicate) {
814
+ throw new Error(`failed to persist direct inbox message item: ${item.itemId}`);
815
+ }
816
+ if (!inserted) {
817
+ return {
818
+ itemId: item.itemId,
819
+ inboxId: inbox.inboxId,
820
+ activated: false,
821
+ };
822
+ }
823
+ this.notifyInboxWatchers(agentId, [item]);
824
+ const activationItem = {
825
+ itemId: item.itemId,
826
+ sourceId: item.sourceId,
827
+ sourceNativeId: item.sourceNativeId,
828
+ eventVariant: item.eventVariant,
829
+ inboxId: item.inboxId,
830
+ occurredAt: item.occurredAt,
831
+ metadata: item.metadata,
832
+ rawPayload: item.rawPayload,
833
+ deliveryHandle: item.deliveryHandle,
834
+ };
835
+ const targets = this.store.listActivationTargetsForAgent(agentId).filter((target) => target.status === "active");
836
+ for (const target of targets) {
837
+ this.enqueueActivationTarget(target, {
838
+ agentId,
839
+ subscriptionId: null,
840
+ sourceId: input.sourceId,
841
+ summary: input.summary,
842
+ item: activationItem,
843
+ });
844
+ }
845
+ return {
846
+ itemId: item.itemId,
847
+ inboxId: inbox.inboxId,
848
+ activated: targets.length > 0,
849
+ };
850
+ }
581
851
  watchInbox(agentId, options, onEvent) {
582
852
  const inbox = this.ensureInboxForAgent(agentId);
583
853
  const pendingItems = [];
@@ -1004,6 +1274,7 @@ class AgentInboxService {
1004
1274
  const target = this.store.updateTerminalActivationTargetHeartbeat(existing.targetId, {
1005
1275
  runtimeKind: input.runtimeKind ?? "unknown",
1006
1276
  runtimeSessionId: input.runtimeSessionId ?? null,
1277
+ runtimePid: input.runtimePid ?? null,
1007
1278
  tmuxPaneId: input.tmuxPaneId ?? null,
1008
1279
  tty: input.tty ?? null,
1009
1280
  termProgram: input.termProgram ?? null,
@@ -1030,6 +1301,7 @@ class AgentInboxService {
1030
1301
  notifyLeaseMs: input.notifyLeaseMs ?? DEFAULT_NOTIFY_LEASE_MS,
1031
1302
  runtimeKind: input.runtimeKind ?? "unknown",
1032
1303
  runtimeSessionId: input.runtimeSessionId ?? null,
1304
+ runtimePid: input.runtimePid ?? null,
1033
1305
  backend: input.backend,
1034
1306
  tmuxPaneId: input.tmuxPaneId ?? null,
1035
1307
  tty: input.tty ?? null,
@@ -1111,6 +1383,15 @@ class AgentInboxService {
1111
1383
  await this.flushNotificationBuffer(key);
1112
1384
  }
1113
1385
  }
1386
+ async awaitInFlightNotificationBuffers() {
1387
+ while (true) {
1388
+ const activeBuffers = Array.from(this.notificationBuffers.values());
1389
+ if (activeBuffers.every((buffer) => !buffer.inFlight)) {
1390
+ return;
1391
+ }
1392
+ await new Promise((resolve) => setTimeout(resolve, 10));
1393
+ }
1394
+ }
1114
1395
  async flushNotificationBuffer(key) {
1115
1396
  const buffer = this.notificationBuffers.get(key);
1116
1397
  if (!buffer || buffer.inFlight || buffer.pending.length === 0) {
@@ -1125,12 +1406,14 @@ class AgentInboxService {
1125
1406
  try {
1126
1407
  const state = this.store.getActivationDispatchState(buffer.agentId, buffer.targetId);
1127
1408
  if (!state) {
1409
+ const inbox = this.ensureInboxForAgent(buffer.agentId);
1128
1410
  const dispatched = await this.dispatchActivationTarget({
1129
1411
  agentId: buffer.agentId,
1130
1412
  targetId: buffer.targetId,
1131
1413
  newItemCount: entries.length,
1414
+ totalUnackedCount: this.store.countInboxItems(inbox.inboxId, false),
1132
1415
  summary: entries[0]?.summary ?? null,
1133
- subscriptionIds: uniqueSorted(entries.map((entry) => entry.subscriptionId)),
1416
+ subscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
1134
1417
  sourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
1135
1418
  items: entries.map((entry) => entry.item),
1136
1419
  });
@@ -1146,7 +1429,7 @@ class AgentInboxService {
1146
1429
  leaseExpiresAt: state.leaseExpiresAt,
1147
1430
  pendingNewItemCount: state.pendingNewItemCount + entries.length,
1148
1431
  pendingSummary: state.pendingSummary ?? entries[0]?.summary ?? null,
1149
- pendingSubscriptionIds: uniqueSorted([...state.pendingSubscriptionIds, ...entries.map((entry) => entry.subscriptionId)]),
1432
+ pendingSubscriptionIds: uniqueSortedNullable([...state.pendingSubscriptionIds, ...entries.map((entry) => entry.subscriptionId)]),
1150
1433
  pendingSourceIds: uniqueSorted([...state.pendingSourceIds, ...entries.map((entry) => entry.sourceId)]),
1151
1434
  updatedAt: (0, util_1.nowIso)(),
1152
1435
  });
@@ -1154,9 +1437,11 @@ class AgentInboxService {
1154
1437
  const hasPendingDuringFlight = buffer.pending.length > 0;
1155
1438
  buffer.inFlight = false;
1156
1439
  if (hasPendingDuringFlight) {
1157
- buffer.timer = setTimeout(() => {
1158
- void this.flushNotificationBuffer(key);
1159
- }, this.activationWindowMs);
1440
+ if (!this.stopping) {
1441
+ buffer.timer = setTimeout(() => {
1442
+ void this.flushNotificationBuffer(key);
1443
+ }, this.activationWindowMs);
1444
+ }
1160
1445
  return;
1161
1446
  }
1162
1447
  this.notificationBuffers.delete(key);
@@ -1196,6 +1481,7 @@ class AgentInboxService {
1196
1481
  agentId,
1197
1482
  targetId,
1198
1483
  newItemCount: state.pendingNewItemCount > 0 ? state.pendingNewItemCount : unacked,
1484
+ totalUnackedCount: unacked,
1199
1485
  summary: state.pendingSummary,
1200
1486
  subscriptionIds: state.pendingSubscriptionIds,
1201
1487
  sourceIds: state.pendingSourceIds,
@@ -1231,11 +1517,32 @@ class AgentInboxService {
1231
1517
  }
1232
1518
  const inbox = this.ensureInboxForAgent(input.agentId);
1233
1519
  const summary = summarizeActivation(inbox.inboxId, input.newItemCount, input.summary);
1520
+ const totalUnackedCount = input.totalUnackedCount ?? this.store.countInboxItems(inbox.inboxId, false);
1234
1521
  try {
1235
1522
  if (target.kind === "terminal") {
1523
+ const gate = await this.activationGate.evaluate(target);
1524
+ if (gate.outcome === "offline") {
1525
+ this.markActivationTargetOffline(target.targetId, `runtime gate: ${gate.reason}`);
1526
+ this.reconcileAgentStatus(target.agentId);
1527
+ return "offline";
1528
+ }
1529
+ if (gate.outcome === "defer") {
1530
+ this.store.upsertActivationDispatchState({
1531
+ agentId: input.agentId,
1532
+ targetId: input.targetId,
1533
+ status: "dirty",
1534
+ leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
1535
+ pendingNewItemCount: input.newItemCount,
1536
+ pendingSummary: input.summary,
1537
+ pendingSubscriptionIds: uniqueSortedNullable(input.subscriptionIds),
1538
+ pendingSourceIds: uniqueSorted(input.sourceIds),
1539
+ updatedAt: (0, util_1.nowIso)(),
1540
+ });
1541
+ return "deferred";
1542
+ }
1236
1543
  const prompt = (0, terminal_1.renderAgentPrompt)({
1237
1544
  inboxId: inbox.inboxId,
1238
- newItemCount: input.newItemCount,
1545
+ totalUnackedCount,
1239
1546
  summary: input.summary,
1240
1547
  });
1241
1548
  await this.terminalDispatcher.dispatch(target, prompt);
@@ -1297,11 +1604,37 @@ class AgentInboxService {
1297
1604
  leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
1298
1605
  pendingNewItemCount: entries.length,
1299
1606
  pendingSummary: entries[0]?.summary ?? null,
1300
- pendingSubscriptionIds: uniqueSorted(entries.map((entry) => entry.subscriptionId)),
1607
+ pendingSubscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
1301
1608
  pendingSourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
1302
1609
  updatedAt: (0, util_1.nowIso)(),
1303
1610
  });
1304
1611
  }
1612
+ detectDirectInboxSender(env) {
1613
+ try {
1614
+ const detected = (0, terminal_1.detectTerminalContext)(env);
1615
+ const target = findExistingTerminalActivationTarget(this.store, {
1616
+ runtimeKind: detected.runtimeKind,
1617
+ runtimeSessionId: detected.runtimeSessionId ?? null,
1618
+ runtimePid: detected.runtimePid ?? null,
1619
+ backend: detected.backend,
1620
+ tmuxPaneId: detected.tmuxPaneId ?? null,
1621
+ tty: detected.tty ?? null,
1622
+ termProgram: detected.termProgram ?? null,
1623
+ itermSessionId: detected.itermSessionId ?? null,
1624
+ });
1625
+ if (target) {
1626
+ return target.agentId;
1627
+ }
1628
+ return detected.runtimeSessionId
1629
+ ?? detected.tmuxPaneId
1630
+ ?? detected.itermSessionId
1631
+ ?? detected.tty
1632
+ ?? null;
1633
+ }
1634
+ catch {
1635
+ return null;
1636
+ }
1637
+ }
1305
1638
  markActivationTargetDelivered(targetId) {
1306
1639
  const now = (0, util_1.nowIso)();
1307
1640
  this.store.updateActivationTargetRuntime(targetId, {
@@ -1366,6 +1699,46 @@ class AgentInboxService {
1366
1699
  lastSeenAt: agent.lastSeenAt,
1367
1700
  });
1368
1701
  }
1702
+ async resumeTerminalActivationTarget(target) {
1703
+ const probeStatus = await this.terminalDispatcher.probeStatus(target);
1704
+ const now = (0, util_1.nowIso)();
1705
+ if (probeStatus === "available") {
1706
+ this.store.updateActivationTargetRuntime(target.targetId, {
1707
+ status: "active",
1708
+ offlineSince: null,
1709
+ consecutiveFailures: 0,
1710
+ lastError: null,
1711
+ updatedAt: now,
1712
+ lastSeenAt: now,
1713
+ });
1714
+ return {
1715
+ targetId: target.targetId,
1716
+ resumed: true,
1717
+ status: "active",
1718
+ reason: "terminal_available",
1719
+ };
1720
+ }
1721
+ if (probeStatus === "gone") {
1722
+ this.store.updateActivationTargetRuntime(target.targetId, {
1723
+ status: "offline",
1724
+ offlineSince: target.offlineSince ?? now,
1725
+ updatedAt: now,
1726
+ lastError: target.lastError ?? "terminal gone",
1727
+ });
1728
+ return {
1729
+ targetId: target.targetId,
1730
+ resumed: false,
1731
+ status: "offline",
1732
+ reason: "terminal_gone",
1733
+ };
1734
+ }
1735
+ return {
1736
+ targetId: target.targetId,
1737
+ resumed: false,
1738
+ status: "offline",
1739
+ reason: "probe_unknown",
1740
+ };
1741
+ }
1369
1742
  runLifecycleCleanupPass(nowMs) {
1370
1743
  this.lastLifecycleCleanupAt = nowMs;
1371
1744
  const removed = new Set();
@@ -1474,6 +1847,9 @@ class AgentInboxService {
1474
1847
  if (agents.length > 0) {
1475
1848
  this.store.save();
1476
1849
  }
1850
+ if (agents.length > 0) {
1851
+ this.rescheduleTimerSync();
1852
+ }
1477
1853
  return { removedAgents: agents.length };
1478
1854
  }
1479
1855
  async syncAllSubscriptions() {
@@ -1531,6 +1907,64 @@ class AgentInboxService {
1531
1907
  console.warn("offline agent gc failed:", error);
1532
1908
  }
1533
1909
  }
1910
+ async syncTimers() {
1911
+ const now = (0, util_1.nowIso)();
1912
+ const due = this.store.listDueTimers(now);
1913
+ for (const timer of due) {
1914
+ try {
1915
+ await this.fireTimer(timer, now);
1916
+ }
1917
+ catch (error) {
1918
+ console.warn(`timer fire failed for ${timer.scheduleId}:`, error);
1919
+ }
1920
+ }
1921
+ this.rescheduleTimerSync();
1922
+ }
1923
+ refreshTimersOnStart() {
1924
+ const now = (0, util_1.nowIso)();
1925
+ for (const timer of this.store.listTimers()) {
1926
+ if (timer.status !== "active") {
1927
+ continue;
1928
+ }
1929
+ const nextFireAt = computeNextTimerFire({
1930
+ mode: timer.mode,
1931
+ at: timer.at ?? null,
1932
+ intervalMs: timer.intervalMs ?? null,
1933
+ cronExpr: timer.cronExpr ?? null,
1934
+ timezone: timer.timezone,
1935
+ fromIso: now,
1936
+ lastFiredAt: timer.lastFiredAt ?? null,
1937
+ restartRecovery: true,
1938
+ });
1939
+ this.store.updateTimer(timer.scheduleId, {
1940
+ nextFireAt,
1941
+ status: nextFireAt ? "active" : "paused",
1942
+ updatedAt: now,
1943
+ });
1944
+ }
1945
+ }
1946
+ rescheduleTimerSync() {
1947
+ if (this.timerSyncTimeout) {
1948
+ clearTimeout(this.timerSyncTimeout);
1949
+ this.timerSyncTimeout = null;
1950
+ }
1951
+ if (this.stopping) {
1952
+ return;
1953
+ }
1954
+ const nearest = this.store.getNearestActiveTimer();
1955
+ if (!nearest?.nextFireAt) {
1956
+ return;
1957
+ }
1958
+ const dueAt = Date.parse(nearest.nextFireAt);
1959
+ if (Number.isNaN(dueAt)) {
1960
+ return;
1961
+ }
1962
+ const delay = Math.max(0, dueAt - Date.now());
1963
+ this.timerSyncTimeout = setTimeout(() => {
1964
+ this.timerSyncTimeout = null;
1965
+ void this.syncTimers();
1966
+ }, delay);
1967
+ }
1534
1968
  refreshSourceIdleState(sourceId) {
1535
1969
  const source = this.store.getSource(sourceId);
1536
1970
  if (!source) {
@@ -1841,6 +2275,9 @@ function isSameTerminalIdentity(target, input) {
1841
2275
  function uniqueSorted(values) {
1842
2276
  return Array.from(new Set(values)).sort();
1843
2277
  }
2278
+ function uniqueSortedNullable(values) {
2279
+ return uniqueSorted(values.filter((value) => typeof value === "string" && value.length > 0));
2280
+ }
1844
2281
  function notificationBufferKey(agentId, targetId) {
1845
2282
  return `${agentId}::${targetId}`;
1846
2283
  }
@@ -1873,3 +2310,299 @@ function summarizeSourceEvent(sourceType, sourceKey, eventVariant) {
1873
2310
  }
1874
2311
  return `${sourceType}:${sourceKey}:${eventVariant}`;
1875
2312
  }
2313
+ function summarizeDirectInboxMessage(sender) {
2314
+ if (sender) {
2315
+ return `direct_text_message:${sender}`;
2316
+ }
2317
+ return "direct_text_message";
2318
+ }
2319
+ function summarizeTimerMessage(scheduleId) {
2320
+ return `timer:${scheduleId}`;
2321
+ }
2322
+ function normalizeDirectInboxMessage(message) {
2323
+ if (typeof message !== "string") {
2324
+ throw new Error("direct inbox message must be a string");
2325
+ }
2326
+ const trimmed = message.trim();
2327
+ if (trimmed.length === 0) {
2328
+ throw new Error("direct inbox message must not be empty");
2329
+ }
2330
+ return trimmed;
2331
+ }
2332
+ function normalizeDirectInboxSender(sender) {
2333
+ if (sender == null) {
2334
+ return null;
2335
+ }
2336
+ if (typeof sender !== "string") {
2337
+ throw new Error("direct inbox sender must be a string");
2338
+ }
2339
+ const trimmed = sender.trim();
2340
+ if (trimmed.length === 0) {
2341
+ throw new Error("direct inbox sender must not be empty");
2342
+ }
2343
+ return trimmed;
2344
+ }
2345
+ function normalizeTimerInput(input) {
2346
+ const modes = [input.at != null, input.every != null, input.cron != null].filter(Boolean).length;
2347
+ if (modes !== 1) {
2348
+ throw new Error("timers require exactly one of at, every, or cron");
2349
+ }
2350
+ if (input.at != null) {
2351
+ const at = normalizeIsoTimestamp(input.at, "timer at");
2352
+ return { mode: "at", at, timezone: input.timezone ?? null };
2353
+ }
2354
+ if (input.every != null) {
2355
+ if (!Number.isInteger(input.every) || input.every < MIN_TIMER_INTERVAL_MS) {
2356
+ throw new Error("timer every interval must be an integer number of milliseconds and at least 60000");
2357
+ }
2358
+ return { mode: "every", every: input.every, timezone: input.timezone ?? null };
2359
+ }
2360
+ const cron = normalizeCronExpression(input.cron);
2361
+ return { mode: "cron", cron, timezone: input.timezone ?? null };
2362
+ }
2363
+ function normalizeIsoTimestamp(value, label) {
2364
+ const parsed = Date.parse(value);
2365
+ if (Number.isNaN(parsed)) {
2366
+ throw new Error(`${label} must be a valid ISO8601 timestamp`);
2367
+ }
2368
+ return new Date(parsed).toISOString();
2369
+ }
2370
+ function normalizeCronExpression(value) {
2371
+ if (typeof value !== "string") {
2372
+ throw new Error("timer cron must be a string");
2373
+ }
2374
+ const trimmed = value.trim().replace(/\s+/g, " ");
2375
+ const fields = trimmed.split(" ");
2376
+ if (fields.length !== 5) {
2377
+ throw new Error("timer cron must use standard 5-field syntax");
2378
+ }
2379
+ for (const field of fields) {
2380
+ if (/[LW#@]/.test(field)) {
2381
+ throw new Error("timer cron does not support extensions like L, W, #, or @reboot");
2382
+ }
2383
+ }
2384
+ parseCronExpression(trimmed);
2385
+ return trimmed;
2386
+ }
2387
+ function detectHostTimezone() {
2388
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
2389
+ }
2390
+ function assertValidTimeZone(timezone) {
2391
+ try {
2392
+ Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
2393
+ }
2394
+ catch {
2395
+ throw new Error(`invalid timezone: ${timezone}`);
2396
+ }
2397
+ }
2398
+ function computeNextTimerFire(input) {
2399
+ const fromMs = Date.parse(input.fromIso);
2400
+ if (Number.isNaN(fromMs)) {
2401
+ return null;
2402
+ }
2403
+ if (input.mode === "at") {
2404
+ if (!input.at) {
2405
+ return null;
2406
+ }
2407
+ const atMs = Date.parse(input.at);
2408
+ if (Number.isNaN(atMs)) {
2409
+ return null;
2410
+ }
2411
+ if (input.lastFiredAt) {
2412
+ return null;
2413
+ }
2414
+ if (atMs <= fromMs) {
2415
+ return input.restartRecovery ? input.at : input.at;
2416
+ }
2417
+ return input.at;
2418
+ }
2419
+ if (input.mode === "every") {
2420
+ if (!input.intervalMs) {
2421
+ return null;
2422
+ }
2423
+ if (!input.lastFiredAt) {
2424
+ return new Date(fromMs + input.intervalMs).toISOString();
2425
+ }
2426
+ const lastFiredMs = Date.parse(input.lastFiredAt);
2427
+ if (Number.isNaN(lastFiredMs)) {
2428
+ return new Date(fromMs + input.intervalMs).toISOString();
2429
+ }
2430
+ const base = input.restartRecovery ? fromMs : Math.max(fromMs, lastFiredMs);
2431
+ return new Date(base + input.intervalMs).toISOString();
2432
+ }
2433
+ if (!input.cronExpr) {
2434
+ return null;
2435
+ }
2436
+ return nextCronOccurrence(input.cronExpr, input.timezone, new Date(fromMs));
2437
+ }
2438
+ function parseCronExpression(expr) {
2439
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = expr.split(" ");
2440
+ return {
2441
+ minute: parseCronField(minute, 0, 59),
2442
+ hour: parseCronField(hour, 0, 23),
2443
+ dayOfMonth: parseCronField(dayOfMonth, 1, 31),
2444
+ month: parseCronField(month, 1, 12),
2445
+ dayOfWeek: parseCronField(dayOfWeek, 0, 7, true),
2446
+ };
2447
+ }
2448
+ function parseCronField(field, min, max, normalizeSunday = false) {
2449
+ if (field === "*") {
2450
+ return { any: true, values: new Set() };
2451
+ }
2452
+ const values = new Set();
2453
+ for (const part of field.split(",")) {
2454
+ const [rangePart, stepPart] = part.split("/");
2455
+ const step = stepPart ? Number(stepPart) : 1;
2456
+ if (!Number.isInteger(step) || step <= 0) {
2457
+ throw new Error(`invalid cron field: ${field}`);
2458
+ }
2459
+ const [startRaw, endRaw] = rangePart === "*" ? [String(min), String(max)] : rangePart.split("-");
2460
+ const start = Number(startRaw);
2461
+ const end = endRaw != null ? Number(endRaw) : start;
2462
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
2463
+ throw new Error(`invalid cron field: ${field}`);
2464
+ }
2465
+ for (let value = start; value <= end; value += step) {
2466
+ values.add(normalizeSunday && value === 7 ? 0 : value);
2467
+ }
2468
+ }
2469
+ return { any: false, values };
2470
+ }
2471
+ function nextCronOccurrence(expr, timezone, after) {
2472
+ const parsed = parseCronExpression(expr);
2473
+ let candidate = addMinutes(after, 1);
2474
+ candidate.setUTCSeconds(0, 0);
2475
+ for (let guard = 0; guard < 10_000; guard += 1) {
2476
+ const zoned = zonedDateParts(candidate, timezone);
2477
+ const nextMonth = nextAllowedValue(parsed.month, zoned.month, 1, 12);
2478
+ if (nextMonth == null) {
2479
+ candidate = utcDateForZoned(timezone, zoned.year + 1, firstAllowedValue(parsed.month, 1, 12), 1, 0, 0);
2480
+ continue;
2481
+ }
2482
+ if (nextMonth !== zoned.month) {
2483
+ candidate = utcDateForZoned(timezone, zoned.year, nextMonth, 1, 0, 0);
2484
+ continue;
2485
+ }
2486
+ if (!matchesCronDay(parsed, zoned)) {
2487
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day + 1, 0, 0);
2488
+ continue;
2489
+ }
2490
+ const nextHour = nextAllowedValue(parsed.hour, zoned.hour, 0, 23);
2491
+ if (nextHour == null) {
2492
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day + 1, firstAllowedValue(parsed.hour, 0, 23), 0);
2493
+ continue;
2494
+ }
2495
+ if (nextHour !== zoned.hour) {
2496
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, nextHour, 0);
2497
+ continue;
2498
+ }
2499
+ const nextMinute = nextAllowedValue(parsed.minute, zoned.minute, 0, 59);
2500
+ if (nextMinute == null) {
2501
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, zoned.hour + 1, firstAllowedValue(parsed.minute, 0, 59));
2502
+ continue;
2503
+ }
2504
+ if (nextMinute !== zoned.minute) {
2505
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, zoned.hour, nextMinute);
2506
+ continue;
2507
+ }
2508
+ if (matchesCron(parsed, zoned)) {
2509
+ return candidate.toISOString();
2510
+ }
2511
+ candidate = addMinutes(candidate, 1);
2512
+ }
2513
+ return null;
2514
+ }
2515
+ function matchesCron(parsed, parts) {
2516
+ const monthMatches = parsed.month.any || parsed.month.values.has(parts.month);
2517
+ const hourMatches = parsed.hour.any || parsed.hour.values.has(parts.hour);
2518
+ const minuteMatches = parsed.minute.any || parsed.minute.values.has(parts.minute);
2519
+ if (!monthMatches || !hourMatches || !minuteMatches) {
2520
+ return false;
2521
+ }
2522
+ const dayOfMonthMatches = parsed.dayOfMonth.any || parsed.dayOfMonth.values.has(parts.day);
2523
+ const dayOfWeekMatches = parsed.dayOfWeek.any || parsed.dayOfWeek.values.has(parts.dayOfWeek);
2524
+ if (!parsed.dayOfMonth.any && !parsed.dayOfWeek.any) {
2525
+ return dayOfMonthMatches || dayOfWeekMatches;
2526
+ }
2527
+ return dayOfMonthMatches && dayOfWeekMatches;
2528
+ }
2529
+ function matchesCronDay(parsed, parts) {
2530
+ const dayOfMonthMatches = parsed.dayOfMonth.any || parsed.dayOfMonth.values.has(parts.day);
2531
+ const dayOfWeekMatches = parsed.dayOfWeek.any || parsed.dayOfWeek.values.has(parts.dayOfWeek);
2532
+ if (!parsed.dayOfMonth.any && !parsed.dayOfWeek.any) {
2533
+ return dayOfMonthMatches || dayOfWeekMatches;
2534
+ }
2535
+ return dayOfMonthMatches && dayOfWeekMatches;
2536
+ }
2537
+ function zonedDateParts(date, timezone) {
2538
+ const formatter = new Intl.DateTimeFormat("en-US", {
2539
+ timeZone: timezone,
2540
+ year: "numeric",
2541
+ month: "2-digit",
2542
+ day: "2-digit",
2543
+ hour: "2-digit",
2544
+ minute: "2-digit",
2545
+ hourCycle: "h23",
2546
+ weekday: "short",
2547
+ });
2548
+ const entries = Object.fromEntries(formatter.formatToParts(date)
2549
+ .filter((part) => part.type !== "literal")
2550
+ .map((part) => [part.type, part.value]));
2551
+ const hour = Number(entries.hour);
2552
+ return {
2553
+ year: Number(entries.year),
2554
+ month: Number(entries.month),
2555
+ day: Number(entries.day),
2556
+ // Some ICU builds format midnight as 24:00 even for exact timestamps.
2557
+ // Normalize that representation so cron matching stays cross-platform.
2558
+ hour: hour === 24 ? 0 : hour,
2559
+ minute: Number(entries.minute),
2560
+ dayOfWeek: weekdayToNumber(entries.weekday),
2561
+ };
2562
+ }
2563
+ function nextAllowedValue(field, current, min, max) {
2564
+ if (field.any) {
2565
+ return current;
2566
+ }
2567
+ for (let value = current; value <= max; value += 1) {
2568
+ if (field.values.has(value)) {
2569
+ return value;
2570
+ }
2571
+ }
2572
+ return null;
2573
+ }
2574
+ function firstAllowedValue(field, min, max) {
2575
+ if (field.any) {
2576
+ return min;
2577
+ }
2578
+ for (let value = min; value <= max; value += 1) {
2579
+ if (field.values.has(value)) {
2580
+ return value;
2581
+ }
2582
+ }
2583
+ return min;
2584
+ }
2585
+ function addMinutes(date, minutes) {
2586
+ return new Date(date.getTime() + minutes * 60_000);
2587
+ }
2588
+ function utcDateForZoned(timezone, year, month, day, hour, minute) {
2589
+ const approxUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
2590
+ const approx = new Date(approxUtc);
2591
+ const zoned = zonedDateParts(approx, timezone);
2592
+ const targetNaiveUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
2593
+ const zonedNaiveUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0, 0);
2594
+ return new Date(approxUtc - (zonedNaiveUtc - targetNaiveUtc));
2595
+ }
2596
+ function weekdayToNumber(value) {
2597
+ switch (value) {
2598
+ case "Sun": return 0;
2599
+ case "Mon": return 1;
2600
+ case "Tue": return 2;
2601
+ case "Wed": return 3;
2602
+ case "Thu": return 4;
2603
+ case "Fri": return 5;
2604
+ case "Sat": return 6;
2605
+ default:
2606
+ throw new Error(`unsupported weekday: ${value}`);
2607
+ }
2608
+ }