@botcord/daemon 0.2.92 → 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 +44 -4
  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 +121 -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 +59 -5
  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,9 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { realpathSync, statSync } from "node:fs";
3
+ import path from "node:path";
2
4
 
3
5
  import type { GatewayLogger } from "./log.js";
4
6
  import { looksLikeRuntimeAuthFailure } from "./runtime-errors.js";
5
7
  import { resolveRoute } from "./router.js";
6
8
  import { sessionKey, type SessionStore } from "./session-store.js";
9
+ import { clearWaitMarker, consumeWaitMarker, resolveWaitMarkerPath, MAX_WAIT_MS } from "./wait-marker.js";
7
10
  import {
8
11
  truncateTextField,
9
12
  type DeliveryStatus,
@@ -50,6 +53,11 @@ const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password
50
53
  /** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
51
54
  const MAX_BATCH_BUFFER_ENTRIES = 40;
52
55
 
56
+ /** Max consecutive agent-driven `botcord wait` parks on one queue before the
57
+ * next turn is forced to produce a real decision. Total accumulated wait is
58
+ * separately bounded by {@link MAX_WAIT_MS}. */
59
+ const MAX_PARKS = 3;
60
+
53
61
  /**
54
62
  * Soft cap on the total characters across raw.batch members in a merged
55
63
  * turn. When exceeded, oldest entries are dropped (with a warn log) so the
@@ -74,6 +82,31 @@ const TYPING_REFRESH_MS = 4000;
74
82
 
75
83
  /** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
76
84
  const TYPING_RECENCY_CAP = 1024;
85
+ const AUTO_ATTACHMENT_LIMIT = 10;
86
+ const AUTO_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
87
+ const AUTO_ATTACHMENT_EXTENSIONS = new Set([
88
+ ".avif",
89
+ ".bmp",
90
+ ".csv",
91
+ ".doc",
92
+ ".docx",
93
+ ".gif",
94
+ ".htm",
95
+ ".html",
96
+ ".jpeg",
97
+ ".jpg",
98
+ ".pdf",
99
+ ".png",
100
+ ".ppt",
101
+ ".pptx",
102
+ ".svg",
103
+ ".webp",
104
+ ".xls",
105
+ ".xlsx",
106
+ ".zip",
107
+ ]);
108
+ const REPLY_LOCAL_PATH_RE =
109
+ /(^|[\s([{"'`])((?:\/|\.{1,2}\/)?(?:[\w@+.-]+\/)+[\w@+.-]+\.(?:avif|bmp|csv|docx?|gif|html?|jpe?g|pdf|png|pptx?|svg|webp|xlsx?|zip))(?=$|[\s)\]}"'`,.!?:;])/gi;
77
110
 
78
111
  function transcriptBlocksVerbose(): boolean {
79
112
  return process.env.BOTCORD_TRANSCRIPT_BLOCKS === "verbose" ||
@@ -403,6 +436,19 @@ interface QueueState {
403
436
  serialBuffer: BufferedSerialEntry[];
404
437
  /** True when the serial-drain worker is actively running (or about to). */
405
438
  serialWorkerActive: boolean;
439
+ /**
440
+ * Active re-wake timer scheduled when a group-room turn ran `botcord wait`.
441
+ * Null when no park is pending. An external arrival on this queue clears it
442
+ * (the new message supersedes the scheduled re-wake); the timer callback
443
+ * nulls it itself before re-dispatching.
444
+ */
445
+ park: NodeJS.Timeout | null;
446
+ /** Consecutive parks on this queue; reset when a turn ends without one (or
447
+ * an external message arrives). Capped by {@link MAX_PARKS}. */
448
+ parkCount: number;
449
+ /** Total park time accumulated across consecutive parks (ms). Capped by
450
+ * {@link MAX_WAIT_MS}. */
451
+ parkAccumMs: number;
406
452
  }
407
453
 
408
454
  interface CloudRunBudgetCaps {
@@ -774,6 +820,9 @@ export class Dispatcher {
774
820
  cancelGen: 0,
775
821
  serialBuffer: [],
776
822
  serialWorkerActive: false,
823
+ park: null,
824
+ parkCount: 0,
825
+ parkAccumMs: 0,
777
826
  };
778
827
  this.queues.set(key, q);
779
828
  }
@@ -981,6 +1030,7 @@ export class Dispatcher {
981
1030
  mergedFromTurnIds: string[] = [],
982
1031
  ): Promise<void> {
983
1032
  const q = this.getQueue(queueKey);
1033
+ this.supersedePendingPark(q);
984
1034
  // Bump the generation on every arrival. Older arrivals still awaiting
985
1035
  // the prior turn's teardown will observe `myGen !== q.cancelGen` when
986
1036
  // they resume and drop out, so only the newest message reaches runTurn.
@@ -1069,6 +1119,7 @@ export class Dispatcher {
1069
1119
  mergedFromTurnIds: string[] = [],
1070
1120
  ): Promise<void> {
1071
1121
  const q = this.getQueue(queueKey);
1122
+ this.supersedePendingPark(q);
1072
1123
  q.serialBuffer.push({ route, msg, channel, turnId });
1073
1124
  while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
1074
1125
  const dropped = q.serialBuffer.shift()!;
@@ -1282,6 +1333,21 @@ export class Dispatcher {
1282
1333
  };
1283
1334
  q.current = slot;
1284
1335
 
1336
+ // Agent-driven `botcord wait` is offered only in non-owner BotCord group
1337
+ // rooms (kind === "group"). When eligible, scope the park marker per queue
1338
+ // (concurrent group-room turns share one agent workspace) and expose its
1339
+ // path to the CLI subprocess via `BOTCORD_WAIT_FILE`.
1340
+ const parkEligible =
1341
+ isBotCordChannel(channel) &&
1342
+ !isOwnerChatRoom(msg) &&
1343
+ msg.conversation.kind === "group";
1344
+ const waitMarkerFile = parkEligible
1345
+ ? resolveWaitMarkerPath(route.cwd, queueKey)
1346
+ : undefined;
1347
+ // Drop any stale marker so that whatever `botcord wait` writes during this
1348
+ // turn is unambiguously from this turn (read back in `finally`).
1349
+ if (waitMarkerFile) clearWaitMarker(waitMarkerFile);
1350
+
1285
1351
  // Dispatched record — marks "this turn entered runtime".
1286
1352
  {
1287
1353
  const composedField = truncateTextField(text);
@@ -1673,6 +1739,7 @@ export class Dispatcher {
1673
1739
  cwd: route.cwd,
1674
1740
  accountId: msg.accountId,
1675
1741
  hubUrl: this.resolveHubUrl?.(msg.accountId),
1742
+ ...(waitMarkerFile ? { waitMarkerFile } : {}),
1676
1743
  extraArgs: route.extraArgs,
1677
1744
  signal: controller.signal,
1678
1745
  trustLevel,
@@ -2083,12 +2150,27 @@ export class Dispatcher {
2083
2150
  return;
2084
2151
  }
2085
2152
 
2153
+ const attachments =
2154
+ (isOwnerChat && isBotCordChannel(channel)
2155
+ ? collectOwnerChatReplyAttachments(replyText, route.cwd)
2156
+ : undefined) ?? [];
2157
+ if (attachments.length > 0) {
2158
+ this.log.info("dispatcher: attaching owner-chat reply artifacts", {
2159
+ agentId: msg.accountId,
2160
+ roomId: msg.conversation.id,
2161
+ topicId: msg.conversation.threadId ?? null,
2162
+ turnId,
2163
+ count: attachments.length,
2164
+ });
2165
+ }
2166
+
2086
2167
  const sendResult = await this.sendReply(channel, {
2087
2168
  channel: msg.channel,
2088
2169
  accountId: msg.accountId,
2089
2170
  conversationId: msg.conversation.id,
2090
2171
  threadId: msg.conversation.threadId ?? null,
2091
2172
  text: replyText,
2173
+ attachments: attachments.length > 0 ? attachments : undefined,
2092
2174
  replyTo: this.providerReplyTo(msg),
2093
2175
  traceId: msg.trace?.id ?? null,
2094
2176
  }, turnId);
@@ -2117,10 +2199,126 @@ export class Dispatcher {
2117
2199
  // newer arrival should find `q.current === slot`, call `abort()`, and
2118
2200
  // let our abort-checks above drop this turn silently.
2119
2201
  if (q.current === slot) q.current = null;
2202
+ // Agent-driven defer: a group-room turn may have run `botcord wait` to
2203
+ // park its decision. Honor it now (timer lives here, not in the runtime).
2204
+ this.maybeSchedulePark(queueKey, route, msg, channel, slot, controller, waitMarkerFile);
2120
2205
  resolveDone();
2121
2206
  }
2122
2207
  }
2123
2208
 
2209
+ /**
2210
+ * Clear a pending re-wake timer because an external message just arrived on
2211
+ * the queue (it supersedes the scheduled re-wake and restarts the dithering
2212
+ * budget). No-op when the timer-fire path already nulled `q.park` — so a
2213
+ * re-wake does NOT reset the consecutive-park counters, keeping the caps
2214
+ * effective across re-wakes.
2215
+ */
2216
+ private supersedePendingPark(q: QueueState): void {
2217
+ if (!q.park) return;
2218
+ clearTimeout(q.park);
2219
+ q.park = null;
2220
+ q.parkCount = 0;
2221
+ q.parkAccumMs = 0;
2222
+ }
2223
+
2224
+ /**
2225
+ * Read the park marker a group-room turn may have written via `botcord wait`
2226
+ * and, if present and within the per-queue caps ({@link MAX_PARKS} /
2227
+ * {@link MAX_WAIT_MS} total), schedule a re-wake that re-dispatches the same
2228
+ * message after the (clamped) wait. A turn that ends without a marker — or in
2229
+ * a non-deferrable room (`waitMarkerFile` unset), or aborted/timed-out —
2230
+ * resets the consecutive-park counters. New messages arriving during the wait
2231
+ * cancel it via {@link supersedePendingPark} (the agent then re-decides with
2232
+ * fresh context). `waitMarkerFile` is the per-queue marker path resolved at
2233
+ * dispatch (undefined when the room is not park-eligible).
2234
+ */
2235
+ private maybeSchedulePark(
2236
+ queueKey: string,
2237
+ route: GatewayRoute,
2238
+ msg: GatewayInboundEnvelope["message"],
2239
+ channel: ChannelAdapter,
2240
+ slot: TurnSlot,
2241
+ controller: AbortController,
2242
+ waitMarkerFile: string | undefined,
2243
+ ): void {
2244
+ const q = this.queues.get(queueKey);
2245
+ if (!q) return;
2246
+
2247
+ if (!waitMarkerFile || slot.timedOut || controller.signal.aborted) {
2248
+ // Not eligible / unclean completion — never honor a marker here.
2249
+ if (waitMarkerFile) clearWaitMarker(waitMarkerFile);
2250
+ q.parkCount = 0;
2251
+ q.parkAccumMs = 0;
2252
+ return;
2253
+ }
2254
+
2255
+ const marker = consumeWaitMarker(waitMarkerFile);
2256
+ if (!marker) {
2257
+ q.parkCount = 0;
2258
+ q.parkAccumMs = 0;
2259
+ return;
2260
+ }
2261
+
2262
+ if (q.parkCount >= MAX_PARKS || q.parkAccumMs >= MAX_WAIT_MS) {
2263
+ this.log.info("dispatcher: park request ignored — cap reached", {
2264
+ agentId: msg.accountId,
2265
+ roomId: msg.conversation.id,
2266
+ topicId: msg.conversation.threadId ?? null,
2267
+ queueKey,
2268
+ parkCount: q.parkCount,
2269
+ parkAccumMs: q.parkAccumMs,
2270
+ });
2271
+ q.parkCount = 0;
2272
+ q.parkAccumMs = 0;
2273
+ return;
2274
+ }
2275
+
2276
+ const remainingBudget = MAX_WAIT_MS - q.parkAccumMs;
2277
+ const waitMs = Math.max(0, Math.min(marker.deadlineMs - Date.now(), remainingBudget));
2278
+ if (waitMs <= 0) {
2279
+ q.parkCount = 0;
2280
+ q.parkAccumMs = 0;
2281
+ return;
2282
+ }
2283
+
2284
+ q.parkCount += 1;
2285
+ q.parkAccumMs += waitMs;
2286
+ this.log.info("dispatcher: parking group-room turn (botcord wait)", {
2287
+ agentId: msg.accountId,
2288
+ roomId: msg.conversation.id,
2289
+ topicId: msg.conversation.threadId ?? null,
2290
+ queueKey,
2291
+ waitMs,
2292
+ parkCount: q.parkCount,
2293
+ reason: marker.reason ?? null,
2294
+ });
2295
+
2296
+ const timer = setTimeout(() => {
2297
+ q.park = null;
2298
+ this.log.info("dispatcher: park elapsed — re-waking", {
2299
+ agentId: msg.accountId,
2300
+ roomId: msg.conversation.id,
2301
+ topicId: msg.conversation.threadId ?? null,
2302
+ queueKey,
2303
+ });
2304
+ void this.runSerial(
2305
+ queueKey,
2306
+ route,
2307
+ this.recomposeUserTurn(msg),
2308
+ msg,
2309
+ channel,
2310
+ randomUUID(),
2311
+ ).catch((err) => {
2312
+ this.log.warn("dispatcher: park re-wake failed", {
2313
+ queueKey,
2314
+ error: err instanceof Error ? err.message : String(err),
2315
+ });
2316
+ });
2317
+ }, waitMs);
2318
+ if (typeof timer.unref === "function") timer.unref();
2319
+ q.park = timer;
2320
+ }
2321
+
2124
2322
  private async sendReply(
2125
2323
  channel: ChannelAdapter,
2126
2324
  outbound: GatewayOutboundMessage,
@@ -2239,6 +2437,100 @@ function nowIso(): string {
2239
2437
  return new Date().toISOString();
2240
2438
  }
2241
2439
 
2440
+ function collectOwnerChatReplyAttachments(text: string, cwd: string): GatewayOutboundMessage["attachments"] {
2441
+ const baseDir = safeRealpath(cwd);
2442
+ if (!baseDir) return undefined;
2443
+
2444
+ const out: NonNullable<GatewayOutboundMessage["attachments"]> = [];
2445
+ const seen = new Set<string>();
2446
+ REPLY_LOCAL_PATH_RE.lastIndex = 0;
2447
+
2448
+ for (const match of text.matchAll(REPLY_LOCAL_PATH_RE)) {
2449
+ const rawPath = match[2];
2450
+ if (!rawPath || looksLikeUrl(rawPath)) continue;
2451
+
2452
+ const resolved = path.isAbsolute(rawPath)
2453
+ ? path.resolve(rawPath)
2454
+ : path.resolve(baseDir, rawPath);
2455
+ const realPath = safeRealpath(resolved);
2456
+ if (!realPath || seen.has(realPath) || !isPathInside(baseDir, realPath)) continue;
2457
+
2458
+ const ext = path.extname(realPath).toLowerCase();
2459
+ if (!AUTO_ATTACHMENT_EXTENSIONS.has(ext)) continue;
2460
+
2461
+ let size = 0;
2462
+ try {
2463
+ const stat = statSync(realPath);
2464
+ if (!stat.isFile()) continue;
2465
+ size = stat.size;
2466
+ } catch {
2467
+ continue;
2468
+ }
2469
+ if (size <= 0 || size > AUTO_ATTACHMENT_MAX_BYTES) continue;
2470
+
2471
+ const contentType = contentTypeForExtension(ext);
2472
+ out.push({
2473
+ filePath: realPath,
2474
+ filename: path.basename(realPath),
2475
+ sourcePath: rawPath,
2476
+ ...(contentType ? { contentType } : {}),
2477
+ ...(contentType?.startsWith("image/") ? { kind: "image" as const } : { kind: "file" as const }),
2478
+ });
2479
+ seen.add(realPath);
2480
+ if (out.length >= AUTO_ATTACHMENT_LIMIT) break;
2481
+ }
2482
+
2483
+ return out.length > 0 ? out : undefined;
2484
+ }
2485
+
2486
+ function safeRealpath(input: string): string | null {
2487
+ try {
2488
+ return realpathSync(input);
2489
+ } catch {
2490
+ return null;
2491
+ }
2492
+ }
2493
+
2494
+ function isPathInside(baseDir: string, candidate: string): boolean {
2495
+ const rel = path.relative(baseDir, candidate);
2496
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
2497
+ }
2498
+
2499
+ function looksLikeUrl(value: string): boolean {
2500
+ return /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//");
2501
+ }
2502
+
2503
+ function contentTypeForExtension(ext: string): string | undefined {
2504
+ switch (ext) {
2505
+ case ".avif":
2506
+ return "image/avif";
2507
+ case ".bmp":
2508
+ return "image/bmp";
2509
+ case ".csv":
2510
+ return "text/csv";
2511
+ case ".gif":
2512
+ return "image/gif";
2513
+ case ".htm":
2514
+ case ".html":
2515
+ return "text/html";
2516
+ case ".jpeg":
2517
+ case ".jpg":
2518
+ return "image/jpeg";
2519
+ case ".pdf":
2520
+ return "application/pdf";
2521
+ case ".png":
2522
+ return "image/png";
2523
+ case ".svg":
2524
+ return "image/svg+xml";
2525
+ case ".webp":
2526
+ return "image/webp";
2527
+ case ".zip":
2528
+ return "application/zip";
2529
+ default:
2530
+ return undefined;
2531
+ }
2532
+ }
2533
+
2242
2534
  function buildQueueKey(msg: GatewayInboundEnvelope["message"]): string {
2243
2535
  const thread = msg.conversation.threadId ?? "";
2244
2536
  return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
@@ -269,6 +269,7 @@ export class CodexAdapter extends NdjsonStreamAdapter {
269
269
  hubUrl: opts.hubUrl,
270
270
  accountId: opts.accountId,
271
271
  basePath: process.env.PATH,
272
+ waitMarkerFile: opts.waitMarkerFile,
272
273
  });
273
274
  const env: NodeJS.ProcessEnv = {
274
275
  ...process.env,
@@ -237,6 +237,7 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
237
237
  hubUrl: opts.hubUrl,
238
238
  accountId: opts.accountId,
239
239
  basePath: process.env.PATH,
240
+ waitMarkerFile: opts.waitMarkerFile,
240
241
  }),
241
242
  FORCE_COLOR: "0",
242
243
  NO_COLOR: "1",
@@ -267,6 +267,7 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
267
267
  hubUrl: opts.hubUrl,
268
268
  accountId: opts.accountId,
269
269
  basePath: process.env.PATH,
270
+ waitMarkerFile: opts.waitMarkerFile,
270
271
  });
271
272
  const env: NodeJS.ProcessEnv = {
272
273
  ...process.env,
@@ -210,6 +210,7 @@ export class KimiAdapter extends NdjsonStreamAdapter {
210
210
  hubUrl: opts.hubUrl,
211
211
  accountId: opts.accountId,
212
212
  basePath: process.env.PATH,
213
+ waitMarkerFile: opts.waitMarkerFile,
213
214
  });
214
215
  return {
215
216
  ...process.env,
@@ -95,6 +95,7 @@ export abstract class NdjsonStreamAdapter implements RuntimeAdapter {
95
95
  hubUrl: opts.hubUrl,
96
96
  accountId: opts.accountId,
97
97
  basePath: process.env.PATH,
98
+ waitMarkerFile: opts.waitMarkerFile,
98
99
  }),
99
100
  };
100
101
  }
@@ -364,6 +364,14 @@ export interface RuntimeRunOptions {
364
364
  * unspecified and the bundled CLI falls back to its own default.
365
365
  */
366
366
  hubUrl?: string;
367
+ /**
368
+ * Absolute path the spawned CLI should write a `botcord wait` park marker to,
369
+ * exposed to the subprocess as `BOTCORD_WAIT_FILE`. Scoped per queue by the
370
+ * dispatcher so concurrent group-room turns sharing one workspace don't
371
+ * clobber each other. Unset for non-deferrable rooms (owner-chat / DM /
372
+ * non-group), in which case `botcord wait` is a harmless no-op.
373
+ */
374
+ waitMarkerFile?: string;
367
375
  signal: AbortSignal;
368
376
  extraArgs?: string[];
369
377
  trustLevel: TrustLevel;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Workspace park-marker transport for the agent-driven `botcord wait` defer.
3
+ *
4
+ * In non-owner BotCord group rooms the dispatcher discards the runtime's final
5
+ * text (the agent replies out-of-band via the `botcord send` CLI → Hub), so a
6
+ * "please re-wake me later" signal cannot ride back on the turn result. Instead
7
+ * the bundled `botcord wait <seconds>` CLI drops a tiny JSON marker into the
8
+ * agent's workspace; the dispatcher reads it at the turn boundary and schedules
9
+ * a re-wake. This keeps the *decision* to wait in the agent (it judges
10
+ * relevance/urgency) while the *timer* lives cheaply in the daemon — no runtime
11
+ * session is held open during the wait.
12
+ *
13
+ * The marker path is scoped per **queue** (channel:agent:room:thread): the
14
+ * dispatcher serializes turns within a queue but NOT across queues that share
15
+ * one agent workspace (`route.cwd`), so two concurrent group-room turns for the
16
+ * same agent would otherwise clobber each other's marker. The dispatcher passes
17
+ * the resolved path to the CLI subprocess via `BOTCORD_WAIT_FILE`.
18
+ *
19
+ * Local daemon-hosted agents only: the marker rides the shared filesystem of
20
+ * `route.cwd`. Cloud (sandboxed) agents need a networked transport — out of
21
+ * scope here.
22
+ */
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+
26
+ /** Filename stem for park markers. */
27
+ export const WAIT_MARKER_PREFIX = ".botcord-wait";
28
+ /** Legacy unscoped marker name — the CLI's fallback when `BOTCORD_WAIT_FILE`
29
+ * is unset. Never consumed by the dispatcher (which always scopes per queue),
30
+ * so an unscoped write is a harmless no-op. */
31
+ export const WAIT_MARKER_FILENAME = `${WAIT_MARKER_PREFIX}.json`;
32
+
33
+ /** Hard ceiling on a single park request (mirrors the CLI clamp). Also used by
34
+ * the dispatcher as the total accumulated-park budget across consecutive
35
+ * re-wakes on one queue. */
36
+ export const MAX_WAIT_MS = 30_000;
37
+
38
+ export interface WaitMarker {
39
+ /** Absolute unix millis the agent wants to be re-woken by (already clamped). */
40
+ deadlineMs: number;
41
+ reason?: string;
42
+ }
43
+
44
+ /** Legacy unscoped path under `cwd` (CLI fallback / tests). */
45
+ export function waitMarkerPath(cwd: string): string {
46
+ return path.join(cwd, WAIT_MARKER_FILENAME);
47
+ }
48
+
49
+ /** Per-queue marker path under the agent workspace. */
50
+ export function resolveWaitMarkerPath(cwd: string, queueKey: string): string {
51
+ const safe = queueKey.replace(/[^a-zA-Z0-9._-]/g, "_");
52
+ return path.join(cwd, `${WAIT_MARKER_PREFIX}.${safe}.json`);
53
+ }
54
+
55
+ /** Best-effort delete of any pre-existing marker at `markerPath`. Called before
56
+ * a turn runs so that whatever `botcord wait` writes during this turn is
57
+ * unambiguously from this turn. */
58
+ export function clearWaitMarker(markerPath: string): void {
59
+ try {
60
+ fs.rmSync(markerPath, { force: true });
61
+ } catch {
62
+ // A leftover marker only costs one wasted park-check next turn — never
63
+ // let cleanup failure abort the dispatch path.
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Read + delete the marker a turn may have written via `botcord wait`, and
69
+ * return the validated request (or null). Always removes the file so the next
70
+ * turn starts clean. `now` is injectable for tests.
71
+ *
72
+ * A deadline in the past — or beyond {@link MAX_WAIT_MS} — is clamped; a
73
+ * non-positive remaining wait yields null (treated as "no wait").
74
+ */
75
+ export function consumeWaitMarker(markerPath: string, now: number = Date.now()): WaitMarker | null {
76
+ let raw: string;
77
+ try {
78
+ raw = fs.readFileSync(markerPath, "utf8");
79
+ } catch {
80
+ return null; // no marker (ENOENT) or unreadable
81
+ }
82
+ try {
83
+ fs.rmSync(markerPath, { force: true });
84
+ } catch {
85
+ // ignore — the pre-turn clear will catch it next time
86
+ }
87
+ let parsed: unknown;
88
+ try {
89
+ parsed = JSON.parse(raw);
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (!parsed || typeof parsed !== "object") return null;
94
+ const obj = parsed as Record<string, unknown>;
95
+ const deadlineMs = typeof obj.deadlineMs === "number" ? obj.deadlineMs : NaN;
96
+ if (!Number.isFinite(deadlineMs)) return null;
97
+ const clamped = Math.min(deadlineMs, now + MAX_WAIT_MS);
98
+ if (clamped <= now) return null;
99
+ const reason = typeof obj.reason === "string" ? obj.reason : undefined;
100
+ return reason !== undefined ? { deadlineMs: clamped, reason } : { deadlineMs: clamped };
101
+ }
@@ -38,6 +38,10 @@ import {
38
38
  startFeishuRegistration,
39
39
  type FeishuDomain,
40
40
  } from "./gateway/channels/feishu-registration.js";
41
+ import {
42
+ discoverFeishuChats,
43
+ type FeishuDiscoveredChat,
44
+ } from "./gateway/channels/feishu.js";
41
45
  import { WECHAT_BASE_INFO, wechatHeaders } from "./gateway/channels/wechat-http.js";
42
46
  import { assertSafeBaseUrl, UnsafeBaseUrlError } from "./gateway/channels/url-guard.js";
43
47
  import { log as daemonLog } from "./log.js";
@@ -172,8 +176,17 @@ interface GatewayRecentSender {
172
176
  label?: string | null;
173
177
  }
174
178
 
179
+ interface GatewayRecentFeishuChat {
180
+ chatId: string;
181
+ senderOpenId: string;
182
+ kind: "direct" | "group";
183
+ label?: string | null;
184
+ lastSeenAt: number;
185
+ }
186
+
175
187
  interface GatewayRecentSendersResult {
176
- senders: GatewayRecentSender[];
188
+ senders?: GatewayRecentSender[];
189
+ chats?: GatewayRecentFeishuChat[];
177
190
  }
178
191
 
179
192
  interface GatewaySendParams {
@@ -213,6 +226,9 @@ export interface GatewayControlContext {
213
226
  startFeishuRegistration: typeof startFeishuRegistration;
214
227
  pollFeishuRegistration: typeof pollFeishuRegistration;
215
228
  };
229
+ feishuDiscoveryClient?: {
230
+ discoverChats: typeof discoverFeishuChats;
231
+ };
216
232
  /** Override the global fetch — used by `test_gateway` for Telegram getMe. */
217
233
  fetchImpl?: FetchLike;
218
234
  }
@@ -228,6 +244,7 @@ export function createGatewayControl(ctx: GatewayControlContext) {
228
244
  const wechatLogin = ctx.wechatLoginClient ?? { getBotQrcode, getQrcodeStatus };
229
245
  const feishuLogin =
230
246
  ctx.feishuLoginClient ?? { startFeishuRegistration, pollFeishuRegistration };
247
+ const feishuDiscovery = ctx.feishuDiscoveryClient ?? { discoverChats: discoverFeishuChats };
231
248
  // W7: validate fetch availability at construction so a missing global is
232
249
  // diagnosed at startup, not during the first control frame. Tests inject
233
250
  // `ctx.fetchImpl` explicitly and bypass the global lookup entirely.
@@ -898,7 +915,7 @@ export function createGatewayControl(ctx: GatewayControlContext) {
898
915
  if (!isProvider(params.provider)) {
899
916
  return badParams(`gateway_recent_senders: unknown provider "${String(params.provider)}"`);
900
917
  }
901
- if (params.provider !== "wechat") {
918
+ if (params.provider !== "wechat" && params.provider !== "feishu") {
902
919
  return badParams(`gateway_recent_senders: provider "${params.provider}" not supported`);
903
920
  }
904
921
  if (!params.loginId) {
@@ -913,12 +930,12 @@ export function createGatewayControl(ctx: GatewayControlContext) {
913
930
  ok: false,
914
931
  error:
915
932
  resolved.state === "missing"
916
- ? { code: "login_missing", message: `wechat login session "${params.loginId}" not found` }
917
- : { code: "login_expired", message: `wechat login session "${params.loginId}" expired` },
933
+ ? { code: "login_missing", message: `${params.provider} login session "${params.loginId}" not found` }
934
+ : { code: "login_expired", message: `${params.provider} login session "${params.loginId}" expired` },
918
935
  };
919
936
  }
920
937
  const session = resolved.session!;
921
- if (session.provider !== "wechat") {
938
+ if (session.provider !== params.provider) {
922
939
  return badParams("gateway_recent_senders: provider does not match login session");
923
940
  }
924
941
  if (session.accountId !== params.accountId) {
@@ -930,6 +947,43 @@ export function createGatewayControl(ctx: GatewayControlContext) {
930
947
  },
931
948
  };
932
949
  }
950
+ if (params.provider === "feishu") {
951
+ if (!session.appId || !session.appSecret || !session.userOpenId) {
952
+ return {
953
+ ok: false,
954
+ error: {
955
+ code: "login_unconfirmed",
956
+ message: "feishu login session has no app credentials yet",
957
+ },
958
+ };
959
+ }
960
+ try {
961
+ const chats = await feishuDiscovery.discoverChats({
962
+ appId: session.appId,
963
+ appSecret: session.appSecret,
964
+ domain: session.domain ?? "feishu",
965
+ userOpenId: session.userOpenId,
966
+ timeoutSeconds: params.timeoutSeconds,
967
+ });
968
+ const result: GatewayRecentSendersResult = {
969
+ chats: chats.map((c: FeishuDiscoveredChat) => ({
970
+ chatId: c.chatId,
971
+ senderOpenId: c.senderOpenId,
972
+ kind: c.kind,
973
+ label: c.label ?? null,
974
+ lastSeenAt: c.lastSeenAt,
975
+ })),
976
+ };
977
+ return { ok: true, result };
978
+ } catch (err) {
979
+ const message = err instanceof Error ? err.message : String(err);
980
+ daemonLog.warn("gateway_recent_senders.feishu discovery failed", { error: message });
981
+ return {
982
+ ok: false,
983
+ error: { code: "provider_unreachable", message },
984
+ };
985
+ }
986
+ }
933
987
  if (!session.botToken) {
934
988
  return {
935
989
  ok: false,
package/src/loop-risk.ts CHANGED
@@ -91,6 +91,7 @@ export function stripBotCordPromptScaffolding(text: string): string {
91
91
  if (line.startsWith("keyword, sender rule")) return false;
92
92
  if (line.startsWith("you do not reply to the group")) return false;
93
93
  if (line.startsWith("Before replying NO_REPLY in a non-owner group room")) return false;
94
+ if (line.startsWith("If you might have something useful to add")) return false;
94
95
  if (line.startsWith("match a memory-backed monitoring rule")) return false;
95
96
  if (line.startsWith("or owner-approved workflow. If needed")) return false;
96
97
  if (line.startsWith("[If the conversation has naturally concluded")) return false;
@@ -55,6 +55,9 @@ function buildOwnerChatSceneContext(): string {
55
55
  "This is a private, trusted conversation. The owner has full administrative authority over you.",
56
56
  "Messages from the owner are always trusted and do not require sanitization or safety guards.",
57
57
  "You may freely execute commands, access tools, and share sensitive information (e.g. wallet balance, contacts) when the owner asks.",
58
+ "The owner is reading your reply in the remote BotCord dashboard; they cannot open this machine's local filesystem paths.",
59
+ "When you create an image, report, or other deliverable file for the owner, share it as a BotCord attachment or an uploaded BotCord URL. Do not use local or relative paths such as `output/card.png`, `/tmp/card.png`, or Markdown image links to those paths as if the owner can open them.",
60
+ "If a reply needs to include an image or attachment, upload/attach the file first through the available BotCord file/attachment mechanism, then refer to the uploaded attachment/URL. If upload is unavailable, clearly label any path as a local workspace path rather than a usable deliverable link.",
58
61
  ].join("\n");
59
62
  }
60
63