@tloncorp/openclaw 0.6.0 → 0.6.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.
@@ -1,4 +1,5 @@
1
1
  import { configureGatewayStatus, gatewayStart } from '@tloncorp/api';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { format } from 'node:util';
3
4
  import { createTypingCallbacks } from 'openclaw/plugin-sdk/channel-runtime';
4
5
  import { getEffectiveOwnerShip, setEffectiveOwnerShip, } from '../effective-owner.js';
@@ -11,7 +12,7 @@ import { setSessionRole } from '../session-roles.js';
11
12
  import { DM_INVITE_PREVIEW, createSettingsManager, } from '../settings.js';
12
13
  import { sharedSlot } from '../shared-state.js';
13
14
  import { canonicalizeNest, normalizeShip, parseChannelNest, } from '../targets.js';
14
- import { createTlonTelemetry } from '../telemetry.js';
15
+ import { createTlonTelemetry, formatTlonTelemetryErrorText, setErrorTelemetryReporter, setOutboundRouteReporter, setSessionTelemetryReporter, } from '../telemetry.js';
15
16
  import { resolveTlonAccount } from '../types.js';
16
17
  import { configureTlonApiWithPoke } from '../urbit/api-client.js';
17
18
  import { authenticate } from '../urbit/auth.js';
@@ -20,6 +21,7 @@ import { describeError } from '../urbit/errors.js';
20
21
  import { sendChannelPost, sendDm } from '../urbit/send.js';
21
22
  import { UrbitSSEClient } from '../urbit/sse-client.js';
22
23
  import { markdownToStory } from '../urbit/story.js';
24
+ import { formatTlonVersionIdentity, resolveTlonSkillVersion, } from '../version.js';
23
25
  import { createPendingApproval, emojiToApprovalAction, findPendingApproval, formatApprovalConfirmation, formatApprovalRequest, formatBlockedList, formatPendingList, isExpired, normalizeNotificationId, pruneExpired, removePendingApproval, } from './approval.js';
24
26
  import { removeBridge, setBridge, } from './command-bridge.js';
25
27
  import { createComputingPresenceTracker } from './computing-presence.js';
@@ -31,6 +33,7 @@ import { clearShadowsForAccount, getLastNudgeStageShadow, getLastOwnerActivity,
31
33
  import { createOwnerReplyPersistenceQueue } from './owner-reply-persistence.js';
32
34
  import { createPendingNudgePersistenceQueue } from './pending-nudge-persistence.js';
33
35
  import { createProcessedMessageTracker } from './processed-messages.js';
36
+ import { isRouteDebugEnabled, recordTlonRouteAndDispatch, routeUpdateWillSkipByPin, tlonDeliveryContext, } from './session-routing.js';
34
37
  import { resolveSettingsMirrorSync } from './settings-sync.js';
35
38
  import { extractCites, extractMessageText, formatModelName, isBotMentioned, isDmAllowed, isOwnerListenSlashCommand, isSummarizationRequest, sanitizeMessageText, shouldEngageInGroup, stripBotMention, } from './utils.js';
36
39
  // Holds the data needed for any module-loader context to (re)configure its
@@ -41,6 +44,12 @@ const apiClientParamsSlot = sharedSlot(API_CLIENT_PARAMS_SLOT);
41
44
  const SETTINGS_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
42
45
  const GATEWAY_STATUS_ACTIVATION_TIMEOUT_MS = 15_000;
43
46
  const GATEWAY_STATUS_ACTIVATION_RETRY_MS = 30_000;
47
+ function classifyPluginError(error) {
48
+ if (error instanceof Error) {
49
+ return error.name || 'Error';
50
+ }
51
+ return typeof error;
52
+ }
44
53
  // Bound an activation poke so a silently-hung promise surfaces as a
45
54
  // retryable error instead of leaving gateway-status dead for the process
46
55
  // lifetime. The underlying poke may still settle after the timeout; the
@@ -135,10 +144,36 @@ export async function monitorTlonProvider(opts = {}) {
135
144
  const accountUrl = account.url;
136
145
  const accountCode = account.code;
137
146
  const botShipName = normalizeShip(account.ship);
147
+ const tlonSkillVersion = await resolveTlonSkillVersion();
148
+ let effectiveOwnerShip = account.ownerShip
149
+ ? normalizeShip(account.ownerShip)
150
+ : null;
151
+ setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
152
+ const telemetry = createTlonTelemetry({
153
+ config: account.telemetry,
154
+ runtime,
155
+ });
156
+ const currentTelemetryOwnerShip = () => getEffectiveOwnerShip(account.accountId) ?? effectiveOwnerShip;
157
+ const capturePluginError = (pluginErrorSource, error, extra) => {
158
+ telemetry?.capturePluginError({
159
+ harness: 'openclaw',
160
+ pluginErrorSource,
161
+ accountId: account.accountId,
162
+ ownerShip: currentTelemetryOwnerShip(),
163
+ botShip: botShipName,
164
+ errorKind: extra?.errorKind ?? classifyPluginError(error),
165
+ errorText: formatTlonTelemetryErrorText(error),
166
+ attempt: extra?.attempt ?? null,
167
+ });
168
+ };
138
169
  runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
170
+ runtime.log?.(`[tlon] version: ${formatTlonVersionIdentity({
171
+ markdown: false,
172
+ tlonSkillVersion,
173
+ }).replace(/\n/g, ' | ')}`);
139
174
  const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
140
175
  // Helper to authenticate with retry logic
141
- async function authenticateWithRetry(maxAttempts = 10) {
176
+ async function authenticateWithRetry(maxAttempts = 10, source = 'auth') {
142
177
  for (let attempt = 1;; attempt++) {
143
178
  if (opts.abortSignal?.aborted) {
144
179
  throw new Error('Aborted while waiting to authenticate');
@@ -148,6 +183,7 @@ export async function monitorTlonProvider(opts = {}) {
148
183
  return await authenticate(accountUrl, accountCode, { ssrfPolicy });
149
184
  }
150
185
  catch (error) {
186
+ capturePluginError(source, error, { attempt });
151
187
  runtime.error?.(`[tlon] Failed to authenticate (attempt ${attempt}): ${error?.message ?? String(error)}`);
152
188
  if (attempt >= maxAttempts) {
153
189
  throw error;
@@ -168,22 +204,29 @@ export async function monitorTlonProvider(opts = {}) {
168
204
  }
169
205
  }
170
206
  let api = null;
171
- const cookie = await authenticateWithRetry();
172
- api = new UrbitSSEClient(account.url, cookie, {
173
- ship: botShipName,
174
- ssrfPolicy,
175
- logger: {
176
- log: (message) => runtime.log?.(message),
177
- error: (message) => runtime.error?.(message),
178
- },
179
- // Re-authenticate on reconnect in case the session expired
180
- onReconnect: async (client) => {
181
- runtime.log?.('[tlon] Re-authenticating on SSE reconnect...');
182
- const newCookie = await authenticateWithRetry(5);
183
- client.updateCookie(newCookie);
184
- runtime.log?.('[tlon] Re-authentication successful');
185
- },
186
- });
207
+ let cookie;
208
+ try {
209
+ cookie = await authenticateWithRetry();
210
+ api = new UrbitSSEClient(account.url, cookie, {
211
+ ship: botShipName,
212
+ ssrfPolicy,
213
+ logger: {
214
+ log: (message) => runtime.log?.(message),
215
+ error: (message) => runtime.error?.(message),
216
+ },
217
+ // Re-authenticate on reconnect in case the session expired
218
+ onReconnect: async (client) => {
219
+ runtime.log?.('[tlon] Re-authenticating on SSE reconnect...');
220
+ const newCookie = await authenticateWithRetry(5, 're_auth');
221
+ client.updateCookie(newCookie);
222
+ runtime.log?.('[tlon] Re-authentication successful');
223
+ },
224
+ });
225
+ }
226
+ catch (error) {
227
+ await telemetry?.close();
228
+ throw error;
229
+ }
187
230
  // Configure @tloncorp/api's global client to use the SSE client's poke for all send operations
188
231
  configureTlonApiWithPoke(api.poke.bind(api), botShipName, account.url);
189
232
  // Publish the SSE-bound poke + ship coords so other module contexts (e.g.
@@ -251,11 +294,11 @@ export async function monitorTlonProvider(opts = {}) {
251
294
  once: true,
252
295
  });
253
296
  // Outer try/finally wraps everything from slot publication onward.
254
- // The reviewer's P2: a synchronous throw between slot publication and
255
- // the inner try at ~line 2719 (constructor, queue setup, bridge
256
- // setup, channel discovery, future edits in this large pre-try
257
- // region) would leave the shared slot orphaned. This outer finally
258
- // catches all of those and runs cleanup unconditionally.
297
+ // A synchronous throw between slot publication and the inner try
298
+ // (constructor, queue setup, bridge setup, channel discovery, future
299
+ // edits in this large pre-try region) would leave the shared slot
300
+ // orphaned. This outer finally catches all of those and runs cleanup
301
+ // unconditionally.
259
302
  try {
260
303
  const computingPresence = createComputingPresenceTracker({ runtime });
261
304
  const processedTracker = createProcessedMessageTracker(2000);
@@ -279,10 +322,6 @@ export async function monitorTlonProvider(opts = {}) {
279
322
  let effectiveAutoAcceptGroupInvites = account.autoAcceptGroupInvites ?? false;
280
323
  let effectiveGroupInviteAllowlist = account.groupInviteAllowlist;
281
324
  let effectiveAutoDiscoverChannels = account.autoDiscoverChannels ?? false;
282
- let effectiveOwnerShip = account.ownerShip
283
- ? normalizeShip(account.ownerShip)
284
- : null;
285
- setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
286
325
  let effectiveOwnerListenEnabled = account.ownerListenEnabled ?? true;
287
326
  // Canonicalize on every read so an entry stored from a slightly-off user
288
327
  // input (e.g. missing "~" or wrong case) still matches incoming nest events.
@@ -313,9 +352,66 @@ export async function monitorTlonProvider(opts = {}) {
313
352
  clearPendingNudge(accountId);
314
353
  pendingNudgeRehydrated = true;
315
354
  };
316
- const telemetry = createTlonTelemetry({
317
- config: account.telemetry,
318
- runtime,
355
+ // Bridge route-resolution telemetry from the global `message_sending` hook
356
+ // to this account's telemetry client. Reports every route-dependent send so
357
+ // we can measure how often a reply lands on webchat instead of Tlon.
358
+ setOutboundRouteReporter((event) => telemetry?.captureOutboundRoute({
359
+ ...event,
360
+ ownerShip: getEffectiveOwnerShip(account.accountId) ?? effectiveOwnerShip,
361
+ botShip: botShipName,
362
+ }));
363
+ setSessionTelemetryReporter((report) => {
364
+ switch (report.kind) {
365
+ case 'lifecycle':
366
+ telemetry?.captureSessionLifecycle(report.event);
367
+ break;
368
+ case 'watchdog':
369
+ telemetry?.captureSessionWatchdog(report.event);
370
+ break;
371
+ case 'recovery':
372
+ telemetry?.captureSessionRecovery(report.event);
373
+ break;
374
+ }
375
+ });
376
+ setErrorTelemetryReporter((report) => {
377
+ switch (report.kind) {
378
+ case 'harness':
379
+ telemetry?.captureHarnessError({
380
+ ...report.event,
381
+ accountId: report.event.accountId ?? account.accountId,
382
+ ownerShip: report.event.ownerShip ?? currentTelemetryOwnerShip(),
383
+ botShip: report.event.botShip || botShipName,
384
+ });
385
+ break;
386
+ case 'plugin':
387
+ telemetry?.capturePluginError({
388
+ harness: 'openclaw',
389
+ pluginErrorSource: report.event.pluginErrorSource,
390
+ accountId: report.event.accountId ?? account.accountId,
391
+ ownerShip: report.event.ownerShip ?? currentTelemetryOwnerShip(),
392
+ botShip: report.event.botShip ?? botShipName,
393
+ errorKind: report.event.errorKind ?? null,
394
+ errorText: report.event.errorText,
395
+ attempt: report.event.attempt ?? null,
396
+ });
397
+ break;
398
+ case 'telemetry':
399
+ telemetry?.captureTelemetryError({
400
+ harness: 'openclaw',
401
+ telemetrySource: report.event.telemetrySource,
402
+ sourceEventName: report.event.sourceEventName ?? null,
403
+ sessionKey: report.event.sessionKey ?? null,
404
+ sessionId: report.event.sessionId ?? null,
405
+ runId: report.event.runId ?? null,
406
+ accountId: report.event.accountId ?? account.accountId,
407
+ agentId: report.event.agentId ?? null,
408
+ ownerShip: report.event.ownerShip ?? currentTelemetryOwnerShip(),
409
+ botShip: report.event.botShip ?? botShipName,
410
+ errorKind: report.event.errorKind ?? null,
411
+ errorText: report.event.errorText,
412
+ });
413
+ break;
414
+ }
319
415
  });
320
416
  // Track threads we've participated in (by parentId) - respond without mention requirement
321
417
  const participatedThreads = new Set();
@@ -698,12 +794,16 @@ export async function monitorTlonProvider(opts = {}) {
698
794
  return;
699
795
  }
700
796
  catch (err) {
797
+ capturePluginError('gateway_status_activation', err, {
798
+ attempt,
799
+ });
701
800
  runtime.error?.(`[gateway-status] activation attempt ${attempt} failed: ${String(err)} — retrying in ${GATEWAY_STATUS_ACTIVATION_RETRY_MS / 1000}s`);
702
801
  }
703
802
  await abortableDelay(GATEWAY_STATUS_ACTIVATION_RETRY_MS, signal);
704
803
  }
705
804
  }
706
805
  catch (err) {
806
+ capturePluginError('gateway_status_activation', err);
707
807
  runtime.error?.(`[gateway-status] start failed: ${String(err)}`);
708
808
  }
709
809
  })();
@@ -1671,12 +1771,51 @@ export async function monitorTlonProvider(opts = {}) {
1671
1771
  ReplyToId: String(parentId),
1672
1772
  }),
1673
1773
  });
1774
+ // ── Durable session-route persistence ───────────────────────
1775
+ // The streamed reply below goes out through our own `deliver` callback
1776
+ // and does not consult session metadata. But later route-dependent sends
1777
+ // (the shared `message` tool, subagents, system-event turns) resolve
1778
+ // their destination from the session store; without a persisted Tlon
1779
+ // route they fall back to webchat. recordTlonRouteAndDispatch (below)
1780
+ // records the route before dispatch and fails open — never blocks the
1781
+ // reply.
1782
+ const routeDebug = isRouteDebugEnabled()
1783
+ ? (rec) => runtime.log?.(`[tlon][route-debug] inbound ${JSON.stringify({
1784
+ messageId,
1785
+ agentId: route.agentId,
1786
+ sessionKey: route.sessionKey,
1787
+ mainSessionKey: route.mainSessionKey,
1788
+ lastRoutePolicy: route.lastRoutePolicy,
1789
+ matchedBy: route.matchedBy,
1790
+ provider: ctxPayload.Provider,
1791
+ surface: ctxPayload.Surface,
1792
+ originatingChannel: ctxPayload.OriginatingChannel,
1793
+ originatingTo: ctxPayload.OriginatingTo,
1794
+ ctxSessionKey: ctxPayload.SessionKey,
1795
+ isGroup,
1796
+ groupChannel: groupChannel ?? null,
1797
+ senderShip,
1798
+ parentId: parentId ?? null,
1799
+ deliverParentId: deliverParentId ?? null,
1800
+ recordSessionKey: rec.recordSessionKey,
1801
+ lastRouteSessionKey: rec.lastRouteSessionKey,
1802
+ target: rec.target,
1803
+ hadUpdateLastRoute: Boolean(rec.updateLastRoute),
1804
+ pinWillSkip: routeUpdateWillSkipByPin(rec.updateLastRoute),
1805
+ skippedReason: rec.skippedReason ?? null,
1806
+ })}`)
1807
+ : undefined;
1674
1808
  const dispatchStartTime = Date.now();
1809
+ const runId = randomUUID();
1675
1810
  const replyTelemetry = telemetry?.startReply({
1676
1811
  sessionKey: route.sessionKey,
1812
+ runId,
1813
+ accountId: account.accountId,
1814
+ agentId: route.agentId,
1677
1815
  ownerShip: effectiveOwnerShip,
1678
1816
  botShip: botShipName,
1679
1817
  chatType: isGroup ? 'groupChannel' : 'dm',
1818
+ destinationKind: isGroup ? 'groupChannel' : 'dm',
1680
1819
  isThreadReply: Boolean(isThreadReply),
1681
1820
  senderRole,
1682
1821
  attachmentCount,
@@ -1685,9 +1824,16 @@ export async function monitorTlonProvider(opts = {}) {
1685
1824
  let selectedModel = null;
1686
1825
  let selectedThinkLevel = null;
1687
1826
  let deliveredMessageCount = 0;
1827
+ let sendAttemptCount = 0;
1828
+ let sendErrorCount = 0;
1829
+ let sendErrorKind = null;
1688
1830
  let replyCharCount = 0;
1689
1831
  let replyWordCount = 0;
1690
1832
  let replyMediaCount = 0;
1833
+ let deliverySkipReason = null;
1834
+ const recordDeliverySkip = (reason) => {
1835
+ deliverySkipReason ??= reason;
1836
+ };
1691
1837
  const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix;
1692
1838
  const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
1693
1839
  const presenceConversationId = isGroup
@@ -1726,6 +1872,7 @@ export async function monitorTlonProvider(opts = {}) {
1726
1872
  })
1727
1873
  : undefined;
1728
1874
  const replyOptions = {
1875
+ runId,
1729
1876
  onModelSelected: ({ provider, model, thinkLevel }) => {
1730
1877
  selectedProvider = provider;
1731
1878
  selectedModel = model;
@@ -1752,8 +1899,29 @@ export async function monitorTlonProvider(opts = {}) {
1752
1899
  let dispatchResult;
1753
1900
  let dispatchError;
1754
1901
  try {
1755
- dispatchResult =
1756
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1902
+ dispatchResult = await recordTlonRouteAndDispatch({
1903
+ session: core.channel.session,
1904
+ cfg,
1905
+ route,
1906
+ ctxPayload,
1907
+ ctxSessionKey: ctxPayload.SessionKey,
1908
+ isGroup,
1909
+ groupChannel,
1910
+ senderShip,
1911
+ parentId,
1912
+ deliverParentId,
1913
+ effectiveOwnerShip,
1914
+ effectiveDmAllowlist,
1915
+ messageId,
1916
+ sessionStore: cfg.session?.store,
1917
+ logError: (msg) => runtime.error?.(msg),
1918
+ // Routine skip / pin-skip diagnostics are debug-gated to avoid
1919
+ // high-volume logs for expected policy cases.
1920
+ logDebug: isRouteDebugEnabled()
1921
+ ? (msg) => runtime.log?.(msg)
1922
+ : undefined,
1923
+ onRecord: routeDebug,
1924
+ dispatch: () => core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1757
1925
  ctx: ctxPayload,
1758
1926
  cfg,
1759
1927
  replyOptions,
@@ -1761,14 +1929,24 @@ export async function monitorTlonProvider(opts = {}) {
1761
1929
  responsePrefix,
1762
1930
  humanDelay,
1763
1931
  typingCallbacks,
1932
+ onSkip: (_payload, info) => {
1933
+ recordDeliverySkip(info.reason);
1934
+ },
1764
1935
  deliver: async (payload) => {
1765
1936
  let replyText = payload.text;
1766
1937
  if (!replyText) {
1938
+ const hasMedia = Array.isArray(payload.mediaUrls)
1939
+ ? payload.mediaUrls.length > 0
1940
+ : Boolean(payload.mediaUrl);
1941
+ recordDeliverySkip(hasMedia
1942
+ ? 'media_only_payload_not_sent'
1943
+ : 'empty_payload_text');
1767
1944
  return;
1768
1945
  }
1769
1946
  // Process any block directives in the response (strips them from text)
1770
1947
  replyText = await processBlockDirectives(replyText, senderShip);
1771
1948
  if (!replyText) {
1949
+ recordDeliverySkip('block_directive_only');
1772
1950
  return;
1773
1951
  } // Response was only a directive
1774
1952
  // Use settings store value if set, otherwise fall back to file config
@@ -1786,13 +1964,26 @@ export async function monitorTlonProvider(opts = {}) {
1786
1964
  replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
1787
1965
  }
1788
1966
  // Add addendum if this is the last response before bot rate limit
1789
- if (isGroup && groupChannel && knownBotShips.has(senderShip)) {
1967
+ if (isGroup &&
1968
+ groupChannel &&
1969
+ knownBotShips.has(senderShip)) {
1790
1970
  const count = consecutiveBotMessages.get(groupChannel) ?? 0;
1791
1971
  if (maxBotResponses > 0 && count === maxBotResponses) {
1792
1972
  const otherBot = formatShipWithNickname(senderShip);
1793
1973
  replyText += `\n\n---\n_This is my last response to ${otherBot} for now. To continue our conversation, someone will need to mention me._`;
1794
1974
  }
1795
1975
  }
1976
+ if (isRouteDebugEnabled()) {
1977
+ runtime.log?.(`[tlon][route-debug] deliver ${JSON.stringify({
1978
+ messageId,
1979
+ isGroup,
1980
+ destination: isGroup
1981
+ ? groupChannel ?? null
1982
+ : senderShip,
1983
+ deliverParentId: deliverParentId ?? null,
1984
+ })}`);
1985
+ }
1986
+ sendAttemptCount += 1;
1796
1987
  if (isGroup && groupChannel) {
1797
1988
  // Send to any channel type (chat, heap, diary) using the nest directly
1798
1989
  await sendChannelPost({
@@ -1819,12 +2010,6 @@ export async function monitorTlonProvider(opts = {}) {
1819
2010
  : undefined,
1820
2011
  });
1821
2012
  }
1822
- if (presenceConversationId) {
1823
- await computingPresence.stopRun({
1824
- conversationId: presenceConversationId,
1825
- runId: presenceRunId,
1826
- });
1827
- }
1828
2013
  deliveredMessageCount += 1;
1829
2014
  replyCharCount += replyText.length;
1830
2015
  replyWordCount += replyText.trim()
@@ -1835,13 +2020,22 @@ export async function monitorTlonProvider(opts = {}) {
1835
2020
  : payload.mediaUrl
1836
2021
  ? 1
1837
2022
  : 0;
2023
+ if (presenceConversationId) {
2024
+ await computingPresence.stopRun({
2025
+ conversationId: presenceConversationId,
2026
+ runId: presenceRunId,
2027
+ });
2028
+ }
1838
2029
  },
1839
2030
  onError: (err, info) => {
1840
2031
  const dispatchDuration = Date.now() - dispatchStartTime;
2032
+ sendErrorCount += 1;
2033
+ sendErrorKind = info.kind;
1841
2034
  runtime.error?.(`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`);
1842
2035
  },
1843
2036
  },
1844
- });
2037
+ }),
2038
+ });
1845
2039
  }
1846
2040
  catch (error) {
1847
2041
  dispatchError = error;
@@ -1849,6 +2043,9 @@ export async function monitorTlonProvider(opts = {}) {
1849
2043
  }
1850
2044
  finally {
1851
2045
  await replyTelemetry?.capture({
2046
+ sendAttemptCount,
2047
+ sendErrorCount,
2048
+ sendErrorKind,
1852
2049
  deliveredMessageCount,
1853
2050
  replyCharCount,
1854
2051
  replyWordCount,
@@ -1857,6 +2054,10 @@ export async function monitorTlonProvider(opts = {}) {
1857
2054
  queuedFinal: dispatchResult?.queuedFinal ?? false,
1858
2055
  queuedFinalCount: dispatchResult?.counts.final ?? 0,
1859
2056
  queuedBlockCount: dispatchResult?.counts.block ?? 0,
2057
+ failedCounts: dispatchResult?.failedCounts,
2058
+ deliverySkipReason,
2059
+ sourceReplyDeliveryMode: dispatchResult?.sourceReplyDeliveryMode ?? null,
2060
+ beforeAgentRunBlocked: dispatchResult?.beforeAgentRunBlocked === true,
1860
2061
  provider: selectedProvider,
1861
2062
  model: selectedModel,
1862
2063
  thinkLevel: selectedThinkLevel,
@@ -1956,6 +2157,8 @@ export async function monitorTlonProvider(opts = {}) {
1956
2157
  core.system.enqueueSystemEvent(eventText, {
1957
2158
  sessionKey: route.sessionKey,
1958
2159
  contextKey: `tlon:reaction:${nest}:${postId}:${reactEmoji}:${ship}`,
2160
+ // Route any resulting system/heartbeat turn back to Tlon.
2161
+ deliveryContext: tlonDeliveryContext(`tlon:${nest}`, route.accountId),
1959
2162
  });
1960
2163
  }
1961
2164
  }
@@ -2290,6 +2493,8 @@ export async function monitorTlonProvider(opts = {}) {
2290
2493
  core.system.enqueueSystemEvent(eventText, {
2291
2494
  sessionKey: route.sessionKey,
2292
2495
  contextKey: `tlon:dm-reaction:${messageId}:${reactEmoji}:${reactAuthor}:${action}`,
2496
+ // Route any resulting system/heartbeat turn back to Tlon.
2497
+ deliveryContext: tlonDeliveryContext(`tlon:${partnerShip || reactAuthor}`, route.accountId),
2293
2498
  });
2294
2499
  runtime.log?.(`[tlon] DM_REACTION: ${eventText}`);
2295
2500
  }
@@ -2421,6 +2626,7 @@ export async function monitorTlonProvider(opts = {}) {
2421
2626
  path: '/v4',
2422
2627
  event: (data) => handleChannelsFirehose(data),
2423
2628
  err: (error) => {
2629
+ capturePluginError('channels_firehose', error);
2424
2630
  runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
2425
2631
  },
2426
2632
  quit: () => {
@@ -2434,6 +2640,7 @@ export async function monitorTlonProvider(opts = {}) {
2434
2640
  path: '/v4',
2435
2641
  event: (data) => handleChatFirehose(data),
2436
2642
  err: (error) => {
2643
+ capturePluginError('chat_firehose', error);
2437
2644
  runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
2438
2645
  },
2439
2646
  quit: () => {
@@ -2492,6 +2699,7 @@ export async function monitorTlonProvider(opts = {}) {
2492
2699
  }
2493
2700
  },
2494
2701
  err: (error) => {
2702
+ capturePluginError('contacts_subscription', error);
2495
2703
  runtime.error?.(`[tlon] Contacts subscription error: ${String(error)}`);
2496
2704
  },
2497
2705
  quit: () => {
@@ -2685,7 +2893,11 @@ export async function monitorTlonProvider(opts = {}) {
2685
2893
  });
2686
2894
  if (route?.sessionKey) {
2687
2895
  const memberDisplay = formatShipWithNickname(ship);
2688
- core.system.enqueueSystemEvent(`[${memberDisplay} joined group ${groupFlag}]`, { sessionKey: route.sessionKey });
2896
+ core.system.enqueueSystemEvent(`[${memberDisplay} joined group ${groupFlag}]`, {
2897
+ sessionKey: route.sessionKey,
2898
+ // Route any resulting system turn back to Tlon.
2899
+ deliveryContext: tlonDeliveryContext(`tlon:${nest}`, route.accountId),
2900
+ });
2689
2901
  runtime.log?.(`[tlon] Member joined: ${ship} → ${groupFlag}`);
2690
2902
  break; // Only inject once per group
2691
2903
  }
@@ -2796,6 +3008,7 @@ export async function monitorTlonProvider(opts = {}) {
2796
3008
  }
2797
3009
  },
2798
3010
  err: (error) => {
3011
+ capturePluginError('groups_ui_subscription', error);
2799
3012
  runtime.error?.(`[tlon] Groups-ui subscription error: ${String(error)}`);
2800
3013
  },
2801
3014
  quit: () => {
@@ -2806,6 +3019,7 @@ export async function monitorTlonProvider(opts = {}) {
2806
3019
  }
2807
3020
  catch (err) {
2808
3021
  // Groups-ui subscription is optional - channel discovery will still work via polling
3022
+ capturePluginError('groups_ui_subscription', err);
2809
3023
  runtime.log?.(`[tlon] Groups-ui subscription failed (will rely on polling): ${String(err)}`);
2810
3024
  }
2811
3025
  // Subscribe to foreigns for auto-accepting group invites
@@ -2925,6 +3139,7 @@ export async function monitorTlonProvider(opts = {}) {
2925
3139
  })();
2926
3140
  },
2927
3141
  err: (error) => {
3142
+ capturePluginError('foreigns_subscription', error);
2928
3143
  runtime.error?.(`[tlon] Foreigns subscription error: ${String(error)}`);
2929
3144
  },
2930
3145
  quit: () => {
@@ -2934,6 +3149,7 @@ export async function monitorTlonProvider(opts = {}) {
2934
3149
  runtime.log?.('[tlon] Subscribed to foreigns (/v1/foreigns) for auto-accepting group invites');
2935
3150
  }
2936
3151
  catch (err) {
3152
+ capturePluginError('foreigns_subscription', err);
2937
3153
  runtime.log?.(`[tlon] Foreigns subscription failed: ${String(err)}`);
2938
3154
  }
2939
3155
  }
@@ -2952,6 +3168,20 @@ export async function monitorTlonProvider(opts = {}) {
2952
3168
  runtime.log?.('[tlon] All subscriptions registered, connecting to SSE stream...');
2953
3169
  await api.connect();
2954
3170
  runtime.log?.('[tlon] Connected! Firehose subscriptions active');
3171
+ telemetry?.captureGatewayConnected({
3172
+ ownerShip: effectiveOwnerShip,
3173
+ botShip: botShipName,
3174
+ tlonSkillVersion: await resolveTlonSkillVersion(),
3175
+ accountId: account.accountId,
3176
+ configured: account.configured,
3177
+ watchedChannelCount: watchedChannels.size,
3178
+ dmAllowlistCount: effectiveDmAllowlist.length,
3179
+ defaultAuthorizedShipsCount: (currentSettings.defaultAuthorizedShips ??
3180
+ account.defaultAuthorizedShips).length,
3181
+ pendingApprovalCount: pendingApprovals.length,
3182
+ autoDiscoverChannels: effectiveAutoDiscoverChannels,
3183
+ ownerListenEnabled: effectiveOwnerListenEnabled,
3184
+ });
2955
3185
  // Periodically refresh channel discovery
2956
3186
  const pollInterval = setInterval(async () => {
2957
3187
  if (!opts.abortSignal?.aborted) {
@@ -2985,6 +3215,7 @@ export async function monitorTlonProvider(opts = {}) {
2985
3215
  });
2986
3216
  }
2987
3217
  catch (err) {
3218
+ capturePluginError('settings_refresh', err);
2988
3219
  runtime.error?.(`[tlon] Settings refresh failed: ${String(err)}`);
2989
3220
  }
2990
3221
  }, SETTINGS_REFRESH_INTERVAL_MS);
@@ -3077,6 +3308,9 @@ export async function monitorTlonProvider(opts = {}) {
3077
3308
  await ownerReplyPersistence.flush();
3078
3309
  await pendingNudgePersistence.flush();
3079
3310
  clearShadowsForAccount(account.accountId);
3311
+ setOutboundRouteReporter(null);
3312
+ setSessionTelemetryReporter(null);
3313
+ setErrorTelemetryReporter(null);
3080
3314
  await telemetry?.close();
3081
3315
  try {
3082
3316
  await api?.close();