@botcord/daemon 0.2.91 → 0.2.93

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 (44) hide show
  1. package/dist/gateway/channels/botcord.d.ts +9 -1
  2. package/dist/gateway/channels/botcord.js +55 -2
  3. package/dist/gateway/channels/feishu.d.ts +56 -0
  4. package/dist/gateway/channels/feishu.js +76 -0
  5. package/dist/gateway/cli-resolver.d.ts +1 -0
  6. package/dist/gateway/cli-resolver.js +2 -0
  7. package/dist/gateway/dispatcher.d.ts +20 -0
  8. package/dist/gateway/dispatcher.js +252 -0
  9. package/dist/gateway/runtimes/codex.js +1 -0
  10. package/dist/gateway/runtimes/deepseek-tui.js +1 -0
  11. package/dist/gateway/runtimes/hermes-agent.js +1 -0
  12. package/dist/gateway/runtimes/kimi.js +1 -0
  13. package/dist/gateway/runtimes/ndjson-stream.js +1 -0
  14. package/dist/gateway/types.d.ts +8 -0
  15. package/dist/gateway/wait-marker.d.ts +32 -0
  16. package/dist/gateway/wait-marker.js +96 -0
  17. package/dist/gateway-control.d.ts +4 -0
  18. package/dist/gateway-control.js +124 -44
  19. package/dist/loop-risk.js +2 -0
  20. package/dist/system-context.js +3 -0
  21. package/dist/turn-text.js +5 -0
  22. package/package.json +3 -3
  23. package/src/__tests__/feishu-channel.test.ts +180 -0
  24. package/src/__tests__/gateway-control.test.ts +493 -0
  25. package/src/__tests__/system-context.test.ts +4 -0
  26. package/src/gateway/__tests__/botcord-channel.test.ts +50 -0
  27. package/src/gateway/__tests__/dispatcher-park.test.ts +207 -0
  28. package/src/gateway/__tests__/dispatcher.test.ts +48 -1
  29. package/src/gateway/__tests__/wait-marker.test.ts +90 -0
  30. package/src/gateway/channels/botcord.ts +79 -5
  31. package/src/gateway/channels/feishu.ts +122 -0
  32. package/src/gateway/cli-resolver.ts +2 -0
  33. package/src/gateway/dispatcher.ts +292 -0
  34. package/src/gateway/runtimes/codex.ts +1 -0
  35. package/src/gateway/runtimes/deepseek-tui.ts +1 -0
  36. package/src/gateway/runtimes/hermes-agent.ts +1 -0
  37. package/src/gateway/runtimes/kimi.ts +1 -0
  38. package/src/gateway/runtimes/ndjson-stream.ts +1 -0
  39. package/src/gateway/types.ts +8 -0
  40. package/src/gateway/wait-marker.ts +101 -0
  41. package/src/gateway-control.ts +150 -48
  42. package/src/loop-risk.ts +1 -0
  43. package/src/system-context.ts +3 -0
  44. package/src/turn-text.ts +5 -0
@@ -1,5 +1,5 @@
1
1
  import WebSocket from "ws";
2
- import { type InboxMessage } from "@botcord/protocol-core";
2
+ import { type InboxMessage, type MessageAttachment } from "@botcord/protocol-core";
3
3
  import type { ChannelAdapter, GatewayInboundMessage, GatewayLogger } from "../index.js";
4
4
  /** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
5
5
  export interface BotCordChannelClient {
@@ -16,9 +16,16 @@ export interface BotCordChannelClient {
16
16
  has_more: boolean;
17
17
  }>;
18
18
  ackMessages(messageIds: string[]): Promise<void>;
19
+ uploadFile?(filePath: string, filename: string, contentType?: string): Promise<{
20
+ original_filename: string;
21
+ url: string;
22
+ content_type?: string;
23
+ size_bytes?: number;
24
+ }>;
19
25
  sendMessage(to: string, text: string, options?: {
20
26
  replyTo?: string;
21
27
  topic?: string;
28
+ attachments?: MessageAttachment[];
22
29
  }): Promise<{
23
30
  hub_msg_id?: string;
24
31
  message_id?: string;
@@ -26,6 +33,7 @@ export interface BotCordChannelClient {
26
33
  sendTypedMessage?(to: string, type: "result" | "error", text: string, options?: {
27
34
  replyTo?: string;
28
35
  topic?: string;
36
+ attachments?: MessageAttachment[];
29
37
  }): Promise<{
30
38
  hub_msg_id?: string;
31
39
  message_id?: string;
@@ -1,3 +1,4 @@
1
+ import { basename } from "node:path";
1
2
  import WebSocket from "ws";
2
3
  import { BotCordClient, buildHubWebSocketUrl, defaultCredentialsFile, loadStoredCredentials, updateCredentialsToken, } from "@botcord/protocol-core";
3
4
  import { sanitizeUntrustedContent } from "./sanitize.js";
@@ -25,6 +26,54 @@ function isUnclaimedAgentError(err) {
25
26
  message.includes("agent_not_claimed_generic") ||
26
27
  message.includes("agent_not_claimed"));
27
28
  }
29
+ async function uploadOutboundAttachments(client, attachments, log) {
30
+ if (attachments.length === 0)
31
+ return { attachments: [], replacements: [] };
32
+ if (!client.uploadFile) {
33
+ log.warn("botcord send: outbound attachments skipped because uploadFile is unavailable", {
34
+ count: attachments.length,
35
+ });
36
+ return { attachments: [], replacements: [] };
37
+ }
38
+ const uploaded = [];
39
+ const replacements = [];
40
+ for (const attachment of attachments) {
41
+ if (!attachment.filePath) {
42
+ log.warn("botcord send: attachment without filePath skipped", {
43
+ filename: attachment.filename ?? null,
44
+ });
45
+ continue;
46
+ }
47
+ try {
48
+ const resp = await client.uploadFile(attachment.filePath, attachment.filename ?? basename(attachment.filePath), attachment.contentType);
49
+ if (attachment.sourcePath) {
50
+ replacements.push({ sourcePath: attachment.sourcePath, url: resp.url });
51
+ }
52
+ uploaded.push({
53
+ filename: resp.original_filename,
54
+ url: resp.url,
55
+ ...(resp.content_type ? { content_type: resp.content_type } : {}),
56
+ ...(typeof resp.size_bytes === "number" ? { size_bytes: resp.size_bytes } : {}),
57
+ });
58
+ }
59
+ catch (err) {
60
+ log.warn("botcord send: attachment upload failed; continuing without it", {
61
+ filename: attachment.filename ?? attachment.filePath,
62
+ error: err instanceof Error ? err.message : String(err),
63
+ });
64
+ }
65
+ }
66
+ return { attachments: uploaded, replacements };
67
+ }
68
+ function rewriteUploadedAttachmentPaths(text, replacements) {
69
+ let out = text;
70
+ for (const { sourcePath, url } of replacements) {
71
+ if (!sourcePath || !url)
72
+ continue;
73
+ out = out.replaceAll(sourcePath, url);
74
+ }
75
+ return out;
76
+ }
28
77
  /** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
29
78
  function defaultClientFactory(input) {
30
79
  const credFile = input.credentialsPath ?? defaultCredentialsFile(input.agentId);
@@ -774,9 +823,13 @@ export function createBotCordChannel(options) {
774
823
  options.replyTo = message.replyTo;
775
824
  if (message.threadId)
776
825
  options.topic = message.threadId;
826
+ const upload = await uploadOutboundAttachments(client, message.attachments ?? [], ctx.log);
827
+ if (upload.attachments.length > 0)
828
+ options.attachments = upload.attachments;
829
+ const text = rewriteUploadedAttachmentPaths(message.text, upload.replacements);
777
830
  const resp = message.type === "error" && client.sendTypedMessage
778
- ? await client.sendTypedMessage(message.conversationId, "error", message.text, options)
779
- : await client.sendMessage(message.conversationId, message.text, options);
831
+ ? await client.sendTypedMessage(message.conversationId, "error", text, options)
832
+ : await client.sendMessage(message.conversationId, text, options);
780
833
  const providerMessageId = (resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
781
834
  (resp && typeof resp.message_id === "string"
782
835
  ? resp.message_id
@@ -13,4 +13,60 @@ export interface FeishuChannelOptions {
13
13
  stateFile?: string;
14
14
  stateDebounceMs?: number;
15
15
  }
16
+ interface FeishuEventSender {
17
+ sender_id?: {
18
+ open_id?: string;
19
+ user_id?: string;
20
+ union_id?: string;
21
+ };
22
+ sender_type?: string;
23
+ tenant_key?: string;
24
+ }
25
+ interface FeishuEventMessage {
26
+ message_id?: string;
27
+ root_id?: string;
28
+ parent_id?: string;
29
+ create_time?: string;
30
+ chat_id?: string;
31
+ chat_type?: string;
32
+ message_type?: string;
33
+ content?: string;
34
+ mentions?: Array<{
35
+ id?: {
36
+ open_id?: string;
37
+ user_id?: string;
38
+ };
39
+ name?: string;
40
+ }>;
41
+ }
42
+ interface FeishuMessageEvent {
43
+ sender?: FeishuEventSender;
44
+ message?: FeishuEventMessage;
45
+ }
46
+ export interface FeishuDiscoveredChat {
47
+ chatId: string;
48
+ senderOpenId: string;
49
+ kind: "direct" | "group";
50
+ label?: string | null;
51
+ lastSeenAt: number;
52
+ }
53
+ export interface FeishuChatDiscoveryOptions {
54
+ appId: string;
55
+ appSecret: string;
56
+ domain?: FeishuDomain;
57
+ userOpenId: string;
58
+ timeoutSeconds?: number;
59
+ sdkOverride?: {
60
+ createWsClient(args: Record<string, unknown>): {
61
+ start(opts: unknown): unknown;
62
+ close(opts?: unknown): unknown;
63
+ };
64
+ createDispatcher(): {
65
+ register(handlers: Record<string, (data: unknown) => unknown>): void;
66
+ };
67
+ };
68
+ }
69
+ export declare function feishuDiscoveryChatFromEvent(event: FeishuMessageEvent, allowedSenderOpenId: string, now?: () => number): FeishuDiscoveredChat | null;
70
+ export declare function discoverFeishuChats(opts: FeishuChatDiscoveryOptions): Promise<FeishuDiscoveredChat[]>;
16
71
  export declare function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter;
72
+ export {};
@@ -54,6 +54,82 @@ function senderLabel(event) {
54
54
  const hit = mentions.find((m) => m.id?.open_id && m.id.open_id === senderOpenId);
55
55
  return typeof hit?.name === "string" && hit.name ? hit.name : undefined;
56
56
  }
57
+ export function feishuDiscoveryChatFromEvent(event, allowedSenderOpenId, now = () => Date.now()) {
58
+ const message = event.message;
59
+ const senderOpenId = event.sender?.sender_id?.open_id;
60
+ const chatId = message?.chat_id;
61
+ if (!message || !senderOpenId || !chatId)
62
+ return null;
63
+ if (senderOpenId !== allowedSenderOpenId)
64
+ return null;
65
+ const chatType = message.chat_type ?? "";
66
+ const kind = chatType === "p2p" ? "direct" : "group";
67
+ const label = senderLabel(event) ?? null;
68
+ return {
69
+ chatId,
70
+ senderOpenId,
71
+ kind,
72
+ label,
73
+ lastSeenAt: Number(message.create_time) || now(),
74
+ };
75
+ }
76
+ export async function discoverFeishuChats(opts) {
77
+ const timeoutSeconds = typeof opts.timeoutSeconds === "number"
78
+ ? Math.min(Math.max(Math.floor(opts.timeoutSeconds), 0), 10)
79
+ : 0;
80
+ const chats = new Map();
81
+ const sdk = Lark;
82
+ const dispatcher = opts.sdkOverride
83
+ ? opts.sdkOverride.createDispatcher()
84
+ : new sdk.EventDispatcher({});
85
+ dispatcher.register({
86
+ "im.message.receive_v1": (data) => {
87
+ const discovered = feishuDiscoveryChatFromEvent(data, opts.userOpenId);
88
+ if (!discovered)
89
+ return;
90
+ const previous = chats.get(discovered.chatId);
91
+ chats.set(discovered.chatId, {
92
+ ...previous,
93
+ ...discovered,
94
+ label: discovered.label ?? previous?.label ?? null,
95
+ lastSeenAt: Math.max(previous?.lastSeenAt ?? 0, discovered.lastSeenAt),
96
+ });
97
+ },
98
+ });
99
+ const wsClientArgs = {
100
+ appId: opts.appId,
101
+ appSecret: opts.appSecret,
102
+ domain: sdkDomain(opts.domain),
103
+ loggerLevel: sdk.LoggerLevel?.info,
104
+ };
105
+ const wsClient = opts.sdkOverride
106
+ ? opts.sdkOverride.createWsClient(wsClientArgs)
107
+ : new sdk.WSClient(wsClientArgs);
108
+ try {
109
+ const startFailure = Promise.resolve()
110
+ .then(() => wsClient.start({ eventDispatcher: dispatcher }))
111
+ .then(() => new Promise(() => { }), (err) => Promise.reject(err));
112
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
113
+ await Promise.race([startFailure, delay(0)]);
114
+ await Promise.race([startFailure, delay(timeoutSeconds * 1000)]);
115
+ }
116
+ finally {
117
+ try {
118
+ const closeResult = wsClient.close({ force: true });
119
+ if (closeResult &&
120
+ (typeof closeResult === "object" || typeof closeResult === "function") &&
121
+ typeof closeResult.then === "function") {
122
+ void Promise.resolve(closeResult).catch(() => {
123
+ // best effort
124
+ });
125
+ }
126
+ }
127
+ catch {
128
+ // best effort
129
+ }
130
+ }
131
+ return [...chats.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
132
+ }
57
133
  export function createFeishuChannel(opts) {
58
134
  const splitAt = opts.splitAt && opts.splitAt > 0 ? opts.splitAt : DEFAULT_SPLIT_AT;
59
135
  const allowedSenderIds = new Set((opts.allowedSenderIds ?? []).map(String));
@@ -31,4 +31,5 @@ export declare function buildCliEnv(opts: {
31
31
  hubUrl?: string;
32
32
  accountId?: string;
33
33
  basePath?: string | undefined;
34
+ waitMarkerFile?: string;
34
35
  }): NodeJS.ProcessEnv;
@@ -63,6 +63,8 @@ export function buildCliEnv(opts) {
63
63
  env.BOTCORD_HUB = opts.hubUrl;
64
64
  if (opts.accountId)
65
65
  env.BOTCORD_AGENT_ID = opts.accountId;
66
+ if (opts.waitMarkerFile)
67
+ env.BOTCORD_WAIT_FILE = opts.waitMarkerFile;
66
68
  const cli = resolveBundledCliBin();
67
69
  if (cli) {
68
70
  const existing = opts.basePath ?? "";
@@ -211,6 +211,26 @@ export declare class Dispatcher {
211
211
  */
212
212
  private recomposeUserTurn;
213
213
  private runTurn;
214
+ /**
215
+ * Clear a pending re-wake timer because an external message just arrived on
216
+ * the queue (it supersedes the scheduled re-wake and restarts the dithering
217
+ * budget). No-op when the timer-fire path already nulled `q.park` — so a
218
+ * re-wake does NOT reset the consecutive-park counters, keeping the caps
219
+ * effective across re-wakes.
220
+ */
221
+ private supersedePendingPark;
222
+ /**
223
+ * Read the park marker a group-room turn may have written via `botcord wait`
224
+ * and, if present and within the per-queue caps ({@link MAX_PARKS} /
225
+ * {@link MAX_WAIT_MS} total), schedule a re-wake that re-dispatches the same
226
+ * message after the (clamped) wait. A turn that ends without a marker — or in
227
+ * a non-deferrable room (`waitMarkerFile` unset), or aborted/timed-out —
228
+ * resets the consecutive-park counters. New messages arriving during the wait
229
+ * cancel it via {@link supersedePendingPark} (the agent then re-decides with
230
+ * fresh context). `waitMarkerFile` is the per-queue marker path resolved at
231
+ * dispatch (undefined when the room is not park-eligible).
232
+ */
233
+ private maybeSchedulePark;
214
234
  private sendReply;
215
235
  private providerReplyTo;
216
236
  private emitInbound;
@@ -1,7 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { realpathSync, statSync } from "node:fs";
3
+ import path from "node:path";
2
4
  import { looksLikeRuntimeAuthFailure } from "./runtime-errors.js";
3
5
  import { resolveRoute } from "./router.js";
4
6
  import { sessionKey } from "./session-store.js";
7
+ import { clearWaitMarker, consumeWaitMarker, resolveWaitMarkerPath, MAX_WAIT_MS } from "./wait-marker.js";
5
8
  import { truncateTextField, } from "./transcript.js";
6
9
  const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
7
10
  const DEFAULT_RUNTIME_AUTH_FAILURE_THRESHOLD = 3;
@@ -17,6 +20,10 @@ const TRANSCRIPT_BLOCK_RAW_LIMIT = 16 * 1024;
17
20
  const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
18
21
  /** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
19
22
  const MAX_BATCH_BUFFER_ENTRIES = 40;
23
+ /** Max consecutive agent-driven `botcord wait` parks on one queue before the
24
+ * next turn is forced to produce a real decision. Total accumulated wait is
25
+ * separately bounded by {@link MAX_WAIT_MS}. */
26
+ const MAX_PARKS = 3;
20
27
  /**
21
28
  * Soft cap on the total characters across raw.batch members in a merged
22
29
  * turn. When exceeded, oldest entries are dropped (with a warn log) so the
@@ -38,6 +45,30 @@ const TYPING_DEBOUNCE_MS = 2000;
38
45
  const TYPING_REFRESH_MS = 4000;
39
46
  /** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
40
47
  const TYPING_RECENCY_CAP = 1024;
48
+ const AUTO_ATTACHMENT_LIMIT = 10;
49
+ const AUTO_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
50
+ const AUTO_ATTACHMENT_EXTENSIONS = new Set([
51
+ ".avif",
52
+ ".bmp",
53
+ ".csv",
54
+ ".doc",
55
+ ".docx",
56
+ ".gif",
57
+ ".htm",
58
+ ".html",
59
+ ".jpeg",
60
+ ".jpg",
61
+ ".pdf",
62
+ ".png",
63
+ ".ppt",
64
+ ".pptx",
65
+ ".svg",
66
+ ".webp",
67
+ ".xls",
68
+ ".xlsx",
69
+ ".zip",
70
+ ]);
71
+ const REPLY_LOCAL_PATH_RE = /(^|[\s([{"'`])((?:\/|\.{1,2}\/)?(?:[\w@+.-]+\/)+[\w@+.-]+\.(?:avif|bmp|csv|docx?|gif|html?|jpe?g|pdf|png|pptx?|svg|webp|xlsx?|zip))(?=$|[\s)\]}"'`,.!?:;])/gi;
41
72
  function transcriptBlocksVerbose() {
42
73
  return process.env.BOTCORD_TRANSCRIPT_BLOCKS === "verbose" ||
43
74
  process.env.BOTCORD_TRACE_VERBOSE === "1";
@@ -502,6 +533,9 @@ export class Dispatcher {
502
533
  cancelGen: 0,
503
534
  serialBuffer: [],
504
535
  serialWorkerActive: false,
536
+ park: null,
537
+ parkCount: 0,
538
+ parkAccumMs: 0,
505
539
  };
506
540
  this.queues.set(key, q);
507
541
  }
@@ -680,6 +714,7 @@ export class Dispatcher {
680
714
  }
681
715
  async runCancelPrevious(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds = []) {
682
716
  const q = this.getQueue(queueKey);
717
+ this.supersedePendingPark(q);
683
718
  // Bump the generation on every arrival. Older arrivals still awaiting
684
719
  // the prior turn's teardown will observe `myGen !== q.cancelGen` when
685
720
  // they resume and drop out, so only the newest message reaches runTurn.
@@ -759,6 +794,7 @@ export class Dispatcher {
759
794
  */
760
795
  async runSerial(queueKey, route, _text, msg, channel, turnId, mergedFromTurnIds = []) {
761
796
  const q = this.getQueue(queueKey);
797
+ this.supersedePendingPark(q);
762
798
  q.serialBuffer.push({ route, msg, channel, turnId });
763
799
  while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
764
800
  const dropped = q.serialBuffer.shift();
@@ -944,6 +980,20 @@ export class Dispatcher {
944
980
  blocks: [],
945
981
  };
946
982
  q.current = slot;
983
+ // Agent-driven `botcord wait` is offered only in non-owner BotCord group
984
+ // rooms (kind === "group"). When eligible, scope the park marker per queue
985
+ // (concurrent group-room turns share one agent workspace) and expose its
986
+ // path to the CLI subprocess via `BOTCORD_WAIT_FILE`.
987
+ const parkEligible = isBotCordChannel(channel) &&
988
+ !isOwnerChatRoom(msg) &&
989
+ msg.conversation.kind === "group";
990
+ const waitMarkerFile = parkEligible
991
+ ? resolveWaitMarkerPath(route.cwd, queueKey)
992
+ : undefined;
993
+ // Drop any stale marker so that whatever `botcord wait` writes during this
994
+ // turn is unambiguously from this turn (read back in `finally`).
995
+ if (waitMarkerFile)
996
+ clearWaitMarker(waitMarkerFile);
947
997
  // Dispatched record — marks "this turn entered runtime".
948
998
  {
949
999
  const composedField = truncateTextField(text);
@@ -1324,6 +1374,7 @@ export class Dispatcher {
1324
1374
  cwd: route.cwd,
1325
1375
  accountId: msg.accountId,
1326
1376
  hubUrl: this.resolveHubUrl?.(msg.accountId),
1377
+ ...(waitMarkerFile ? { waitMarkerFile } : {}),
1327
1378
  extraArgs: route.extraArgs,
1328
1379
  signal: controller.signal,
1329
1380
  trustLevel,
@@ -1727,12 +1778,25 @@ export class Dispatcher {
1727
1778
  if (controller.signal.aborted && !slot.timedOut) {
1728
1779
  return;
1729
1780
  }
1781
+ const attachments = (isOwnerChat && isBotCordChannel(channel)
1782
+ ? collectOwnerChatReplyAttachments(replyText, route.cwd)
1783
+ : undefined) ?? [];
1784
+ if (attachments.length > 0) {
1785
+ this.log.info("dispatcher: attaching owner-chat reply artifacts", {
1786
+ agentId: msg.accountId,
1787
+ roomId: msg.conversation.id,
1788
+ topicId: msg.conversation.threadId ?? null,
1789
+ turnId,
1790
+ count: attachments.length,
1791
+ });
1792
+ }
1730
1793
  const sendResult = await this.sendReply(channel, {
1731
1794
  channel: msg.channel,
1732
1795
  accountId: msg.accountId,
1733
1796
  conversationId: msg.conversation.id,
1734
1797
  threadId: msg.conversation.threadId ?? null,
1735
1798
  text: replyText,
1799
+ attachments: attachments.length > 0 ? attachments : undefined,
1736
1800
  replyTo: this.providerReplyTo(msg),
1737
1801
  traceId: msg.trace?.id ?? null,
1738
1802
  }, turnId);
@@ -1763,9 +1827,106 @@ export class Dispatcher {
1763
1827
  // let our abort-checks above drop this turn silently.
1764
1828
  if (q.current === slot)
1765
1829
  q.current = null;
1830
+ // Agent-driven defer: a group-room turn may have run `botcord wait` to
1831
+ // park its decision. Honor it now (timer lives here, not in the runtime).
1832
+ this.maybeSchedulePark(queueKey, route, msg, channel, slot, controller, waitMarkerFile);
1766
1833
  resolveDone();
1767
1834
  }
1768
1835
  }
1836
+ /**
1837
+ * Clear a pending re-wake timer because an external message just arrived on
1838
+ * the queue (it supersedes the scheduled re-wake and restarts the dithering
1839
+ * budget). No-op when the timer-fire path already nulled `q.park` — so a
1840
+ * re-wake does NOT reset the consecutive-park counters, keeping the caps
1841
+ * effective across re-wakes.
1842
+ */
1843
+ supersedePendingPark(q) {
1844
+ if (!q.park)
1845
+ return;
1846
+ clearTimeout(q.park);
1847
+ q.park = null;
1848
+ q.parkCount = 0;
1849
+ q.parkAccumMs = 0;
1850
+ }
1851
+ /**
1852
+ * Read the park marker a group-room turn may have written via `botcord wait`
1853
+ * and, if present and within the per-queue caps ({@link MAX_PARKS} /
1854
+ * {@link MAX_WAIT_MS} total), schedule a re-wake that re-dispatches the same
1855
+ * message after the (clamped) wait. A turn that ends without a marker — or in
1856
+ * a non-deferrable room (`waitMarkerFile` unset), or aborted/timed-out —
1857
+ * resets the consecutive-park counters. New messages arriving during the wait
1858
+ * cancel it via {@link supersedePendingPark} (the agent then re-decides with
1859
+ * fresh context). `waitMarkerFile` is the per-queue marker path resolved at
1860
+ * dispatch (undefined when the room is not park-eligible).
1861
+ */
1862
+ maybeSchedulePark(queueKey, route, msg, channel, slot, controller, waitMarkerFile) {
1863
+ const q = this.queues.get(queueKey);
1864
+ if (!q)
1865
+ return;
1866
+ if (!waitMarkerFile || slot.timedOut || controller.signal.aborted) {
1867
+ // Not eligible / unclean completion — never honor a marker here.
1868
+ if (waitMarkerFile)
1869
+ clearWaitMarker(waitMarkerFile);
1870
+ q.parkCount = 0;
1871
+ q.parkAccumMs = 0;
1872
+ return;
1873
+ }
1874
+ const marker = consumeWaitMarker(waitMarkerFile);
1875
+ if (!marker) {
1876
+ q.parkCount = 0;
1877
+ q.parkAccumMs = 0;
1878
+ return;
1879
+ }
1880
+ if (q.parkCount >= MAX_PARKS || q.parkAccumMs >= MAX_WAIT_MS) {
1881
+ this.log.info("dispatcher: park request ignored — cap reached", {
1882
+ agentId: msg.accountId,
1883
+ roomId: msg.conversation.id,
1884
+ topicId: msg.conversation.threadId ?? null,
1885
+ queueKey,
1886
+ parkCount: q.parkCount,
1887
+ parkAccumMs: q.parkAccumMs,
1888
+ });
1889
+ q.parkCount = 0;
1890
+ q.parkAccumMs = 0;
1891
+ return;
1892
+ }
1893
+ const remainingBudget = MAX_WAIT_MS - q.parkAccumMs;
1894
+ const waitMs = Math.max(0, Math.min(marker.deadlineMs - Date.now(), remainingBudget));
1895
+ if (waitMs <= 0) {
1896
+ q.parkCount = 0;
1897
+ q.parkAccumMs = 0;
1898
+ return;
1899
+ }
1900
+ q.parkCount += 1;
1901
+ q.parkAccumMs += waitMs;
1902
+ this.log.info("dispatcher: parking group-room turn (botcord wait)", {
1903
+ agentId: msg.accountId,
1904
+ roomId: msg.conversation.id,
1905
+ topicId: msg.conversation.threadId ?? null,
1906
+ queueKey,
1907
+ waitMs,
1908
+ parkCount: q.parkCount,
1909
+ reason: marker.reason ?? null,
1910
+ });
1911
+ const timer = setTimeout(() => {
1912
+ q.park = null;
1913
+ this.log.info("dispatcher: park elapsed — re-waking", {
1914
+ agentId: msg.accountId,
1915
+ roomId: msg.conversation.id,
1916
+ topicId: msg.conversation.threadId ?? null,
1917
+ queueKey,
1918
+ });
1919
+ void this.runSerial(queueKey, route, this.recomposeUserTurn(msg), msg, channel, randomUUID()).catch((err) => {
1920
+ this.log.warn("dispatcher: park re-wake failed", {
1921
+ queueKey,
1922
+ error: err instanceof Error ? err.message : String(err),
1923
+ });
1924
+ });
1925
+ }, waitMs);
1926
+ if (typeof timer.unref === "function")
1927
+ timer.unref();
1928
+ q.park = timer;
1929
+ }
1769
1930
  async sendReply(channel, outbound, turnId) {
1770
1931
  try {
1771
1932
  await channel.send({ message: outbound, log: this.log });
@@ -1872,6 +2033,97 @@ export class Dispatcher {
1872
2033
  function nowIso() {
1873
2034
  return new Date().toISOString();
1874
2035
  }
2036
+ function collectOwnerChatReplyAttachments(text, cwd) {
2037
+ const baseDir = safeRealpath(cwd);
2038
+ if (!baseDir)
2039
+ return undefined;
2040
+ const out = [];
2041
+ const seen = new Set();
2042
+ REPLY_LOCAL_PATH_RE.lastIndex = 0;
2043
+ for (const match of text.matchAll(REPLY_LOCAL_PATH_RE)) {
2044
+ const rawPath = match[2];
2045
+ if (!rawPath || looksLikeUrl(rawPath))
2046
+ continue;
2047
+ const resolved = path.isAbsolute(rawPath)
2048
+ ? path.resolve(rawPath)
2049
+ : path.resolve(baseDir, rawPath);
2050
+ const realPath = safeRealpath(resolved);
2051
+ if (!realPath || seen.has(realPath) || !isPathInside(baseDir, realPath))
2052
+ continue;
2053
+ const ext = path.extname(realPath).toLowerCase();
2054
+ if (!AUTO_ATTACHMENT_EXTENSIONS.has(ext))
2055
+ continue;
2056
+ let size = 0;
2057
+ try {
2058
+ const stat = statSync(realPath);
2059
+ if (!stat.isFile())
2060
+ continue;
2061
+ size = stat.size;
2062
+ }
2063
+ catch {
2064
+ continue;
2065
+ }
2066
+ if (size <= 0 || size > AUTO_ATTACHMENT_MAX_BYTES)
2067
+ continue;
2068
+ const contentType = contentTypeForExtension(ext);
2069
+ out.push({
2070
+ filePath: realPath,
2071
+ filename: path.basename(realPath),
2072
+ sourcePath: rawPath,
2073
+ ...(contentType ? { contentType } : {}),
2074
+ ...(contentType?.startsWith("image/") ? { kind: "image" } : { kind: "file" }),
2075
+ });
2076
+ seen.add(realPath);
2077
+ if (out.length >= AUTO_ATTACHMENT_LIMIT)
2078
+ break;
2079
+ }
2080
+ return out.length > 0 ? out : undefined;
2081
+ }
2082
+ function safeRealpath(input) {
2083
+ try {
2084
+ return realpathSync(input);
2085
+ }
2086
+ catch {
2087
+ return null;
2088
+ }
2089
+ }
2090
+ function isPathInside(baseDir, candidate) {
2091
+ const rel = path.relative(baseDir, candidate);
2092
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
2093
+ }
2094
+ function looksLikeUrl(value) {
2095
+ return /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//");
2096
+ }
2097
+ function contentTypeForExtension(ext) {
2098
+ switch (ext) {
2099
+ case ".avif":
2100
+ return "image/avif";
2101
+ case ".bmp":
2102
+ return "image/bmp";
2103
+ case ".csv":
2104
+ return "text/csv";
2105
+ case ".gif":
2106
+ return "image/gif";
2107
+ case ".htm":
2108
+ case ".html":
2109
+ return "text/html";
2110
+ case ".jpeg":
2111
+ case ".jpg":
2112
+ return "image/jpeg";
2113
+ case ".pdf":
2114
+ return "application/pdf";
2115
+ case ".png":
2116
+ return "image/png";
2117
+ case ".svg":
2118
+ return "image/svg+xml";
2119
+ case ".webp":
2120
+ return "image/webp";
2121
+ case ".zip":
2122
+ return "application/zip";
2123
+ default:
2124
+ return undefined;
2125
+ }
2126
+ }
1875
2127
  function buildQueueKey(msg) {
1876
2128
  const thread = msg.conversation.threadId ?? "";
1877
2129
  return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
@@ -251,6 +251,7 @@ export class CodexAdapter extends NdjsonStreamAdapter {
251
251
  hubUrl: opts.hubUrl,
252
252
  accountId: opts.accountId,
253
253
  basePath: process.env.PATH,
254
+ waitMarkerFile: opts.waitMarkerFile,
254
255
  });
255
256
  const env = {
256
257
  ...process.env,
@@ -184,6 +184,7 @@ export class DeepseekTuiAdapter {
184
184
  hubUrl: opts.hubUrl,
185
185
  accountId: opts.accountId,
186
186
  basePath: process.env.PATH,
187
+ waitMarkerFile: opts.waitMarkerFile,
187
188
  }),
188
189
  FORCE_COLOR: "0",
189
190
  NO_COLOR: "1",
@@ -223,6 +223,7 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
223
223
  hubUrl: opts.hubUrl,
224
224
  accountId: opts.accountId,
225
225
  basePath: process.env.PATH,
226
+ waitMarkerFile: opts.waitMarkerFile,
226
227
  });
227
228
  const env = {
228
229
  ...process.env,
@@ -194,6 +194,7 @@ export class KimiAdapter extends NdjsonStreamAdapter {
194
194
  hubUrl: opts.hubUrl,
195
195
  accountId: opts.accountId,
196
196
  basePath: process.env.PATH,
197
+ waitMarkerFile: opts.waitMarkerFile,
197
198
  });
198
199
  return {
199
200
  ...process.env,
@@ -36,6 +36,7 @@ export class NdjsonStreamAdapter {
36
36
  hubUrl: opts.hubUrl,
37
37
  accountId: opts.accountId,
38
38
  basePath: process.env.PATH,
39
+ waitMarkerFile: opts.waitMarkerFile,
39
40
  }),
40
41
  };
41
42
  }