@holon-run/agentinbox 0.2.0 → 0.3.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);
@@ -578,6 +694,109 @@ class AgentInboxService {
578
694
  listInboxItems(agentId, options) {
579
695
  return this.store.listInboxItems(this.ensureInboxForAgent(agentId).inboxId, options);
580
696
  }
697
+ async addDirectInboxTextMessage(agentId, input, env = process.env) {
698
+ this.getAgent(agentId);
699
+ const message = normalizeDirectInboxMessage(input.message);
700
+ const sender = normalizeDirectInboxSender(input.sender) ?? this.detectDirectInboxSender(env);
701
+ return this.materializeAgentInboxItem(agentId, {
702
+ sourceId: DIRECT_INBOX_SOURCE_ID,
703
+ sourceNativeId: (0, util_1.generateId)("direct"),
704
+ eventVariant: DIRECT_INBOX_EVENT_VARIANT,
705
+ summary: summarizeDirectInboxMessage(sender),
706
+ rawPayload: {
707
+ type: "direct_text_message",
708
+ message,
709
+ sender,
710
+ },
711
+ });
712
+ }
713
+ async fireTimer(timer, now) {
714
+ const scheduledFireAt = timer.nextFireAt ?? now;
715
+ this.materializeAgentInboxItem(timer.agentId, {
716
+ sourceId: TIMER_INBOX_SOURCE_ID,
717
+ sourceNativeId: `${timer.scheduleId}:${scheduledFireAt}`,
718
+ eventVariant: TIMER_INBOX_EVENT_VARIANT,
719
+ summary: summarizeTimerMessage(timer.scheduleId),
720
+ rawPayload: {
721
+ type: "timer_fired",
722
+ scheduleId: timer.scheduleId,
723
+ message: timer.message,
724
+ sender: timer.sender ?? DEFAULT_TIMER_SENDER,
725
+ },
726
+ occurredAt: scheduledFireAt,
727
+ allowDuplicate: true,
728
+ });
729
+ const nextFireAt = computeNextTimerFire({
730
+ mode: timer.mode,
731
+ at: timer.at ?? null,
732
+ intervalMs: timer.intervalMs ?? null,
733
+ cronExpr: timer.cronExpr ?? null,
734
+ timezone: timer.timezone,
735
+ fromIso: scheduledFireAt,
736
+ lastFiredAt: scheduledFireAt,
737
+ restartRecovery: false,
738
+ });
739
+ this.store.updateTimer(timer.scheduleId, {
740
+ status: nextFireAt ? "active" : "paused",
741
+ nextFireAt,
742
+ lastFiredAt: now,
743
+ updatedAt: now,
744
+ });
745
+ }
746
+ materializeAgentInboxItem(agentId, input) {
747
+ const inbox = this.ensureInboxForAgent(agentId);
748
+ const occurredAt = input.occurredAt ?? (0, util_1.nowIso)();
749
+ const item = {
750
+ itemId: (0, util_1.generateId)("item"),
751
+ sourceId: input.sourceId,
752
+ sourceNativeId: input.sourceNativeId,
753
+ eventVariant: input.eventVariant,
754
+ inboxId: inbox.inboxId,
755
+ occurredAt,
756
+ metadata: {},
757
+ rawPayload: input.rawPayload,
758
+ deliveryHandle: null,
759
+ ackedAt: null,
760
+ };
761
+ const inserted = this.store.insertInboxItem(item);
762
+ if (!inserted && !input.allowDuplicate) {
763
+ throw new Error(`failed to persist direct inbox message item: ${item.itemId}`);
764
+ }
765
+ if (!inserted) {
766
+ return {
767
+ itemId: item.itemId,
768
+ inboxId: inbox.inboxId,
769
+ activated: false,
770
+ };
771
+ }
772
+ this.notifyInboxWatchers(agentId, [item]);
773
+ const activationItem = {
774
+ itemId: item.itemId,
775
+ sourceId: item.sourceId,
776
+ sourceNativeId: item.sourceNativeId,
777
+ eventVariant: item.eventVariant,
778
+ inboxId: item.inboxId,
779
+ occurredAt: item.occurredAt,
780
+ metadata: item.metadata,
781
+ rawPayload: item.rawPayload,
782
+ deliveryHandle: item.deliveryHandle,
783
+ };
784
+ const targets = this.store.listActivationTargetsForAgent(agentId).filter((target) => target.status === "active");
785
+ for (const target of targets) {
786
+ this.enqueueActivationTarget(target, {
787
+ agentId,
788
+ subscriptionId: null,
789
+ sourceId: input.sourceId,
790
+ summary: input.summary,
791
+ item: activationItem,
792
+ });
793
+ }
794
+ return {
795
+ itemId: item.itemId,
796
+ inboxId: inbox.inboxId,
797
+ activated: targets.length > 0,
798
+ };
799
+ }
581
800
  watchInbox(agentId, options, onEvent) {
582
801
  const inbox = this.ensureInboxForAgent(agentId);
583
802
  const pendingItems = [];
@@ -1004,6 +1223,7 @@ class AgentInboxService {
1004
1223
  const target = this.store.updateTerminalActivationTargetHeartbeat(existing.targetId, {
1005
1224
  runtimeKind: input.runtimeKind ?? "unknown",
1006
1225
  runtimeSessionId: input.runtimeSessionId ?? null,
1226
+ runtimePid: input.runtimePid ?? null,
1007
1227
  tmuxPaneId: input.tmuxPaneId ?? null,
1008
1228
  tty: input.tty ?? null,
1009
1229
  termProgram: input.termProgram ?? null,
@@ -1030,6 +1250,7 @@ class AgentInboxService {
1030
1250
  notifyLeaseMs: input.notifyLeaseMs ?? DEFAULT_NOTIFY_LEASE_MS,
1031
1251
  runtimeKind: input.runtimeKind ?? "unknown",
1032
1252
  runtimeSessionId: input.runtimeSessionId ?? null,
1253
+ runtimePid: input.runtimePid ?? null,
1033
1254
  backend: input.backend,
1034
1255
  tmuxPaneId: input.tmuxPaneId ?? null,
1035
1256
  tty: input.tty ?? null,
@@ -1111,6 +1332,15 @@ class AgentInboxService {
1111
1332
  await this.flushNotificationBuffer(key);
1112
1333
  }
1113
1334
  }
1335
+ async awaitInFlightNotificationBuffers() {
1336
+ while (true) {
1337
+ const activeBuffers = Array.from(this.notificationBuffers.values());
1338
+ if (activeBuffers.every((buffer) => !buffer.inFlight)) {
1339
+ return;
1340
+ }
1341
+ await new Promise((resolve) => setTimeout(resolve, 10));
1342
+ }
1343
+ }
1114
1344
  async flushNotificationBuffer(key) {
1115
1345
  const buffer = this.notificationBuffers.get(key);
1116
1346
  if (!buffer || buffer.inFlight || buffer.pending.length === 0) {
@@ -1130,7 +1360,7 @@ class AgentInboxService {
1130
1360
  targetId: buffer.targetId,
1131
1361
  newItemCount: entries.length,
1132
1362
  summary: entries[0]?.summary ?? null,
1133
- subscriptionIds: uniqueSorted(entries.map((entry) => entry.subscriptionId)),
1363
+ subscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
1134
1364
  sourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
1135
1365
  items: entries.map((entry) => entry.item),
1136
1366
  });
@@ -1146,7 +1376,7 @@ class AgentInboxService {
1146
1376
  leaseExpiresAt: state.leaseExpiresAt,
1147
1377
  pendingNewItemCount: state.pendingNewItemCount + entries.length,
1148
1378
  pendingSummary: state.pendingSummary ?? entries[0]?.summary ?? null,
1149
- pendingSubscriptionIds: uniqueSorted([...state.pendingSubscriptionIds, ...entries.map((entry) => entry.subscriptionId)]),
1379
+ pendingSubscriptionIds: uniqueSortedNullable([...state.pendingSubscriptionIds, ...entries.map((entry) => entry.subscriptionId)]),
1150
1380
  pendingSourceIds: uniqueSorted([...state.pendingSourceIds, ...entries.map((entry) => entry.sourceId)]),
1151
1381
  updatedAt: (0, util_1.nowIso)(),
1152
1382
  });
@@ -1154,9 +1384,11 @@ class AgentInboxService {
1154
1384
  const hasPendingDuringFlight = buffer.pending.length > 0;
1155
1385
  buffer.inFlight = false;
1156
1386
  if (hasPendingDuringFlight) {
1157
- buffer.timer = setTimeout(() => {
1158
- void this.flushNotificationBuffer(key);
1159
- }, this.activationWindowMs);
1387
+ if (!this.stopping) {
1388
+ buffer.timer = setTimeout(() => {
1389
+ void this.flushNotificationBuffer(key);
1390
+ }, this.activationWindowMs);
1391
+ }
1160
1392
  return;
1161
1393
  }
1162
1394
  this.notificationBuffers.delete(key);
@@ -1233,6 +1465,26 @@ class AgentInboxService {
1233
1465
  const summary = summarizeActivation(inbox.inboxId, input.newItemCount, input.summary);
1234
1466
  try {
1235
1467
  if (target.kind === "terminal") {
1468
+ const gate = await this.activationGate.evaluate(target);
1469
+ if (gate.outcome === "offline") {
1470
+ this.markActivationTargetOffline(target.targetId, `runtime gate: ${gate.reason}`);
1471
+ this.reconcileAgentStatus(target.agentId);
1472
+ return "offline";
1473
+ }
1474
+ if (gate.outcome === "defer") {
1475
+ this.store.upsertActivationDispatchState({
1476
+ agentId: input.agentId,
1477
+ targetId: input.targetId,
1478
+ status: "dirty",
1479
+ leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
1480
+ pendingNewItemCount: input.newItemCount,
1481
+ pendingSummary: input.summary,
1482
+ pendingSubscriptionIds: uniqueSortedNullable(input.subscriptionIds),
1483
+ pendingSourceIds: uniqueSorted(input.sourceIds),
1484
+ updatedAt: (0, util_1.nowIso)(),
1485
+ });
1486
+ return "deferred";
1487
+ }
1236
1488
  const prompt = (0, terminal_1.renderAgentPrompt)({
1237
1489
  inboxId: inbox.inboxId,
1238
1490
  newItemCount: input.newItemCount,
@@ -1297,11 +1549,37 @@ class AgentInboxService {
1297
1549
  leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
1298
1550
  pendingNewItemCount: entries.length,
1299
1551
  pendingSummary: entries[0]?.summary ?? null,
1300
- pendingSubscriptionIds: uniqueSorted(entries.map((entry) => entry.subscriptionId)),
1552
+ pendingSubscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
1301
1553
  pendingSourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
1302
1554
  updatedAt: (0, util_1.nowIso)(),
1303
1555
  });
1304
1556
  }
1557
+ detectDirectInboxSender(env) {
1558
+ try {
1559
+ const detected = (0, terminal_1.detectTerminalContext)(env);
1560
+ const target = findExistingTerminalActivationTarget(this.store, {
1561
+ runtimeKind: detected.runtimeKind,
1562
+ runtimeSessionId: detected.runtimeSessionId ?? null,
1563
+ runtimePid: detected.runtimePid ?? null,
1564
+ backend: detected.backend,
1565
+ tmuxPaneId: detected.tmuxPaneId ?? null,
1566
+ tty: detected.tty ?? null,
1567
+ termProgram: detected.termProgram ?? null,
1568
+ itermSessionId: detected.itermSessionId ?? null,
1569
+ });
1570
+ if (target) {
1571
+ return target.agentId;
1572
+ }
1573
+ return detected.runtimeSessionId
1574
+ ?? detected.tmuxPaneId
1575
+ ?? detected.itermSessionId
1576
+ ?? detected.tty
1577
+ ?? null;
1578
+ }
1579
+ catch {
1580
+ return null;
1581
+ }
1582
+ }
1305
1583
  markActivationTargetDelivered(targetId) {
1306
1584
  const now = (0, util_1.nowIso)();
1307
1585
  this.store.updateActivationTargetRuntime(targetId, {
@@ -1474,6 +1752,9 @@ class AgentInboxService {
1474
1752
  if (agents.length > 0) {
1475
1753
  this.store.save();
1476
1754
  }
1755
+ if (agents.length > 0) {
1756
+ this.rescheduleTimerSync();
1757
+ }
1477
1758
  return { removedAgents: agents.length };
1478
1759
  }
1479
1760
  async syncAllSubscriptions() {
@@ -1531,6 +1812,64 @@ class AgentInboxService {
1531
1812
  console.warn("offline agent gc failed:", error);
1532
1813
  }
1533
1814
  }
1815
+ async syncTimers() {
1816
+ const now = (0, util_1.nowIso)();
1817
+ const due = this.store.listDueTimers(now);
1818
+ for (const timer of due) {
1819
+ try {
1820
+ await this.fireTimer(timer, now);
1821
+ }
1822
+ catch (error) {
1823
+ console.warn(`timer fire failed for ${timer.scheduleId}:`, error);
1824
+ }
1825
+ }
1826
+ this.rescheduleTimerSync();
1827
+ }
1828
+ refreshTimersOnStart() {
1829
+ const now = (0, util_1.nowIso)();
1830
+ for (const timer of this.store.listTimers()) {
1831
+ if (timer.status !== "active") {
1832
+ continue;
1833
+ }
1834
+ const nextFireAt = computeNextTimerFire({
1835
+ mode: timer.mode,
1836
+ at: timer.at ?? null,
1837
+ intervalMs: timer.intervalMs ?? null,
1838
+ cronExpr: timer.cronExpr ?? null,
1839
+ timezone: timer.timezone,
1840
+ fromIso: now,
1841
+ lastFiredAt: timer.lastFiredAt ?? null,
1842
+ restartRecovery: true,
1843
+ });
1844
+ this.store.updateTimer(timer.scheduleId, {
1845
+ nextFireAt,
1846
+ status: nextFireAt ? "active" : "paused",
1847
+ updatedAt: now,
1848
+ });
1849
+ }
1850
+ }
1851
+ rescheduleTimerSync() {
1852
+ if (this.timerSyncTimeout) {
1853
+ clearTimeout(this.timerSyncTimeout);
1854
+ this.timerSyncTimeout = null;
1855
+ }
1856
+ if (this.stopping) {
1857
+ return;
1858
+ }
1859
+ const nearest = this.store.getNearestActiveTimer();
1860
+ if (!nearest?.nextFireAt) {
1861
+ return;
1862
+ }
1863
+ const dueAt = Date.parse(nearest.nextFireAt);
1864
+ if (Number.isNaN(dueAt)) {
1865
+ return;
1866
+ }
1867
+ const delay = Math.max(0, dueAt - Date.now());
1868
+ this.timerSyncTimeout = setTimeout(() => {
1869
+ this.timerSyncTimeout = null;
1870
+ void this.syncTimers();
1871
+ }, delay);
1872
+ }
1534
1873
  refreshSourceIdleState(sourceId) {
1535
1874
  const source = this.store.getSource(sourceId);
1536
1875
  if (!source) {
@@ -1841,6 +2180,9 @@ function isSameTerminalIdentity(target, input) {
1841
2180
  function uniqueSorted(values) {
1842
2181
  return Array.from(new Set(values)).sort();
1843
2182
  }
2183
+ function uniqueSortedNullable(values) {
2184
+ return uniqueSorted(values.filter((value) => typeof value === "string" && value.length > 0));
2185
+ }
1844
2186
  function notificationBufferKey(agentId, targetId) {
1845
2187
  return `${agentId}::${targetId}`;
1846
2188
  }
@@ -1873,3 +2215,299 @@ function summarizeSourceEvent(sourceType, sourceKey, eventVariant) {
1873
2215
  }
1874
2216
  return `${sourceType}:${sourceKey}:${eventVariant}`;
1875
2217
  }
2218
+ function summarizeDirectInboxMessage(sender) {
2219
+ if (sender) {
2220
+ return `direct_text_message:${sender}`;
2221
+ }
2222
+ return "direct_text_message";
2223
+ }
2224
+ function summarizeTimerMessage(scheduleId) {
2225
+ return `timer:${scheduleId}`;
2226
+ }
2227
+ function normalizeDirectInboxMessage(message) {
2228
+ if (typeof message !== "string") {
2229
+ throw new Error("direct inbox message must be a string");
2230
+ }
2231
+ const trimmed = message.trim();
2232
+ if (trimmed.length === 0) {
2233
+ throw new Error("direct inbox message must not be empty");
2234
+ }
2235
+ return trimmed;
2236
+ }
2237
+ function normalizeDirectInboxSender(sender) {
2238
+ if (sender == null) {
2239
+ return null;
2240
+ }
2241
+ if (typeof sender !== "string") {
2242
+ throw new Error("direct inbox sender must be a string");
2243
+ }
2244
+ const trimmed = sender.trim();
2245
+ if (trimmed.length === 0) {
2246
+ throw new Error("direct inbox sender must not be empty");
2247
+ }
2248
+ return trimmed;
2249
+ }
2250
+ function normalizeTimerInput(input) {
2251
+ const modes = [input.at != null, input.every != null, input.cron != null].filter(Boolean).length;
2252
+ if (modes !== 1) {
2253
+ throw new Error("timers require exactly one of at, every, or cron");
2254
+ }
2255
+ if (input.at != null) {
2256
+ const at = normalizeIsoTimestamp(input.at, "timer at");
2257
+ return { mode: "at", at, timezone: input.timezone ?? null };
2258
+ }
2259
+ if (input.every != null) {
2260
+ if (!Number.isInteger(input.every) || input.every < MIN_TIMER_INTERVAL_MS) {
2261
+ throw new Error("timer every interval must be an integer number of milliseconds and at least 60000");
2262
+ }
2263
+ return { mode: "every", every: input.every, timezone: input.timezone ?? null };
2264
+ }
2265
+ const cron = normalizeCronExpression(input.cron);
2266
+ return { mode: "cron", cron, timezone: input.timezone ?? null };
2267
+ }
2268
+ function normalizeIsoTimestamp(value, label) {
2269
+ const parsed = Date.parse(value);
2270
+ if (Number.isNaN(parsed)) {
2271
+ throw new Error(`${label} must be a valid ISO8601 timestamp`);
2272
+ }
2273
+ return new Date(parsed).toISOString();
2274
+ }
2275
+ function normalizeCronExpression(value) {
2276
+ if (typeof value !== "string") {
2277
+ throw new Error("timer cron must be a string");
2278
+ }
2279
+ const trimmed = value.trim().replace(/\s+/g, " ");
2280
+ const fields = trimmed.split(" ");
2281
+ if (fields.length !== 5) {
2282
+ throw new Error("timer cron must use standard 5-field syntax");
2283
+ }
2284
+ for (const field of fields) {
2285
+ if (/[LW#@]/.test(field)) {
2286
+ throw new Error("timer cron does not support extensions like L, W, #, or @reboot");
2287
+ }
2288
+ }
2289
+ parseCronExpression(trimmed);
2290
+ return trimmed;
2291
+ }
2292
+ function detectHostTimezone() {
2293
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
2294
+ }
2295
+ function assertValidTimeZone(timezone) {
2296
+ try {
2297
+ Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
2298
+ }
2299
+ catch {
2300
+ throw new Error(`invalid timezone: ${timezone}`);
2301
+ }
2302
+ }
2303
+ function computeNextTimerFire(input) {
2304
+ const fromMs = Date.parse(input.fromIso);
2305
+ if (Number.isNaN(fromMs)) {
2306
+ return null;
2307
+ }
2308
+ if (input.mode === "at") {
2309
+ if (!input.at) {
2310
+ return null;
2311
+ }
2312
+ const atMs = Date.parse(input.at);
2313
+ if (Number.isNaN(atMs)) {
2314
+ return null;
2315
+ }
2316
+ if (input.lastFiredAt) {
2317
+ return null;
2318
+ }
2319
+ if (atMs <= fromMs) {
2320
+ return input.restartRecovery ? input.at : input.at;
2321
+ }
2322
+ return input.at;
2323
+ }
2324
+ if (input.mode === "every") {
2325
+ if (!input.intervalMs) {
2326
+ return null;
2327
+ }
2328
+ if (!input.lastFiredAt) {
2329
+ return new Date(fromMs + input.intervalMs).toISOString();
2330
+ }
2331
+ const lastFiredMs = Date.parse(input.lastFiredAt);
2332
+ if (Number.isNaN(lastFiredMs)) {
2333
+ return new Date(fromMs + input.intervalMs).toISOString();
2334
+ }
2335
+ const base = input.restartRecovery ? fromMs : Math.max(fromMs, lastFiredMs);
2336
+ return new Date(base + input.intervalMs).toISOString();
2337
+ }
2338
+ if (!input.cronExpr) {
2339
+ return null;
2340
+ }
2341
+ return nextCronOccurrence(input.cronExpr, input.timezone, new Date(fromMs));
2342
+ }
2343
+ function parseCronExpression(expr) {
2344
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = expr.split(" ");
2345
+ return {
2346
+ minute: parseCronField(minute, 0, 59),
2347
+ hour: parseCronField(hour, 0, 23),
2348
+ dayOfMonth: parseCronField(dayOfMonth, 1, 31),
2349
+ month: parseCronField(month, 1, 12),
2350
+ dayOfWeek: parseCronField(dayOfWeek, 0, 7, true),
2351
+ };
2352
+ }
2353
+ function parseCronField(field, min, max, normalizeSunday = false) {
2354
+ if (field === "*") {
2355
+ return { any: true, values: new Set() };
2356
+ }
2357
+ const values = new Set();
2358
+ for (const part of field.split(",")) {
2359
+ const [rangePart, stepPart] = part.split("/");
2360
+ const step = stepPart ? Number(stepPart) : 1;
2361
+ if (!Number.isInteger(step) || step <= 0) {
2362
+ throw new Error(`invalid cron field: ${field}`);
2363
+ }
2364
+ const [startRaw, endRaw] = rangePart === "*" ? [String(min), String(max)] : rangePart.split("-");
2365
+ const start = Number(startRaw);
2366
+ const end = endRaw != null ? Number(endRaw) : start;
2367
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
2368
+ throw new Error(`invalid cron field: ${field}`);
2369
+ }
2370
+ for (let value = start; value <= end; value += step) {
2371
+ values.add(normalizeSunday && value === 7 ? 0 : value);
2372
+ }
2373
+ }
2374
+ return { any: false, values };
2375
+ }
2376
+ function nextCronOccurrence(expr, timezone, after) {
2377
+ const parsed = parseCronExpression(expr);
2378
+ let candidate = addMinutes(after, 1);
2379
+ candidate.setUTCSeconds(0, 0);
2380
+ for (let guard = 0; guard < 10_000; guard += 1) {
2381
+ const zoned = zonedDateParts(candidate, timezone);
2382
+ const nextMonth = nextAllowedValue(parsed.month, zoned.month, 1, 12);
2383
+ if (nextMonth == null) {
2384
+ candidate = utcDateForZoned(timezone, zoned.year + 1, firstAllowedValue(parsed.month, 1, 12), 1, 0, 0);
2385
+ continue;
2386
+ }
2387
+ if (nextMonth !== zoned.month) {
2388
+ candidate = utcDateForZoned(timezone, zoned.year, nextMonth, 1, 0, 0);
2389
+ continue;
2390
+ }
2391
+ if (!matchesCronDay(parsed, zoned)) {
2392
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day + 1, 0, 0);
2393
+ continue;
2394
+ }
2395
+ const nextHour = nextAllowedValue(parsed.hour, zoned.hour, 0, 23);
2396
+ if (nextHour == null) {
2397
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day + 1, firstAllowedValue(parsed.hour, 0, 23), 0);
2398
+ continue;
2399
+ }
2400
+ if (nextHour !== zoned.hour) {
2401
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, nextHour, 0);
2402
+ continue;
2403
+ }
2404
+ const nextMinute = nextAllowedValue(parsed.minute, zoned.minute, 0, 59);
2405
+ if (nextMinute == null) {
2406
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, zoned.hour + 1, firstAllowedValue(parsed.minute, 0, 59));
2407
+ continue;
2408
+ }
2409
+ if (nextMinute !== zoned.minute) {
2410
+ candidate = utcDateForZoned(timezone, zoned.year, zoned.month, zoned.day, zoned.hour, nextMinute);
2411
+ continue;
2412
+ }
2413
+ if (matchesCron(parsed, zoned)) {
2414
+ return candidate.toISOString();
2415
+ }
2416
+ candidate = addMinutes(candidate, 1);
2417
+ }
2418
+ return null;
2419
+ }
2420
+ function matchesCron(parsed, parts) {
2421
+ const monthMatches = parsed.month.any || parsed.month.values.has(parts.month);
2422
+ const hourMatches = parsed.hour.any || parsed.hour.values.has(parts.hour);
2423
+ const minuteMatches = parsed.minute.any || parsed.minute.values.has(parts.minute);
2424
+ if (!monthMatches || !hourMatches || !minuteMatches) {
2425
+ return false;
2426
+ }
2427
+ const dayOfMonthMatches = parsed.dayOfMonth.any || parsed.dayOfMonth.values.has(parts.day);
2428
+ const dayOfWeekMatches = parsed.dayOfWeek.any || parsed.dayOfWeek.values.has(parts.dayOfWeek);
2429
+ if (!parsed.dayOfMonth.any && !parsed.dayOfWeek.any) {
2430
+ return dayOfMonthMatches || dayOfWeekMatches;
2431
+ }
2432
+ return dayOfMonthMatches && dayOfWeekMatches;
2433
+ }
2434
+ function matchesCronDay(parsed, parts) {
2435
+ const dayOfMonthMatches = parsed.dayOfMonth.any || parsed.dayOfMonth.values.has(parts.day);
2436
+ const dayOfWeekMatches = parsed.dayOfWeek.any || parsed.dayOfWeek.values.has(parts.dayOfWeek);
2437
+ if (!parsed.dayOfMonth.any && !parsed.dayOfWeek.any) {
2438
+ return dayOfMonthMatches || dayOfWeekMatches;
2439
+ }
2440
+ return dayOfMonthMatches && dayOfWeekMatches;
2441
+ }
2442
+ function zonedDateParts(date, timezone) {
2443
+ const formatter = new Intl.DateTimeFormat("en-US", {
2444
+ timeZone: timezone,
2445
+ year: "numeric",
2446
+ month: "2-digit",
2447
+ day: "2-digit",
2448
+ hour: "2-digit",
2449
+ minute: "2-digit",
2450
+ hourCycle: "h23",
2451
+ weekday: "short",
2452
+ });
2453
+ const entries = Object.fromEntries(formatter.formatToParts(date)
2454
+ .filter((part) => part.type !== "literal")
2455
+ .map((part) => [part.type, part.value]));
2456
+ const hour = Number(entries.hour);
2457
+ return {
2458
+ year: Number(entries.year),
2459
+ month: Number(entries.month),
2460
+ day: Number(entries.day),
2461
+ // Some ICU builds format midnight as 24:00 even for exact timestamps.
2462
+ // Normalize that representation so cron matching stays cross-platform.
2463
+ hour: hour === 24 ? 0 : hour,
2464
+ minute: Number(entries.minute),
2465
+ dayOfWeek: weekdayToNumber(entries.weekday),
2466
+ };
2467
+ }
2468
+ function nextAllowedValue(field, current, min, max) {
2469
+ if (field.any) {
2470
+ return current;
2471
+ }
2472
+ for (let value = current; value <= max; value += 1) {
2473
+ if (field.values.has(value)) {
2474
+ return value;
2475
+ }
2476
+ }
2477
+ return null;
2478
+ }
2479
+ function firstAllowedValue(field, min, max) {
2480
+ if (field.any) {
2481
+ return min;
2482
+ }
2483
+ for (let value = min; value <= max; value += 1) {
2484
+ if (field.values.has(value)) {
2485
+ return value;
2486
+ }
2487
+ }
2488
+ return min;
2489
+ }
2490
+ function addMinutes(date, minutes) {
2491
+ return new Date(date.getTime() + minutes * 60_000);
2492
+ }
2493
+ function utcDateForZoned(timezone, year, month, day, hour, minute) {
2494
+ const approxUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
2495
+ const approx = new Date(approxUtc);
2496
+ const zoned = zonedDateParts(approx, timezone);
2497
+ const targetNaiveUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
2498
+ const zonedNaiveUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0, 0);
2499
+ return new Date(approxUtc - (zonedNaiveUtc - targetNaiveUtc));
2500
+ }
2501
+ function weekdayToNumber(value) {
2502
+ switch (value) {
2503
+ case "Sun": return 0;
2504
+ case "Mon": return 1;
2505
+ case "Tue": return 2;
2506
+ case "Wed": return 3;
2507
+ case "Thu": return 4;
2508
+ case "Fri": return 5;
2509
+ case "Sat": return 6;
2510
+ default:
2511
+ throw new Error(`unsupported weekday: ${value}`);
2512
+ }
2513
+ }