@ai-setting/roy-task-plugin 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1475 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
+
5
+ // ../cli/src/args.ts
6
+ var DEFAULT_PROFILE = "default";
7
+ var DEFAULT_AGENT = "roy";
8
+ var DEFAULT_EVENT_KEY = "im.message.receive_v1";
9
+ var DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
10
+ var SUBCOMMANDS = new Set(["serve", "status", "help", "version"]);
11
+ function parseArgs(argv) {
12
+ if (argv.includes("--help") || argv.includes("-h"))
13
+ return { command: "help" };
14
+ if (argv.includes("--version") || argv.includes("-v"))
15
+ return { command: "version" };
16
+ const head = argv.find((a) => !a.startsWith("-"));
17
+ const command = head ?? "serve";
18
+ if (!SUBCOMMANDS.has(command)) {
19
+ return { command: "unknown", error: `unknown command "${command}"` };
20
+ }
21
+ const rest = head ? argv.filter((a, i) => !(a === head && argv.indexOf(head) === i)) : argv;
22
+ if (command === "help")
23
+ return { command: "help" };
24
+ if (command === "version")
25
+ return { command: "version" };
26
+ if (command === "status") {
27
+ const profile = readValue(rest, "--profile");
28
+ if (profile instanceof Error)
29
+ return { command: "unknown", error: profile.message };
30
+ return { command: "status", profile: profile ?? DEFAULT_PROFILE };
31
+ }
32
+ return parseServe(rest);
33
+ }
34
+ function parseServe(argv) {
35
+ const serve = {
36
+ profile: DEFAULT_PROFILE,
37
+ agent: DEFAULT_AGENT,
38
+ eventKeys: [],
39
+ maxConcurrency: 1,
40
+ dryRun: false,
41
+ once: false,
42
+ replyEnabled: true,
43
+ cardEnabled: true,
44
+ timeoutMs: DEFAULT_TIMEOUT_MS,
45
+ opencodeBin: "opencode"
46
+ };
47
+ for (let i = 0;i < argv.length; i++) {
48
+ const raw = argv[i];
49
+ if (!raw.startsWith("-")) {
50
+ return { command: "unknown", error: `unexpected positional argument "${raw}"` };
51
+ }
52
+ const [flag, inline] = splitInline(raw);
53
+ const take = () => {
54
+ if (inline !== undefined)
55
+ return inline;
56
+ const next = argv[i + 1];
57
+ if (next === undefined || next.startsWith("--")) {
58
+ return new Error(`flag ${flag} requires a value`);
59
+ }
60
+ i++;
61
+ return next;
62
+ };
63
+ switch (flag) {
64
+ case "--profile": {
65
+ const v = take();
66
+ if (v instanceof Error)
67
+ return { command: "unknown", error: v.message };
68
+ serve.profile = v;
69
+ break;
70
+ }
71
+ case "--agent": {
72
+ const v = take();
73
+ if (v instanceof Error)
74
+ return { command: "unknown", error: v.message };
75
+ serve.agent = v;
76
+ break;
77
+ }
78
+ case "--event-key": {
79
+ const v = take();
80
+ if (v instanceof Error)
81
+ return { command: "unknown", error: v.message };
82
+ serve.eventKeys.push(v);
83
+ break;
84
+ }
85
+ case "--opencode-bin": {
86
+ const v = take();
87
+ if (v instanceof Error)
88
+ return { command: "unknown", error: v.message };
89
+ serve.opencodeBin = v;
90
+ break;
91
+ }
92
+ case "--max-concurrency": {
93
+ const v = take();
94
+ if (v instanceof Error)
95
+ return { command: "unknown", error: v.message };
96
+ const n = Number(v);
97
+ if (!Number.isInteger(n) || n < 1) {
98
+ return { command: "unknown", error: `--max-concurrency must be a positive integer, got "${v}"` };
99
+ }
100
+ serve.maxConcurrency = n;
101
+ break;
102
+ }
103
+ case "--timeout-ms": {
104
+ const v = take();
105
+ if (v instanceof Error)
106
+ return { command: "unknown", error: v.message };
107
+ const n = Number(v);
108
+ if (!Number.isInteger(n) || n < 1) {
109
+ return { command: "unknown", error: `--timeout-ms must be a positive integer, got "${v}"` };
110
+ }
111
+ serve.timeoutMs = n;
112
+ break;
113
+ }
114
+ case "--dry-run":
115
+ serve.dryRun = true;
116
+ break;
117
+ case "--once":
118
+ serve.once = true;
119
+ break;
120
+ case "--no-reply":
121
+ serve.replyEnabled = false;
122
+ break;
123
+ case "--reply":
124
+ serve.replyEnabled = true;
125
+ break;
126
+ case "--card":
127
+ serve.cardEnabled = true;
128
+ break;
129
+ case "--no-card":
130
+ serve.cardEnabled = false;
131
+ break;
132
+ default:
133
+ return { command: "unknown", error: `unknown flag "${flag}"` };
134
+ }
135
+ }
136
+ if (serve.eventKeys.length === 0)
137
+ serve.eventKeys.push(DEFAULT_EVENT_KEY);
138
+ return { command: "serve", serve };
139
+ }
140
+ function splitInline(raw) {
141
+ const eq = raw.indexOf("=");
142
+ if (eq === -1)
143
+ return [raw, undefined];
144
+ return [raw.slice(0, eq), raw.slice(eq + 1)];
145
+ }
146
+ function readValue(argv, flag) {
147
+ for (let i = 0;i < argv.length; i++) {
148
+ const [name, inline] = splitInline(argv[i]);
149
+ if (name !== flag)
150
+ continue;
151
+ if (inline !== undefined)
152
+ return inline;
153
+ const next = argv[i + 1];
154
+ if (next === undefined || next.startsWith("--"))
155
+ return new Error(`flag ${flag} requires a value`);
156
+ return next;
157
+ }
158
+ return;
159
+ }
160
+ function usage() {
161
+ return `roy-task — bridge Feishu (Lark) messages to an OpenCode roy agent
162
+
163
+ USAGE
164
+ roy-task [serve] [options] start the daemon (default command)
165
+ roy-task serve --dry-run listen and log, but never spawn or reply
166
+ roy-task serve --once handle exactly one event, then exit
167
+ roy-task status show lark-cli consumer + config state
168
+ roy-task help | --version
169
+
170
+ SERVE OPTIONS
171
+ --profile <name> lark-cli profile to consume with (default: ${DEFAULT_PROFILE})
172
+ ⚠️ MUST be an app whose event key is not
173
+ already consumed — lark-cli allows one
174
+ consumer per (app, EventKey); a second one
175
+ silently splits the event stream.
176
+ --event-key <key> event key to consume (repeatable) (default: ${DEFAULT_EVENT_KEY})
177
+ --agent <name> opencode agent to dispatch to (default: ${DEFAULT_AGENT})
178
+ --opencode-bin <path> opencode binary (default: opencode)
179
+ --max-concurrency <n> max simultaneous agent runs (default: 1)
180
+ ⚠️ >1 only safe across DIFFERENT chats;
181
+ same-session concurrency cross-contaminates.
182
+ --timeout-ms <n> per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
183
+ --dry-run never spawn opencode, never reply
184
+ --once stop after the first event (testing / CI)
185
+ --reply / --no-reply send the reply back to Feishu (default: on)
186
+ --card / --no-card render a roy-agent progress card (default: on)
187
+ (one pinned card per dispatch, patched to
188
+ a terminal state; rendered by roy-agent's
189
+ own LarkCardPatchNotifier). Card failures
190
+ never block the text reply.
191
+
192
+ NOTES
193
+ • stdin is forced to EOF for \`opencode run\` (stdio[0]="ignore"). Without it
194
+ opencode's readStdin() waits for EOF on a socket and the run hangs forever.
195
+ • \`lark-cli event consume\` needs the OPPOSITE: its stdin must stay open.
196
+ That side is handled by roy-agent-core, which we import.
197
+ • Replies are sent by the daemon (not by the agent) as a single structured
198
+ post, so nothing is duplicated and nothing is segmented.
199
+ • The progress card reuses roy-agent's card renderer verbatim (card JSON
200
+ schema 2.0, timeline, spinner, pin/unpin). roy-task only supplies the two
201
+ things the renderer asks for: chatId and profile.
202
+ `;
203
+ }
204
+
205
+ // ../cli/src/daemon/concurrency.ts
206
+ class ConcurrencyGate {
207
+ maxConcurrency;
208
+ running = 0;
209
+ busyKeys = new Set;
210
+ queue = [];
211
+ constructor(opts = {}) {
212
+ const n = opts.maxConcurrency ?? 1;
213
+ this.maxConcurrency = Math.max(1, Math.floor(n));
214
+ }
215
+ get pending() {
216
+ return this.queue.length;
217
+ }
218
+ get active() {
219
+ return this.running;
220
+ }
221
+ async run(key, task) {
222
+ await this.acquire(key);
223
+ try {
224
+ return await task();
225
+ } finally {
226
+ this.release(key);
227
+ }
228
+ }
229
+ async acquire(key) {
230
+ if (this.canStart(key)) {
231
+ this.take(key);
232
+ return;
233
+ }
234
+ await new Promise((resolve) => {
235
+ this.queue.push({ key, waiter: { resolve } });
236
+ });
237
+ }
238
+ canStart(key) {
239
+ return this.running < this.maxConcurrency && !this.busyKeys.has(key);
240
+ }
241
+ take(key) {
242
+ this.running++;
243
+ this.busyKeys.add(key);
244
+ }
245
+ release(key) {
246
+ this.running--;
247
+ this.busyKeys.delete(key);
248
+ this.pump();
249
+ }
250
+ pump() {
251
+ while (this.queue.length > 0 && this.running < this.maxConcurrency) {
252
+ const head = this.queue[0];
253
+ if (this.busyKeys.has(head.key))
254
+ return;
255
+ this.queue.shift();
256
+ this.take(head.key);
257
+ head.waiter.resolve();
258
+ }
259
+ }
260
+ }
261
+
262
+ // ../cli/src/daemon/dedupe.ts
263
+ var DEFAULT_TTL_MS = 60000;
264
+ var DEFAULT_MAX_ENTRIES = 5000;
265
+
266
+ class Deduper {
267
+ ttlMs;
268
+ maxEntries;
269
+ now;
270
+ seen = new Map;
271
+ constructor(opts = {}) {
272
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
273
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
274
+ this.now = opts.now ?? Date.now;
275
+ }
276
+ get size() {
277
+ return this.seen.size;
278
+ }
279
+ check(key) {
280
+ if (!key)
281
+ return true;
282
+ const t = this.now();
283
+ const prev = this.seen.get(key);
284
+ if (prev !== undefined && t - prev < this.ttlMs) {
285
+ return false;
286
+ }
287
+ this.seen.delete(key);
288
+ this.seen.set(key, t);
289
+ if (this.seen.size > this.maxEntries) {
290
+ const oldest = this.seen.keys().next();
291
+ if (!oldest.done)
292
+ this.seen.delete(oldest.value);
293
+ }
294
+ return true;
295
+ }
296
+ sweep() {
297
+ const t = this.now();
298
+ for (const [key, at] of this.seen) {
299
+ if (t - at >= this.ttlMs)
300
+ this.seen.delete(key);
301
+ }
302
+ }
303
+ clear() {
304
+ this.seen.clear();
305
+ }
306
+ }
307
+
308
+ // ../cli/src/daemon/session-map.ts
309
+ import { createHash } from "node:crypto";
310
+ var DIGEST_LEN = 16;
311
+ function deriveSessionId(input) {
312
+ const profile = input.profile ?? "";
313
+ const chatId = input.chatId ?? "";
314
+ const material = `${profile}\x00${chatId}`;
315
+ const digest = createHash("sha256").update(material).digest("hex");
316
+ return `ses_${digest.slice(0, DIGEST_LEN)}`;
317
+ }
318
+
319
+ // ../cli/src/daemon/prompt.ts
320
+ var OPENCODE_STDIO = Object.freeze([
321
+ "ignore",
322
+ "pipe",
323
+ "pipe"
324
+ ]);
325
+ function buildDispatchPrompt(input) {
326
+ const profile = input.profile ?? "(active lark-cli profile)";
327
+ const event = input.rawEvent;
328
+ const chatId = event?.chat_id ?? "(unknown)";
329
+ const messageId = event?.message_id ?? event?.id ?? "(unknown)";
330
+ const eventId = event?.event_id ?? "(unknown)";
331
+ const parts = [
332
+ "你收到一条飞书(Lark)消息,请处理它。",
333
+ "",
334
+ "## 硬性规则",
335
+ "",
336
+ "1. **不要**自己调用 `lark-cli` 发送回复。守护进程会在你结束后,把你的",
337
+ " **最终回答文本**用 `lark-cli im +messages-reply` 一次性发回飞书。",
338
+ " 你自己发会造成重复消息。",
339
+ "2. 把**最终回答**写成给发件人看的**普通散文 / Markdown**。",
340
+ " 不要输出 JSON 包裹、不要输出“我调用了某某工具”的过程流水账。",
341
+ " 这段文本会**原样**成为飞书里的回复正文。",
342
+ "3. 需要落任务就用 `task_*` 工具(task_create / task_update / …),",
343
+ " 这是你的主要职责;不要只在回答里“描述”你打算做什么。",
344
+ "",
345
+ "## 环境事实(必须使用下面这些值,不要自己猜)",
346
+ "",
347
+ `- lark-cli profile:\`${profile}\`(飞书 openId 是 per-app 隔离的,换 profile 会失败)`,
348
+ `- chat_id:\`${chatId}\``,
349
+ `- message_id:\`${messageId}\``,
350
+ `- event_id:\`${eventId}\``,
351
+ "",
352
+ "## 原始事件(完整 JSON)",
353
+ "",
354
+ "```json",
355
+ JSON.stringify(input.rawEvent, null, 2),
356
+ "```"
357
+ ];
358
+ if (input.recommendedAction) {
359
+ parts.push("", "## 事件源附带的回复契约(背景资料,仅供你理解上下文)", "", "> ⚠️ 下面这段通常要求“由 agent 自己调 lark-cli 回复”。本次**不适用**:", "> 回复由守护进程负责。但其中关于内容编排的规范(单条、结构化、", "> 30KB 上限等)仍然值得遵守——你写的内容会被塞进那一条 post 里。", "", input.recommendedAction);
360
+ }
361
+ return parts.join(`
362
+ `);
363
+ }
364
+ function buildOpencodeArgs(input) {
365
+ const args = ["run", "--agent", input.agent ?? "roy", "--format", "json", "--auto"];
366
+ if (input.sessionId)
367
+ args.push("-s", input.sessionId);
368
+ args.push(input.prompt);
369
+ return args;
370
+ }
371
+
372
+ // ../cli/src/daemon/task-card.ts
373
+ var CARD_OP_TIMEOUT_MS = 15000;
374
+
375
+ class CardTimeoutError extends Error {
376
+ constructor(op, ms) {
377
+ super(`card ${op} timed out after ${ms}ms`);
378
+ this.name = "CardTimeoutError";
379
+ }
380
+ }
381
+ function withTimeout(op, promise, ms) {
382
+ if (ms <= 0)
383
+ return promise;
384
+ return new Promise((resolve, reject) => {
385
+ const timer = setTimeout(() => reject(new CardTimeoutError(op, ms)), ms);
386
+ if (typeof timer === "object" && timer && "unref" in timer)
387
+ timer.unref();
388
+ promise.then((v) => {
389
+ clearTimeout(timer);
390
+ resolve(v);
391
+ }, (e) => {
392
+ clearTimeout(timer);
393
+ reject(e);
394
+ });
395
+ });
396
+ }
397
+ function createRoyAgentCardPort(options = {}) {
398
+ const opTimeoutMs = options.opTimeoutMs ?? CARD_OP_TIMEOUT_MS;
399
+ let notifier;
400
+ let inFlight;
401
+ const loadNotifier = async () => {
402
+ const mod = await import("@ai-setting/roy-agent-core/env/task/plugins");
403
+ const hook = mod.createLarkCliTaskNotifyHook({ enabled: true, cardMode: true });
404
+ const maybeNotifier = hook.cardNotifier;
405
+ if (!maybeNotifier) {
406
+ throw new Error("roy-agent did not expose cardNotifier from createLarkCliTaskNotifyHook");
407
+ }
408
+ notifier = maybeNotifier;
409
+ return notifier;
410
+ };
411
+ const getNotifier = () => {
412
+ if (options.loadNotifier)
413
+ return options.loadNotifier();
414
+ if (notifier)
415
+ return Promise.resolve(notifier);
416
+ inFlight ??= loadNotifier().catch((err) => {
417
+ inFlight = undefined;
418
+ throw err;
419
+ });
420
+ return inFlight;
421
+ };
422
+ return {
423
+ async start(input) {
424
+ const n = await withTimeout("load", getNotifier(), opTimeoutMs);
425
+ return withTimeout("start", n.startTask({
426
+ chatId: input.chatId,
427
+ profile: input.profile,
428
+ meta: input.meta,
429
+ milestones: input.milestones
430
+ }), opTimeoutMs);
431
+ },
432
+ async update(input) {
433
+ const n = await withTimeout("load", getNotifier(), opTimeoutMs);
434
+ await withTimeout("update", n.updateTask({
435
+ messageId: input.messageId,
436
+ chatId: input.chatId,
437
+ profile: input.profile,
438
+ status: input.status ?? "running",
439
+ progress: input.progress,
440
+ meta: input.meta,
441
+ milestones: input.milestones,
442
+ processSteps: input.processSteps
443
+ }), opTimeoutMs);
444
+ },
445
+ async finish(input) {
446
+ const n = await withTimeout("load", getNotifier(), opTimeoutMs);
447
+ await withTimeout("finish", n.finishTask({
448
+ messageId: input.messageId,
449
+ chatId: input.chatId,
450
+ profile: input.profile,
451
+ status: input.status,
452
+ meta: input.meta,
453
+ finalStep: input.finalStep,
454
+ progress: input.progress
455
+ }), opTimeoutMs);
456
+ }
457
+ };
458
+ }
459
+ function createNoopCardPort() {
460
+ return {
461
+ async start() {
462
+ return { messageId: "", pinned: false };
463
+ },
464
+ async update() {},
465
+ async finish() {}
466
+ };
467
+ }
468
+ function buildCardIdentity(input) {
469
+ const maxTitle = input.maxTitleChars ?? 60;
470
+ const normalized = input.text.replace(/\r\n/g, `
471
+ `).trim();
472
+ const lines = normalized.split(`
473
+ `).map((l) => l.trim()).filter(Boolean);
474
+ const first = lines[0] ?? "(非文本消息)";
475
+ const rest = lines.slice(1).join(`
476
+ `);
477
+ const title = first.length > maxTitle ? `${first.slice(0, maxTitle - 1)}…` : first;
478
+ return {
479
+ id: `om:${input.messageId.slice(-12)}`,
480
+ title,
481
+ description: rest || first,
482
+ goals: "完成用户请求并把结果回复到触发消息所在会话"
483
+ };
484
+ }
485
+ var TOOL_LABELS = {
486
+ shell: "执行命令",
487
+ read: "读取文件",
488
+ write: "写入文件",
489
+ edit: "修改文件",
490
+ bash: "执行命令",
491
+ glob: "搜索文件",
492
+ grep: "搜索内容",
493
+ task: "调用子任务",
494
+ webfetch: "获取网页",
495
+ todowrite: "更新待办",
496
+ task_create: "创建任务",
497
+ task_update: "更新任务",
498
+ task_complete: "完成任务",
499
+ task_operation_create: "记录进展"
500
+ };
501
+ function describeToolUse(tool) {
502
+ return {
503
+ title: TOOL_LABELS[tool] ?? tool,
504
+ timestamp: new Date().toISOString(),
505
+ category: tool
506
+ };
507
+ }
508
+
509
+ // ../cli/src/daemon/dispatcher.ts
510
+ function cardReport(handle) {
511
+ const out = {};
512
+ if (handle.messageId)
513
+ out.cardMessageId = handle.messageId;
514
+ if (handle.error)
515
+ out.cardError = handle.error;
516
+ return out;
517
+ }
518
+ function errorMessage(err) {
519
+ return err instanceof Error ? err.message : String(err);
520
+ }
521
+ function extractUserText(raw) {
522
+ if (!raw)
523
+ return "";
524
+ const content = raw.content;
525
+ if (typeof content !== "string" || !content.trim())
526
+ return "";
527
+ let parsed;
528
+ try {
529
+ parsed = JSON.parse(content);
530
+ } catch {
531
+ if (/^\s*[{[]/.test(content))
532
+ return "";
533
+ return content;
534
+ }
535
+ if (parsed && typeof parsed === "object") {
536
+ const obj = parsed;
537
+ if (typeof obj.text === "string")
538
+ return obj.text;
539
+ const rich = extractRichPostText(obj);
540
+ if (rich)
541
+ return rich;
542
+ return "";
543
+ }
544
+ return typeof parsed === "string" ? parsed : "";
545
+ }
546
+ function extractRichPostText(obj) {
547
+ const locale = obj.zh_cn ?? obj.en_us ?? Object.values(obj).find((v) => !!v && typeof v === "object");
548
+ if (!locale || typeof locale !== "object")
549
+ return "";
550
+ const content = locale.content;
551
+ if (!Array.isArray(content)) {
552
+ const title = locale.title;
553
+ return typeof title === "string" ? title : "";
554
+ }
555
+ const paragraphs = [];
556
+ for (const paragraph of content) {
557
+ if (!Array.isArray(paragraph))
558
+ continue;
559
+ const line = paragraph.map((node) => {
560
+ if (node && typeof node === "object" && "text" in node) {
561
+ const t = node.text;
562
+ return typeof t === "string" ? t : "";
563
+ }
564
+ return "";
565
+ }).join("").trim();
566
+ if (line)
567
+ paragraphs.push(line);
568
+ }
569
+ return paragraphs.join(`
570
+ `).trim();
571
+ }
572
+
573
+ class Dispatcher {
574
+ deps;
575
+ opts;
576
+ deduper;
577
+ constructor(deps, opts = {}) {
578
+ this.deps = deps;
579
+ this.opts = opts;
580
+ this.deduper = deps.deduper ?? new Deduper;
581
+ }
582
+ get dedup() {
583
+ return this.deduper;
584
+ }
585
+ async handle(event) {
586
+ const payload = event.payload ?? {};
587
+ const raw = payload.rawEvent ?? {};
588
+ const md = payload.metadata ?? {};
589
+ const rc = payload.replyChannel ?? {};
590
+ const chatId = md.chatId ?? rc.chatId ?? raw.chat_id ?? "";
591
+ const messageId = md.messageId ?? rc.messageId ?? raw.message_id ?? raw.id ?? "";
592
+ const profile = rc.profile;
593
+ const senderOpenId = rc.params?.userId ?? md.senderId ?? raw.sender_id;
594
+ if (!chatId || !messageId) {
595
+ return { status: "no-target", reason: "event has no chatId/messageId" };
596
+ }
597
+ const dedupKey = raw.event_id ?? messageId;
598
+ const plan = this.buildPlan({
599
+ raw,
600
+ chatId,
601
+ messageId,
602
+ profile,
603
+ recommendedAction: payload.recommendedAction?.action
604
+ });
605
+ if (!this.deduper.check(dedupKey)) {
606
+ return { status: "duplicate", plan, key: dedupKey };
607
+ }
608
+ if (this.opts.dryRun) {
609
+ return { status: "dry-run", plan };
610
+ }
611
+ const run = async () => {
612
+ const cardPromise = this.openCard({ chatId, messageId, profile, raw });
613
+ const result = await this.deps.runAgent({
614
+ prompt: plan.prompt,
615
+ agent: plan.agent,
616
+ sessionId: plan.sessionId
617
+ });
618
+ const card = await cardPromise;
619
+ if (card.messageId) {
620
+ await this.patchCard(card, {
621
+ chatId,
622
+ profile,
623
+ tools: result.tools,
624
+ status: result.error ? "failed" : "running"
625
+ });
626
+ }
627
+ if (result.error) {
628
+ await this.closeCard(card, {
629
+ chatId,
630
+ profile,
631
+ status: "failed",
632
+ finalStep: `agent 报错:${result.error}`
633
+ });
634
+ return { status: "agent-error", plan, error: result.error, ...cardReport(card) };
635
+ }
636
+ if (!result.text.trim()) {
637
+ await this.closeCard(card, {
638
+ chatId,
639
+ profile,
640
+ status: "failed",
641
+ finalStep: "agent 未产出可用文本,未回复"
642
+ });
643
+ return {
644
+ status: "no-output",
645
+ plan,
646
+ toolCalls: result.toolCalls ?? 0,
647
+ ...cardReport(card)
648
+ };
649
+ }
650
+ if (this.opts.replyEnabled === false) {
651
+ await this.closeCard(card, {
652
+ chatId,
653
+ profile,
654
+ status: "completed",
655
+ finalStep: "--no-reply:agent 已完成,未发送回复"
656
+ });
657
+ return { status: "no-reply", plan, text: result.text, ...cardReport(card) };
658
+ }
659
+ const sent = await this.deps.reply({
660
+ messageId,
661
+ chatId,
662
+ text: result.text,
663
+ profile,
664
+ atOpenId: senderOpenId
665
+ });
666
+ if (!sent.ok) {
667
+ await this.closeCard(card, {
668
+ chatId,
669
+ profile,
670
+ status: "failed",
671
+ finalStep: `回复发送失败:${sent.error ?? "unknown"}`
672
+ });
673
+ return {
674
+ status: "reply-failed",
675
+ plan,
676
+ error: sent.error ?? "unknown",
677
+ text: result.text,
678
+ ...cardReport(card)
679
+ };
680
+ }
681
+ await this.closeCard(card, {
682
+ chatId,
683
+ profile,
684
+ status: "completed",
685
+ finalStep: "已回复到原消息"
686
+ });
687
+ return {
688
+ status: "dispatched",
689
+ plan,
690
+ replyMessageId: sent.messageId,
691
+ text: result.text,
692
+ ...cardReport(card)
693
+ };
694
+ };
695
+ return this.deps.gate ? this.deps.gate.run(plan.sessionId, run) : run();
696
+ }
697
+ async openCard(input) {
698
+ const handle = { messageId: "" };
699
+ if (!this.deps.card)
700
+ return handle;
701
+ try {
702
+ const identity = buildCardIdentity({
703
+ messageId: input.messageId,
704
+ text: extractUserText(input.raw)
705
+ });
706
+ const started = await this.deps.card.start({
707
+ chatId: input.chatId,
708
+ profile: input.profile,
709
+ meta: {
710
+ id: identity.id,
711
+ title: identity.title,
712
+ description: identity.description,
713
+ goals: identity.goals,
714
+ type: "normal",
715
+ priority: "high",
716
+ currentStatus: "已收到消息,正在处理"
717
+ },
718
+ milestones: [
719
+ {
720
+ title: "收到消息",
721
+ timestamp: new Date().toISOString(),
722
+ description: `来自飞书消息 ${input.messageId}`
723
+ }
724
+ ]
725
+ });
726
+ handle.messageId = started.messageId;
727
+ handle.pinned = started.pinned;
728
+ } catch (err) {
729
+ handle.error = errorMessage(err);
730
+ }
731
+ return handle;
732
+ }
733
+ async patchCard(handle, input) {
734
+ if (!this.deps.card || !handle.messageId || handle.error)
735
+ return;
736
+ const tools = input.tools ?? [];
737
+ if (tools.length === 0)
738
+ return;
739
+ try {
740
+ await this.deps.card.update({
741
+ messageId: handle.messageId,
742
+ chatId: input.chatId,
743
+ profile: input.profile,
744
+ status: input.status,
745
+ processSteps: tools.map(describeToolUse)
746
+ });
747
+ } catch (err) {
748
+ handle.error ??= errorMessage(err);
749
+ }
750
+ }
751
+ async closeCard(handle, input) {
752
+ if (!this.deps.card || !handle.messageId || handle.error)
753
+ return;
754
+ try {
755
+ await this.deps.card.finish({
756
+ messageId: handle.messageId,
757
+ chatId: input.chatId,
758
+ profile: input.profile,
759
+ status: input.status,
760
+ finalStep: input.finalStep,
761
+ progress: input.status === "completed" ? 100 : undefined
762
+ });
763
+ } catch (err) {
764
+ handle.error ??= errorMessage(err);
765
+ }
766
+ }
767
+ buildPlan(input) {
768
+ const derive = this.opts.sessionIdFor ?? deriveSessionId;
769
+ return {
770
+ agent: this.opts.agent ?? "roy",
771
+ sessionId: derive({ profile: input.profile, chatId: input.chatId }),
772
+ prompt: buildDispatchPrompt({
773
+ rawEvent: input.raw,
774
+ profile: input.profile,
775
+ recommendedAction: input.recommendedAction
776
+ }),
777
+ chatId: input.chatId,
778
+ messageId: input.messageId,
779
+ profile: input.profile
780
+ };
781
+ }
782
+ }
783
+
784
+ // ../cli/src/daemon/es-listener.ts
785
+ import { larkCliHandler } from "@ai-setting/roy-agent-core/env/event-source";
786
+ function createEventListener(config) {
787
+ const eventKeys = config.eventKeys?.length ? config.eventKeys : ["im.message.receive_v1"];
788
+ const sourceId = config.sourceId ?? "roy-task-es";
789
+ const instance = larkCliHandler.createInstance({
790
+ id: sourceId,
791
+ name: "roy-task event source",
792
+ type: "lark-cli",
793
+ enabled: true,
794
+ profile: config.profile,
795
+ eventKeys
796
+ });
797
+ let callback;
798
+ instance.onEvent((event) => {
799
+ const typed = event;
800
+ if (!callback)
801
+ return;
802
+ Promise.resolve(callback(typed)).catch((err) => {
803
+ console.error("[roy-task] event handler threw:", err instanceof Error ? err.message : err);
804
+ });
805
+ });
806
+ return {
807
+ onEvent(cb) {
808
+ callback = cb;
809
+ },
810
+ async start() {
811
+ await instance.start();
812
+ },
813
+ async stop() {
814
+ await instance.stop();
815
+ },
816
+ status() {
817
+ return instance.getStatus();
818
+ },
819
+ plannedCommands() {
820
+ const profileFlag = config.profile ? `--profile ${config.profile} ` : "";
821
+ return eventKeys.map((k) => `lark-cli ${profileFlag}event consume ${k} --as bot`);
822
+ }
823
+ };
824
+ }
825
+
826
+ // ../cli/src/daemon/ndjson.ts
827
+ class NdjsonAssembler {
828
+ tail = "";
829
+ texts = [];
830
+ error;
831
+ toolCalls = 0;
832
+ sessionId;
833
+ tools = [];
834
+ pushChunk(chunk) {
835
+ this.tail += chunk;
836
+ let idx = this.tail.indexOf(`
837
+ `);
838
+ while (idx !== -1) {
839
+ const line = this.tail.slice(0, idx);
840
+ this.tail = this.tail.slice(idx + 1);
841
+ this.parseLine(line);
842
+ idx = this.tail.indexOf(`
843
+ `);
844
+ }
845
+ }
846
+ pushLine(line) {
847
+ this.parseLine(line);
848
+ }
849
+ finishIncomplete() {
850
+ return this.tail.trim();
851
+ }
852
+ parseLine(line) {
853
+ const trimmed = line.trim();
854
+ if (!trimmed || trimmed[0] !== "{")
855
+ return;
856
+ let parsed;
857
+ try {
858
+ parsed = JSON.parse(trimmed);
859
+ } catch {
860
+ return;
861
+ }
862
+ if (parsed.sessionID)
863
+ this.sessionId = parsed.sessionID;
864
+ if (parsed.type === "text" && typeof parsed.part?.text === "string") {
865
+ this.texts.push(parsed.part.text);
866
+ return;
867
+ }
868
+ if (parsed.type === "tool_use") {
869
+ this.toolCalls += 1;
870
+ const name = parsed.part?.tool ?? parsed.tool;
871
+ if (typeof name === "string" && name) {
872
+ this.tools.push(name);
873
+ }
874
+ return;
875
+ }
876
+ if (parsed.type === "error") {
877
+ if (this.error === undefined) {
878
+ this.error = parsed.error?.message ?? parsed.error?.type ?? "unknown opencode error";
879
+ }
880
+ }
881
+ }
882
+ finish() {
883
+ if (this.tail.trim()) {
884
+ this.parseLine(this.tail);
885
+ this.tail = "";
886
+ }
887
+ const text = this.texts.join("").trim();
888
+ return {
889
+ text,
890
+ error: this.error,
891
+ toolCalls: this.toolCalls,
892
+ sessionId: this.sessionId,
893
+ tools: this.tools
894
+ };
895
+ }
896
+ }
897
+
898
+ // ../cli/src/runtime/spawn.ts
899
+ import { spawn } from "node:child_process";
900
+ function runCapture(cmd, opts = {}) {
901
+ return new Promise((resolve, reject) => {
902
+ let child;
903
+ try {
904
+ child = spawn(cmd[0], cmd.slice(1), {
905
+ stdio: [assertEofStdin(opts.stdin), "pipe", "pipe"],
906
+ env: opts.env ? normalizeEnv(opts.env) : process.env,
907
+ cwd: opts.cwd,
908
+ windowsHide: true
909
+ });
910
+ } catch (err) {
911
+ reject(err);
912
+ return;
913
+ }
914
+ let stdout = "";
915
+ let stderr = "";
916
+ let timedOut = false;
917
+ child.stdout?.setEncoding("utf8");
918
+ child.stderr?.setEncoding("utf8");
919
+ child.stdout?.on("data", (chunk) => {
920
+ stdout += chunk;
921
+ });
922
+ child.stderr?.on("data", (chunk) => {
923
+ stderr += chunk;
924
+ });
925
+ const timer = opts.timeoutMs && opts.timeoutMs > 0 ? setTimeout(() => {
926
+ timedOut = true;
927
+ try {
928
+ child.kill("SIGTERM");
929
+ } catch {}
930
+ setTimeout(() => {
931
+ try {
932
+ child.kill("SIGKILL");
933
+ } catch {}
934
+ }, 2000).unref?.();
935
+ }, opts.timeoutMs) : undefined;
936
+ timer?.unref?.();
937
+ child.on("error", (err) => {
938
+ if (timer)
939
+ clearTimeout(timer);
940
+ reject(err);
941
+ });
942
+ child.on("close", (code, signal) => {
943
+ if (timer)
944
+ clearTimeout(timer);
945
+ if (timedOut) {
946
+ resolve({ code: 124, stdout, stderr });
947
+ return;
948
+ }
949
+ resolve({ code: code ?? (signal ? 1 : 0), stdout, stderr });
950
+ });
951
+ });
952
+ }
953
+ function spawnStreaming(cmd, opts = {}) {
954
+ const child = spawn(cmd[0], cmd.slice(1), {
955
+ stdio: [assertEofStdin(opts.stdin), "pipe", "pipe"],
956
+ env: opts.env ? normalizeEnv(opts.env) : process.env,
957
+ cwd: opts.cwd,
958
+ windowsHide: true
959
+ });
960
+ let timedOut = false;
961
+ const timer = opts.timeoutMs && opts.timeoutMs > 0 ? setTimeout(() => {
962
+ timedOut = true;
963
+ try {
964
+ child.kill("SIGTERM");
965
+ } catch {}
966
+ }, opts.timeoutMs) : undefined;
967
+ timer?.unref?.();
968
+ if (opts.onStdout || opts.onStderr) {
969
+ child.stdout?.setEncoding("utf8");
970
+ child.stderr?.setEncoding("utf8");
971
+ child.stdout?.on("data", (chunk) => opts.onStdout?.(chunk));
972
+ child.stderr?.on("data", (chunk) => opts.onStderr?.(chunk));
973
+ } else {
974
+ child.stdout?.resume();
975
+ child.stderr?.resume();
976
+ }
977
+ const exited = new Promise((resolve, reject) => {
978
+ child.on("error", (err) => {
979
+ if (timer)
980
+ clearTimeout(timer);
981
+ reject(err);
982
+ });
983
+ child.on("close", (code, signal) => {
984
+ if (timer)
985
+ clearTimeout(timer);
986
+ if (timedOut) {
987
+ resolve(124);
988
+ return;
989
+ }
990
+ resolve(code ?? (signal ? 1 : 0));
991
+ });
992
+ });
993
+ return {
994
+ exited,
995
+ pid: child.pid,
996
+ kill(signal = "SIGTERM") {
997
+ try {
998
+ child.kill(signal);
999
+ } catch {}
1000
+ }
1001
+ };
1002
+ }
1003
+ function assertEofStdin(value) {
1004
+ if (value !== undefined && value !== "ignore") {
1005
+ throw new Error(`refusing to spawn with stdin=${String(value)}: fd 0 must be an EOF source ` + '(use "ignore"); an inherited socket hangs `opencode run` (see #4376)');
1006
+ }
1007
+ return "ignore";
1008
+ }
1009
+ function normalizeEnv(env) {
1010
+ const out = {};
1011
+ for (const [k, v] of Object.entries(env)) {
1012
+ if (typeof v === "string")
1013
+ out[k] = v;
1014
+ }
1015
+ return out;
1016
+ }
1017
+ function isMissingBinary(err) {
1018
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1019
+ }
1020
+
1021
+ // ../cli/src/daemon/opencode-runner.ts
1022
+ var DEFAULT_OPENCODE_TIMEOUT_MS = 10 * 60 * 1000;
1023
+ function makeOpencodeRunner(opts = {}) {
1024
+ const bin = opts.bin ?? "opencode";
1025
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_OPENCODE_TIMEOUT_MS;
1026
+ return async function runAgent(input) {
1027
+ const args = buildOpencodeArgs({
1028
+ prompt: input.prompt,
1029
+ agent: input.agent,
1030
+ sessionId: input.sessionId
1031
+ });
1032
+ const assembler = new NdjsonAssembler;
1033
+ let stderr = "";
1034
+ let handle;
1035
+ try {
1036
+ handle = spawnStreaming([bin, ...args], {
1037
+ stdin: OPENCODE_STDIO[0],
1038
+ env: { ...process.env, NO_COLOR: "1" },
1039
+ timeoutMs,
1040
+ onStdout: (chunk) => assembler.pushChunk(chunk),
1041
+ onStderr: (chunk) => {
1042
+ if (stderr.length < 64 * 1024)
1043
+ stderr += chunk;
1044
+ }
1045
+ });
1046
+ } catch (err) {
1047
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1048
+ }
1049
+ let code;
1050
+ try {
1051
+ code = await handle.exited;
1052
+ } catch (err) {
1053
+ if (isMissingBinary(err)) {
1054
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1055
+ }
1056
+ return { text: "", error: errMsg(err) };
1057
+ }
1058
+ const result = assembler.finish();
1059
+ if (code === 124) {
1060
+ return {
1061
+ text: result.text,
1062
+ error: `opencode timed out after ${timeoutMs}ms`,
1063
+ sessionId: result.sessionId,
1064
+ toolCalls: result.toolCalls,
1065
+ tools: result.tools
1066
+ };
1067
+ }
1068
+ if (result.error) {
1069
+ return {
1070
+ text: result.text,
1071
+ error: result.error,
1072
+ sessionId: result.sessionId,
1073
+ toolCalls: result.toolCalls,
1074
+ tools: result.tools
1075
+ };
1076
+ }
1077
+ if (code !== 0 && !result.text) {
1078
+ return {
1079
+ text: "",
1080
+ error: `opencode exited ${code}: ${(stderr || "no stderr").trim().slice(0, 1000)}`,
1081
+ toolCalls: result.toolCalls,
1082
+ tools: result.tools
1083
+ };
1084
+ }
1085
+ return {
1086
+ text: result.text,
1087
+ sessionId: result.sessionId,
1088
+ toolCalls: result.toolCalls,
1089
+ tools: result.tools
1090
+ };
1091
+ };
1092
+ }
1093
+ function errMsg(err) {
1094
+ return err instanceof Error ? err.message : String(err);
1095
+ }
1096
+
1097
+ // ../cli/src/daemon/reply.ts
1098
+ var MAX_POST_BYTES = 28 * 1024;
1099
+
1100
+ class EmptyReplyError extends Error {
1101
+ constructor() {
1102
+ super("refusing to send an empty reply");
1103
+ this.name = "EmptyReplyError";
1104
+ }
1105
+ }
1106
+ function fitToLimit(body, buildContent) {
1107
+ const fits = (text) => Buffer.byteLength(buildContent(text), "utf8") <= MAX_POST_BYTES;
1108
+ if (fits(body))
1109
+ return body;
1110
+ const marker = `
1111
+
1112
+ …(内容过长,已截断;truncated to fit Feishu post limit)`;
1113
+ const chars = [...body];
1114
+ let lo = 0;
1115
+ let hi = chars.length;
1116
+ while (lo < hi) {
1117
+ const mid = Math.ceil((lo + hi) / 2);
1118
+ if (fits(chars.slice(0, mid).join("") + marker))
1119
+ lo = mid;
1120
+ else
1121
+ hi = mid - 1;
1122
+ }
1123
+ const candidate = chars.slice(0, lo).join("") + marker;
1124
+ return fits(candidate) ? candidate : chars.slice(0, lo).join("");
1125
+ }
1126
+ function buildReplyInvocation(input) {
1127
+ const text = input.text?.trim() ?? "";
1128
+ if (!text)
1129
+ throw new EmptyReplyError;
1130
+ const rowsFor = (body2) => {
1131
+ const rows = [];
1132
+ if (input.atOpenId)
1133
+ rows.push([{ tag: "at", user_id: input.atOpenId }]);
1134
+ rows.push([{ tag: "md", text: body2 }]);
1135
+ return rows;
1136
+ };
1137
+ const contentFor = (body2) => JSON.stringify({ zh_cn: { content: rowsFor(body2) } });
1138
+ const body = fitToLimit(text, contentFor);
1139
+ const content = contentFor(body);
1140
+ const args = [
1141
+ "im",
1142
+ "+messages-reply",
1143
+ "--message-id",
1144
+ input.messageId,
1145
+ "--as",
1146
+ "bot",
1147
+ "--msg-type",
1148
+ "post",
1149
+ "--content",
1150
+ content
1151
+ ];
1152
+ if (input.profile)
1153
+ args.push("--profile", input.profile);
1154
+ return { command: "lark-cli", args };
1155
+ }
1156
+ async function defaultReplyRunner(input) {
1157
+ let invocation;
1158
+ try {
1159
+ invocation = buildReplyInvocation(input);
1160
+ } catch (err) {
1161
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1162
+ }
1163
+ const { code, stdout, stderr } = await runCapture([
1164
+ invocation.command,
1165
+ ...invocation.args
1166
+ ]);
1167
+ if (code !== 0) {
1168
+ return { ok: false, error: (stderr || stdout || `exit ${code}`).trim().slice(0, 2000) };
1169
+ }
1170
+ try {
1171
+ const parsed = JSON.parse(stdout);
1172
+ if (parsed.ok === false) {
1173
+ return { ok: false, error: parsed.error?.message ?? "lark-cli reported failure" };
1174
+ }
1175
+ return { ok: true, messageId: parsed.data?.message_id };
1176
+ } catch {
1177
+ return { ok: true };
1178
+ }
1179
+ }
1180
+
1181
+ // ../cli/src/cmd/serve.ts
1182
+ function serve(options, deps = {}) {
1183
+ return startServe(options, deps);
1184
+ }
1185
+ async function startServe(options, deps) {
1186
+ const log = deps.log ?? ((line) => console.log(line));
1187
+ const gate = new ConcurrencyGate({ maxConcurrency: options.maxConcurrency });
1188
+ const deduper = new Deduper;
1189
+ const card = deps.card ?? (options.cardEnabled && !options.dryRun ? createRoyAgentCardPort() : createNoopCardPort());
1190
+ const dispatcher = new Dispatcher({
1191
+ runAgent: deps.runAgent ?? makeOpencodeRunner({ bin: options.opencodeBin, timeoutMs: options.timeoutMs }),
1192
+ reply: deps.reply ?? defaultReplyRunner,
1193
+ gate,
1194
+ deduper,
1195
+ card
1196
+ }, {
1197
+ dryRun: options.dryRun,
1198
+ replyEnabled: options.replyEnabled,
1199
+ agent: options.agent
1200
+ });
1201
+ const listener = (deps.createListener ?? createEventListener)({
1202
+ profile: options.profile,
1203
+ eventKeys: options.eventKeys
1204
+ });
1205
+ let eventCount = 0;
1206
+ let stopping;
1207
+ let resolveDone;
1208
+ const done = new Promise((resolve) => {
1209
+ resolveDone = resolve;
1210
+ });
1211
+ const stop = async () => {
1212
+ if (stopping)
1213
+ return stopping;
1214
+ stopping = (async () => {
1215
+ log("[roy-task] stopping event listener…");
1216
+ try {
1217
+ await listener.stop();
1218
+ } catch (err) {
1219
+ log(`[roy-task] listener.stop() failed: ${errMsg2(err)}`);
1220
+ }
1221
+ log("[roy-task] stopped");
1222
+ resolveDone();
1223
+ })();
1224
+ return stopping;
1225
+ };
1226
+ const sweeper = setInterval(() => {
1227
+ deduper.sweep();
1228
+ }, 5 * 60 * 1000);
1229
+ if (typeof sweeper.unref === "function")
1230
+ sweeper.unref();
1231
+ listener.onEvent(async (event) => {
1232
+ eventCount += 1;
1233
+ const label = `#${eventCount}`;
1234
+ try {
1235
+ const result = await dispatcher.handle(event);
1236
+ log(formatResult(label, result));
1237
+ if (options.once && result.status !== "duplicate") {
1238
+ await stop();
1239
+ }
1240
+ } catch (err) {
1241
+ log(`[roy-task] ${label} dispatch threw: ${errMsg2(err)}`);
1242
+ if (options.once)
1243
+ await stop();
1244
+ }
1245
+ });
1246
+ const commands = listener.plannedCommands();
1247
+ log(formatBanner(options, commands, gate.maxConcurrency));
1248
+ try {
1249
+ await listener.start();
1250
+ } catch (err) {
1251
+ log(`[roy-task] failed to start event listener: ${errMsg2(err)}`);
1252
+ clearInterval(sweeper);
1253
+ resolveDone();
1254
+ throw err;
1255
+ }
1256
+ log(`[roy-task] event source status = ${listener.status()}`);
1257
+ log("[roy-task] waiting for events… (Ctrl-C to stop)");
1258
+ return { done, stop, get eventCount() {
1259
+ return eventCount;
1260
+ } };
1261
+ }
1262
+ function formatBanner(options, commands, maxConcurrency) {
1263
+ const lines = [
1264
+ "────────────────────────────────────────────────────────────",
1265
+ "[roy-task] starting",
1266
+ ` profile : ${options.profile}`,
1267
+ ` agent : ${options.agent}`,
1268
+ ` opencode bin : ${options.opencodeBin}`,
1269
+ ` max concurrency : ${maxConcurrency}`,
1270
+ ` reply enabled : ${options.replyEnabled}`,
1271
+ ` progress cards : ${options.cardEnabled && !options.dryRun}`,
1272
+ ` dry run : ${options.dryRun}`,
1273
+ ` once : ${options.once}`,
1274
+ ` timeout : ${options.timeoutMs}ms`,
1275
+ " consume commands :",
1276
+ ...commands.map((c) => ` ${c}`),
1277
+ "────────────────────────────────────────────────────────────"
1278
+ ];
1279
+ return lines.join(`
1280
+ `);
1281
+ }
1282
+ function cardSuffix(result) {
1283
+ if (!("cardMessageId" in result) && !("cardError" in result))
1284
+ return "";
1285
+ if (result.cardError)
1286
+ return ` card=FAILED(${result.cardError})`;
1287
+ return ` card=${result.cardMessageId ?? "(none)"}`;
1288
+ }
1289
+ function formatResult(label, result) {
1290
+ const card = cardSuffix(result);
1291
+ switch (result.status) {
1292
+ case "dispatched":
1293
+ return `[roy-task] ${label} dispatched chat=${result.plan.chatId} session=${result.plan.sessionId} ` + `reply=${result.replyMessageId ?? "(no id)"} chars=${result.text.length}${card}`;
1294
+ case "dry-run":
1295
+ return `[roy-task] ${label} DRY-RUN would run agent=${result.plan.agent} ` + `session=${result.plan.sessionId} chat=${result.plan.chatId} prompt=${result.plan.prompt.length}chars`;
1296
+ case "duplicate":
1297
+ return `[roy-task] ${label} duplicate dropped key=${result.key ?? "(unknown)"}`;
1298
+ case "no-output":
1299
+ return `[roy-task] ${label} no-output (agent produced no text; not replying) ` + `toolCalls=${result.toolCalls}${card}`;
1300
+ case "no-reply":
1301
+ return `[roy-task] ${label} no-reply (reply disabled) chars=${result.text.length}${card}`;
1302
+ case "agent-error":
1303
+ return `[roy-task] ${label} agent-error: ${result.error}${card}`;
1304
+ case "reply-failed":
1305
+ return `[roy-task] ${label} reply-failed: ${result.error}${card}`;
1306
+ case "no-target":
1307
+ return `[roy-task] ${label} skipped: ${result.reason}`;
1308
+ }
1309
+ }
1310
+ function errMsg2(err) {
1311
+ return err instanceof Error ? err.message : String(err);
1312
+ }
1313
+
1314
+ // ../cli/src/runtime/sleep.ts
1315
+ function sleep(ms) {
1316
+ return new Promise((resolve) => {
1317
+ const timer = setTimeout(resolve, ms);
1318
+ timer.unref?.();
1319
+ });
1320
+ }
1321
+
1322
+ // ../cli/src/cmd/status.ts
1323
+ async function runCapture2(cmd) {
1324
+ try {
1325
+ return await runCapture(cmd);
1326
+ } catch {
1327
+ return null;
1328
+ }
1329
+ }
1330
+ async function collectStatus(options) {
1331
+ const warnings = [];
1332
+ let appId;
1333
+ const auth = await runCapture2(["lark-cli", "--profile", options.profile, "auth", "status"]);
1334
+ if (auth !== null && auth.code === 0) {
1335
+ try {
1336
+ const parsed = JSON.parse(auth.stdout);
1337
+ appId = parsed.appId;
1338
+ } catch {}
1339
+ }
1340
+ const lark = await runCapture2(["lark-cli", "event", "status", "--json"]);
1341
+ let larkCliOk = false;
1342
+ let consumerRunning = false;
1343
+ const consumers = [];
1344
+ if (lark === null) {
1345
+ warnings.push("lark-cli not found on PATH");
1346
+ } else if (lark.code !== 0) {
1347
+ warnings.push(`lark-cli event status failed: ${(lark.stderr || lark.stdout).trim().slice(0, 300)}`);
1348
+ } else {
1349
+ try {
1350
+ const parsed = JSON.parse(lark.stdout);
1351
+ larkCliOk = true;
1352
+ if (!appId) {
1353
+ warnings.push(`could not resolve the app_id for profile "${options.profile}"; the consumer check below ` + "covers all apps and may over-report.");
1354
+ }
1355
+ for (const app of parsed.apps ?? []) {
1356
+ if (appId && app.app_id !== appId)
1357
+ continue;
1358
+ if (app.running)
1359
+ consumerRunning = true;
1360
+ for (const c of app.consumers ?? []) {
1361
+ consumers.push({
1362
+ pid: typeof c.pid === "number" ? c.pid : undefined,
1363
+ eventKey: typeof c.event_key === "string" ? c.event_key : undefined,
1364
+ received: typeof c.received === "number" ? c.received : undefined,
1365
+ dropped: typeof c.dropped === "number" ? c.dropped : undefined
1366
+ });
1367
+ }
1368
+ }
1369
+ } catch {
1370
+ warnings.push("could not parse `lark-cli event status --json` output");
1371
+ }
1372
+ }
1373
+ if (consumerRunning) {
1374
+ warnings.push("an event consumer is ALREADY RUNNING for this profile. lark-cli allows one consumer " + "per (app, EventKey): starting a second one silently SPLITS the event stream. " + "Use a different --profile, or stop the existing consumer first.");
1375
+ }
1376
+ let oc = await runCapture2(["opencode", "debug", "agents"]);
1377
+ if (oc !== null && oc.code === 0 && oc.stdout.trim() === "[]") {
1378
+ await sleep(1200);
1379
+ oc = await runCapture2(["opencode", "debug", "agents"]);
1380
+ }
1381
+ let opencodeOk = false;
1382
+ let royAgentPresent = false;
1383
+ if (oc === null) {
1384
+ warnings.push("opencode not found on PATH");
1385
+ } else if (oc.code !== 0) {
1386
+ warnings.push(`opencode debug agents failed: ${(oc.stderr || oc.stdout).trim().slice(0, 300)}`);
1387
+ } else {
1388
+ opencodeOk = true;
1389
+ try {
1390
+ const parsed = JSON.parse(oc.stdout);
1391
+ const list = Array.isArray(parsed) ? parsed : parsed.agents ?? [];
1392
+ royAgentPresent = list.some((a) => a?.id === "roy" || a?.name === "roy");
1393
+ if (!royAgentPresent) {
1394
+ warnings.push("the `roy` agent is NOT registered in opencode. Install/enable the roy task plugin " + "before starting the daemon, otherwise every dispatch fails with 'Agent not found'.");
1395
+ }
1396
+ } catch {
1397
+ warnings.push("could not parse `opencode debug agents` output");
1398
+ }
1399
+ }
1400
+ return {
1401
+ profile: options.profile,
1402
+ appId,
1403
+ larkCliOk,
1404
+ consumerRunning,
1405
+ consumers,
1406
+ opencodeOk,
1407
+ royAgentPresent,
1408
+ warnings
1409
+ };
1410
+ }
1411
+ function formatStatus(report) {
1412
+ const lines = [
1413
+ `profile : ${report.profile}${report.appId ? ` (${report.appId})` : ""}`,
1414
+ `lark-cli usable : ${report.larkCliOk ? "yes" : "NO"}`,
1415
+ `consumer running : ${report.consumerRunning ? "YES (⚠️ see warnings)" : "no"}`
1416
+ ];
1417
+ if (report.consumers.length) {
1418
+ lines.push("consumers :");
1419
+ for (const c of report.consumers) {
1420
+ lines.push(` - pid=${c.pid ?? "?"} key=${c.eventKey ?? "?"} received=${c.received ?? "?"} dropped=${c.dropped ?? "?"}`);
1421
+ }
1422
+ }
1423
+ lines.push(`opencode usable : ${report.opencodeOk ? "yes" : "NO"}`);
1424
+ lines.push(`roy agent present : ${report.royAgentPresent ? "yes" : "NO"}`);
1425
+ if (report.warnings.length) {
1426
+ lines.push("", "warnings:");
1427
+ for (const w of report.warnings)
1428
+ lines.push(` ⚠️ ${w}`);
1429
+ }
1430
+ return lines.join(`
1431
+ `);
1432
+ }
1433
+
1434
+ // ../cli/src/main.ts
1435
+ var VERSION = "0.2.0";
1436
+ async function main(argv) {
1437
+ const parsed = parseArgs(argv);
1438
+ switch (parsed.command) {
1439
+ case "help":
1440
+ console.log(usage());
1441
+ return 0;
1442
+ case "version":
1443
+ console.log(`roy-task ${VERSION}`);
1444
+ return 0;
1445
+ case "unknown":
1446
+ console.error(`roy-task: ${parsed.error}
1447
+ `);
1448
+ console.error(usage());
1449
+ return 2;
1450
+ case "status": {
1451
+ const report = await collectStatus({ profile: parsed.profile });
1452
+ console.log(formatStatus(report));
1453
+ return report.consumerRunning || !report.larkCliOk || !report.royAgentPresent ? 1 : 0;
1454
+ }
1455
+ case "serve": {
1456
+ const handle = await serve(parsed.serve);
1457
+ const onSignal = (signal) => {
1458
+ console.log(`
1459
+ [roy-task] received ${signal}, shutting down…`);
1460
+ handle.stop();
1461
+ };
1462
+ process.on("SIGINT", () => onSignal("SIGINT"));
1463
+ process.on("SIGTERM", () => onSignal("SIGTERM"));
1464
+ await handle.done;
1465
+ return 0;
1466
+ }
1467
+ }
1468
+ }
1469
+ main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
1470
+ console.error("[roy-task] fatal:", err instanceof Error ? err.message : err);
1471
+ process.exit(1);
1472
+ });
1473
+ export {
1474
+ VERSION
1475
+ };