openclaw-lark-multi-agent 1.8.19 → 1.8.21

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.
@@ -8,8 +8,10 @@ export interface ChatInfo {
8
8
  members: string;
9
9
  /** Comma-separated member names */
10
10
  memberNames: string;
11
- /** Which bot owns this chat (for p2p isolation) */
11
+ /** Display label of the p2p owner; not a durable identity once pinned. */
12
12
  ownerBot: string;
13
+ /** Stable application identity of the p2p owner. Empty on legacy rows. */
14
+ ownerAppId?: string;
13
15
  /** Legacy chat-level free discussion flag; per-bot mode is authoritative. */
14
16
  freeDiscussion: boolean;
15
17
  verbose: boolean;
@@ -169,7 +171,7 @@ export declare class MessageStore {
169
171
  recoverUnsentReservations(bot: string): number;
170
172
  saveAdmission(sessionKey: string, submissionId: string, state: string, record: object): void;
171
173
  listAdmissions(sessionKey: string): any[];
172
- stopExactRun(sessionKey: string, runId: string): void;
174
+ stopExactRun(sessionKey: string, runId: string, userStopped?: boolean): void;
173
175
  isStoppedObservation(sessionKey: string, runId: string): boolean;
174
176
  hasUnconfirmedRunDelivery(bot: string, chat: string, runId: string): boolean;
175
177
  /** UI-only witness of an exact run's visible reply in the original Bot/chat.
@@ -188,6 +190,27 @@ export declare class MessageStore {
188
190
  hasYieldedObservation(sessionKey: string): boolean;
189
191
  getRunObservation(sessionKey: string, runId: string): any;
190
192
  runObservationState(sessionKey: string, runId: string): string | undefined;
193
+ /** Observed reply latency for this chat, from real delivered replies.
194
+ *
195
+ * Used to size the "input still unconfirmed" window. A fixed short timer
196
+ * misfired on slow sessions where nearly every legitimate reply took longer
197
+ * than the timer, producing a false "not confirmed" notice for a run that
198
+ * was in fact progressing normally. Returns null when there is no history. */
199
+ observedReplyLatencyMs(bot: string, chat: string): number | null;
200
+ /** True only when THIS run was explicitly stopped by the user.
201
+ *
202
+ * Distinct from a retired observation: a group /stop retires every bot's
203
+ * observation on the shared session, which is NOT a user request to kill
204
+ * each individual run. */
205
+ isExplicitlyStoppedObservation(sessionKey: string, runId: string): boolean;
206
+ /** Retire session-scoped observation, but NEVER a run whose committed
207
+ * terminal reply has already been observed.
208
+ *
209
+ * A run with terminalObserved=true holds a COMPUTED answer. Retiring its
210
+ * observation (via /stop, /reset or an abort) removes the only durable
211
+ * custody that answer has, so recoverExactRuns can never deliver it and the
212
+ * computed reply is silently lost. A run without a terminal observation has
213
+ * nothing to deliver yet and may be retired normally. */
191
214
  stopSessionObservations(sessionKey: string): void;
192
215
  settleExactRunReactions(sessionKey: string, runId: string): void;
193
216
  finishRunObservation(sessionKey: string, runId: string, state?: string): void;
@@ -209,6 +232,23 @@ export declare class MessageStore {
209
232
  isInsertionReserved(bot: string, chat: string, row: number): boolean;
210
233
  stopInsertionAtEpoch(bot: string, chat: string, row: number, epoch: number): void;
211
234
  stopInsertions(bot: string, chat: string): void;
235
+ /** Group-scoped stop cleanup: every bot in this chat, not just the caller.
236
+ * A bare /stop in a multi-bot group is executed by ONE bot, but each bot keeps
237
+ * its own pending_triggers / insertion_custody / dispatch_custody / reaction
238
+ * rows for the same chat. Stopping only the executing bot left the others
239
+ * believing the chat was still busy, so their next message was queued and
240
+ * never dispatched. */
241
+ stopChatForAllBots(chat: string, exceptBot?: string): {
242
+ stoppedInsertions: number;
243
+ clearedTriggers: number;
244
+ clearedCustody: number;
245
+ };
246
+ /** Same predicate as stopInsertions, but reports how many rows it retired. */
247
+ stopInsertionsCount(bot: string, chat: string): number;
248
+ /** Drop a bot's dispatch custody for a chat regardless of owner/batch
249
+ * identity. forceExpireQueueOwner can no-op when the local queue owner is
250
+ * already gone, which used to strand the row forever. */
251
+ clearDispatchCustodyForChat(bot: string, chat: string): number;
212
252
  hasUnknownDeliveryOperation(outbox: number): boolean;
213
253
  /** Includes non-visible uploads/doc creation: absence of a message_id is
214
254
  * not proof that no SDK effect has been attempted or accepted. */
@@ -333,6 +373,10 @@ export declare class MessageStore {
333
373
  runId: string;
334
374
  }, content: string, attachmentsJson: string): boolean;
335
375
  hasRunFinalDelivery(botName: string, chatId: string, p: ErrorProvenance, confirmed: boolean): boolean;
376
+ /** Compatibility is exact provenance/legacy key + original app/epoch, never
377
+ * text similarity. Existing attempted/unknown/delivered rows retain custody;
378
+ * this lookup neither requeues nor edits historical deliveries. */
379
+ findRunErrorOwner(bot: string, chat: string, p: ErrorProvenance): DeliveryOutboxItem | undefined;
336
380
  renewUnsentErrorDelivery(id: number, content: string, contentHash: string, metadata: string): void;
337
381
  /** Withdraw only an unsent error notice. Never claim it was delivered or touch a result row. */
338
382
  withdrawErrorDelivery(id: number): void;
@@ -421,6 +465,21 @@ export declare class MessageStore {
421
465
  * guard shutting everyone down after N total bot messages.
422
466
  */
423
467
  getBotStreak(chatId: string, botName: string): number;
468
+ /** Authorize the already app-specific SDK's private chat. The first event
469
+ * pins a new chat; a matching legacy label may be backfilled. Thereafter only
470
+ * the exact application pin authorizes access, including across bot renames.
471
+ * Existing unowned/mismatched/malformed rows fail closed, not first-claim wins.
472
+ * This operation changes no history, sync, pending input or delivery state. */
473
+ claimP2pOwner(chatId: string, botName: string, appId: string): boolean;
474
+ /** Operator-only CAS after independently verifying the original account.
475
+ * No runtime caller: a retired legacy label alone cannot prove app ownership.
476
+ * Idempotent for the exact destination, refuses any conflicting existing pin.
477
+ * Preserves cache metadata/timestamps and every business/history table.
478
+ * verifiedPreviousOwnerBot is an explicit independently verified old scope
479
+ * when an earlier operator repair already replaced the legacy cache label. */
480
+ migrateLegacyP2pOwner(chatId: string, expectedOwnerBot: string, botName: string, appId: string, verifiedPreviousOwnerBot?: string): boolean;
481
+ private rememberP2pOwnerAlias;
482
+ private validOwnerAppId;
424
483
  upsertChatInfo(info: ChatInfo): void;
425
484
  setFreeDiscussion(chatId: string, on: boolean): void;
426
485
  setVerbose(chatId: string, verbose: boolean): void;
@@ -458,6 +517,13 @@ export declare class MessageStore {
458
517
  */
459
518
  markBotProcessed(botName: string, messageId: string): void;
460
519
  tryMarkBotProcessed(botName: string, messageId: string): boolean;
520
+ /** Private-only monotonic ingress fence. Write even for an owner-rejected
521
+ * event: a rejected input is a tombstone, never permission to retry after a
522
+ * rename. Legacy name tombstones are consulted ONLY in a pinned/explicitly
523
+ * verified owner scope (plus the existing current-name fence), never inferred
524
+ * from message history or mentions. Each duplicate promotes only its own ID;
525
+ * no historical name ledger is copied or renamed. Ownership is checked later. */
526
+ tryMarkP2pProcessed(appId: string, chatId: string, botName: string, messageId: string): boolean;
461
527
  createGoJob(j: {
462
528
  botName: string;
463
529
  chatId: string;
@@ -1,4 +1,4 @@
1
- import { errorIdentity } from "./terminal-errors.js";
1
+ import { closedStopReason, errorNoticeIdentity, errorIdentity } from "./terminal-errors.js";
2
2
  import Database from "better-sqlite3";
3
3
  import { resolve } from "path";
4
4
  import { mkdirSync } from "fs";
@@ -258,9 +258,10 @@ export class MessageStore {
258
258
  listAdmissions(sessionKey) {
259
259
  return this.db.prepare('SELECT * FROM admission_custody WHERE session_key=? ORDER BY rowid DESC LIMIT 100').all(sessionKey).map(r => ({ ...JSON.parse(r.record_json), submissionId: r.submission_id, state: r.state }));
260
260
  }
261
- stopExactRun(sessionKey, runId) {
262
- this.db.prepare(`INSERT INTO run_observations VALUES(?,?,'{}','stopped',0)
263
- ON CONFLICT(session_key,run_id) DO UPDATE SET state='stopped'`).run(sessionKey, runId);
261
+ stopExactRun(sessionKey, runId, userStopped = false) {
262
+ const record = userStopped ? { userStopped: true } : {};
263
+ this.db.prepare(`INSERT INTO run_observations VALUES(?,?,?,'stopped',0)
264
+ ON CONFLICT(session_key,run_id) DO UPDATE SET state='stopped', record_json=json_patch(run_observations.record_json,excluded.record_json)`).run(sessionKey, runId, JSON.stringify(record));
264
265
  }
265
266
  isStoppedObservation(sessionKey, runId) {
266
267
  return !!this.db.prepare("SELECT 1 FROM run_observations WHERE session_key=? AND run_id=? AND state='stopped'").get(sessionKey, runId);
@@ -329,8 +330,45 @@ export class MessageStore {
329
330
  runObservationState(sessionKey, runId) {
330
331
  return this.db.prepare('SELECT state FROM run_observations WHERE session_key=? AND run_id=?').get(sessionKey, runId)?.state;
331
332
  }
333
+ /** Observed reply latency for this chat, from real delivered replies.
334
+ *
335
+ * Used to size the "input still unconfirmed" window. A fixed short timer
336
+ * misfired on slow sessions where nearly every legitimate reply took longer
337
+ * than the timer, producing a false "not confirmed" notice for a run that
338
+ * was in fact progressing normally. Returns null when there is no history. */
339
+ observedReplyLatencyMs(bot, chat) {
340
+ // p90 over real delivered replies. MAX would be dominated by a single
341
+ // pathological outlier and inflate the window for every later request.
342
+ const sql = "SELECT (d.delivered_at - m.timestamp) AS lat FROM delivered_replies d" +
343
+ " JOIN messages m ON m.id=d.trigger_message_row_id" +
344
+ " WHERE d.bot_name=? AND d.chat_id=? AND d.delivered_at > m.timestamp AND m.timestamp > 0" +
345
+ " ORDER BY d.delivered_at DESC LIMIT 30";
346
+ const rows = this.db.prepare(sql).all(bot, chat);
347
+ const lats = rows.map(r => Number(r.lat)).filter(x => Number.isFinite(x) && x > 0).sort((a, b) => a - b);
348
+ if (!lats.length)
349
+ return null;
350
+ const p90 = lats[Math.min(lats.length - 1, Math.floor(lats.length * 0.9))];
351
+ return Number.isFinite(p90) && p90 > 0 ? p90 : null;
352
+ }
353
+ /** True only when THIS run was explicitly stopped by the user.
354
+ *
355
+ * Distinct from a retired observation: a group /stop retires every bot's
356
+ * observation on the shared session, which is NOT a user request to kill
357
+ * each individual run. */
358
+ isExplicitlyStoppedObservation(sessionKey, runId) {
359
+ const row = this.db.prepare("SELECT 1 FROM run_observations WHERE session_key=? AND run_id=? AND state='stopped' AND json_extract(record_json,'$.userStopped')=1").get(sessionKey, runId);
360
+ return !!row;
361
+ }
362
+ /** Retire session-scoped observation, but NEVER a run whose committed
363
+ * terminal reply has already been observed.
364
+ *
365
+ * A run with terminalObserved=true holds a COMPUTED answer. Retiring its
366
+ * observation (via /stop, /reset or an abort) removes the only durable
367
+ * custody that answer has, so recoverExactRuns can never deliver it and the
368
+ * computed reply is silently lost. A run without a terminal observation has
369
+ * nothing to deliver yet and may be retired normally. */
332
370
  stopSessionObservations(sessionKey) {
333
- this.db.prepare("UPDATE run_observations SET state='stopped' WHERE session_key=? AND state IN ('observing','outbox_owned')").run(sessionKey);
371
+ this.db.prepare("UPDATE run_observations SET state='stopped' WHERE session_key=? AND state IN ('observing','outbox_owned') AND json_extract(record_json,'$.terminalObserved') IS NOT 1").run(sessionKey);
334
372
  }
335
373
  settleExactRunReactions(sessionKey, runId) {
336
374
  const targets = this.db.prepare('SELECT bot_name,chat_id FROM run_delivery_targets WHERE run_id=?').all(runId);
@@ -406,6 +444,45 @@ export class MessageStore {
406
444
  stopInsertions(bot, chat) {
407
445
  this.db.prepare("UPDATE insertion_custody SET state='stopped' WHERE bot_name=? AND chat_id=? AND (state NOT IN ('committed','ordinary') OR state='ordinary' AND json_type(record_json,'$.followupAdmissionGuard')='object')").run(bot, chat);
408
446
  }
447
+ /** Group-scoped stop cleanup: every bot in this chat, not just the caller.
448
+ * A bare /stop in a multi-bot group is executed by ONE bot, but each bot keeps
449
+ * its own pending_triggers / insertion_custody / dispatch_custody / reaction
450
+ * rows for the same chat. Stopping only the executing bot left the others
451
+ * believing the chat was still busy, so their next message was queued and
452
+ * never dispatched. */
453
+ stopChatForAllBots(chat, exceptBot) {
454
+ const bots = this.db.prepare("SELECT DISTINCT bot_name FROM pending_triggers WHERE chat_id=? UNION SELECT DISTINCT bot_name FROM insertion_custody WHERE chat_id=? UNION SELECT DISTINCT bot_name FROM dispatch_custody WHERE chat_id=? UNION SELECT DISTINCT bot_name FROM reaction_custody WHERE chat_id=?").all(chat, chat, chat, chat).map((r) => r.bot_name);
455
+ let stoppedInsertions = 0, clearedTriggers = 0, clearedCustody = 0;
456
+ for (const bot of bots) {
457
+ if (bot === exceptBot) {
458
+ // The executing bot already bumped its own epoch and cancelled its own
459
+ // reaction intents; touching them again would desynchronize its
460
+ // in-memory generation from the durable one.
461
+ stoppedInsertions += this.stopInsertionsCount(bot, chat);
462
+ clearedTriggers += this.clearAllPendingTriggers(bot, chat);
463
+ clearedCustody += this.clearDispatchCustodyForChat(bot, chat);
464
+ continue;
465
+ }
466
+ // Invalidate every epoch-scoped record for this bot/chat: a stale
467
+ // dispatch_custody with an old stopEpoch can never be released later.
468
+ this.bumpStopGeneration(bot, chat);
469
+ this.cancelReactionIntents(bot, chat, this.getStopGeneration(bot, chat));
470
+ stoppedInsertions += this.stopInsertionsCount(bot, chat);
471
+ clearedTriggers += this.clearAllPendingTriggers(bot, chat);
472
+ clearedCustody += this.clearDispatchCustodyForChat(bot, chat);
473
+ }
474
+ return { stoppedInsertions, clearedTriggers, clearedCustody };
475
+ }
476
+ /** Same predicate as stopInsertions, but reports how many rows it retired. */
477
+ stopInsertionsCount(bot, chat) {
478
+ return this.db.prepare("UPDATE insertion_custody SET state='stopped' WHERE bot_name=? AND chat_id=? AND (state NOT IN ('committed','ordinary') OR state='ordinary' AND json_type(record_json,'$.followupAdmissionGuard')='object')").run(bot, chat).changes;
479
+ }
480
+ /** Drop a bot's dispatch custody for a chat regardless of owner/batch
481
+ * identity. forceExpireQueueOwner can no-op when the local queue owner is
482
+ * already gone, which used to strand the row forever. */
483
+ clearDispatchCustodyForChat(bot, chat) {
484
+ return this.db.prepare("DELETE FROM dispatch_custody WHERE bot_name=? AND chat_id=?").run(bot, chat).changes;
485
+ }
409
486
  hasUnknownDeliveryOperation(outbox) {
410
487
  return !!this.db.prepare(`SELECT 1 FROM delivery_operations o WHERE outbox_id=? AND state IN ('attempted','unknown')
411
488
  AND NOT EXISTS(SELECT 1 FROM delivery_receipt_custody k WHERE k.outbox_id=o.outbox_id AND k.operation_key=o.operation_key) LIMIT 1`).get(outbox);
@@ -772,7 +849,7 @@ export class MessageStore {
772
849
  this.db.prepare('UPDATE go_action_lifecycle SET state=?,receipt_json=? WHERE go_id=? AND action_key=? AND state!=?').run(state, receipt === undefined ? '' : JSON.stringify(receipt), goId, actionKey, 'accepted');
773
850
  }
774
851
  observeRunError(botName, chatId, provenance) {
775
- const noticeId = errorIdentity(provenance.sessionKey, provenance.runId);
852
+ const noticeId = errorNoticeIdentity(provenance);
776
853
  const previous = this.getErrorNotice(botName, chatId, noticeId);
777
854
  if (previous) {
778
855
  if (provenance.userStop) {
@@ -783,8 +860,8 @@ export class MessageStore {
783
860
  }
784
861
  // A committed transcript abort is weaker than a gateway terminal error.
785
862
  // One stronger envelope can wake a parked observation; repeats cannot.
786
- const terminalChat = (p) => p.source === "chat" && (p.state === "error" || (p.state === "final" && p.stopReason === "error"));
787
- if (!provenance.userStop && !previous.provenance.userStop && terminalChat(provenance) && !terminalChat(previous.provenance)
863
+ const terminalChat = (p) => p.source === "chat" && (p.state === "error" || p.state === 'aborted' && !!closedStopReason(p.stopReason) || (p.state === "final" && p.stopReason === "error"));
864
+ if (!provenance.userStop && !previous.provenance.userStop && terminalChat(provenance) && (!terminalChat(previous.provenance) || !!closedStopReason(provenance.stopReason) && !closedStopReason(previous.provenance.stopReason))
788
865
  && ["pending", "parked"].includes(previous.status)) {
789
866
  previous.provenance = provenance;
790
867
  previous.status = "pending";
@@ -842,8 +919,45 @@ export class MessageStore {
842
919
  AND json_extract(delivery_meta_json,'$.provenance.final')=1
843
920
  AND json_extract(delivery_meta_json,'$.provenance.sessionKey')=?
844
921
  AND json_extract(delivery_meta_json,'$.provenance.runId')=?
922
+ AND (? IS NULL OR json_extract(delivery_meta_json,'$.botIdentity')=?)
923
+ AND (? IS NULL OR json_extract(delivery_meta_json,'$.deliveryStopEpoch')=?)
924
+ AND (? IS NULL OR json_extract(delivery_meta_json,'$.provenance.sessionId') IS NULL OR json_extract(delivery_meta_json,'$.provenance.sessionId')=?)
845
925
  AND ${confirmed ? "status='delivered'" : "status IN ('pending','delivering')"} LIMIT 1`)
846
- .get(botName, chatId, p.sessionKey, p.runId));
926
+ .get(botName, chatId, p.sessionKey, p.runId, p.sessionId ? p.botIdentity ?? null : null, p.sessionId ? p.botIdentity ?? null : null, p.sessionId ? p.stopEpoch ?? null : null, p.sessionId ? p.stopEpoch ?? null : null, p.sessionId ?? null, p.sessionId ?? null));
927
+ }
928
+ /** Compatibility is exact provenance/legacy key + original app/epoch, never
929
+ * text similarity. Existing attempted/unknown/delivered rows retain custody;
930
+ * this lookup neither requeues nor edits historical deliveries. */
931
+ findRunErrorOwner(bot, chat, p) {
932
+ const target = this.getDeliveryRunTarget(bot, chat, p.runId);
933
+ const oldKey = `run-error:${errorIdentity(p.sessionKey, p.runId)}`;
934
+ const triggerKey = target?.triggerId ? `trigger:${target.triggerId}:provider-error` : '';
935
+ const rows = this.db.prepare(`SELECT * FROM delivery_outbox WHERE bot_name=? AND chat_id=?
936
+ AND source_type IN ('run_error','provider_error') AND (delivery_key IN (?,?,?) OR
937
+ json_extract(CASE WHEN json_valid(delivery_meta_json) THEN delivery_meta_json ELSE '{}' END,'$.provenance.runId')=?) ORDER BY id LIMIT 100`)
938
+ .all(bot, chat, oldKey, triggerKey, `run-error:${errorNoticeIdentity(p)}`, p.runId);
939
+ for (const row of rows) {
940
+ let meta;
941
+ try {
942
+ meta = JSON.parse(row.delivery_meta_json);
943
+ }
944
+ catch {
945
+ continue;
946
+ }
947
+ if (p.botIdentity && meta.botIdentity !== p.botIdentity || p.stopEpoch !== undefined && meta.deliveryStopEpoch !== p.stopEpoch)
948
+ continue;
949
+ const q = meta.provenance;
950
+ if (q?.runId && q.runId !== p.runId || q?.sessionKey && q.sessionKey !== p.sessionKey)
951
+ continue;
952
+ if (p.sessionId && q?.sessionId && q.sessionId !== p.sessionId)
953
+ continue;
954
+ const exact = q?.runId === p.runId && q.sessionKey === p.sessionKey;
955
+ const legacy = row.delivery_key === oldKey || row.delivery_key === triggerKey && target?.sessionKey === p.sessionKey &&
956
+ (p.stopEpoch === undefined || target.stopGeneration === p.stopEpoch) && (!p.sessionId || !target.sessionId || target.sessionId === p.sessionId);
957
+ if (exact || legacy)
958
+ return this.mapDelivery(row);
959
+ }
960
+ return undefined;
847
961
  }
848
962
  renewUnsentErrorDelivery(id, content, contentHash, metadata) {
849
963
  this.db.prepare(`UPDATE delivery_outbox SET status='pending',content=?,content_hash=?,delivery_meta_json=?,updated_at=?
@@ -921,6 +1035,18 @@ export class MessageStore {
921
1035
  PRIMARY KEY (bot_name, message_id)
922
1036
  );
923
1037
 
1038
+ -- Private ingress dedup only: neither table grants chat ownership or
1039
+ -- changes name-scoped sessions, sync, triggers, delivery or group routing.
1040
+ CREATE TABLE IF NOT EXISTS p2p_processed_events (
1041
+ app_id TEXT NOT NULL, chat_id TEXT NOT NULL, message_id TEXT NOT NULL,
1042
+ PRIMARY KEY (app_id, chat_id, message_id)
1043
+ );
1044
+ CREATE TABLE IF NOT EXISTS p2p_owner_aliases (
1045
+ app_id TEXT NOT NULL, chat_id TEXT NOT NULL, bot_name TEXT NOT NULL,
1046
+ proof TEXT NOT NULL CHECK(proof IN ('pinned_owner','verified_migration')),
1047
+ PRIMARY KEY (app_id, chat_id, bot_name)
1048
+ );
1049
+
924
1050
  -- Tracks recalled human messages. Recalled messages are kept in the raw
925
1051
  -- message ledger for audit, but excluded from future OpenClaw context and
926
1052
  -- pending trigger processing.
@@ -1183,6 +1309,13 @@ export class MessageStore {
1183
1309
  catch {
1184
1310
  // Column already exists
1185
1311
  }
1312
+ // Additive only: never infer ownership from a retired display name. Legacy
1313
+ // rows are pinned lazily only when their existing owner label still matches.
1314
+ this.db.transaction(() => {
1315
+ if (!this.db.prepare('PRAGMA table_info(chat_info)').all().some(row => row.name === 'owner_app_id')) {
1316
+ this.db.exec(`ALTER TABLE chat_info ADD COLUMN owner_app_id TEXT NOT NULL DEFAULT ''`);
1317
+ }
1318
+ }).immediate();
1186
1319
  // Migration: add trigger_kind column if missing
1187
1320
  try {
1188
1321
  this.db.exec(`ALTER TABLE messages ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT 'normal'`);
@@ -1727,10 +1860,87 @@ export class MessageStore {
1727
1860
  return count;
1728
1861
  }
1729
1862
  // --- Chat info ---
1863
+ /** Authorize the already app-specific SDK's private chat. The first event
1864
+ * pins a new chat; a matching legacy label may be backfilled. Thereafter only
1865
+ * the exact application pin authorizes access, including across bot renames.
1866
+ * Existing unowned/mismatched/malformed rows fail closed, not first-claim wins.
1867
+ * This operation changes no history, sync, pending input or delivery state. */
1868
+ claimP2pOwner(chatId, botName, appId) {
1869
+ if (!chatId || !botName?.trim() || !this.validOwnerAppId(appId))
1870
+ return false;
1871
+ return this.db.transaction(() => {
1872
+ const row = this.db.prepare('SELECT chat_type,owner_bot,owner_app_id FROM chat_info WHERE chat_id=?').get(chatId);
1873
+ if (!row) {
1874
+ this.db.prepare(`INSERT INTO chat_info(chat_id,chat_type,chat_name,owner_bot,owner_app_id,updated_at)
1875
+ VALUES(?,'p2p','私聊',?,?,?)`).run(chatId, botName, appId, Date.now());
1876
+ this.rememberP2pOwnerAlias(chatId, appId, botName, 'pinned_owner');
1877
+ return true;
1878
+ }
1879
+ if (row.chat_type !== 'p2p')
1880
+ return false;
1881
+ if (row.owner_app_id === '') {
1882
+ if (row.owner_bot !== botName)
1883
+ return false;
1884
+ const pinned = this.db.prepare(`UPDATE chat_info SET owner_app_id=? WHERE chat_id=? AND chat_type='p2p' AND owner_bot=? AND owner_app_id=''`)
1885
+ .run(appId, chatId, botName).changes === 1;
1886
+ if (pinned)
1887
+ this.rememberP2pOwnerAlias(chatId, appId, botName, 'pinned_owner');
1888
+ return pinned;
1889
+ }
1890
+ if (!this.validOwnerAppId(row.owner_app_id) || row.owner_app_id !== appId)
1891
+ return false;
1892
+ // Preserve the proven previous scope BEFORE replacing the readable label.
1893
+ this.rememberP2pOwnerAlias(chatId, appId, row.owner_bot, 'pinned_owner');
1894
+ if (row.owner_bot !== botName) {
1895
+ const renamed = this.db.prepare(`UPDATE chat_info SET owner_bot=? WHERE chat_id=? AND chat_type='p2p' AND owner_app_id=?`)
1896
+ .run(botName, chatId, appId).changes === 1;
1897
+ if (renamed)
1898
+ this.rememberP2pOwnerAlias(chatId, appId, botName, 'pinned_owner');
1899
+ return renamed;
1900
+ }
1901
+ return true;
1902
+ }).immediate();
1903
+ }
1904
+ /** Operator-only CAS after independently verifying the original account.
1905
+ * No runtime caller: a retired legacy label alone cannot prove app ownership.
1906
+ * Idempotent for the exact destination, refuses any conflicting existing pin.
1907
+ * Preserves cache metadata/timestamps and every business/history table.
1908
+ * verifiedPreviousOwnerBot is an explicit independently verified old scope
1909
+ * when an earlier operator repair already replaced the legacy cache label. */
1910
+ migrateLegacyP2pOwner(chatId, expectedOwnerBot, botName, appId, verifiedPreviousOwnerBot) {
1911
+ if (!chatId || !expectedOwnerBot?.trim() || !botName?.trim() || !this.validOwnerAppId(appId)
1912
+ || (verifiedPreviousOwnerBot !== undefined && !verifiedPreviousOwnerBot.trim()))
1913
+ return false;
1914
+ return this.db.transaction(() => {
1915
+ const migrated = this.db.prepare(`UPDATE chat_info SET owner_bot=?,owner_app_id=?
1916
+ WHERE chat_id=? AND chat_type='p2p' AND owner_bot=? AND owner_app_id=''`)
1917
+ .run(botName, appId, chatId, expectedOwnerBot).changes === 1;
1918
+ const row = this.db.prepare('SELECT chat_type,owner_bot,owner_app_id FROM chat_info WHERE chat_id=?').get(chatId);
1919
+ if (row?.chat_type !== 'p2p' || row.owner_bot !== botName || row.owner_app_id !== appId)
1920
+ return false;
1921
+ if (migrated)
1922
+ this.rememberP2pOwnerAlias(chatId, appId, expectedOwnerBot, 'verified_migration');
1923
+ this.rememberP2pOwnerAlias(chatId, appId, botName, 'pinned_owner');
1924
+ if (verifiedPreviousOwnerBot !== undefined)
1925
+ this.rememberP2pOwnerAlias(chatId, appId, verifiedPreviousOwnerBot, 'verified_migration');
1926
+ return true;
1927
+ }).immediate();
1928
+ }
1929
+ rememberP2pOwnerAlias(chatId, appId, botName, proof) {
1930
+ if (typeof botName !== 'string' || !botName.trim())
1931
+ return;
1932
+ this.db.prepare('INSERT OR IGNORE INTO p2p_owner_aliases(app_id,chat_id,bot_name,proof) VALUES(?,?,?,?)')
1933
+ .run(appId, chatId, botName, proof);
1934
+ }
1935
+ validOwnerAppId(value) {
1936
+ // Exact, opaque identity, not a display name. Do not normalize malformed
1937
+ // pins into either a valid identity or the empty legacy sentinel.
1938
+ return typeof value === 'string' && /^[A-Za-z0-9_-]+$/.test(value);
1939
+ }
1730
1940
  upsertChatInfo(info) {
1731
1941
  this.db.prepare(`
1732
- INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, discuss, discuss_max_rounds, chairman_bot, locale, updated_at)
1733
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1942
+ INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, owner_app_id, discuss, discuss_max_rounds, chairman_bot, locale, updated_at)
1943
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1734
1944
  ON CONFLICT (chat_id) DO UPDATE SET
1735
1945
  chat_type = excluded.chat_type,
1736
1946
  chat_name = excluded.chat_name,
@@ -1738,13 +1948,13 @@ export class MessageStore {
1738
1948
  member_names = excluded.member_names,
1739
1949
  verbose = excluded.verbose,
1740
1950
  free_discussion = excluded.free_discussion,
1741
- owner_bot = CASE WHEN excluded.owner_bot != '' THEN excluded.owner_bot ELSE chat_info.owner_bot END,
1951
+ owner_bot = CASE WHEN chat_info.chat_type = 'p2p' THEN chat_info.owner_bot WHEN excluded.owner_bot != '' THEN excluded.owner_bot ELSE chat_info.owner_bot END,
1742
1952
  discuss = CASE WHEN excluded.discuss != 0 THEN excluded.discuss ELSE chat_info.discuss END,
1743
1953
  discuss_max_rounds = CASE WHEN excluded.discuss_max_rounds != 10 THEN excluded.discuss_max_rounds ELSE chat_info.discuss_max_rounds END,
1744
1954
  chairman_bot = CASE WHEN excluded.chairman_bot != '' THEN excluded.chairman_bot ELSE chat_info.chairman_bot END,
1745
1955
  locale = CASE WHEN excluded.locale != '' THEN excluded.locale ELSE chat_info.locale END,
1746
1956
  updated_at = excluded.updated_at
1747
- `).run(info.chatId, info.chatType, info.chatName, info.members, info.memberNames, info.verbose ? 1 : 0, info.freeDiscussion ? 1 : 0, info.ownerBot || '', info.discuss ? 1 : 0, info.discussMaxRounds || 10, info.chairmanBot || '', info.locale || '', info.updatedAt);
1957
+ `).run(info.chatId, info.chatType, info.chatName, info.members, info.memberNames, info.verbose ? 1 : 0, info.freeDiscussion ? 1 : 0, info.ownerBot || '', info.ownerAppId ?? '', info.discuss ? 1 : 0, info.discussMaxRounds || 10, info.chairmanBot || '', info.locale || '', info.updatedAt);
1748
1958
  }
1749
1959
  setFreeDiscussion(chatId, on) {
1750
1960
  this.db.prepare(`UPDATE chat_info SET free_discussion = ? WHERE chat_id = ?`).run(on ? 1 : 0, chatId);
@@ -1868,6 +2078,7 @@ export class MessageStore {
1868
2078
  members: row.members,
1869
2079
  memberNames: row.member_names,
1870
2080
  ownerBot: row.owner_bot || '',
2081
+ ownerAppId: row.owner_app_id,
1871
2082
  freeDiscussion: !!row.free_discussion,
1872
2083
  verbose: !!row.verbose,
1873
2084
  discuss: !!row.discuss,
@@ -1886,6 +2097,7 @@ export class MessageStore {
1886
2097
  members: r.members,
1887
2098
  memberNames: r.member_names,
1888
2099
  ownerBot: r.owner_bot || '',
2100
+ ownerAppId: r.owner_app_id,
1889
2101
  freeDiscussion: !!r.free_discussion,
1890
2102
  verbose: !!r.verbose,
1891
2103
  discuss: !!r.discuss,
@@ -1926,6 +2138,30 @@ export class MessageStore {
1926
2138
  const result = this.db.prepare(`INSERT OR IGNORE INTO processed_events (bot_name, message_id) VALUES (?, ?)`).run(botName, messageId);
1927
2139
  return result.changes === 1;
1928
2140
  }
2141
+ /** Private-only monotonic ingress fence. Write even for an owner-rejected
2142
+ * event: a rejected input is a tombstone, never permission to retry after a
2143
+ * rename. Legacy name tombstones are consulted ONLY in a pinned/explicitly
2144
+ * verified owner scope (plus the existing current-name fence), never inferred
2145
+ * from message history or mentions. Each duplicate promotes only its own ID;
2146
+ * no historical name ledger is copied or renamed. Ownership is checked later. */
2147
+ tryMarkP2pProcessed(appId, chatId, botName, messageId) {
2148
+ if (!this.validOwnerAppId(appId) || !chatId || !botName?.trim() || !messageId)
2149
+ return false;
2150
+ return this.db.transaction(() => {
2151
+ const owner = this.db.prepare('SELECT chat_type,owner_bot,owner_app_id FROM chat_info WHERE chat_id=?').get(chatId);
2152
+ if (owner?.chat_type === 'p2p' && owner.owner_app_id === appId) {
2153
+ // Intake dedup runs before metadata refresh/startup can replace a label.
2154
+ this.rememberP2pOwnerAlias(chatId, appId, owner.owner_bot, 'pinned_owner');
2155
+ }
2156
+ const legacyDuplicate = !!this.db.prepare(`SELECT 1 FROM p2p_owner_aliases a
2157
+ JOIN processed_events p ON p.bot_name=a.bot_name
2158
+ WHERE a.app_id=? AND a.chat_id=? AND p.message_id=? LIMIT 1`).get(appId, chatId, messageId);
2159
+ const freshApp = this.db.prepare('INSERT OR IGNORE INTO p2p_processed_events(app_id,chat_id,message_id) VALUES(?,?,?)')
2160
+ .run(appId, chatId, messageId).changes === 1;
2161
+ const freshName = this.tryMarkBotProcessed(botName, messageId);
2162
+ return freshApp && freshName && !legacyDuplicate;
2163
+ }).immediate();
2164
+ }
1929
2165
  // --- Go / Go-Checker supervision (DESIGN.md §10) ---
1930
2166
  createGoJob(j) {
1931
2167
  const now = Date.now();
@@ -72,10 +72,6 @@ export type ChatAttachment = {
72
72
  };
73
73
  export declare const GATEWAY_PROTOCOL_MIN = 4;
74
74
  export declare const GATEWAY_PROTOCOL_MAX = 4;
75
- /**
76
- * OpenClaw Gateway WebSocket client.
77
- * Full agent pipeline — tools, memory, skills, context management by OpenClaw.
78
- */
79
75
  export declare class OpenClawClient {
80
76
  private goEvidenceObservers;
81
77
  /** Raw full event observer. Observation is NOT a Feishu delivery receipt. */
@@ -234,8 +230,6 @@ export declare class OpenClawClient {
234
230
  */
235
231
  private isGenericChatError;
236
232
  private resolveTerminalChatError;
237
- private formatTerminalRunError;
238
- private formatDurationSeconds;
239
233
  private isRecoverableAgentError;
240
234
  createSession(params: {
241
235
  key: string;