@clawling/clawchat-plugin-openclaw 2026.10.9-2 → 2026.10.9-4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { applyDmRecallDefault } from "./dm-recall-default.js";
2
+ import { HOST_FLOOR, HOST_TOOL_SEARCH_DEFAULT_ON, hostAtLeast, hostToolSearchEnabled } from "./host-version.js";
2
3
  import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, recallTargetMessageId, } from "./protocol-types.js";
3
4
  import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
4
5
  import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
@@ -29,7 +30,9 @@ import { flushAlignedOutboundQueue, getAlignedOutboundQueueSize, sendOpenclawCla
29
30
  import { formatWsLog } from "./ws-log.js";
30
31
  import { createNotifySignalObserver, createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.js";
31
32
  import { clawChatDbPathForStateDir, getClawChatStore, makeOwnerProfilePersister, } from "./storage.js";
32
- import { getClawChatGroupPrompt, getClawChatUserPrompt } from "./plugin-prompts.js";
33
+ import { getClawChatGroupPrompt, getClawChatPlatformPrompt, getClawChatUserPrompt } from "./plugin-prompts.js";
34
+ import { classifyChat, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.js";
35
+ import { resolveOwnerIdentity } from "./owner-identity.js";
33
36
  import { loadClawChatPromptMetadata, renderClawChatProfilePrompt, resolveSenderRelation, } from "./profile-prompt.js";
34
37
  import { buildNoteMemorySection } from "./note-injection.js";
35
38
  import { buildGroupCatchUp, CATCH_UP_FETCH_ROWS, DeliveredGroupMessages } from "./group-catch-up.js";
@@ -713,6 +716,8 @@ async function waitForActivationCredentials(params) {
713
716
  });
714
717
  return null;
715
718
  }
719
+ let warnedBelowHostFloor = false;
720
+ let loggedHostToolSearch = false;
716
721
  export async function startOpenclawClawlingGateway(params) {
717
722
  const { cfg, abortSignal, setStatus, getStatus, log } = params;
718
723
  let account = params.account;
@@ -720,6 +725,19 @@ export async function startOpenclawClawlingGateway(params) {
720
725
  const runtime = getOpenclawClawlingRuntime();
721
726
  const accountId = account.accountId;
722
727
  const store = resolveConnectionStore(params, runtime);
728
+ if (!warnedBelowHostFloor && !hostAtLeast(runtime.version, HOST_FLOOR)) {
729
+ warnedBelowHostFloor = true;
730
+ log?.warn?.(`clawchat-plugin-openclaw: host ${runtime.version ?? "unknown"} is below ${HOST_FLOOR}; turns your owner did not start keep the host tools in their schema and rely on the before_tool_call block`);
731
+ }
732
+ if (!loggedHostToolSearch &&
733
+ hostAtLeast(runtime.version, HOST_TOOL_SEARCH_DEFAULT_ON) &&
734
+ hostToolSearchEnabled(cfg)) {
735
+ loggedHostToolSearch = true;
736
+ log?.info?.("clawchat-plugin-openclaw: host tool search is on; turns your owner did not start can still use only the allowlisted tools (set tools.toolSearch.enabled=false to turn tool search off; this affects every turn)");
737
+ }
738
+ if (!params.boundaryWarningIssued && account.nonOwnerHostTools === "approve") {
739
+ log?.warn?.("clawchat-plugin-openclaw: non-owner-host-tools approve is not supported yet; using off");
740
+ }
723
741
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} hasOwnerUserId=${Boolean(account.ownerUserId)} websocketUrl=${account.websocketUrl || "(empty)"}`);
724
742
  // Freeze one report device_id for this process; reused for the paired report
725
743
  // so the backend links both to the same row. Imported from ./client.ts.
@@ -1488,6 +1506,7 @@ export async function startOpenclawClawlingGateway(params) {
1488
1506
  return;
1489
1507
  void startOpenclawClawlingGateway({
1490
1508
  ...params,
1509
+ boundaryWarningIssued: true,
1491
1510
  account: { ...params.account },
1492
1511
  // Carry the live refresh token forward (a rotation earlier this lifecycle
1493
1512
  // leaves the in-memory `cfg` stale; see refreshTokenOverride).
@@ -1535,6 +1554,7 @@ export async function startOpenclawClawlingGateway(params) {
1535
1554
  // agent must NOT re-read it — thread the live refresh token forward instead.
1536
1555
  await startOpenclawClawlingGateway({
1537
1556
  ...params,
1557
+ boundaryWarningIssued: true,
1538
1558
  account: {
1539
1559
  ...params.account,
1540
1560
  configured: true,
@@ -2116,15 +2136,25 @@ export async function startOpenclawClawlingGateway(params) {
2116
2136
  // "invalid conversation id" and msghub never acks the send. Proactive owner
2117
2137
  // messages must be addressed to the direct conversation recorded at
2118
2138
  // activation (parity with the hermes adapter's _owner_direct_chat_id).
2119
- const resolveOwnerDirectConversationId = () => {
2120
- const ownerUserId = account.ownerUserId?.trim();
2121
- if (!ownerUserId)
2122
- return null;
2123
- const conversationId = store
2124
- ?.getActivationConversation?.({ platform: "openclaw", accountId })
2125
- ?.conversationId?.trim();
2126
- return conversationId || null;
2139
+ // One warning per failure class for this lifecycle; never logs ids or SQL.
2140
+ const ownerIdentityWarnings = new Set();
2141
+ const warnOwnerIdentity = (failure) => {
2142
+ if (ownerIdentityWarnings.has(failure))
2143
+ return;
2144
+ ownerIdentityWarnings.add(failure);
2145
+ try {
2146
+ log?.warn?.(`clawchat-plugin-openclaw: ${failure}; owner identity unavailable`);
2147
+ }
2148
+ catch {
2149
+ // Logging cannot change the (fail-closed) answer.
2150
+ }
2127
2151
  };
2152
+ const resolveOwnerDirectConversationId = () =>
2153
+ // Shared strict resolver (src/owner-identity.ts): the SQLite activation
2154
+ // row decides; the config `ownerUserId` mirror may be stale or absent and
2155
+ // can neither hide nor invent the owner's direct chat. Unavailable storage
2156
+ // or a cleared/rejected token yields no owner DM (fail closed).
2157
+ resolveOwnerIdentity(store, accountId, account.ownerUserId, warnOwnerIdentity, params.rejectedActivationToken).conversationId;
2128
2158
  // R3: the owner's direct conversation is never gated by the dead-chat send
2129
2159
  // guard. If it were, the very message explaining why the agent went dark
2130
2160
  // (skill-update notices, auth/logout notices, group owner-attention
@@ -2836,8 +2866,28 @@ export async function startOpenclawClawlingGateway(params) {
2836
2866
  }
2837
2867
  turn = await withCachedGroupSenderNames(turn, memoryRoot);
2838
2868
  const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
2839
- const turnPrompt = await buildPromptForTurn(turn, memoryRoot);
2840
2869
  const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
2870
+ // Owner turn <=> the owner's activation direct chat. Resolved per turn;
2871
+ // any failure leaves it null, which classifies as non-owner (fail closed).
2872
+ let ownerConversationId = null;
2873
+ try {
2874
+ ownerConversationId = resolveOwnerDirectConversationId();
2875
+ }
2876
+ catch {
2877
+ ownerConversationId = null;
2878
+ }
2879
+ const turnClass = classifyChat({
2880
+ kind: turn.peer.kind,
2881
+ chatId: turn.peer.id,
2882
+ ownerDirectConversationId: ownerConversationId,
2883
+ });
2884
+ const narrowHostTools = turnClass === "non-owner";
2885
+ const baseTurnPrompt = await buildPromptForTurn(turn, memoryRoot);
2886
+ const nonOwnerMediaGuidance = "To send an image or file here, include it in your final reply as an http(s) URL; local files cannot be sent from this chat.";
2887
+ const turnPrompt = [
2888
+ ...(narrowHostTools ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
2889
+ baseTurnPrompt,
2890
+ ].filter(Boolean).join("\n\n");
2841
2891
  const ctxPayload = resolveChannelContextBuilder(rt)({
2842
2892
  channel: CHANNEL_ID,
2843
2893
  accountId: route.accountId ?? accountId,
@@ -2883,6 +2933,8 @@ export async function startOpenclawClawlingGateway(params) {
2883
2933
  ...(isOpenClawTextSlashCommand(turn.rawBody, cfg) && senderIsOwner
2884
2934
  ? { commands: { authorized: true, source: "text" } }
2885
2935
  : {}),
2936
+ // toolPolicy: host 2026.8.1+ (floor)
2937
+ ...(narrowHostTools ? { toolPolicy: { allow: [...NON_OWNER_TOOL_ALLOWLIST] } } : {}),
2886
2938
  },
2887
2939
  ...(memoryRoot ? { extra: { memoryRoot } } : {}),
2888
2940
  // Deliver the rendered ClawChat per-turn prompt (owner agent_behavior,
@@ -2934,7 +2986,7 @@ export async function startOpenclawClawlingGateway(params) {
2934
2986
  if (stagedDirectPrompt) {
2935
2987
  stageClawChatPromptInjection({
2936
2988
  sessionKey: resolvedSessionKey,
2937
- prompt: turnPrompt,
2989
+ prompt: baseTurnPrompt,
2938
2990
  });
2939
2991
  }
2940
2992
  try {
@@ -2988,6 +3040,7 @@ export async function startOpenclawClawlingGateway(params) {
2988
3040
  // conversation, not `ownerUserId` (a usr_… chat_id is rejected by
2989
3041
  // member-backend). Passed as a thunk so it is resolved at send time.
2990
3042
  resolveOwnerConversationId: resolveOwnerDirectConversationId,
3043
+ nonOwnerTurn: narrowHostTools,
2991
3044
  log,
2992
3045
  });
2993
3046
  const agentsConfigured = Object.keys(cfg.agents ?? {});
@@ -2996,7 +3049,14 @@ export async function startOpenclawClawlingGateway(params) {
2996
3049
  const dispatchResult = await rt.reply.withReplyDispatcher({
2997
3050
  dispatcher,
2998
3051
  onSettled: () => markDispatchIdle(),
2999
- run: () => runWithTerminalClawChatSendScope(terminalSendScopeId, () => rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg: dispatchCfg, dispatcher, replyOptions })),
3052
+ run: () => runWithTerminalClawChatSendScope({ scopeId: terminalSendScopeId, sessionKey: resolvedSessionKey }, () => rt.reply.dispatchReplyFromConfig({
3053
+ ctx: ctxPayload,
3054
+ cfg: dispatchCfg,
3055
+ dispatcher,
3056
+ replyOptions: narrowHostTools
3057
+ ? { ...replyOptions, toolsAllow: [...NON_OWNER_TOOL_ALLOWLIST] }
3058
+ : replyOptions,
3059
+ })),
3000
3060
  });
3001
3061
  const counts = dispatchResult?.counts ?? {};
3002
3062
  const queuedFinal = Boolean(dispatchResult?.queuedFinal);
@@ -3516,6 +3576,7 @@ export async function startOpenclawClawlingGateway(params) {
3516
3576
  if (activationAccount.source === "sqlite" && !abortSignal.aborted) {
3517
3577
  await startOpenclawClawlingGateway({
3518
3578
  ...params,
3579
+ boundaryWarningIssued: true,
3519
3580
  account: {
3520
3581
  ...params.account,
3521
3582
  configured: false,
@@ -184,6 +184,30 @@ CREATE TABLE IF NOT EXISTS recalled_messages (
184
184
  recalled_at INTEGER NOT NULL,
185
185
  PRIMARY KEY (platform, account_id, message_id)
186
186
  );
187
+ `,
188
+ },
189
+ {
190
+ // Explicit message-id arguments are matched case-insensitively
191
+ // (findSeenMessageInChat); without these the lookup scans the whole
192
+ // unpruned ledger synchronously.
193
+ version: 11,
194
+ name: "message_id_nocase_indexes",
195
+ sql: `
196
+ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_message_id_nocase
197
+ ON clawchat_messages(message_id COLLATE NOCASE);
198
+ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_protocol_message_id_nocase
199
+ ON clawchat_messages(protocol_message_id COLLATE NOCASE);
200
+ `,
201
+ },
202
+ {
203
+ // A tool chat id from the model is mapped to the case already stored for
204
+ // that chat (findStoredChatId) before a row is written, so one chat never
205
+ // ends up under two cases.
206
+ version: 12,
207
+ name: "chat_id_nocase_index",
208
+ sql: `
209
+ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_chat_id_nocase
210
+ ON clawchat_messages(chat_id COLLATE NOCASE);
187
211
  `,
188
212
  },
189
213
  ];
@@ -362,6 +386,40 @@ export class ClawChatStore {
362
386
  };
363
387
  }) ?? null;
364
388
  }
389
+ /**
390
+ * Strict owner-identity read for the non-owner turn boundary. Unlike the
391
+ * other getters (which collapse failure into null), this keeps "storage
392
+ * unavailable", "no activation row" and "row" apart — including incomplete
393
+ * rows — because only a proven-absent row may fall back to the config mirror
394
+ * (see `resolveOwnerIdentity`). Nothing is logged here: the caller warns
395
+ * once per failure class, and no SQL or credentials reach the log.
396
+ */
397
+ lookupActivationIdentity(input) {
398
+ if (!this.ensureInitialized())
399
+ return { status: "unavailable" };
400
+ try {
401
+ const row = this.requireDb()
402
+ .prepare("SELECT owner_user_id, conversation_id, access_token FROM activations WHERE platform = ? AND account_id = ?")
403
+ .get(input.platform, input.accountId);
404
+ if (!row)
405
+ return { status: "absent" };
406
+ const value = (key) => {
407
+ const raw = row[key];
408
+ return typeof raw === "string" ? raw.trim() || null : null;
409
+ };
410
+ return {
411
+ status: "row",
412
+ row: {
413
+ ownerUserId: value("owner_user_id"),
414
+ conversationId: value("conversation_id"),
415
+ accessToken: value("access_token"),
416
+ },
417
+ };
418
+ }
419
+ catch {
420
+ return { status: "unavailable" };
421
+ }
422
+ }
365
423
  claimPendingActivationBootstrap(input) {
366
424
  return this.write(() => {
367
425
  const now = Date.now();
@@ -469,6 +527,55 @@ export class ClawChatStore {
469
527
  .run(input.eventType, input.traceId ?? null, input.chatId ?? null, input.text ?? null, toJson(input.raw), input.accountId, input.kind, input.direction, input.messageId);
470
528
  });
471
529
  }
530
+ /**
531
+ * The stored chat id and message id of this account's ledger row that has
532
+ * `messageId` in `chatId`, both compared case-insensitively. The message id
533
+ * matches the client id (`message_id`) or the acked protocol id
534
+ * (`protocol_message_id`); the one that matched is returned, in its stored
535
+ * case, so callers put the real id on the wire. Answers `false` when no row
536
+ * matches and `null` when the store is unavailable, so callers can tell
537
+ * "not seen" from "cannot say". Seeks via the NOCASE message-id indexes
538
+ * (migration 11); platform, account and chat are post-filters.
539
+ */
540
+ findSeenMessageInChat(input) {
541
+ if (!input.chatId || !input.messageId)
542
+ return false;
543
+ return this.read(() => {
544
+ const row = this.requireDb()
545
+ .prepare(FIND_SEEN_MESSAGE_SQL)
546
+ .get(input.messageId, input.platform, input.accountId, input.chatId, input.messageId, input.platform, input.accountId, input.chatId);
547
+ if (typeof row?.chat_id !== "string" || typeof row.matched_id !== "string")
548
+ return false;
549
+ return { chatId: row.chat_id, messageId: row.matched_id };
550
+ });
551
+ }
552
+ /**
553
+ * The chat id as this account's ledger already stores it for `chatId`
554
+ * (compared case-insensitively), preferring an inbound row, whose case is
555
+ * the server's. `false` when the ledger has no row for that chat, `null`
556
+ * when the store is unavailable. Seeks via the NOCASE chat-id index
557
+ * (migration 12); platform, account and direction are post-filters.
558
+ */
559
+ findStoredChatId(input) {
560
+ if (!input.chatId)
561
+ return false;
562
+ return this.read(() => {
563
+ const row = this.requireDb()
564
+ .prepare(FIND_STORED_CHAT_ID_SQL)
565
+ .get(input.chatId, input.platform, input.accountId, input.chatId, input.platform, input.accountId);
566
+ return typeof row?.chat_id === "string" ? row.chat_id : false;
567
+ });
568
+ }
569
+ /** True when `message_id` was recalled for this (platform, account). */
570
+ isMessageRecalled(input) {
571
+ if (!input.messageId)
572
+ return false;
573
+ const row = this.requireDb()
574
+ .prepare(`SELECT 1 FROM recalled_messages
575
+ WHERE platform = ? AND account_id = ? AND message_id = ?`)
576
+ .get(input.platform, input.accountId, input.messageId);
577
+ return row !== undefined;
578
+ }
472
579
  /**
473
580
  * Erase every ledger row for one `message_id` — the local half of a
474
581
  * server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
@@ -487,19 +594,8 @@ export class ClawChatStore {
487
594
  * the row may never have been stored (a message that arrived before this
488
595
  * account was paired, or one dropped by the inbound filter). `null` means the
489
596
  * store is unavailable.
490
- */
491
- /** True when `message_id` was recalled for this (platform, account). */
492
- isMessageRecalled(input) {
493
- if (!input.messageId)
494
- return false;
495
- const row = this.requireDb()
496
- .prepare(`SELECT 1 FROM recalled_messages
497
- WHERE platform = ? AND account_id = ? AND message_id = ?`)
498
- .get(input.platform, input.accountId, input.messageId);
499
- return row !== undefined;
500
- }
501
- /**
502
- * Record the permanent recall tombstone and purge any row already stored,
597
+ *
598
+ * Records the permanent recall tombstone and purges any row already stored,
503
599
  * atomically. Returns the number of ledger rows deleted (0 when the recall
504
600
  * arrived before the message did — the tombstone still stands and the later
505
601
  * ingest is refused by {@link claimMessageOnce}).
@@ -710,13 +806,18 @@ export class ClawChatStore {
710
806
  * as `'thinking'` are excluded so the @-mention prior-context block never
711
807
  * surfaces the agent's intermediate output as conversation history.
712
808
  * Uses `idx_clawchat_messages_chat_created` for performance.
809
+ * `ignoreCase` matches `chat_id` case-insensitively (rows keep their stored
810
+ * case) so memory scope can recover a group id's original case from a
811
+ * lowercased session key; it cannot use that index, so callers that already
812
+ * know the exact id leave it off.
713
813
  * Returns an empty array if the store is unavailable.
714
814
  */
715
- listRecentGroupMessages(platform, accountId, chatId, limit) {
815
+ listRecentGroupMessages(platform, accountId, chatId, limit, options = {}) {
816
+ const chatMatch = options.ignoreCase ? "chat_id = ? COLLATE NOCASE" : "chat_id = ?";
716
817
  return this.read(() => {
717
818
  const rows = this.requireDb()
718
819
  .prepare(`SELECT * FROM clawchat_messages
719
- WHERE platform = ? AND account_id = ? AND chat_id = ? AND kind = 'message'
820
+ WHERE platform = ? AND account_id = ? AND ${chatMatch} AND kind = 'message'
720
821
  ORDER BY created_at DESC, rowid DESC LIMIT ?`)
721
822
  .all(platform, accountId, chatId, limit);
722
823
  return rows.reverse(); // oldest-first for prompt order
@@ -821,6 +922,43 @@ export class ClawChatStore {
821
922
  this.log?.error?.(`${prefix}: ${safeErrorMessage(err)}`);
822
923
  }
823
924
  }
925
+ /**
926
+ * Lookup behind {@link ClawChatStore.findStoredChatId}: an inbound row first,
927
+ * then any row. Both halves seek `idx_clawchat_messages_chat_id_nocase`.
928
+ * Exported so a test can pin the query plan.
929
+ */
930
+ export const FIND_STORED_CHAT_ID_SQL = `SELECT chat_id FROM (
931
+ SELECT chat_id, 0 AS rank FROM (
932
+ SELECT chat_id FROM clawchat_messages
933
+ WHERE chat_id = ? COLLATE NOCASE AND platform = ? AND account_id = ? AND direction = 'inbound'
934
+ LIMIT 1
935
+ )
936
+ UNION ALL
937
+ SELECT chat_id, 1 AS rank FROM (
938
+ SELECT chat_id FROM clawchat_messages
939
+ WHERE chat_id = ? COLLATE NOCASE AND platform = ? AND account_id = ?
940
+ LIMIT 1
941
+ )
942
+ ) ORDER BY rank LIMIT 1`;
943
+ /**
944
+ * Lookup behind {@link ClawChatStore.findSeenMessageInChat}. Two index seeks
945
+ * (`message_id` / `protocol_message_id`, both COLLATE NOCASE, migration 11);
946
+ * platform, account and chat are post-filters. A `message_id` match wins.
947
+ * `INDEXED BY` pins the seek: without table statistics the planner would
948
+ * otherwise pick the chat-id index (migration 12) and walk the whole chat.
949
+ * Exported so a test can pin the query plan.
950
+ */
951
+ export const FIND_SEEN_MESSAGE_SQL = `SELECT chat_id, matched_id FROM (
952
+ SELECT chat_id, message_id AS matched_id, 0 AS rank
953
+ FROM clawchat_messages INDEXED BY idx_clawchat_messages_message_id_nocase
954
+ WHERE message_id = ? COLLATE NOCASE
955
+ AND platform = ? AND account_id = ? AND chat_id = ? COLLATE NOCASE
956
+ UNION ALL
957
+ SELECT chat_id, protocol_message_id AS matched_id, 1 AS rank
958
+ FROM clawchat_messages INDEXED BY idx_clawchat_messages_protocol_message_id_nocase
959
+ WHERE protocol_message_id = ? COLLATE NOCASE
960
+ AND platform = ? AND account_id = ? AND chat_id = ? COLLATE NOCASE
961
+ ) ORDER BY rank LIMIT 1`;
824
962
  let singleton = null;
825
963
  export function createClawChatStore(options = {}) {
826
964
  return new ClawChatStore(options);
@@ -1,12 +1,67 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  const DEFAULT_TTL_MS = 60_000;
3
3
  const terminalSends = new Map();
4
- const terminalSendScope = new AsyncLocalStorage();
4
+ const turnScopeStorage = new AsyncLocalStorage();
5
+ const openScopesBySession = new Map();
6
+ function normalizeSessionKey(sessionKey) {
7
+ const normalized = sessionKey?.trim().toLowerCase();
8
+ return normalized ? normalized : null;
9
+ }
5
10
  function key(accountId, chatId, scopeId) {
6
11
  return `${accountId}\0${chatId}\0${scopeId}`;
7
12
  }
8
- export function runWithTerminalClawChatSendScope(scopeId, fn) {
9
- return terminalSendScope.run(scopeId, fn);
13
+ function isPromiseLike(value) {
14
+ return typeof value?.then === "function";
15
+ }
16
+ /**
17
+ * Run one dispatched turn. `sessionKey` is the session the host runs the turn
18
+ * in; consumers only see this turn when their trusted session key matches.
19
+ * The scope closes when `fn` returns or its promise settles.
20
+ */
21
+ export function runWithTerminalClawChatSendScope(init, fn) {
22
+ const scope = {
23
+ scopeId: init.scopeId,
24
+ sessionKey: normalizeSessionKey(init.sessionKey),
25
+ open: true,
26
+ superseded: false,
27
+ };
28
+ const sessionKey = scope.sessionKey;
29
+ if (sessionKey) {
30
+ const open = openScopesBySession.get(sessionKey) ?? new Set();
31
+ for (const earlier of open)
32
+ earlier.superseded = true;
33
+ open.add(scope);
34
+ openScopesBySession.set(sessionKey, open);
35
+ }
36
+ const close = () => {
37
+ scope.open = false;
38
+ if (!sessionKey)
39
+ return;
40
+ const open = openScopesBySession.get(sessionKey);
41
+ open?.delete(scope);
42
+ if (open?.size === 0)
43
+ openScopesBySession.delete(sessionKey);
44
+ };
45
+ let result;
46
+ try {
47
+ result = turnScopeStorage.run(scope, fn);
48
+ }
49
+ catch (err) {
50
+ close();
51
+ throw err;
52
+ }
53
+ if (isPromiseLike(result)) {
54
+ return Promise.resolve(result).finally(close);
55
+ }
56
+ close();
57
+ return result;
58
+ }
59
+ /** The caller's own open turn, or null when it cannot be tied to the caller's session. */
60
+ function resolveCallerScope(sessionKey) {
61
+ const scope = turnScopeStorage.getStore();
62
+ if (!scope?.open || !scope.sessionKey)
63
+ return null;
64
+ return scope.sessionKey === normalizeSessionKey(sessionKey) ? scope : null;
10
65
  }
11
66
  /**
12
67
  * Drop every marker whose TTL has lapsed. A marker is only otherwise deleted
@@ -22,7 +77,7 @@ function sweepExpiredTerminalSends(nowMs) {
22
77
  }
23
78
  }
24
79
  export function markTerminalClawChatSend(params) {
25
- const scopeId = params.scopeId ?? terminalSendScope.getStore();
80
+ const scopeId = params.scopeId ?? resolveCallerScope(params.sessionKey)?.scopeId;
26
81
  if (!scopeId)
27
82
  return;
28
83
  const nowMs = params.nowMs ?? Date.now();
@@ -34,7 +89,7 @@ export function markTerminalClawChatSend(params) {
34
89
  });
35
90
  }
36
91
  export function consumeTerminalClawChatSend(params) {
37
- const scopeId = params.scopeId ?? terminalSendScope.getStore();
92
+ const scopeId = params.scopeId;
38
93
  if (!scopeId)
39
94
  return null;
40
95
  const recordKey = key(params.accountId, params.chatId, scopeId);
@@ -48,17 +103,19 @@ export function consumeTerminalClawChatSend(params) {
48
103
  }
49
104
  export function clearTerminalClawChatSendsForTest() {
50
105
  terminalSends.clear();
106
+ openScopesBySession.clear();
51
107
  }
52
108
  /**
53
- * Read the current turn's ambient send scope (accountId\0chatId\0messageId).
54
- * Returns null when not inside a dispatch scope, or when the scope id is not
55
- * the tripartite form (e.g. a test-only opaque scope id).
109
+ * The calling turn's default reaction target (accountId\0chatId\0messageId).
110
+ * Returns null unless the tool call's trusted `sessionKey` matches the open
111
+ * turn in its async context and no later turn of that session has started
112
+ * since; also null for a non-tripartite scope id (e.g. a test-only one).
56
113
  */
57
- export function getCurrentTerminalSendScope() {
58
- const store = terminalSendScope.getStore();
59
- if (!store)
114
+ export function getCurrentTerminalSendScope(params) {
115
+ const scope = resolveCallerScope(params.sessionKey);
116
+ if (!scope || scope.superseded)
60
117
  return null;
61
- const parts = store.split("\0");
118
+ const parts = scope.scopeId.split("\0");
62
119
  if (parts.length !== 3)
63
120
  return null;
64
121
  const [accountId, chatId, messageId] = parts;