@tloncorp/openclaw 0.1.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.
Files changed (88) hide show
  1. package/README.md +232 -23
  2. package/dist/index.js +292 -43
  3. package/dist/index.js.map +1 -1
  4. package/dist/setup-api.js +3 -0
  5. package/dist/setup-api.js.map +1 -0
  6. package/dist/setup-entry.js +4 -0
  7. package/dist/setup-entry.js.map +1 -0
  8. package/dist/src/actions.js +30 -8
  9. package/dist/src/actions.js.map +1 -1
  10. package/dist/src/channel.js +156 -373
  11. package/dist/src/channel.js.map +1 -1
  12. package/dist/src/channel.runtime.js +142 -0
  13. package/dist/src/channel.runtime.js.map +1 -0
  14. package/dist/src/config-schema.js +41 -1
  15. package/dist/src/config-schema.js.map +1 -1
  16. package/dist/src/effective-owner.js +22 -0
  17. package/dist/src/effective-owner.js.map +1 -0
  18. package/dist/src/gateway-status.js +72 -0
  19. package/dist/src/gateway-status.js.map +1 -0
  20. package/dist/src/monitor/approval.js +194 -96
  21. package/dist/src/monitor/approval.js.map +1 -1
  22. package/dist/src/monitor/command-auth.js +62 -0
  23. package/dist/src/monitor/command-auth.js.map +1 -0
  24. package/dist/src/monitor/command-bridge.js +27 -0
  25. package/dist/src/monitor/command-bridge.js.map +1 -0
  26. package/dist/src/monitor/computing-presence.js +221 -0
  27. package/dist/src/monitor/computing-presence.js.map +1 -0
  28. package/dist/src/monitor/discovery.js +17 -9
  29. package/dist/src/monitor/discovery.js.map +1 -1
  30. package/dist/src/monitor/index.js +960 -251
  31. package/dist/src/monitor/index.js.map +1 -1
  32. package/dist/src/monitor/media.js +195 -30
  33. package/dist/src/monitor/media.js.map +1 -1
  34. package/dist/src/monitor/nudge-runner.js +232 -0
  35. package/dist/src/monitor/nudge-runner.js.map +1 -0
  36. package/dist/src/monitor/nudge-state.js +58 -0
  37. package/dist/src/monitor/nudge-state.js.map +1 -0
  38. package/dist/src/monitor/owner-reply-persistence.js +92 -0
  39. package/dist/src/monitor/owner-reply-persistence.js.map +1 -0
  40. package/dist/src/monitor/pending-nudge-persistence.js +15 -0
  41. package/dist/src/monitor/pending-nudge-persistence.js.map +1 -0
  42. package/dist/src/monitor/settings-sync.js +28 -0
  43. package/dist/src/monitor/settings-sync.js.map +1 -0
  44. package/dist/src/monitor/utils.js +21 -4
  45. package/dist/src/monitor/utils.js.map +1 -1
  46. package/dist/src/nudge-decision.js +309 -0
  47. package/dist/src/nudge-decision.js.map +1 -0
  48. package/dist/src/nudge-messages.js +25 -0
  49. package/dist/src/nudge-messages.js.map +1 -0
  50. package/dist/src/nudge-scheduler.js +91 -0
  51. package/dist/src/nudge-scheduler.js.map +1 -0
  52. package/dist/src/pending-nudge.js +57 -0
  53. package/dist/src/pending-nudge.js.map +1 -0
  54. package/dist/src/session-roles.js +39 -0
  55. package/dist/src/session-roles.js.map +1 -0
  56. package/dist/src/settings.js +82 -6
  57. package/dist/src/settings.js.map +1 -1
  58. package/dist/src/setup-core.js +164 -0
  59. package/dist/src/setup-core.js.map +1 -0
  60. package/dist/src/setup-surface.js +85 -0
  61. package/dist/src/setup-surface.js.map +1 -0
  62. package/dist/src/telemetry.js +252 -0
  63. package/dist/src/telemetry.js.map +1 -0
  64. package/dist/src/tlon-binary.js +46 -0
  65. package/dist/src/tlon-binary.js.map +1 -0
  66. package/dist/src/tlon-tool-guard.js +44 -0
  67. package/dist/src/tlon-tool-guard.js.map +1 -0
  68. package/dist/src/tool-trace.js +100 -0
  69. package/dist/src/tool-trace.js.map +1 -0
  70. package/dist/src/types.js +35 -0
  71. package/dist/src/types.js.map +1 -1
  72. package/dist/src/urbit/api-client.js +4 -3
  73. package/dist/src/urbit/api-client.js.map +1 -1
  74. package/dist/src/urbit/base-url.js +2 -2
  75. package/dist/src/urbit/base-url.js.map +1 -1
  76. package/dist/src/urbit/fetch.js +1 -1
  77. package/dist/src/urbit/fetch.js.map +1 -1
  78. package/dist/src/urbit/send.js +6 -2
  79. package/dist/src/urbit/send.js.map +1 -1
  80. package/dist/src/urbit/sse-client.js +13 -2
  81. package/dist/src/urbit/sse-client.js.map +1 -1
  82. package/dist/src/urbit/upload.js +25 -20
  83. package/dist/src/urbit/upload.js.map +1 -1
  84. package/dist/src/version.generated.js +3 -0
  85. package/dist/src/version.generated.js.map +1 -0
  86. package/package.json +32 -25
  87. package/dist/src/onboarding.js +0 -178
  88. package/dist/src/onboarding.js.map +0 -1
@@ -1,20 +1,45 @@
1
+ import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
2
+ import { configureGatewayStatus, gatewayStart } from "@tloncorp/api";
1
3
  import { format } from "node:util";
4
+ import { getEffectiveOwnerShip, setEffectiveOwnerShip } from "../effective-owner.js";
5
+ import { getGatewayStatusManager, computeLeaseUntil, ACTIVE_WINDOW_SECS, OFFLINE_REPLY_COOLDOWN_SECS, } from "../gateway-status.js";
6
+ import { registerPersistCallback, syncPendingNudgeFromStore, getPendingNudge, clearPendingNudge, setPendingNudge, isNudgeEligible, } from "../pending-nudge.js";
2
7
  import { getTlonRuntime } from "../runtime.js";
8
+ import { setSessionRole } from "../session-roles.js";
3
9
  import { createSettingsManager } from "../settings.js";
4
10
  import { normalizeShip, parseChannelNest } from "../targets.js";
11
+ import { createTlonTelemetry } from "../telemetry.js";
5
12
  import { resolveTlonAccount } from "../types.js";
13
+ import { configureTlonApiWithPoke } from "../urbit/api-client.js";
6
14
  import { authenticate } from "../urbit/auth.js";
7
15
  import { ssrfPolicyFromAllowPrivateNetwork } from "../urbit/context.js";
8
- import { configureTlonApiWithPoke } from "../urbit/api-client.js";
9
16
  import { sendDm, sendChannelPost } from "../urbit/send.js";
10
- import { markdownToStory } from "../urbit/story.js";
11
17
  import { UrbitSSEClient } from "../urbit/sse-client.js";
12
- import { createPendingApproval, formatApprovalRequest, formatApprovalConfirmation, parseApprovalResponse, isApprovalResponse, findPendingApproval, removePendingApproval, parseAdminCommand, isAdminCommand, formatBlockedList, formatPendingList, } from "./approval.js";
18
+ import { markdownToStory } from "../urbit/story.js";
19
+ import { createPendingApproval, formatApprovalRequest, formatApprovalConfirmation, findPendingApproval, removePendingApproval, pruneExpired, formatBlockedList, formatPendingList, isExpired, emojiToApprovalAction, normalizeNotificationId, } from "./approval.js";
20
+ import { setBridge, removeBridge } from "./command-bridge.js";
21
+ import { createComputingPresenceTracker } from "./computing-presence.js";
13
22
  import { fetchAllChannels, fetchInitData } from "./discovery.js";
14
- import { cacheMessage, lookupCachedMessage, getChannelHistory, fetchThreadHistory } from "./history.js";
15
- import { downloadMessageImages } from "./media.js";
23
+ import { cacheMessage, lookupCachedMessage, getChannelHistory, fetchChannelHistory, fetchThreadHistory, } from "./history.js";
24
+ import { downloadMessageImages, parseBlobData, formatBlobAnnotations, downloadBlobAttachments, } from "./media.js";
25
+ import { createNudgeRunner, shouldStartNudgeRunner } from "./nudge-runner.js";
26
+ import { clearShadowsForAccount, getLastNudgeStageShadow, getLastOwnerActivity, ownerActivityFromSettings, setLastNudgeStageShadow, setLastOwnerActivity, } from "./nudge-state.js";
27
+ import { createOwnerReplyPersistenceQueue } from "./owner-reply-persistence.js";
28
+ import { createPendingNudgePersistenceQueue } from "./pending-nudge-persistence.js";
16
29
  import { createProcessedMessageTracker } from "./processed-messages.js";
17
- import { extractMessageText, extractCites, formatModelName, isBotMentioned, stripBotMention, isDmAllowed, isSummarizationRequest, } from "./utils.js";
30
+ import { resolveSettingsMirrorSync } from "./settings-sync.js";
31
+ import { extractMessageText, extractCites, formatModelName, isBotMentioned, stripBotMention, isDmAllowed, isSummarizationRequest, sanitizeMessageText, } from "./utils.js";
32
+ /** Refresh stale settings subscription state periodically as a fallback for silently-dead SSE subscriptions. */
33
+ const SETTINGS_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
34
+ /**
35
+ * Extract ship from author field, handling both string (ship) and object (bot-meta) formats.
36
+ */
37
+ function extractAuthorShip(author) {
38
+ if (typeof author === "object" && author !== null && "ship" in author) {
39
+ return author.ship;
40
+ }
41
+ return typeof author === "string" ? author : "";
42
+ }
18
43
  /**
19
44
  * Resolve channel authorization by merging file config with settings store.
20
45
  * Settings store takes precedence for fields it defines.
@@ -112,10 +137,14 @@ export async function monitorTlonProvider(opts = {}) {
112
137
  });
113
138
  // Configure @tloncorp/api's global client to use the SSE client's poke for all send operations
114
139
  configureTlonApiWithPoke(api.poke.bind(api), botShipName, account.url);
140
+ const computingPresence = createComputingPresenceTracker({ runtime });
115
141
  const processedTracker = createProcessedMessageTracker(2000);
116
142
  let groupChannels = [];
117
143
  const channelToGroup = new Map();
118
144
  let botNickname = null;
145
+ let botAvatar = null;
146
+ // Helper to get bot profile for outbound messages
147
+ const getBotProfile = () => botNickname || botAvatar ? { nickname: botNickname || "", avatar: botAvatar || "" } : undefined;
119
148
  // Settings store manager for hot-reloading config
120
149
  const settingsManager = createSettingsManager(api, {
121
150
  log: (msg) => runtime.log?.(msg),
@@ -131,10 +160,36 @@ export async function monitorTlonProvider(opts = {}) {
131
160
  let effectiveOwnerShip = account.ownerShip
132
161
  ? normalizeShip(account.ownerShip)
133
162
  : null;
163
+ setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
134
164
  let pendingApprovals = [];
135
165
  let currentSettings = {};
166
+ // Tracks whether pendingNudge has been successfully rehydrated from the settings
167
+ // store (or locally set/cleared). While false, refresh is allowed to recover a
168
+ // persisted pendingNudge that was missed due to a transient startup scry failure.
169
+ // Once true, the in-memory state is authoritative and refresh cannot clobber it.
170
+ let pendingNudgeRehydrated = false;
171
+ /** Set pending nudge and take ownership so refresh cannot clobber. */
172
+ const setLocalPendingNudge = (accountId, nudge) => {
173
+ setPendingNudge(accountId, nudge);
174
+ pendingNudgeRehydrated = true;
175
+ };
176
+ /** Clear pending nudge and take ownership so refresh cannot resurrect stale store data. */
177
+ const clearLocalPendingNudge = (accountId) => {
178
+ clearPendingNudge(accountId);
179
+ pendingNudgeRehydrated = true;
180
+ };
181
+ const telemetry = createTlonTelemetry({
182
+ config: account.telemetry,
183
+ runtime,
184
+ });
136
185
  // Track threads we've participated in (by parentId) - respond without mention requirement
137
186
  const participatedThreads = new Set();
187
+ // Track consecutive bot responses per channel/DM for rate limiting
188
+ // Key: channel nest or dm partner ship, Value: count of consecutive bot messages
189
+ const consecutiveBotMessages = new Map();
190
+ // Known bot ships (ships that have sent messages with BotProfile author)
191
+ const knownBotShips = new Set();
192
+ const maxBotResponses = account.maxConsecutiveBotResponses ?? 3;
138
193
  // Track DM senders per session to detect shared sessions (security warning)
139
194
  const dmSendersBySession = new Map();
140
195
  let sharedSessionWarningSent = false;
@@ -143,14 +198,15 @@ export async function monitorTlonProvider(opts = {}) {
143
198
  // Sanitize nickname to prevent format injection
144
199
  function sanitizeNickname(nickname) {
145
200
  return nickname
146
- .replace(/[\[\]()]/g, "") // Remove format-breaking chars
201
+ .replace(/[[\]()]/g, "") // Remove format-breaking chars
147
202
  .slice(0, 50); // Reasonable length limit
148
203
  }
149
204
  // Format a ship with nickname if available
150
205
  function formatShipWithNickname(ship) {
151
206
  const nickname = nicknameCache.get(ship);
152
- if (!nickname)
207
+ if (!nickname) {
153
208
  return ship;
209
+ }
154
210
  const sanitized = sanitizeNickname(nickname);
155
211
  return sanitized ? `${ship} (${sanitized})` : ship;
156
212
  }
@@ -160,9 +216,10 @@ export async function monitorTlonProvider(opts = {}) {
160
216
  if (selfProfile && typeof selfProfile === "object") {
161
217
  const profile = selfProfile;
162
218
  botNickname = profile.nickname?.value || null;
219
+ botAvatar = profile.avatar?.value || null;
163
220
  if (botNickname) {
164
221
  runtime.log?.(`[tlon] Bot nickname: ${botNickname}`);
165
- nicknameCache.set(botShipName, botNickname);
222
+ nicknameCache.set(botShipName, sanitizeNickname(botNickname));
166
223
  }
167
224
  }
168
225
  }
@@ -171,12 +228,12 @@ export async function monitorTlonProvider(opts = {}) {
171
228
  }
172
229
  // Fetch all contacts to populate nickname cache
173
230
  try {
174
- const allContacts = await api.scry("/contacts/v1/all.json");
231
+ const allContacts = (await api.scry("/contacts/v1/all.json"));
175
232
  if (allContacts && typeof allContacts === "object") {
176
233
  for (const [ship, contact] of Object.entries(allContacts)) {
177
234
  const nickname = contact?.nickname?.value ?? contact?.nickname;
178
235
  if (nickname && typeof nickname === "string") {
179
- nicknameCache.set(normalizeShip(ship), nickname);
236
+ nicknameCache.set(normalizeShip(ship), sanitizeNickname(nickname));
180
237
  }
181
238
  }
182
239
  runtime.log?.(`[tlon] Loaded ${nicknameCache.size} contact nickname(s)`);
@@ -187,6 +244,22 @@ export async function monitorTlonProvider(opts = {}) {
187
244
  }
188
245
  // Store init foreigns for processing after settings are loaded
189
246
  let initForeigns = null;
247
+ // Group name cache for human-readable display (flag -> title)
248
+ const groupNameCache = new Map();
249
+ // Build display context for approval formatting
250
+ function buildDisplayContext() {
251
+ const channelNames = new Map();
252
+ for (const nest of watchedChannels) {
253
+ const parsed = parseChannelNest(nest);
254
+ if (parsed) {
255
+ channelNames.set(nest, parsed.channelName);
256
+ }
257
+ }
258
+ return {
259
+ channelNames,
260
+ groupNames: groupNameCache,
261
+ };
262
+ }
190
263
  // Migrate file config to settings store (seed on first run)
191
264
  async function migrateConfigToSettings() {
192
265
  const migrations = [
@@ -230,12 +303,17 @@ export async function monitorTlonProvider(opts = {}) {
230
303
  fileValue: account.showModelSignature,
231
304
  settingsValue: currentSettings.showModelSig,
232
305
  },
306
+ {
307
+ key: "ownerShip",
308
+ fileValue: account.ownerShip,
309
+ settingsValue: currentSettings.ownerShip,
310
+ },
233
311
  ];
234
312
  for (const { key, fileValue, settingsValue } of migrations) {
235
313
  // Only migrate if file has a value and settings store doesn't
236
314
  const hasFileValue = Array.isArray(fileValue) ? fileValue.length > 0 : fileValue != null;
237
315
  const hasSettingsValue = Array.isArray(settingsValue)
238
- ? settingsValue.length > 0
316
+ ? true // empty array = intentionally set in settings store
239
317
  : settingsValue != null;
240
318
  if (hasFileValue && !hasSettingsValue) {
241
319
  try {
@@ -259,11 +337,28 @@ export async function monitorTlonProvider(opts = {}) {
259
337
  }
260
338
  }
261
339
  }
340
+ // Clear stale in-memory pending-nudge state before settings load.
341
+ // If load fails during a same-process restart, we should not keep attributing
342
+ // owner replies against a previous monitor run's record.
343
+ syncPendingNudgeFromStore(account.accountId, null);
344
+ // Drop stale per-process shadows from any prior run in the same process.
345
+ // Mirrors the same-process-restart reasoning as the pending-nudge sync above.
346
+ clearShadowsForAccount(account.accountId);
262
347
  // Load settings from settings store (hot-reloadable config)
263
348
  try {
264
- currentSettings = await settingsManager.load();
265
- // Migrate file config to settings store if not already present
266
- await migrateConfigToSettings();
349
+ const loadResult = await settingsManager.load();
350
+ currentSettings = loadResult.settings;
351
+ // Only seed file config into %settings when the startup snapshot is fresh.
352
+ // On a transient startup scry failure, `load()` preserves the last known
353
+ // snapshot (or `{}` on first load). Running migration against a stale
354
+ // snapshot would treat every persisted override as absent and clobber it
355
+ // with file-backed values once the settings agent recovers.
356
+ if (loadResult.fresh) {
357
+ await migrateConfigToSettings();
358
+ }
359
+ else {
360
+ runtime.log?.("[tlon] Skipping config->settings migration on stale startup snapshot");
361
+ }
267
362
  // Apply settings overrides
268
363
  // Note: groupChannels from settings store are merged AFTER discovery runs (below)
269
364
  if (currentSettings.defaultAuthorizedShips?.length) {
@@ -273,9 +368,9 @@ export async function monitorTlonProvider(opts = {}) {
273
368
  effectiveAutoDiscoverChannels = currentSettings.autoDiscoverChannels;
274
369
  runtime.log?.(`[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`);
275
370
  }
276
- if (currentSettings.dmAllowlist?.length) {
371
+ if (currentSettings.dmAllowlist !== undefined) {
277
372
  effectiveDmAllowlist = currentSettings.dmAllowlist;
278
- runtime.log?.(`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`);
373
+ runtime.log?.(`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.length > 0 ? effectiveDmAllowlist.join(", ") : "(empty)"}`);
279
374
  }
280
375
  if (currentSettings.showModelSig !== undefined) {
281
376
  effectiveShowModelSig = currentSettings.showModelSig;
@@ -294,8 +389,21 @@ export async function monitorTlonProvider(opts = {}) {
294
389
  }
295
390
  if (currentSettings.ownerShip) {
296
391
  effectiveOwnerShip = normalizeShip(currentSettings.ownerShip);
392
+ setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
297
393
  runtime.log?.(`[tlon] Using ownerShip from settings store: ${effectiveOwnerShip}`);
298
394
  }
395
+ // Rehydrate pending nudge from settings store only if the scry returned real data.
396
+ // On fallback (scry failure), leave pendingNudgeRehydrated false so the refresh
397
+ // recovery path can still pick up a persisted pendingNudge later.
398
+ if (loadResult.fresh) {
399
+ syncPendingNudgeFromStore(account.accountId, currentSettings.pendingNudge ?? null);
400
+ pendingNudgeRehydrated = true;
401
+ }
402
+ // Seed nudge shadows from the loaded settings snapshot. Missing fields
403
+ // seed the shadow as absent / 0 — the tick short-circuits on null
404
+ // activity, which is correct for a cold startup with an empty store.
405
+ setLastOwnerActivity(account.accountId, ownerActivityFromSettings(currentSettings));
406
+ setLastNudgeStageShadow(account.accountId, currentSettings.lastNudgeStage ?? 0);
299
407
  if (currentSettings.pendingApprovals?.length) {
300
408
  pendingApprovals = currentSettings.pendingApprovals;
301
409
  runtime.log?.(`[tlon] Loaded ${pendingApprovals.length} pending approval(s) from settings`);
@@ -304,6 +412,97 @@ export async function monitorTlonProvider(opts = {}) {
304
412
  catch (err) {
305
413
  runtime.log?.(`[tlon] Settings store not available, using file config: ${String(err)}`);
306
414
  }
415
+ const pendingNudgePersistence = createPendingNudgePersistenceQueue(async (nudge) => {
416
+ try {
417
+ if (nudge) {
418
+ await api.poke({
419
+ app: "settings",
420
+ mark: "settings-event",
421
+ json: {
422
+ "put-entry": {
423
+ desk: "moltbot",
424
+ "bucket-key": "tlon",
425
+ "entry-key": "pendingNudge",
426
+ value: JSON.stringify(nudge),
427
+ },
428
+ },
429
+ });
430
+ }
431
+ else {
432
+ await api.poke({
433
+ app: "settings",
434
+ mark: "settings-event",
435
+ json: {
436
+ "del-entry": {
437
+ desk: "moltbot",
438
+ "bucket-key": "tlon",
439
+ "entry-key": "pendingNudge",
440
+ },
441
+ },
442
+ });
443
+ }
444
+ }
445
+ catch (err) {
446
+ runtime.error?.(nudge
447
+ ? `[tlon] Failed to persist pendingNudge: ${String(err)}`
448
+ : `[tlon] Failed to clear pendingNudge: ${String(err)}`);
449
+ }
450
+ });
451
+ // Register per-account persist callback for pending nudge writes.
452
+ registerPersistCallback(account.accountId, (nudge) => {
453
+ pendingNudgePersistence.enqueue(nudge);
454
+ });
455
+ const ownerReplyPersistence = createOwnerReplyPersistenceQueue(api, {
456
+ error: (msg) => runtime.error?.(msg),
457
+ });
458
+ let nudgeRunner = null;
459
+ // Clear expired pending nudge on startup (after persist callback is registered so del-entry fires).
460
+ const rehydratedNudge = getPendingNudge(account.accountId);
461
+ if (rehydratedNudge && !isNudgeEligible(rehydratedNudge)) {
462
+ const ageMs = Date.now() - rehydratedNudge.sentAt;
463
+ clearLocalPendingNudge(account.accountId);
464
+ runtime.log?.(`[tlon] Cleared expired pending nudge on startup (stage ${rehydratedNudge.stage}, age ${ageMs}ms)`);
465
+ }
466
+ // ── Gateway-status: non-blocking background activation ──────
467
+ // getGatewayStatusManager() returns null when multi-account or zero accounts configured
468
+ // (see index.ts registration gate).
469
+ const gsManager = getGatewayStatusManager();
470
+ if (gsManager && effectiveOwnerShip) {
471
+ const capturedOwnerShip = effectiveOwnerShip;
472
+ const signal = opts.abortSignal;
473
+ // Fire-and-forget: wait for gateway_start signal, then activate.
474
+ // Does NOT block monitor startup — discovery, subscriptions, etc. proceed immediately.
475
+ void (async () => {
476
+ try {
477
+ const raced = await Promise.race([
478
+ gsManager.waitForGatewayStart().then(() => "started"),
479
+ ...(signal
480
+ ? [
481
+ new Promise((r) => signal.addEventListener("abort", () => r("aborted"), { once: true })),
482
+ ]
483
+ : []),
484
+ ]);
485
+ if (raced !== "started" || gsManager.stopped) {
486
+ return;
487
+ }
488
+ await configureGatewayStatus({
489
+ owner: capturedOwnerShip,
490
+ activeWindowSecs: ACTIVE_WINDOW_SECS,
491
+ offlineReplyCooldownSecs: OFFLINE_REPLY_COOLDOWN_SECS,
492
+ });
493
+ await gatewayStart({
494
+ bootId: gsManager.bootId,
495
+ leaseUntil: computeLeaseUntil(),
496
+ });
497
+ gsManager.markActivated();
498
+ gsManager.startHeartbeat();
499
+ runtime.log?.(`[gateway-status] activated (bootId=${gsManager.bootId}, owner=${capturedOwnerShip})`);
500
+ }
501
+ catch (err) {
502
+ runtime.error?.(`[gateway-status] start failed: ${String(err)}`);
503
+ }
504
+ })();
505
+ }
307
506
  // Run channel discovery AFTER settings are loaded (so settings store value is used)
308
507
  if (effectiveAutoDiscoverChannels) {
309
508
  try {
@@ -315,6 +514,10 @@ export async function monitorTlonProvider(opts = {}) {
315
514
  for (const [nest, groupFlag] of initData.channelToGroup) {
316
515
  channelToGroup.set(nest, groupFlag);
317
516
  }
517
+ // Populate group name cache for human-readable display
518
+ for (const [flag, title] of initData.groupNames) {
519
+ groupNameCache.set(flag, title);
520
+ }
318
521
  initForeigns = initData.foreigns;
319
522
  }
320
523
  catch (error) {
@@ -427,6 +630,33 @@ export async function monitorTlonProvider(opts = {}) {
427
630
  runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
428
631
  }
429
632
  }
633
+ // Helper to remove ship from dmAllowlist in both memory and settings store
634
+ async function removeFromDmAllowlist(ship) {
635
+ const normalizedShip = normalizeShip(ship);
636
+ const before = effectiveDmAllowlist.length;
637
+ effectiveDmAllowlist = effectiveDmAllowlist.filter((s) => s !== normalizedShip);
638
+ if (effectiveDmAllowlist.length === before) {
639
+ return; // Ship wasn't on the list
640
+ }
641
+ try {
642
+ await api.poke({
643
+ app: "settings",
644
+ mark: "settings-event",
645
+ json: {
646
+ "put-entry": {
647
+ desk: "moltbot",
648
+ "bucket-key": "tlon",
649
+ "entry-key": "dmAllowlist",
650
+ value: effectiveDmAllowlist,
651
+ },
652
+ },
653
+ });
654
+ runtime.log?.(`[tlon] Removed ${normalizedShip} from dmAllowlist`);
655
+ }
656
+ catch (err) {
657
+ runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
658
+ }
659
+ }
430
660
  // Helper to update channelRules in settings store
431
661
  async function addToChannelAllowlist(ship, channelNest) {
432
662
  const normalizedShip = normalizeShip(ship);
@@ -476,12 +706,26 @@ export async function monitorTlonProvider(opts = {}) {
476
706
  runtime.error?.(`[tlon] Failed to block ship ${normalizedShip}: ${String(err)}`);
477
707
  }
478
708
  }
709
+ /**
710
+ * Scry the chat agent's blocked ship list with an explicit timeout.
711
+ * The urbitFetch timeout (30s) may not fire if the underlying connection
712
+ * stalls (e.g. after a chat-block-ship poke causes the agent to restart).
713
+ * This wrapper guarantees resolution within SCRY_TIMEOUT_MS.
714
+ */
715
+ const SCRY_TIMEOUT_MS = 15_000;
716
+ async function scryBlockedShips() {
717
+ const blocked = (await Promise.race([
718
+ api.scry("/chat/blocked.json"),
719
+ new Promise((_, reject) => setTimeout(() => reject(new Error("blocked list scry timeout")), SCRY_TIMEOUT_MS)),
720
+ ]));
721
+ return Array.isArray(blocked) ? blocked : [];
722
+ }
479
723
  // Check if a ship is blocked using Tlon's native block list
480
724
  async function isShipBlocked(ship) {
481
725
  const normalizedShip = normalizeShip(ship);
482
726
  try {
483
- const blocked = (await api.scry("/chat/blocked.json"));
484
- return Array.isArray(blocked) && blocked.some((s) => normalizeShip(s) === normalizedShip);
727
+ const blocked = await scryBlockedShips();
728
+ return blocked.some((s) => normalizeShip(s) === normalizedShip);
485
729
  }
486
730
  catch (err) {
487
731
  runtime.log?.(`[tlon] Failed to check blocked list: ${String(err)}`);
@@ -491,8 +735,7 @@ export async function monitorTlonProvider(opts = {}) {
491
735
  // Get all blocked ships
492
736
  async function getBlockedShips() {
493
737
  try {
494
- const blocked = (await api.scry("/chat/blocked.json"));
495
- return Array.isArray(blocked) ? blocked : [];
738
+ return await scryBlockedShips();
496
739
  }
497
740
  catch (err) {
498
741
  runtime.log?.(`[tlon] Failed to get blocked list: ${String(err)}`);
@@ -503,6 +746,11 @@ export async function monitorTlonProvider(opts = {}) {
503
746
  async function unblockShip(ship) {
504
747
  const normalizedShip = normalizeShip(ship);
505
748
  try {
749
+ const blocked = await isShipBlocked(normalizedShip);
750
+ if (!blocked) {
751
+ runtime.log?.(`[tlon] Ship ${normalizedShip} is not blocked; skipping unblock`);
752
+ return true;
753
+ }
506
754
  await api.poke({
507
755
  app: "chat",
508
756
  mark: "chat-unblock-ship",
@@ -516,24 +764,65 @@ export async function monitorTlonProvider(opts = {}) {
516
764
  return false;
517
765
  }
518
766
  }
519
- // Helper to send DM notification to owner
767
+ // Helper to send DM notification to owner. Returns the message ID if sent successfully.
520
768
  async function sendOwnerNotification(message) {
521
769
  if (!effectiveOwnerShip) {
522
770
  runtime.log?.("[tlon] No ownerShip configured, cannot send notification");
523
- return;
771
+ return undefined;
524
772
  }
525
773
  try {
526
- await sendDm({
774
+ const result = await sendDm({
775
+ botProfile: getBotProfile(),
527
776
  fromShip: botShipName,
528
777
  toShip: effectiveOwnerShip,
529
778
  text: message,
530
779
  });
531
780
  runtime.log?.(`[tlon] Sent notification to owner ${effectiveOwnerShip}`);
781
+ return result.messageId;
532
782
  }
533
783
  catch (err) {
534
784
  runtime.error?.(`[tlon] Failed to send notification to owner: ${String(err)}`);
785
+ return undefined;
535
786
  }
536
787
  }
788
+ // Regex to match block directives in agent responses
789
+ // Format: [BLOCK_USER: ~ship-name | reason for blocking]
790
+ const blockDirectiveRegex = /\[BLOCK_USER:\s*(~[\w-]+)\s*\|\s*(.+?)\]/g;
791
+ // Process block directives from agent response and return text with directives stripped
792
+ async function processBlockDirectives(text, senderShip) {
793
+ const matches = [...text.matchAll(blockDirectiveRegex)];
794
+ if (matches.length > 0) {
795
+ runtime.log?.(`[tlon] Found ${matches.length} block directive(s) in response`);
796
+ runtime.log?.(`[tlon] Sender ship: "${senderShip}" -> normalized: "${normalizeShip(senderShip)}"`);
797
+ runtime.log?.(`[tlon] Owner ship: "${effectiveOwnerShip}"`);
798
+ }
799
+ for (const match of matches) {
800
+ const targetShip = normalizeShip(match[1]);
801
+ const reason = match[2].trim();
802
+ runtime.log?.(`[tlon] Processing block directive: target="${targetShip}", reason="${reason}"`);
803
+ // Safety: Never block the owner
804
+ if (effectiveOwnerShip && targetShip === effectiveOwnerShip) {
805
+ runtime.log?.(`[tlon] Agent attempted to block owner ship ${targetShip} - ignoring`);
806
+ continue;
807
+ }
808
+ // Only allow blocking the current message sender (not arbitrary third parties)
809
+ const normalizedSender = normalizeShip(senderShip);
810
+ if (targetShip !== normalizedSender) {
811
+ runtime.log?.(`[tlon] Agent tried to block "${targetShip}" but sender is "${normalizedSender}" - ignoring`);
812
+ continue;
813
+ }
814
+ // Block the abusive sender
815
+ runtime.log?.(`[tlon] Executing block for ${targetShip}...`);
816
+ await blockShip(targetShip);
817
+ // Notify owner
818
+ if (effectiveOwnerShip) {
819
+ await sendOwnerNotification(`[Agent Action] Blocked ${targetShip}\nReason: ${reason}`);
820
+ }
821
+ runtime.log?.(`[tlon] Agent blocked ${targetShip}: ${reason}`);
822
+ }
823
+ // Strip directives from visible response
824
+ return text.replace(blockDirectiveRegex, "").trim();
825
+ }
537
826
  // Queue a new approval request and notify the owner
538
827
  async function queueApprovalRequest(approval) {
539
828
  // Check if ship is blocked - silently ignore
@@ -554,33 +843,36 @@ export async function monitorTlonProvider(opts = {}) {
554
843
  existing.messagePreview = approval.messagePreview;
555
844
  }
556
845
  runtime.log?.(`[tlon] Updated existing approval for ${approval.requestingShip} (${approval.type}) - re-sending notification`);
846
+ // Send notification first, then save once with the notification ID.
847
+ // Saving before sendOwnerNotification causes a race: the settings subscription
848
+ // event replaces pendingApprovals in-memory, so the notificationMessageId
849
+ // set on the old object reference is lost.
850
+ const existMsg = formatApprovalRequest(existing, buildDisplayContext());
851
+ const existNotifId = await sendOwnerNotification(existMsg);
852
+ if (existNotifId) {
853
+ existing.notificationMessageId = normalizeNotificationId(existNotifId);
854
+ }
557
855
  await savePendingApprovals();
558
- const message = formatApprovalRequest(existing);
559
- await sendOwnerNotification(message);
560
856
  return;
561
857
  }
858
+ // Send notification before saving so notificationMessageId is included
859
+ // in the single save. See comment above about the settings subscription race.
860
+ const message = formatApprovalRequest(approval, buildDisplayContext());
861
+ const notifId = await sendOwnerNotification(message);
862
+ if (notifId) {
863
+ approval.notificationMessageId = normalizeNotificationId(notifId);
864
+ }
562
865
  pendingApprovals.push(approval);
563
866
  await savePendingApprovals();
564
- const message = formatApprovalRequest(approval);
565
- await sendOwnerNotification(message);
566
867
  runtime.log?.(`[tlon] Queued approval request: ${approval.id} (${approval.type} from ${approval.requestingShip})`);
567
868
  }
568
- // Process the owner's approval response
569
- async function handleApprovalResponse(text) {
570
- const parsed = parseApprovalResponse(text);
571
- if (!parsed) {
572
- return false;
573
- }
574
- const approval = findPendingApproval(pendingApprovals, parsed.id);
575
- if (!approval) {
576
- await sendOwnerNotification("No pending approval found" + (parsed.id ? ` for ID: ${parsed.id}` : ""));
577
- return true; // Still consumed the message
578
- }
579
- if (parsed.action === "approve") {
869
+ // ── Approval action execution ─────────────────────────────────────
870
+ // Shared by the slash command bridge and the reaction-based approval handler.
871
+ async function executeApprovalAction(approval, action) {
872
+ if (action === "approve") {
580
873
  switch (approval.type) {
581
874
  case "dm":
582
875
  await addToDmAllowlist(approval.requestingShip);
583
- // Process the original message if available
584
876
  if (approval.originalMessage) {
585
877
  runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} after approval`);
586
878
  await processMessage({
@@ -590,15 +882,15 @@ export async function monitorTlonProvider(opts = {}) {
590
882
  messageContent: approval.originalMessage.messageContent,
591
883
  isGroup: false,
592
884
  timestamp: approval.originalMessage.timestamp,
885
+ blobField: approval.originalMessage.blob,
593
886
  });
594
887
  }
595
888
  break;
596
889
  case "channel":
597
890
  if (approval.channelNest) {
598
891
  await addToChannelAllowlist(approval.requestingShip, approval.channelNest);
599
- // Process the original message if available
600
892
  if (approval.originalMessage) {
601
- const parsed = parseChannelNest(approval.channelNest);
893
+ const nest = parseChannelNest(approval.channelNest);
602
894
  runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} in ${approval.channelNest} after approval`);
603
895
  await processMessage({
604
896
  messageId: approval.originalMessage.messageId,
@@ -607,17 +899,17 @@ export async function monitorTlonProvider(opts = {}) {
607
899
  messageContent: approval.originalMessage.messageContent,
608
900
  isGroup: true,
609
901
  channelNest: approval.channelNest,
610
- hostShip: parsed?.hostShip,
611
- channelName: parsed?.channelName,
902
+ hostShip: nest?.hostShip,
903
+ channelName: nest?.channelName,
612
904
  timestamp: approval.originalMessage.timestamp,
613
905
  parentId: approval.originalMessage.parentId,
614
906
  isThreadReply: approval.originalMessage.isThreadReply,
907
+ blobField: approval.originalMessage.blob,
615
908
  });
616
909
  }
617
910
  }
618
911
  break;
619
912
  case "group":
620
- // Accept the group invite (don't add to allowlist - each invite requires approval)
621
913
  if (approval.groupFlag) {
622
914
  try {
623
915
  await api.poke({
@@ -629,8 +921,6 @@ export async function monitorTlonProvider(opts = {}) {
629
921
  },
630
922
  });
631
923
  runtime.log?.(`[tlon] Joined group ${approval.groupFlag} after approval`);
632
- // Immediately discover channels from the newly joined group
633
- // Small delay to allow the join to propagate
634
924
  setTimeout(async () => {
635
925
  try {
636
926
  const discoveredChannels = await fetchAllChannels(api, runtime);
@@ -656,58 +946,52 @@ export async function monitorTlonProvider(opts = {}) {
656
946
  }
657
947
  break;
658
948
  }
659
- await sendOwnerNotification(formatApprovalConfirmation(approval, "approve"));
660
949
  }
661
- else if (parsed.action === "block") {
662
- // Block the ship using Tlon's native blocking
950
+ else if (action === "block") {
663
951
  await blockShip(approval.requestingShip);
664
- await sendOwnerNotification(formatApprovalConfirmation(approval, "block"));
665
- }
666
- else {
667
- // Denied - just remove from pending, no notification to requester
668
- await sendOwnerNotification(formatApprovalConfirmation(approval, "deny"));
952
+ await removeFromDmAllowlist(approval.requestingShip);
669
953
  }
670
- // Remove from pending
954
+ // "deny" — no side effects beyond removing from pending
671
955
  pendingApprovals = removePendingApproval(pendingApprovals, approval.id);
672
956
  await savePendingApprovals();
673
- return true;
957
+ return formatApprovalConfirmation(approval, action, buildDisplayContext());
674
958
  }
675
- // Handle admin commands from owner (unblock, blocked, pending)
676
- async function handleAdminCommand(text) {
677
- const command = parseAdminCommand(text);
678
- if (!command) {
679
- return false;
680
- }
681
- switch (command.type) {
682
- case "blocked": {
683
- const blockedShips = await getBlockedShips();
684
- await sendOwnerNotification(formatBlockedList(blockedShips));
685
- runtime.log?.(`[tlon] Owner requested blocked ships list (${blockedShips.length} ships)`);
686
- return true;
687
- }
688
- case "pending": {
689
- await sendOwnerNotification(formatPendingList(pendingApprovals));
690
- runtime.log?.(`[tlon] Owner requested pending approvals list (${pendingApprovals.length} pending)`);
691
- return true;
692
- }
693
- case "unblock": {
694
- const shipToUnblock = command.ship;
695
- const isBlocked = await isShipBlocked(shipToUnblock);
696
- if (!isBlocked) {
697
- await sendOwnerNotification(`${shipToUnblock} is not blocked.`);
698
- return true;
699
- }
700
- const success = await unblockShip(shipToUnblock);
701
- if (success) {
702
- await sendOwnerNotification(`Unblocked ${shipToUnblock}.`);
703
- }
704
- else {
705
- await sendOwnerNotification(`Failed to unblock ${shipToUnblock}.`);
706
- }
707
- return true;
959
+ // ── Command bridge ──────────────────────────────────────────────────
960
+ // Exposes approval/admin actions to slash commands registered in index.ts.
961
+ // Handlers return response text; the slash command framework sends it back.
962
+ const accountKey = opts.accountId ?? undefined;
963
+ const commandBridge = {
964
+ get ownerShip() {
965
+ return effectiveOwnerShip;
966
+ },
967
+ async handleAction(action, id) {
968
+ // Prune expired approvals
969
+ pendingApprovals = pruneExpired(pendingApprovals);
970
+ await savePendingApprovals();
971
+ const approval = findPendingApproval(pendingApprovals, id);
972
+ if (!approval) {
973
+ return "No pending approval found" + (id ? ` for ID: #${id}` : ".");
708
974
  }
709
- }
710
- }
975
+ return executeApprovalAction(approval, action);
976
+ },
977
+ async getPendingList() {
978
+ return formatPendingList(pendingApprovals, buildDisplayContext());
979
+ },
980
+ async getBlockedList() {
981
+ const blockedShips = await getBlockedShips();
982
+ return formatBlockedList(blockedShips);
983
+ },
984
+ async handleUnblock(ship) {
985
+ runtime.log?.(`[tlon] handleUnblock: checking if ${ship} is blocked...`);
986
+ const blocked = await isShipBlocked(ship);
987
+ if (!blocked) {
988
+ return `${ship} is not blocked.`;
989
+ }
990
+ const success = await unblockShip(ship);
991
+ return success ? `Unblocked ${ship}.` : `Failed to unblock ${ship}.`;
992
+ },
993
+ };
994
+ setBridge(accountKey, commandBridge);
711
995
  // Check if a ship is the owner (always allowed to DM)
712
996
  function isOwner(ship) {
713
997
  if (!effectiveOwnerShip) {
@@ -731,14 +1015,14 @@ export async function monitorTlonProvider(opts = {}) {
731
1015
  return /^~?[a-z-]+$/i.test(normalized) ? normalized : "";
732
1016
  }
733
1017
  const processMessage = async (params) => {
734
- const { messageId, senderShip, isGroup, channelNest, hostShip, channelName, timestamp, parentId, isThreadReply, messageContent, } = params;
1018
+ const { messageId, senderShip, isGroup, channelNest, hostShip: _hostShip, channelName: _channelName, timestamp, parentId, isThreadReply, messageContent, } = params;
735
1019
  // replyParentId overrides parentId for the deliver callback (thread reply routing)
736
1020
  // but doesn't affect the ctx payload (MessageThreadId/ReplyToId).
737
1021
  // Used for reactions: agent sees no thread context (so it responds), but
738
1022
  // the reply is still delivered as a thread reply.
739
1023
  const deliverParentId = params.replyParentId ?? parentId;
740
1024
  const groupChannel = channelNest; // For compatibility
741
- let messageText = params.messageText;
1025
+ let messageText = sanitizeMessageText(params.messageText);
742
1026
  const rawMessageText = messageText; // Preserve original before any modifications
743
1027
  // Strip bot mention EARLY, before thread context is prepended.
744
1028
  // This ensures [Current message] in thread context won't contain the bot ship name,
@@ -746,42 +1030,73 @@ export async function monitorTlonProvider(opts = {}) {
746
1030
  if (isGroup) {
747
1031
  messageText = stripBotMention(messageText, botShipName);
748
1032
  }
749
- // Track owner interaction timestamp for heartbeat engagement recovery.
750
- // Store both epoch ms (for code) and ISO date (for LLM — models can't reliably convert epoch).
1033
+ // Track owner interaction timestamp for the nudge scheduler.
1034
+ // The shadows update synchronously; the durable %settings writes happen
1035
+ // in the background via an ordered queue so the owner-DM hot path never
1036
+ // waits on an Urbit RTT.
751
1037
  if (isOwner(senderShip)) {
752
- const isoDate = new Date(timestamp).toISOString().split("T")[0]; // YYYY-MM-DD
753
- Promise.all([
754
- api.poke({
755
- app: "settings",
756
- mark: "settings-event",
757
- json: {
758
- "put-entry": {
759
- desk: "moltbot",
760
- "bucket-key": "tlon",
761
- "entry-key": "lastOwnerMessageAt",
762
- value: timestamp,
763
- },
764
- },
765
- }),
766
- api.poke({
767
- app: "settings",
768
- mark: "settings-event",
769
- json: {
770
- "put-entry": {
771
- desk: "moltbot",
772
- "bucket-key": "tlon",
773
- "entry-key": "lastOwnerMessageDate",
774
- value: isoDate,
775
- },
776
- },
777
- }),
778
- ])
779
- .then(() => {
780
- runtime.log?.(`[tlon] Updated lastOwnerMessageAt: ${timestamp} (${isoDate})`);
781
- })
782
- .catch((err) => {
783
- runtime.error?.(`[tlon] Failed to update lastOwnerMessageAt: ${String(err)}`);
1038
+ const isoDate = new Date(timestamp).toISOString().split("T")[0] ?? ""; // YYYY-MM-DD
1039
+ // (1a) Synchronous shadow: owner activity. Updated FIRST so any tick
1040
+ // that observes both shadows sees "activity-first" ordering.
1041
+ setLastOwnerActivity(account.accountId, { at: timestamp, date: isoDate });
1042
+ // Check for pending nudge re-engagement. Stage is cleared on ANY owner
1043
+ // reply when the stage shadow is non-zero (or pendingNudge is present)
1044
+ // so the next inactivity cycle can send the same stage again. Gating on
1045
+ // `pendingNudge` alone would miss the in-flight-tick race: the scheduler
1046
+ // pokes `lastNudgeStage` and sets the shadow before `sendDm()`, but
1047
+ // only writes `pendingNudge` after the send resolves — so a reply that
1048
+ // lands in that window would otherwise leave the stage stuck.
1049
+ const pending = getPendingNudge(account.accountId);
1050
+ const shadowStage = getLastNudgeStageShadow(account.accountId) ?? 0;
1051
+ const willClearStage = shadowStage > 0 || Boolean(pending);
1052
+ // (1b) Synchronous shadow: stage cleared (only when we'd clear).
1053
+ if (willClearStage) {
1054
+ setLastNudgeStageShadow(account.accountId, 0);
1055
+ }
1056
+ // (2) Enqueue durable writes. The queue awaits the put-entries before
1057
+ // issuing the del-entry on the wire, closing the crash-consistency
1058
+ // gap. The handler does NOT await the queue.
1059
+ ownerReplyPersistence.enqueue({
1060
+ at: timestamp,
1061
+ date: isoDate,
1062
+ clearStage: willClearStage,
784
1063
  });
1064
+ if (pending) {
1065
+ if (isNudgeEligible(pending, timestamp)) {
1066
+ const reengagedAt = timestamp;
1067
+ telemetry?.captureHeartbeatReengagement({
1068
+ ownerShip: pending.ownerShip,
1069
+ botShip: account.ship ?? "",
1070
+ nudgeStage: pending.stage,
1071
+ nudgeSentAt: pending.sentAt,
1072
+ reengagedAt,
1073
+ reengagementDelayMs: reengagedAt - pending.sentAt,
1074
+ channel: "tlon",
1075
+ accountId: pending.accountId,
1076
+ });
1077
+ runtime.log?.(`[tlon] Heartbeat nudge re-engagement: stage ${pending.stage}, delay ${reengagedAt - pending.sentAt}ms`);
1078
+ }
1079
+ else {
1080
+ runtime.log?.(`[tlon] Pending nudge expired (stage ${pending.stage}, sent ${pending.sentAt})`);
1081
+ }
1082
+ clearLocalPendingNudge(account.accountId);
1083
+ }
1084
+ // Inject reply context for the agent when the reply appears to be a
1085
+ // response to a recent, eligible nudge.
1086
+ //
1087
+ // Restricted to DMs (`!isGroup`). The nudge itself was sent as a DM,
1088
+ // so prefacing a channel/group reply with DM-only context — including
1089
+ // the verbatim nudge `content` — would leak that context into an
1090
+ // unrelated public conversation.
1091
+ if (pending && isNudgeEligible(pending, timestamp) && !isGroup) {
1092
+ const sentIso = new Date(pending.sentAt).toISOString();
1093
+ const contentBlock = pending.content ? `Message content:\n\n${pending.content}\n\n` : "";
1094
+ messageText =
1095
+ `[Context: You recently sent ${pending.ownerShip} a stage-${pending.stage} ` +
1096
+ `re-engagement nudge at ${sentIso}. ${contentBlock}` +
1097
+ `The owner's reply below may be responding to that nudge.]\n\n` +
1098
+ messageText;
1099
+ }
785
1100
  }
786
1101
  // Download any images from the message content
787
1102
  let attachments = [];
@@ -796,14 +1111,39 @@ export async function monitorTlonProvider(opts = {}) {
796
1111
  runtime.log?.(`[tlon] Failed to download images: ${error?.message ?? String(error)}`);
797
1112
  }
798
1113
  }
1114
+ // Parse and handle blob attachments (files, voice memos, videos)
1115
+ const blobData = parseBlobData(params.blobField);
1116
+ if (blobData) {
1117
+ // Add text annotations so the agent knows what was attached
1118
+ const blobAnnotations = formatBlobAnnotations(blobData);
1119
+ if (blobAnnotations) {
1120
+ messageText = blobAnnotations + "\n" + messageText;
1121
+ runtime.log?.(`[tlon] Added blob annotations: ${blobAnnotations} attachment(s)`);
1122
+ }
1123
+ // Download blob files as attachments
1124
+ try {
1125
+ const { attachments: blobAttachments, notices: blobDownloadNotices } = await downloadBlobAttachments(blobData);
1126
+ if (blobDownloadNotices.length > 0) {
1127
+ messageText = blobDownloadNotices.join("\n") + "\n" + messageText;
1128
+ runtime.log?.(`[tlon] Skipped oversized blob attachment(s): ${blobDownloadNotices.join(" | ")}`);
1129
+ }
1130
+ if (blobAttachments.length > 0) {
1131
+ attachments = attachments.concat(blobAttachments);
1132
+ runtime.log?.(`[tlon] Downloaded blob attachment(s) ${JSON.stringify(blobAttachments)}`);
1133
+ }
1134
+ }
1135
+ catch (error) {
1136
+ runtime.log?.(`[tlon] Failed to download blob attachments: ${error?.message ?? String(error)}`);
1137
+ }
1138
+ }
799
1139
  // Fetch thread context when entering a thread for the first time
800
1140
  if (isThreadReply && parentId && groupChannel) {
801
1141
  try {
802
1142
  const threadHistory = await fetchThreadHistory(api, groupChannel, parentId, 20, runtime);
803
1143
  if (threadHistory.length > 0) {
804
1144
  const threadContext = threadHistory
805
- .slice(-10) // Last 10 messages for context
806
- .map((msg) => `${msg.author}: ${msg.content}`)
1145
+ .slice(-20) // Last 20 thread messages for context
1146
+ .map((msg) => `${formatShipWithNickname(msg.author)}: ${sanitizeMessageText(msg.content)}`)
807
1147
  .join("\n");
808
1148
  // Prepend thread context to the message
809
1149
  // Include note about ongoing conversation for agent judgment
@@ -817,6 +1157,31 @@ export async function monitorTlonProvider(opts = {}) {
817
1157
  // Continue without thread context - not critical
818
1158
  }
819
1159
  }
1160
+ // Fetch recent channel history on mention (non-thread) so the agent has
1161
+ // context about what the channel has been discussing.
1162
+ if (isGroup && groupChannel && !isThreadReply) {
1163
+ try {
1164
+ const recentHistory = await fetchChannelHistory(api, groupChannel, 20, runtime);
1165
+ if (recentHistory.length > 0) {
1166
+ // Filter out the current message itself (avoid duplication)
1167
+ const contextMessages = recentHistory
1168
+ .filter((msg) => msg.id !== params.messageId)
1169
+ .slice(0, 20)
1170
+ .toReversed() // oldest first for natural reading order
1171
+ .map((msg) => `${formatShipWithNickname(msg.author)}: ${sanitizeMessageText(msg.content)}`)
1172
+ .join("\n");
1173
+ if (contextMessages) {
1174
+ const contextNote = `[Recent channel activity - ${recentHistory.length} messages. Use this context to understand what's being discussed.]`;
1175
+ messageText = `${contextNote}\n\n${contextMessages}\n\n[Current message (mentioned you)]\n${messageText}`;
1176
+ runtime?.log?.(`[tlon] Added channel context (${recentHistory.length} messages) to mention in ${groupChannel}`);
1177
+ }
1178
+ }
1179
+ }
1180
+ catch (error) {
1181
+ runtime?.log?.(`[tlon] Could not fetch channel context: ${error?.message ?? String(error)}`);
1182
+ // Continue without channel context - not critical
1183
+ }
1184
+ }
820
1185
  if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
821
1186
  try {
822
1187
  const history = await getChannelHistory(api, groupChannel, 50, runtime);
@@ -824,6 +1189,7 @@ export async function monitorTlonProvider(opts = {}) {
824
1189
  const noHistoryMsg = "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
825
1190
  if (isGroup && groupChannel) {
826
1191
  await sendChannelPost({
1192
+ botProfile: getBotProfile(),
827
1193
  fromShip: botShipName,
828
1194
  nest: groupChannel,
829
1195
  story: markdownToStory(noHistoryMsg),
@@ -831,6 +1197,7 @@ export async function monitorTlonProvider(opts = {}) {
831
1197
  }
832
1198
  else {
833
1199
  await sendDm({
1200
+ botProfile: getBotProfile(),
834
1201
  fromShip: botShipName,
835
1202
  toShip: senderShip,
836
1203
  text: noHistoryMsg,
@@ -839,7 +1206,7 @@ export async function monitorTlonProvider(opts = {}) {
839
1206
  return;
840
1207
  }
841
1208
  const historyText = history
842
- .map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
1209
+ .map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${sanitizeMessageText(msg.content)}`)
843
1210
  .join("\n");
844
1211
  messageText =
845
1212
  `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
@@ -853,13 +1220,19 @@ export async function monitorTlonProvider(opts = {}) {
853
1220
  const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`;
854
1221
  if (isGroup && groupChannel) {
855
1222
  await sendChannelPost({
1223
+ botProfile: getBotProfile(),
856
1224
  fromShip: botShipName,
857
1225
  nest: groupChannel,
858
1226
  story: markdownToStory(errorMsg),
859
1227
  });
860
1228
  }
861
1229
  else {
862
- await sendDm({ fromShip: botShipName, toShip: senderShip, text: errorMsg });
1230
+ await sendDm({
1231
+ botProfile: getBotProfile(),
1232
+ fromShip: botShipName,
1233
+ toShip: senderShip,
1234
+ text: errorMsg,
1235
+ });
863
1236
  }
864
1237
  return;
865
1238
  }
@@ -894,6 +1267,7 @@ export async function monitorTlonProvider(opts = {}) {
894
1267
  `Docs: https://docs.openclaw.ai/concepts/session#secure-dm-mode`;
895
1268
  // Send async, don't block message processing
896
1269
  sendDm({
1270
+ botProfile: getBotProfile(),
897
1271
  fromShip: botShipName,
898
1272
  toShip: effectiveOwnerShip,
899
1273
  text: warningMsg,
@@ -903,10 +1277,14 @@ export async function monitorTlonProvider(opts = {}) {
903
1277
  senders.add(senderShip);
904
1278
  }
905
1279
  const senderRole = isOwner(senderShip) ? "owner" : "user";
1280
+ // Store role for before_tool_call hook (tool access control)
1281
+ setSessionRole(route.sessionKey, senderRole);
1282
+ runtime.log?.(`[tlon] Stored session role: sessionKey=${route.sessionKey}, role=${senderRole}`);
906
1283
  const senderDisplay = formatShipWithNickname(senderShip);
907
1284
  const fromLabel = isGroup
908
1285
  ? `${senderDisplay} [${senderRole}] in ${channelNest}`
909
1286
  : `${senderDisplay} [${senderRole}]`;
1287
+ const attachmentCount = attachments.length;
910
1288
  // Compute command authorization for slash commands (owner-only)
911
1289
  const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(messageText, cfg);
912
1290
  let commandAuthorized = false;
@@ -945,11 +1323,10 @@ export async function monitorTlonProvider(opts = {}) {
945
1323
  body: bodyWithAttachments,
946
1324
  });
947
1325
  // Use raw text (no thread context) for command detection so "/status" is recognized
948
- const commandBody = isGroup
949
- ? stripBotMention(rawMessageText, botShipName)
950
- : rawMessageText;
1326
+ const commandBody = isGroup ? stripBotMention(rawMessageText, botShipName) : rawMessageText;
951
1327
  const ctxPayload = core.channel.reply.finalizeInboundContext({
952
1328
  Body: body,
1329
+ BodyForAgent: bodyWithAttachments,
953
1330
  RawBody: messageText,
954
1331
  CommandBody: commandBody,
955
1332
  From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
@@ -966,65 +1343,196 @@ export async function monitorTlonProvider(opts = {}) {
966
1343
  Provider: "tlon",
967
1344
  Surface: "tlon",
968
1345
  MessageSid: messageId,
969
- // Include downloaded media attachments
970
- ...(attachments.length > 0 && { Attachments: attachments }),
1346
+ // Include downloaded media attachments (MediaPaths/MediaUrls/MediaTypes for OpenClaw media pipeline)
1347
+ ...(attachments.length > 0 && {
1348
+ MediaPaths: attachments.map((a) => a.path),
1349
+ MediaUrls: attachments.map((a) => a.path),
1350
+ MediaTypes: attachments.map((a) => a.contentType),
1351
+ }),
971
1352
  OriginatingChannel: "tlon",
972
- OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
1353
+ OriginatingTo: `tlon:${isGroup ? groupChannel : senderShip}`,
973
1354
  // Include thread context for automatic reply routing
974
1355
  ...(parentId && { MessageThreadId: String(parentId), ReplyToId: String(parentId) }),
975
1356
  });
976
1357
  const dispatchStartTime = Date.now();
1358
+ const replyTelemetry = telemetry?.startReply({
1359
+ sessionKey: route.sessionKey,
1360
+ ownerShip: effectiveOwnerShip,
1361
+ botShip: botShipName,
1362
+ chatType: isGroup ? "groupChannel" : "dm",
1363
+ isThreadReply: Boolean(isThreadReply),
1364
+ senderRole,
1365
+ attachmentCount,
1366
+ });
1367
+ let selectedProvider = null;
1368
+ let selectedModel = null;
1369
+ let selectedThinkLevel = null;
1370
+ let deliveredMessageCount = 0;
1371
+ let replyCharCount = 0;
1372
+ let replyWordCount = 0;
1373
+ let replyMediaCount = 0;
977
1374
  const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix;
978
1375
  const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
979
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
980
- ctx: ctxPayload,
981
- cfg,
982
- dispatcherOptions: {
983
- responsePrefix,
984
- humanDelay,
985
- deliver: async (payload) => {
986
- let replyText = payload.text;
987
- if (!replyText) {
988
- return;
989
- }
990
- // Use settings store value if set, otherwise fall back to file config
991
- const showSignature = effectiveShowModelSig;
992
- if (showSignature) {
993
- const modelInfo = payload.metadata?.model ||
994
- payload.model ||
995
- route.model ||
996
- cfg.agents?.defaults?.model?.primary;
997
- replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
998
- }
999
- if (isGroup && groupChannel) {
1000
- // Send to any channel type (chat, heap, diary) using the nest directly
1001
- await sendChannelPost({
1002
- fromShip: botShipName,
1003
- nest: groupChannel,
1004
- story: markdownToStory(replyText),
1005
- replyToId: deliverParentId ?? undefined,
1006
- });
1007
- // Track thread participation for future replies without mention
1008
- if (deliverParentId) {
1009
- participatedThreads.add(String(deliverParentId));
1010
- runtime.log?.(`[tlon] Now tracking thread for future replies: ${deliverParentId}`);
1011
- }
1012
- }
1013
- else {
1014
- await sendDm({ fromShip: botShipName, toShip: senderShip, text: replyText, replyToId: deliverParentId ? String(deliverParentId) : undefined });
1015
- }
1376
+ const presenceConversationId = isGroup ? (groupChannel ?? null) : senderShip;
1377
+ const presenceRunId = String(messageId);
1378
+ const typingCallbacks = presenceConversationId
1379
+ ? createTypingCallbacks({
1380
+ start: async () => {
1381
+ await computingPresence.refreshRun({
1382
+ conversationId: presenceConversationId,
1383
+ runId: presenceRunId,
1384
+ });
1385
+ },
1386
+ stop: async () => {
1387
+ await computingPresence.stopRun({
1388
+ conversationId: presenceConversationId,
1389
+ runId: presenceRunId,
1390
+ });
1391
+ },
1392
+ onStartError: (err) => {
1393
+ runtime.error?.(`[tlon] Failed to start computing presence for ${presenceConversationId}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1016
1394
  },
1017
- onError: (err, info) => {
1018
- const dispatchDuration = Date.now() - dispatchStartTime;
1019
- runtime.error?.(`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`);
1395
+ onStopError: (err) => {
1396
+ runtime.error?.(`[tlon] Failed to stop computing presence for ${presenceConversationId}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1020
1397
  },
1398
+ keepaliveIntervalMs: 20_000,
1399
+ })
1400
+ : undefined;
1401
+ const replyOptions = {
1402
+ onModelSelected: ({ provider, model, thinkLevel }) => {
1403
+ selectedProvider = provider;
1404
+ selectedModel = model;
1405
+ selectedThinkLevel = thinkLevel ?? null;
1021
1406
  },
1022
- });
1407
+ ...(presenceConversationId
1408
+ ? {
1409
+ onAssistantMessageStart: async () => {
1410
+ await computingPresence.clearToolCalls({
1411
+ conversationId: presenceConversationId,
1412
+ runId: presenceRunId,
1413
+ });
1414
+ },
1415
+ onToolStart: async (payload) => {
1416
+ await computingPresence.addToolCall({
1417
+ conversationId: presenceConversationId,
1418
+ runId: presenceRunId,
1419
+ toolName: payload.name,
1420
+ });
1421
+ },
1422
+ }
1423
+ : {}),
1424
+ };
1425
+ let dispatchResult;
1426
+ let dispatchError;
1427
+ try {
1428
+ dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1429
+ ctx: ctxPayload,
1430
+ cfg,
1431
+ replyOptions,
1432
+ dispatcherOptions: {
1433
+ responsePrefix,
1434
+ humanDelay,
1435
+ typingCallbacks,
1436
+ deliver: async (payload) => {
1437
+ let replyText = payload.text;
1438
+ if (!replyText) {
1439
+ return;
1440
+ }
1441
+ // Process any block directives in the response (strips them from text)
1442
+ replyText = await processBlockDirectives(replyText, senderShip);
1443
+ if (!replyText) {
1444
+ return;
1445
+ } // Response was only a directive
1446
+ // Use settings store value if set, otherwise fall back to file config
1447
+ const showSignature = effectiveShowModelSig;
1448
+ if (showSignature) {
1449
+ const modelCfg = cfg.agents?.defaults?.model;
1450
+ const modelInfo = selectedModel ||
1451
+ payload.metadata?.model ||
1452
+ payload.model ||
1453
+ route.model ||
1454
+ (typeof modelCfg === "string" ? modelCfg : modelCfg?.primary);
1455
+ replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
1456
+ }
1457
+ // Add addendum if this is the last response before bot rate limit
1458
+ if (isGroup && groupChannel && knownBotShips.has(senderShip)) {
1459
+ const count = consecutiveBotMessages.get(groupChannel) ?? 0;
1460
+ if (maxBotResponses > 0 && count === maxBotResponses) {
1461
+ const otherBot = formatShipWithNickname(senderShip);
1462
+ replyText += `\n\n---\n_This is my last response to ${otherBot} for now. To continue our conversation, someone will need to mention me._`;
1463
+ }
1464
+ }
1465
+ if (isGroup && groupChannel) {
1466
+ // Send to any channel type (chat, heap, diary) using the nest directly
1467
+ await sendChannelPost({
1468
+ botProfile: getBotProfile(),
1469
+ fromShip: botShipName,
1470
+ nest: groupChannel,
1471
+ story: markdownToStory(replyText),
1472
+ replyToId: deliverParentId ?? undefined,
1473
+ });
1474
+ // Track thread participation for future replies without mention
1475
+ if (deliverParentId) {
1476
+ participatedThreads.add(String(deliverParentId));
1477
+ runtime.log?.(`[tlon] Now tracking thread for future replies: ${deliverParentId}`);
1478
+ }
1479
+ }
1480
+ else {
1481
+ await sendDm({
1482
+ botProfile: getBotProfile(),
1483
+ fromShip: botShipName,
1484
+ toShip: senderShip,
1485
+ text: replyText,
1486
+ replyToId: deliverParentId ? String(deliverParentId) : undefined,
1487
+ });
1488
+ }
1489
+ if (presenceConversationId) {
1490
+ await computingPresence.stopRun({
1491
+ conversationId: presenceConversationId,
1492
+ runId: presenceRunId,
1493
+ });
1494
+ }
1495
+ deliveredMessageCount += 1;
1496
+ replyCharCount += replyText.length;
1497
+ replyWordCount += replyText.trim() ? replyText.trim().split(/\s+/).length : 0;
1498
+ replyMediaCount += Array.isArray(payload.mediaUrls)
1499
+ ? payload.mediaUrls.length
1500
+ : payload.mediaUrl
1501
+ ? 1
1502
+ : 0;
1503
+ },
1504
+ onError: (err, info) => {
1505
+ const dispatchDuration = Date.now() - dispatchStartTime;
1506
+ runtime.error?.(`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`);
1507
+ },
1508
+ },
1509
+ });
1510
+ }
1511
+ catch (error) {
1512
+ dispatchError = error;
1513
+ throw error;
1514
+ }
1515
+ finally {
1516
+ await replyTelemetry?.capture({
1517
+ deliveredMessageCount,
1518
+ replyCharCount,
1519
+ replyWordCount,
1520
+ replyMediaCount,
1521
+ dispatchDurationMs: Date.now() - dispatchStartTime,
1522
+ queuedFinal: dispatchResult?.queuedFinal ?? false,
1523
+ queuedFinalCount: dispatchResult?.counts.final ?? 0,
1524
+ queuedBlockCount: dispatchResult?.counts.block ?? 0,
1525
+ provider: selectedProvider,
1526
+ model: selectedModel,
1527
+ thinkLevel: selectedThinkLevel,
1528
+ dispatchError,
1529
+ });
1530
+ }
1023
1531
  };
1024
1532
  // Track which channels we're interested in for filtering firehose events
1025
1533
  const watchedChannels = new Set(groupChannels);
1026
1534
  const _watchedDMs = new Set();
1027
- // Firehose handler for all channel messages (/v2)
1535
+ // Firehose handler for all channel messages (/v4)
1028
1536
  const handleChannelsFirehose = async (event) => {
1029
1537
  try {
1030
1538
  const nest = event?.nest;
@@ -1056,8 +1564,9 @@ export async function monitorTlonProvider(opts = {}) {
1056
1564
  : (response?.post?.id ?? "unknown");
1057
1565
  for (const [reactShip, reactEmoji] of Object.entries(effectiveReacts)) {
1058
1566
  const ship = normalizeShip(reactShip);
1059
- if (!ship || ship === botShipName)
1567
+ if (!ship || ship === botShipName) {
1060
1568
  continue;
1569
+ }
1061
1570
  try {
1062
1571
  const route = core.channel.routing.resolveAgentRoute({
1063
1572
  cfg,
@@ -1070,7 +1579,9 @@ export async function monitorTlonProvider(opts = {}) {
1070
1579
  const contentSnippet = cached?.content
1071
1580
  ? ` (message: "${cached.content.substring(0, 200)}${cached.content.length > 200 ? "..." : ""}")`
1072
1581
  : "";
1073
- const authorInfo = cached?.author ? ` (by ${formatShipWithNickname(cached.author)})` : "";
1582
+ const authorInfo = cached?.author
1583
+ ? ` (by ${formatShipWithNickname(cached.author)})`
1584
+ : "";
1074
1585
  const reactorDisplay = formatShipWithNickname(ship);
1075
1586
  const eventText = `Tlon reaction in ${nest}: ${reactEmoji} by ${reactorDisplay} on post ${postId}${authorInfo}${contentSnippet}`;
1076
1587
  runtime.log?.(`[tlon] REACTION: ${eventText}`);
@@ -1080,9 +1591,7 @@ export async function monitorTlonProvider(opts = {}) {
1080
1591
  // Include context so agent knows what was reacted to, since we're
1081
1592
  // deliberately omitting thread context (parentId) to avoid the agent
1082
1593
  // suppressing responses when it sees its own message in thread history.
1083
- const reactionParentId = replyReacts
1084
- ? (response?.post?.id ?? postId)
1085
- : postId;
1594
+ const reactionParentId = replyReacts ? (response?.post?.id ?? postId) : postId;
1086
1595
  const reactText = cached?.content
1087
1596
  ? `${reactEmoji} (reacting to: "${cached.content}")`
1088
1597
  : reactEmoji;
@@ -1116,17 +1625,17 @@ export async function monitorTlonProvider(opts = {}) {
1116
1625
  }
1117
1626
  // Handle post responses (new posts and replies)
1118
1627
  const essay = response?.post?.["r-post"]?.set?.essay;
1119
- const memo = response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
1120
- if (!essay && !memo) {
1628
+ const replyEssay = response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.["reply-essay"];
1629
+ const content = replyEssay || essay;
1630
+ if (!content) {
1121
1631
  return;
1122
1632
  }
1123
- const content = memo || essay;
1124
- const isThreadReply = Boolean(memo);
1633
+ const isThreadReply = Boolean(replyEssay);
1125
1634
  const messageId = isThreadReply ? response?.post?.["r-post"]?.reply?.id : response?.post?.id;
1126
1635
  if (!processedTracker.mark(messageId)) {
1127
1636
  return;
1128
1637
  }
1129
- const senderShip = normalizeShip(content?.author ?? "");
1638
+ const senderShip = normalizeShip(extractAuthorShip(content?.author));
1130
1639
  if (!senderShip) {
1131
1640
  return;
1132
1641
  }
@@ -1134,7 +1643,8 @@ export async function monitorTlonProvider(opts = {}) {
1134
1643
  const citedContent = await resolveAllCites(content.content);
1135
1644
  const rawText = extractMessageText(content.content);
1136
1645
  const messageText = citedContent + rawText;
1137
- if (!messageText.trim()) {
1646
+ const hasBlob = Boolean(content?.blob);
1647
+ if (!messageText.trim() && !hasBlob) {
1138
1648
  return;
1139
1649
  }
1140
1650
  // Cache ALL messages (including bot's own) so reaction lookups have context
@@ -1144,10 +1654,18 @@ export async function monitorTlonProvider(opts = {}) {
1144
1654
  timestamp: content.sent || Date.now(),
1145
1655
  id: messageId,
1146
1656
  });
1657
+ // Check if sender is a bot (BotProfile object has ship, nickname, avatar)
1658
+ const authorRaw = content?.author;
1659
+ const isSenderBot = typeof authorRaw === "object" && authorRaw !== null && "ship" in authorRaw;
1660
+ if (isSenderBot) {
1661
+ knownBotShips.add(senderShip);
1662
+ }
1147
1663
  // Skip processing bot's own messages (but they're already cached above)
1148
1664
  if (senderShip === botShipName) {
1149
1665
  return;
1150
1666
  }
1667
+ // Check if sender is a known bot (for rate limiting later)
1668
+ const isKnownBot = isSenderBot || knownBotShips.has(senderShip);
1151
1669
  // Get thread info early for participation check
1152
1670
  const seal = isThreadReply
1153
1671
  ? response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
@@ -1158,13 +1676,33 @@ export async function monitorTlonProvider(opts = {}) {
1158
1676
  // 2. Thread replies where we've participated - respond if relevant (let agent decide)
1159
1677
  const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
1160
1678
  const inParticipatedThread = isThreadReply && parentId && participatedThreads.has(String(parentId));
1161
- if (!mentioned && !inParticipatedThread) {
1679
+ const isOwnerBlob = hasBlob && isOwner(senderShip);
1680
+ if (!mentioned && !inParticipatedThread && !isOwnerBlob) {
1162
1681
  return;
1163
1682
  }
1164
1683
  // Log why we're responding
1165
- if (inParticipatedThread && !mentioned) {
1684
+ if (isOwnerBlob && !mentioned && !inParticipatedThread) {
1685
+ runtime.log?.(`[tlon] Responding to owner blob-only message in ${nest}`);
1686
+ }
1687
+ else if (inParticipatedThread && !mentioned) {
1166
1688
  runtime.log?.(`[tlon] Responding to thread we participated in (no mention): ${parentId}`);
1167
1689
  }
1690
+ // Rate limit consecutive bot responses (only in group channels)
1691
+ if (isKnownBot) {
1692
+ const count = (consecutiveBotMessages.get(nest) ?? 0) + 1;
1693
+ consecutiveBotMessages.set(nest, count);
1694
+ runtime.log?.(`[tlon] Bot mention from ${senderShip} in ${nest}: consecutive count = ${count}`);
1695
+ if (maxBotResponses > 0 && count > maxBotResponses) {
1696
+ runtime.log?.(`[tlon] Rate limiting: skipping response to bot ${senderShip} (count ${count} > limit ${maxBotResponses})`);
1697
+ return;
1698
+ }
1699
+ }
1700
+ else {
1701
+ // Human mention resets the consecutive bot counter
1702
+ // (requires explicit engagement, not just any human message)
1703
+ consecutiveBotMessages.set(nest, 0);
1704
+ runtime.log?.(`[tlon] Human mention from ${senderShip} in ${nest}: reset bot counter`);
1705
+ }
1168
1706
  // Owner is always allowed
1169
1707
  if (isOwner(senderShip)) {
1170
1708
  runtime.log?.(`[tlon] Owner ${senderShip} is always allowed in channels`);
@@ -1188,8 +1726,9 @@ export async function monitorTlonProvider(opts = {}) {
1188
1726
  timestamp: content.sent || Date.now(),
1189
1727
  parentId: parentId ?? undefined,
1190
1728
  isThreadReply,
1729
+ blob: content.blob ?? undefined,
1191
1730
  },
1192
- });
1731
+ }, pendingApprovals.map((a) => a.id));
1193
1732
  await queueApprovalRequest(approval);
1194
1733
  }
1195
1734
  else {
@@ -1205,6 +1744,7 @@ export async function monitorTlonProvider(opts = {}) {
1205
1744
  senderShip,
1206
1745
  messageText,
1207
1746
  messageContent: content.content, // Pass raw content for media extraction
1747
+ blobField: content.blob,
1208
1748
  isGroup: true,
1209
1749
  channelNest: nest,
1210
1750
  hostShip: parsed?.hostShip,
@@ -1218,7 +1758,7 @@ export async function monitorTlonProvider(opts = {}) {
1218
1758
  runtime.error?.(`[tlon] Error handling channel firehose event: ${error?.message ?? String(error)}`);
1219
1759
  }
1220
1760
  };
1221
- // Firehose handler for all DM messages (/v3)
1761
+ // Firehose handler for all DM messages (/v4)
1222
1762
  // Track which DM invites we've already processed to avoid duplicate accepts
1223
1763
  const processedDmInvites = new Set();
1224
1764
  const handleChatFirehose = async (event) => {
@@ -1268,7 +1808,7 @@ export async function monitorTlonProvider(opts = {}) {
1268
1808
  type: "dm",
1269
1809
  requestingShip: ship,
1270
1810
  messagePreview: "(DM invite - no message yet)",
1271
- });
1811
+ }, pendingApprovals.map((a) => a.id));
1272
1812
  await queueApprovalRequest(approval);
1273
1813
  processedDmInvites.add(ship); // Mark as processed to avoid duplicate notifications
1274
1814
  }
@@ -1290,9 +1830,34 @@ export async function monitorTlonProvider(opts = {}) {
1290
1830
  if (dmAddReact || dmDelReact) {
1291
1831
  const isAdd = Boolean(dmAddReact);
1292
1832
  const reactData = dmAddReact || dmDelReact;
1293
- const reactAuthor = normalizeShip(reactData?.author ?? reactData?.ship ?? "");
1294
- const reactEmoji = reactData?.react ?? "";
1833
+ const reactAuthor = normalizeShip(extractAuthorShip(reactData?.author) || reactData?.ship || "");
1834
+ const reactEmoji = dmAddReact?.react ?? "";
1295
1835
  if (reactAuthor && reactAuthor !== botShipName) {
1836
+ // Check if this is an approval reaction from the owner on a notification message
1837
+ if (isAdd && isOwner(reactAuthor)) {
1838
+ const approvalAction = emojiToApprovalAction(reactEmoji);
1839
+ if (approvalAction) {
1840
+ const normalizedEventId = normalizeNotificationId(messageId);
1841
+ const matchedApproval = pendingApprovals.find((a) => a.notificationMessageId === normalizedEventId);
1842
+ if (matchedApproval) {
1843
+ if (isExpired(matchedApproval)) {
1844
+ runtime.log?.(`[tlon] Ignoring reaction on expired approval #${matchedApproval.id}`);
1845
+ // Fall through to normal reaction handling
1846
+ }
1847
+ else {
1848
+ runtime.log?.(`[tlon] Reaction-based approval: ${reactEmoji} → ${approvalAction} for #${matchedApproval.id}`);
1849
+ try {
1850
+ const confirmText = await executeApprovalAction(matchedApproval, approvalAction);
1851
+ await sendOwnerNotification(confirmText);
1852
+ }
1853
+ catch (err) {
1854
+ runtime.error?.(`[tlon] Reaction approval error: ${String(err)}`);
1855
+ }
1856
+ return;
1857
+ }
1858
+ }
1859
+ }
1860
+ }
1296
1861
  try {
1297
1862
  const partnerShip = extractDmPartnerShip(whom);
1298
1863
  const route = core.channel.routing.resolveAgentRoute({
@@ -1326,7 +1891,9 @@ export async function monitorTlonProvider(opts = {}) {
1326
1891
  const contentSnippet = cached?.content
1327
1892
  ? ` (message: "${cached.content.substring(0, 200)}${cached.content.length > 200 ? "..." : ""}")`
1328
1893
  : "";
1329
- const authorInfo = cached?.author ? ` (by ${formatShipWithNickname(cached.author)})` : "";
1894
+ const authorInfo = cached?.author
1895
+ ? ` (by ${formatShipWithNickname(cached.author)})`
1896
+ : "";
1330
1897
  const reactorDisplay = formatShipWithNickname(reactAuthor);
1331
1898
  const eventText = `Tlon DM reaction ${action}: ${reactEmoji} by ${reactorDisplay} on message ${messageId}${authorInfo}${contentSnippet}`;
1332
1899
  core.system.enqueueSystemEvent(eventText, {
@@ -1342,19 +1909,19 @@ export async function monitorTlonProvider(opts = {}) {
1342
1909
  }
1343
1910
  return;
1344
1911
  }
1345
- // Extract memo from DM thread reply
1346
- const dmReplyMemo = dmReply?.delta?.add?.memo;
1912
+ // Extract reply-essay from DM thread reply
1913
+ const dmReplyEssay = dmReply?.delta?.add?.["reply-essay"];
1347
1914
  const dmReplyParentId = dmReply ? event.id : undefined;
1348
- const isDmThreadReply = Boolean(dmReplyMemo);
1349
- const dmContent = essay || dmReplyMemo;
1915
+ const isDmThreadReply = Boolean(dmReplyEssay);
1916
+ const dmContent = essay || dmReplyEssay;
1350
1917
  // For DM thread replies, extract the reply's own ID (distinct from the parent post ID)
1351
1918
  // The reply ID may be in dmReply.id, or we construct it from author/sent
1352
1919
  let dmReplyOwnId;
1353
1920
  if (isDmThreadReply && dmReply) {
1354
1921
  dmReplyOwnId = dmReply.id ?? dmReply.delta?.add?.id;
1355
1922
  // If no explicit reply ID, construct from author/sent (same format as our outbound)
1356
- if (!dmReplyOwnId && dmReplyMemo?.author && dmReplyMemo?.sent) {
1357
- dmReplyOwnId = `${normalizeShip(dmReplyMemo.author)}/${dmReplyMemo.sent}`;
1923
+ if (!dmReplyOwnId && dmReplyEssay?.author && dmReplyEssay?.sent) {
1924
+ dmReplyOwnId = `${normalizeShip(extractAuthorShip(dmReplyEssay.author))}/${dmReplyEssay.sent}`;
1358
1925
  }
1359
1926
  }
1360
1927
  if (!dmContent) {
@@ -1365,7 +1932,7 @@ export async function monitorTlonProvider(opts = {}) {
1365
1932
  if (!processedTracker.mark(effectiveMessageId)) {
1366
1933
  return;
1367
1934
  }
1368
- const authorShip = normalizeShip(dmContent?.author ?? "");
1935
+ const authorShip = normalizeShip(extractAuthorShip(dmContent.author));
1369
1936
  const partnerShip = extractDmPartnerShip(whom);
1370
1937
  const senderShip = partnerShip || authorShip;
1371
1938
  // Cache DM messages (including bot's own) so reaction lookups have context
@@ -1394,25 +1961,10 @@ export async function monitorTlonProvider(opts = {}) {
1394
1961
  const citedContent = await resolveAllCites(dmContent.content);
1395
1962
  const rawText = extractMessageText(dmContent.content);
1396
1963
  const messageText = citedContent + rawText;
1397
- if (!messageText.trim()) {
1964
+ const hasBlob = Boolean(dmContent?.blob);
1965
+ if (!messageText.trim() && !hasBlob) {
1398
1966
  return;
1399
1967
  }
1400
- // Check if this is the owner sending an approval response
1401
- if (isOwner(senderShip) && isApprovalResponse(messageText)) {
1402
- const handled = await handleApprovalResponse(messageText);
1403
- if (handled) {
1404
- runtime.log?.(`[tlon] Processed approval response from owner: ${messageText}`);
1405
- return;
1406
- }
1407
- }
1408
- // Check if this is the owner sending an admin command
1409
- if (isOwner(senderShip) && isAdminCommand(messageText)) {
1410
- const handled = await handleAdminCommand(messageText);
1411
- if (handled) {
1412
- runtime.log?.(`[tlon] Processed admin command from owner: ${messageText}`);
1413
- return;
1414
- }
1415
- }
1416
1968
  // Owner is always allowed to DM (bypass allowlist)
1417
1969
  if (isOwner(senderShip)) {
1418
1970
  runtime.log?.(`[tlon] Processing DM from owner ${senderShip}${isDmThreadReply ? ` (thread reply, parent=${dmReplyParentId}, replyId=${effectiveMessageId})` : ""}`);
@@ -1421,6 +1973,7 @@ export async function monitorTlonProvider(opts = {}) {
1421
1973
  senderShip,
1422
1974
  messageText,
1423
1975
  messageContent: dmContent.content,
1976
+ blobField: dmContent.blob,
1424
1977
  isGroup: false,
1425
1978
  timestamp: dmContent.sent || Date.now(),
1426
1979
  parentId: dmReplyParentId,
@@ -1441,8 +1994,9 @@ export async function monitorTlonProvider(opts = {}) {
1441
1994
  messageText,
1442
1995
  messageContent: dmContent.content,
1443
1996
  timestamp: dmContent.sent || Date.now(),
1997
+ blob: dmContent.blob ?? undefined,
1444
1998
  },
1445
- });
1999
+ }, pendingApprovals.map((a) => a.id));
1446
2000
  await queueApprovalRequest(approval);
1447
2001
  }
1448
2002
  else {
@@ -1455,6 +2009,7 @@ export async function monitorTlonProvider(opts = {}) {
1455
2009
  senderShip,
1456
2010
  messageText,
1457
2011
  messageContent: dmContent.content, // Pass raw content for media extraction
2012
+ blobField: dmContent.blob,
1458
2013
  isGroup: false,
1459
2014
  timestamp: dmContent.sent || Date.now(),
1460
2015
  parentId: dmReplyParentId,
@@ -1467,11 +2022,11 @@ export async function monitorTlonProvider(opts = {}) {
1467
2022
  };
1468
2023
  try {
1469
2024
  runtime.log?.("[tlon] Subscribing to firehose updates...");
1470
- // Subscribe to channels firehose (/v2)
2025
+ // Subscribe to channels firehose (/v4)
1471
2026
  await api.subscribe({
1472
2027
  app: "channels",
1473
- path: "/v2",
1474
- event: handleChannelsFirehose,
2028
+ path: "/v4",
2029
+ event: (data) => handleChannelsFirehose(data),
1475
2030
  err: (error) => {
1476
2031
  runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
1477
2032
  },
@@ -1479,12 +2034,12 @@ export async function monitorTlonProvider(opts = {}) {
1479
2034
  runtime.log?.("[tlon] Channels firehose quit received, SSE client will resubscribe");
1480
2035
  },
1481
2036
  });
1482
- runtime.log?.("[tlon] Subscribed to channels firehose (/v2)");
1483
- // Subscribe to chat/DM firehose (/v3)
2037
+ runtime.log?.("[tlon] Subscribed to channels firehose (/v4)");
2038
+ // Subscribe to chat/DM firehose (/v4)
1484
2039
  await api.subscribe({
1485
2040
  app: "chat",
1486
- path: "/v3",
1487
- event: handleChatFirehose,
2041
+ path: "/v4",
2042
+ event: (data) => handleChatFirehose(data),
1488
2043
  err: (error) => {
1489
2044
  runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
1490
2045
  },
@@ -1492,7 +2047,7 @@ export async function monitorTlonProvider(opts = {}) {
1492
2047
  runtime.log?.("[tlon] Chat firehose quit received, SSE client will resubscribe");
1493
2048
  },
1494
2049
  });
1495
- runtime.log?.("[tlon] Subscribed to chat firehose (/v3)");
2050
+ runtime.log?.("[tlon] Subscribed to chat firehose (/v4)");
1496
2051
  // Subscribe to contacts updates to track nickname changes
1497
2052
  await api.subscribe({
1498
2053
  app: "contacts",
@@ -1502,18 +2057,24 @@ export async function monitorTlonProvider(opts = {}) {
1502
2057
  // Look for self profile updates
1503
2058
  if (event?.self) {
1504
2059
  const selfUpdate = event.self;
1505
- if (selfUpdate?.contact?.nickname?.value !== undefined) {
2060
+ if (selfUpdate?.contact?.nickname?.value !== undefined ||
2061
+ selfUpdate?.contact?.avatar?.value !== undefined) {
1506
2062
  const newNickname = selfUpdate.contact.nickname.value || null;
1507
2063
  if (newNickname !== botNickname) {
1508
2064
  botNickname = newNickname;
1509
2065
  runtime.log?.(`[tlon] Bot nickname updated: ${botNickname}`);
1510
2066
  if (botNickname) {
1511
- nicknameCache.set(botShipName, botNickname);
2067
+ nicknameCache.set(botShipName, sanitizeNickname(botNickname));
1512
2068
  }
1513
2069
  else {
1514
2070
  nicknameCache.delete(botShipName);
1515
2071
  }
1516
2072
  }
2073
+ const newAvatar = selfUpdate.contact?.avatar?.value || null;
2074
+ if (newAvatar !== botAvatar) {
2075
+ botAvatar = newAvatar;
2076
+ runtime.log?.(`[tlon] Bot avatar updated: ${botAvatar ? "set" : "cleared"}`);
2077
+ }
1517
2078
  }
1518
2079
  }
1519
2080
  // Look for peer profile updates (other users)
@@ -1522,7 +2083,7 @@ export async function monitorTlonProvider(opts = {}) {
1522
2083
  const nickname = event.peer.contact?.nickname?.value ?? event.peer.contact?.nickname;
1523
2084
  if (ship) {
1524
2085
  if (nickname && typeof nickname === "string") {
1525
- nicknameCache.set(ship, nickname);
2086
+ nicknameCache.set(ship, sanitizeNickname(nickname));
1526
2087
  }
1527
2088
  else {
1528
2089
  nicknameCache.delete(ship);
@@ -1543,8 +2104,34 @@ export async function monitorTlonProvider(opts = {}) {
1543
2104
  });
1544
2105
  runtime.log?.("[tlon] Subscribed to contacts updates (/v1/news)");
1545
2106
  // Subscribe to settings store for hot-reloading config
1546
- settingsManager.onChange((newSettings) => {
1547
- currentSettings = newSettings;
2107
+ const applySettingsSnapshot = (newSettings, source, opts = {}) => {
2108
+ const prevSettings = currentSettings;
2109
+ // If pendingNudge has been rehydrated (startup succeeded or monitor has locally
2110
+ // set/cleared it), the in-memory state is authoritative — refreshes cannot clobber
2111
+ // it or resurrect stale store echoes. If not yet rehydrated (startup scry failed),
2112
+ // allow the store value through so refresh can recover the persisted record.
2113
+ let effectivePendingNudge;
2114
+ if (pendingNudgeRehydrated) {
2115
+ effectivePendingNudge = getPendingNudge(account.accountId) ?? undefined;
2116
+ }
2117
+ else if (newSettings.pendingNudge) {
2118
+ syncPendingNudgeFromStore(account.accountId, newSettings.pendingNudge);
2119
+ pendingNudgeRehydrated = true;
2120
+ effectivePendingNudge = newSettings.pendingNudge;
2121
+ runtime.log?.("[tlon] Settings refresh: recovered persisted pendingNudge after startup failure");
2122
+ }
2123
+ else {
2124
+ effectivePendingNudge = undefined;
2125
+ }
2126
+ const nextRuntimeSettings = {
2127
+ ...newSettings,
2128
+ pendingNudge: effectivePendingNudge,
2129
+ };
2130
+ if (source === "refresh" &&
2131
+ JSON.stringify(prevSettings) === JSON.stringify(nextRuntimeSettings)) {
2132
+ currentSettings = nextRuntimeSettings;
2133
+ return;
2134
+ }
1548
2135
  // Update watched channels if settings changed
1549
2136
  if (newSettings.groupChannels?.length) {
1550
2137
  const newChannels = newSettings.groupChannels;
@@ -1557,11 +2144,10 @@ export async function monitorTlonProvider(opts = {}) {
1557
2144
  // Note: we don't remove channels from watchedChannels to avoid missing messages
1558
2145
  // during transitions. The authorization check handles access control.
1559
2146
  }
1560
- // Update DM allowlist
2147
+ // Update DM allowlist — respect empty lists (don't fall back to file config)
1561
2148
  if (newSettings.dmAllowlist !== undefined) {
1562
- effectiveDmAllowlist =
1563
- newSettings.dmAllowlist.length > 0 ? newSettings.dmAllowlist : account.dmAllowlist;
1564
- runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
2149
+ effectiveDmAllowlist = newSettings.dmAllowlist;
2150
+ runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.length > 0 ? effectiveDmAllowlist.join(", ") : "(empty)"}`);
1565
2151
  }
1566
2152
  // Update model signature setting
1567
2153
  if (newSettings.showModelSig !== undefined) {
@@ -1594,20 +2180,68 @@ export async function monitorTlonProvider(opts = {}) {
1594
2180
  effectiveAutoDiscoverChannels = newSettings.autoDiscoverChannels;
1595
2181
  runtime.log?.(`[tlon] Settings: autoDiscoverChannels = ${effectiveAutoDiscoverChannels}`);
1596
2182
  }
1597
- // Update owner ship
1598
- if (newSettings.ownerShip !== undefined) {
1599
- effectiveOwnerShip = newSettings.ownerShip
1600
- ? normalizeShip(newSettings.ownerShip)
1601
- : account.ownerShip
1602
- ? normalizeShip(account.ownerShip)
1603
- : null;
2183
+ // ownerShip is applied on both live subscription and refresh.
2184
+ // pendingNudge is only rehydrated from the store during startup load. Once the
2185
+ // monitor is running, the in-memory pending state is authoritative so refreshes
2186
+ // cannot clobber live state or resurrect stale store echoes.
2187
+ const sync = resolveSettingsMirrorSync({
2188
+ prevSettings,
2189
+ newSettings,
2190
+ fileConfigOwnerShip: account.ownerShip ? normalizeShip(account.ownerShip) : null,
2191
+ });
2192
+ if (sync.ownerShipChanged) {
2193
+ effectiveOwnerShip = sync.effectiveOwnerShip;
1604
2194
  runtime.log?.(`[tlon] Settings: ownerShip = ${effectiveOwnerShip}`);
2195
+ setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
2196
+ }
2197
+ // Reconcile the scheduler's owner-activity shadow with live settings
2198
+ // changes. Subscription events are authoritative (real-time ship echo
2199
+ // of a poke, admin override, test harness seeding). Refresh updates
2200
+ // are trusted only when `load()` returned `{ fresh: true }` — on
2201
+ // `fresh: false` the manager preserves the last-known snapshot, which
2202
+ // may not yet reflect a locally observed owner reply the ship hasn't
2203
+ // echoed back, so clobbering the shadow from that path would regress
2204
+ // the fix that motivated the shadow in the first place.
2205
+ //
2206
+ // Gating on a prev/new diff means a subscription event for some
2207
+ // unrelated key (e.g. channelRules) cannot reset the shadow via the
2208
+ // snapshot's unchanged owner-activity fields.
2209
+ const shadowReconcileTrusted = source === "subscription" || opts.fresh === true;
2210
+ const ownerActivityChanged = prevSettings.lastOwnerMessageAt !== newSettings.lastOwnerMessageAt ||
2211
+ prevSettings.lastOwnerMessageDate !== newSettings.lastOwnerMessageDate;
2212
+ if (shadowReconcileTrusted && ownerActivityChanged) {
2213
+ setLastOwnerActivity(account.accountId, ownerActivityFromSettings(newSettings));
2214
+ runtime.log?.(`[tlon] nudge: reconciled lastOwnerActivity shadow from ${source} (at=${newSettings.lastOwnerMessageAt ?? "null"})`);
2215
+ }
2216
+ // Reconcile the scheduler's stage shadow with live `lastNudgeStage`
2217
+ // changes for the same trust-and-diff reasons as the activity branch
2218
+ // above. Without this, an external `%settings` clear (or admin
2219
+ // lower) cannot move the in-memory guard down — the runner's
2220
+ // `resolveAuthoritativeStage()` currently uses the shadow as the
2221
+ // authoritative stage, so a stuck-high shadow suppresses later
2222
+ // same-stage nudges.
2223
+ //
2224
+ // Trust gate: subscription events are real-time and only fire when
2225
+ // storage actually transitioned, so they cannot represent a stale
2226
+ // post-poke read. Refresh is trusted only when `load()` returned
2227
+ // `{ fresh: true }`, matching the activity-shadow rule. Scry is
2228
+ // still useful for drift logging, but it is not part of the
2229
+ // runner's stage guard today.
2230
+ const stageChanged = prevSettings.lastNudgeStage !== newSettings.lastNudgeStage;
2231
+ if (shadowReconcileTrusted && stageChanged) {
2232
+ const nextStage = (newSettings.lastNudgeStage ?? 0);
2233
+ setLastNudgeStageShadow(account.accountId, nextStage);
2234
+ runtime.log?.(`[tlon] nudge: reconciled lastNudgeStageShadow from ${source} (stage=${nextStage})`);
1605
2235
  }
1606
2236
  // Update pending approvals
1607
2237
  if (newSettings.pendingApprovals !== undefined) {
1608
2238
  pendingApprovals = newSettings.pendingApprovals;
1609
2239
  runtime.log?.(`[tlon] Settings: pendingApprovals updated (${pendingApprovals.length} items)`);
1610
2240
  }
2241
+ currentSettings = nextRuntimeSettings;
2242
+ };
2243
+ settingsManager.onChange((newSettings) => {
2244
+ applySettingsSnapshot(newSettings, "subscription");
1611
2245
  });
1612
2246
  try {
1613
2247
  await settingsManager.startSubscription();
@@ -1803,7 +2437,8 @@ export async function monitorTlonProvider(opts = {}) {
1803
2437
  type: "group",
1804
2438
  requestingShip: inviterShip,
1805
2439
  groupFlag,
1806
- });
2440
+ groupTitle: validInvite.preview?.meta?.title,
2441
+ }, pendingApprovals.map((a) => a.id));
1807
2442
  await queueApprovalRequest(approval);
1808
2443
  processedGroupInvites.add(groupFlag);
1809
2444
  }
@@ -1822,7 +2457,8 @@ export async function monitorTlonProvider(opts = {}) {
1822
2457
  type: "group",
1823
2458
  requestingShip: inviterShip,
1824
2459
  groupFlag,
1825
- });
2460
+ groupTitle: validInvite.preview?.meta?.title,
2461
+ }, pendingApprovals.map((a) => a.id));
1826
2462
  await queueApprovalRequest(approval);
1827
2463
  processedGroupInvites.add(groupFlag);
1828
2464
  }
@@ -1915,11 +2551,73 @@ export async function monitorTlonProvider(opts = {}) {
1915
2551
  }
1916
2552
  }
1917
2553
  }, 2 * 60 * 1000);
2554
+ // Periodically re-scry settings as a fallback for stale subscriptions.
2555
+ // The settings subscription can silently die (SSE quit without reconnect),
2556
+ // leaving both authorization state and heartbeat telemetry mirrors stale.
2557
+ const settingsRefreshInterval = setInterval(async () => {
2558
+ if (opts.abortSignal?.aborted) {
2559
+ return;
2560
+ }
2561
+ try {
2562
+ const refreshResult = await settingsManager.load();
2563
+ applySettingsSnapshot(refreshResult.settings, "refresh", { fresh: refreshResult.fresh });
2564
+ }
2565
+ catch (err) {
2566
+ runtime.error?.(`[tlon] Settings refresh failed: ${String(err)}`);
2567
+ }
2568
+ }, SETTINGS_REFRESH_INTERVAL_MS);
2569
+ // Plugin-owned re-engagement nudge scheduler. Owns tick lifecycle and
2570
+ // reentrancy; runs independently of LLM heartbeat.
2571
+ //
2572
+ // Gating is computed by the pure `shouldStartNudgeRunner` helper; see
2573
+ // that function for the two invariants (explicit opt-in flag + exactly
2574
+ // one configured Tlon account).
2575
+ //
2576
+ // `TLON_NUDGE_TICK_INTERVAL_MS` exists so the integration harness can
2577
+ // drive ticks on a short cadence without rebuilding the plugin; in
2578
+ // production the default 15-minute interval applies.
2579
+ const nudgeStartDecision = shouldStartNudgeRunner(cfg);
2580
+ if (!nudgeStartDecision.start) {
2581
+ runtime.log?.(`[tlon] nudge: scheduler disabled — ${nudgeStartDecision.detail}`);
2582
+ }
2583
+ else {
2584
+ const intervalEnv = process.env.TLON_NUDGE_TICK_INTERVAL_MS;
2585
+ const intervalMsOverride = intervalEnv ? Number(intervalEnv) : NaN;
2586
+ nudgeRunner = createNudgeRunner({
2587
+ accountId: account.accountId,
2588
+ botShip: botShipName,
2589
+ api,
2590
+ cfg,
2591
+ getSettings: () => currentSettings,
2592
+ getEffectiveOwnerShip,
2593
+ getLastOwnerActivity,
2594
+ getLastNudgeStageShadow,
2595
+ setLastNudgeStageShadow,
2596
+ setLocalPendingNudge,
2597
+ sendDm,
2598
+ getBotProfile,
2599
+ telemetry,
2600
+ runtime,
2601
+ abortSignal: opts.abortSignal,
2602
+ ownerReplyPersistence,
2603
+ ...(Number.isFinite(intervalMsOverride) && intervalMsOverride > 0
2604
+ ? { intervalMs: intervalMsOverride }
2605
+ : {}),
2606
+ });
2607
+ nudgeRunner.start();
2608
+ }
1918
2609
  if (opts.abortSignal) {
1919
2610
  const signal = opts.abortSignal;
1920
2611
  await new Promise((resolve) => {
1921
2612
  signal.addEventListener("abort", () => {
1922
2613
  clearInterval(pollInterval);
2614
+ clearInterval(settingsRefreshInterval);
2615
+ // Kick off scheduler shutdown; don't block the event-handler
2616
+ // callback. The `finally` block awaits the same stop promise
2617
+ // before draining the persistence queues and closing the
2618
+ // api, so any in-flight tick is guaranteed to settle first.
2619
+ void nudgeRunner?.stop();
2620
+ gsManager?.stopHeartbeat();
1923
2621
  resolve(null);
1924
2622
  }, { once: true });
1925
2623
  });
@@ -1929,6 +2627,17 @@ export async function monitorTlonProvider(opts = {}) {
1929
2627
  }
1930
2628
  }
1931
2629
  finally {
2630
+ removeBridge(accountKey, commandBridge);
2631
+ // Await the scheduler drain before flushing persistence queues.
2632
+ // `stop()` waits for any in-flight tick to finish so its final
2633
+ // `setLocalPendingNudge` / `enqueueStageClear` / etc. writes land
2634
+ // inside the queues we flush below, rather than leaking into a
2635
+ // half-closed api after cleanup.
2636
+ await nudgeRunner?.stop();
2637
+ await ownerReplyPersistence.flush();
2638
+ await pendingNudgePersistence.flush();
2639
+ clearShadowsForAccount(account.accountId);
2640
+ await telemetry?.close();
1932
2641
  try {
1933
2642
  await api?.close();
1934
2643
  }