@ai-setting/roy-task-plugin 0.1.0 → 0.2.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,1105 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../cli/src/args.ts
4
+ var DEFAULT_PROFILE = "default";
5
+ var DEFAULT_AGENT = "roy";
6
+ var DEFAULT_EVENT_KEY = "im.message.receive_v1";
7
+ var DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
8
+ var SUBCOMMANDS = new Set(["serve", "status", "help", "version"]);
9
+ function parseArgs(argv) {
10
+ if (argv.includes("--help") || argv.includes("-h"))
11
+ return { command: "help" };
12
+ if (argv.includes("--version") || argv.includes("-v"))
13
+ return { command: "version" };
14
+ const head = argv.find((a) => !a.startsWith("-"));
15
+ const command = head ?? "serve";
16
+ if (!SUBCOMMANDS.has(command)) {
17
+ return { command: "unknown", error: `unknown command "${command}"` };
18
+ }
19
+ const rest = head ? argv.filter((a, i) => !(a === head && argv.indexOf(head) === i)) : argv;
20
+ if (command === "help")
21
+ return { command: "help" };
22
+ if (command === "version")
23
+ return { command: "version" };
24
+ if (command === "status") {
25
+ const profile = readValue(rest, "--profile");
26
+ if (profile instanceof Error)
27
+ return { command: "unknown", error: profile.message };
28
+ return { command: "status", profile: profile ?? DEFAULT_PROFILE };
29
+ }
30
+ return parseServe(rest);
31
+ }
32
+ function parseServe(argv) {
33
+ const serve = {
34
+ profile: DEFAULT_PROFILE,
35
+ agent: DEFAULT_AGENT,
36
+ eventKeys: [],
37
+ maxConcurrency: 1,
38
+ dryRun: false,
39
+ once: false,
40
+ replyEnabled: true,
41
+ timeoutMs: DEFAULT_TIMEOUT_MS,
42
+ opencodeBin: "opencode"
43
+ };
44
+ for (let i = 0;i < argv.length; i++) {
45
+ const raw = argv[i];
46
+ if (!raw.startsWith("-")) {
47
+ return { command: "unknown", error: `unexpected positional argument "${raw}"` };
48
+ }
49
+ const [flag, inline] = splitInline(raw);
50
+ const take = () => {
51
+ if (inline !== undefined)
52
+ return inline;
53
+ const next = argv[i + 1];
54
+ if (next === undefined || next.startsWith("--")) {
55
+ return new Error(`flag ${flag} requires a value`);
56
+ }
57
+ i++;
58
+ return next;
59
+ };
60
+ switch (flag) {
61
+ case "--profile": {
62
+ const v = take();
63
+ if (v instanceof Error)
64
+ return { command: "unknown", error: v.message };
65
+ serve.profile = v;
66
+ break;
67
+ }
68
+ case "--agent": {
69
+ const v = take();
70
+ if (v instanceof Error)
71
+ return { command: "unknown", error: v.message };
72
+ serve.agent = v;
73
+ break;
74
+ }
75
+ case "--event-key": {
76
+ const v = take();
77
+ if (v instanceof Error)
78
+ return { command: "unknown", error: v.message };
79
+ serve.eventKeys.push(v);
80
+ break;
81
+ }
82
+ case "--opencode-bin": {
83
+ const v = take();
84
+ if (v instanceof Error)
85
+ return { command: "unknown", error: v.message };
86
+ serve.opencodeBin = v;
87
+ break;
88
+ }
89
+ case "--max-concurrency": {
90
+ const v = take();
91
+ if (v instanceof Error)
92
+ return { command: "unknown", error: v.message };
93
+ const n = Number(v);
94
+ if (!Number.isInteger(n) || n < 1) {
95
+ return { command: "unknown", error: `--max-concurrency must be a positive integer, got "${v}"` };
96
+ }
97
+ serve.maxConcurrency = n;
98
+ break;
99
+ }
100
+ case "--timeout-ms": {
101
+ const v = take();
102
+ if (v instanceof Error)
103
+ return { command: "unknown", error: v.message };
104
+ const n = Number(v);
105
+ if (!Number.isInteger(n) || n < 1) {
106
+ return { command: "unknown", error: `--timeout-ms must be a positive integer, got "${v}"` };
107
+ }
108
+ serve.timeoutMs = n;
109
+ break;
110
+ }
111
+ case "--dry-run":
112
+ serve.dryRun = true;
113
+ break;
114
+ case "--once":
115
+ serve.once = true;
116
+ break;
117
+ case "--no-reply":
118
+ serve.replyEnabled = false;
119
+ break;
120
+ case "--reply":
121
+ serve.replyEnabled = true;
122
+ break;
123
+ default:
124
+ return { command: "unknown", error: `unknown flag "${flag}"` };
125
+ }
126
+ }
127
+ if (serve.eventKeys.length === 0)
128
+ serve.eventKeys.push(DEFAULT_EVENT_KEY);
129
+ return { command: "serve", serve };
130
+ }
131
+ function splitInline(raw) {
132
+ const eq = raw.indexOf("=");
133
+ if (eq === -1)
134
+ return [raw, undefined];
135
+ return [raw.slice(0, eq), raw.slice(eq + 1)];
136
+ }
137
+ function readValue(argv, flag) {
138
+ for (let i = 0;i < argv.length; i++) {
139
+ const [name, inline] = splitInline(argv[i]);
140
+ if (name !== flag)
141
+ continue;
142
+ if (inline !== undefined)
143
+ return inline;
144
+ const next = argv[i + 1];
145
+ if (next === undefined || next.startsWith("--"))
146
+ return new Error(`flag ${flag} requires a value`);
147
+ return next;
148
+ }
149
+ return;
150
+ }
151
+ function usage() {
152
+ return `roy-task — bridge Feishu (Lark) messages to an OpenCode roy agent
153
+
154
+ USAGE
155
+ roy-task [serve] [options] start the daemon (default command)
156
+ roy-task serve --dry-run listen and log, but never spawn or reply
157
+ roy-task serve --once handle exactly one event, then exit
158
+ roy-task status show lark-cli consumer + config state
159
+ roy-task help | --version
160
+
161
+ SERVE OPTIONS
162
+ --profile <name> lark-cli profile to consume with (default: ${DEFAULT_PROFILE})
163
+ ⚠️ MUST be an app whose event key is not
164
+ already consumed — lark-cli allows one
165
+ consumer per (app, EventKey); a second one
166
+ silently splits the event stream.
167
+ --event-key <key> event key to consume (repeatable) (default: ${DEFAULT_EVENT_KEY})
168
+ --agent <name> opencode agent to dispatch to (default: ${DEFAULT_AGENT})
169
+ --opencode-bin <path> opencode binary (default: opencode)
170
+ --max-concurrency <n> max simultaneous agent runs (default: 1)
171
+ ⚠️ >1 only safe across DIFFERENT chats;
172
+ same-session concurrency cross-contaminates.
173
+ --timeout-ms <n> per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
174
+ --dry-run never spawn opencode, never reply
175
+ --once stop after the first event (testing / CI)
176
+ --reply / --no-reply send the reply back to Feishu (default: on)
177
+
178
+ NOTES
179
+ • stdin is forced to EOF for \`opencode run\` (stdio[0]="ignore"). Without it
180
+ opencode's readStdin() waits for EOF on a socket and the run hangs forever.
181
+ • \`lark-cli event consume\` needs the OPPOSITE: its stdin must stay open.
182
+ That side is handled by roy-agent-core, which we import.
183
+ • Replies are sent by the daemon (not by the agent) as a single structured
184
+ post, so nothing is duplicated and nothing is segmented.
185
+ `;
186
+ }
187
+
188
+ // ../cli/src/daemon/concurrency.ts
189
+ class ConcurrencyGate {
190
+ maxConcurrency;
191
+ running = 0;
192
+ busyKeys = new Set;
193
+ queue = [];
194
+ constructor(opts = {}) {
195
+ const n = opts.maxConcurrency ?? 1;
196
+ this.maxConcurrency = Math.max(1, Math.floor(n));
197
+ }
198
+ get pending() {
199
+ return this.queue.length;
200
+ }
201
+ get active() {
202
+ return this.running;
203
+ }
204
+ async run(key, task) {
205
+ await this.acquire(key);
206
+ try {
207
+ return await task();
208
+ } finally {
209
+ this.release(key);
210
+ }
211
+ }
212
+ async acquire(key) {
213
+ if (this.canStart(key)) {
214
+ this.take(key);
215
+ return;
216
+ }
217
+ await new Promise((resolve) => {
218
+ this.queue.push({ key, waiter: { resolve } });
219
+ });
220
+ }
221
+ canStart(key) {
222
+ return this.running < this.maxConcurrency && !this.busyKeys.has(key);
223
+ }
224
+ take(key) {
225
+ this.running++;
226
+ this.busyKeys.add(key);
227
+ }
228
+ release(key) {
229
+ this.running--;
230
+ this.busyKeys.delete(key);
231
+ this.pump();
232
+ }
233
+ pump() {
234
+ while (this.queue.length > 0 && this.running < this.maxConcurrency) {
235
+ const head = this.queue[0];
236
+ if (this.busyKeys.has(head.key))
237
+ return;
238
+ this.queue.shift();
239
+ this.take(head.key);
240
+ head.waiter.resolve();
241
+ }
242
+ }
243
+ }
244
+
245
+ // ../cli/src/daemon/dedupe.ts
246
+ var DEFAULT_TTL_MS = 60000;
247
+ var DEFAULT_MAX_ENTRIES = 5000;
248
+
249
+ class Deduper {
250
+ ttlMs;
251
+ maxEntries;
252
+ now;
253
+ seen = new Map;
254
+ constructor(opts = {}) {
255
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
256
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
257
+ this.now = opts.now ?? Date.now;
258
+ }
259
+ get size() {
260
+ return this.seen.size;
261
+ }
262
+ check(key) {
263
+ if (!key)
264
+ return true;
265
+ const t = this.now();
266
+ const prev = this.seen.get(key);
267
+ if (prev !== undefined && t - prev < this.ttlMs) {
268
+ return false;
269
+ }
270
+ this.seen.delete(key);
271
+ this.seen.set(key, t);
272
+ if (this.seen.size > this.maxEntries) {
273
+ const oldest = this.seen.keys().next();
274
+ if (!oldest.done)
275
+ this.seen.delete(oldest.value);
276
+ }
277
+ return true;
278
+ }
279
+ sweep() {
280
+ const t = this.now();
281
+ for (const [key, at] of this.seen) {
282
+ if (t - at >= this.ttlMs)
283
+ this.seen.delete(key);
284
+ }
285
+ }
286
+ clear() {
287
+ this.seen.clear();
288
+ }
289
+ }
290
+
291
+ // ../cli/src/daemon/session-map.ts
292
+ import { createHash } from "node:crypto";
293
+ var DIGEST_LEN = 16;
294
+ function deriveSessionId(input) {
295
+ const profile = input.profile ?? "";
296
+ const chatId = input.chatId ?? "";
297
+ const material = `${profile}\x00${chatId}`;
298
+ const digest = createHash("sha256").update(material).digest("hex");
299
+ return `ses_${digest.slice(0, DIGEST_LEN)}`;
300
+ }
301
+
302
+ // ../cli/src/daemon/prompt.ts
303
+ var OPENCODE_STDIO = Object.freeze([
304
+ "ignore",
305
+ "pipe",
306
+ "pipe"
307
+ ]);
308
+ function buildDispatchPrompt(input) {
309
+ const profile = input.profile ?? "(active lark-cli profile)";
310
+ const event = input.rawEvent;
311
+ const chatId = event?.chat_id ?? "(unknown)";
312
+ const messageId = event?.message_id ?? event?.id ?? "(unknown)";
313
+ const eventId = event?.event_id ?? "(unknown)";
314
+ const parts = [
315
+ "你收到一条飞书(Lark)消息,请处理它。",
316
+ "",
317
+ "## 硬性规则",
318
+ "",
319
+ "1. **不要**自己调用 `lark-cli` 发送回复。守护进程会在你结束后,把你的",
320
+ " **最终回答文本**用 `lark-cli im +messages-reply` 一次性发回飞书。",
321
+ " 你自己发会造成重复消息。",
322
+ "2. 把**最终回答**写成给发件人看的**普通散文 / Markdown**。",
323
+ " 不要输出 JSON 包裹、不要输出“我调用了某某工具”的过程流水账。",
324
+ " 这段文本会**原样**成为飞书里的回复正文。",
325
+ "3. 需要落任务就用 `task_*` 工具(task_create / task_update / …),",
326
+ " 这是你的主要职责;不要只在回答里“描述”你打算做什么。",
327
+ "",
328
+ "## 环境事实(必须使用下面这些值,不要自己猜)",
329
+ "",
330
+ `- lark-cli profile:\`${profile}\`(飞书 openId 是 per-app 隔离的,换 profile 会失败)`,
331
+ `- chat_id:\`${chatId}\``,
332
+ `- message_id:\`${messageId}\``,
333
+ `- event_id:\`${eventId}\``,
334
+ "",
335
+ "## 原始事件(完整 JSON)",
336
+ "",
337
+ "```json",
338
+ JSON.stringify(input.rawEvent, null, 2),
339
+ "```"
340
+ ];
341
+ if (input.recommendedAction) {
342
+ parts.push("", "## 事件源附带的回复契约(背景资料,仅供你理解上下文)", "", "> ⚠️ 下面这段通常要求“由 agent 自己调 lark-cli 回复”。本次**不适用**:", "> 回复由守护进程负责。但其中关于内容编排的规范(单条、结构化、", "> 30KB 上限等)仍然值得遵守——你写的内容会被塞进那一条 post 里。", "", input.recommendedAction);
343
+ }
344
+ return parts.join(`
345
+ `);
346
+ }
347
+ function buildOpencodeArgs(input) {
348
+ const args = ["run", "--agent", input.agent ?? "roy", "--format", "json", "--auto"];
349
+ if (input.sessionId)
350
+ args.push("-s", input.sessionId);
351
+ args.push(input.prompt);
352
+ return args;
353
+ }
354
+
355
+ // ../cli/src/daemon/dispatcher.ts
356
+ class Dispatcher {
357
+ deps;
358
+ opts;
359
+ deduper;
360
+ constructor(deps, opts = {}) {
361
+ this.deps = deps;
362
+ this.opts = opts;
363
+ this.deduper = deps.deduper ?? new Deduper;
364
+ }
365
+ get dedup() {
366
+ return this.deduper;
367
+ }
368
+ async handle(event) {
369
+ const payload = event.payload ?? {};
370
+ const raw = payload.rawEvent ?? {};
371
+ const md = payload.metadata ?? {};
372
+ const rc = payload.replyChannel ?? {};
373
+ const chatId = md.chatId ?? rc.chatId ?? raw.chat_id ?? "";
374
+ const messageId = md.messageId ?? rc.messageId ?? raw.message_id ?? raw.id ?? "";
375
+ const profile = rc.profile;
376
+ const senderOpenId = rc.params?.userId ?? md.senderId ?? raw.sender_id;
377
+ if (!chatId || !messageId) {
378
+ return { status: "no-target", reason: "event has no chatId/messageId" };
379
+ }
380
+ const dedupKey = raw.event_id ?? messageId;
381
+ const plan = this.buildPlan({
382
+ raw,
383
+ chatId,
384
+ messageId,
385
+ profile,
386
+ recommendedAction: payload.recommendedAction?.action
387
+ });
388
+ if (!this.deduper.check(dedupKey)) {
389
+ return { status: "duplicate", plan, key: dedupKey };
390
+ }
391
+ if (this.opts.dryRun) {
392
+ return { status: "dry-run", plan };
393
+ }
394
+ const run = async () => {
395
+ const result = await this.deps.runAgent({
396
+ prompt: plan.prompt,
397
+ agent: plan.agent,
398
+ sessionId: plan.sessionId
399
+ });
400
+ if (result.error) {
401
+ return { status: "agent-error", plan, error: result.error };
402
+ }
403
+ if (!result.text.trim()) {
404
+ return { status: "no-output", plan, toolCalls: result.toolCalls ?? 0 };
405
+ }
406
+ if (this.opts.replyEnabled === false) {
407
+ return { status: "no-reply", plan, text: result.text };
408
+ }
409
+ const sent = await this.deps.reply({
410
+ messageId,
411
+ chatId,
412
+ text: result.text,
413
+ profile,
414
+ atOpenId: senderOpenId
415
+ });
416
+ if (!sent.ok) {
417
+ return { status: "reply-failed", plan, error: sent.error ?? "unknown", text: result.text };
418
+ }
419
+ return { status: "dispatched", plan, replyMessageId: sent.messageId, text: result.text };
420
+ };
421
+ return this.deps.gate ? this.deps.gate.run(plan.sessionId, run) : run();
422
+ }
423
+ buildPlan(input) {
424
+ const derive = this.opts.sessionIdFor ?? deriveSessionId;
425
+ return {
426
+ agent: this.opts.agent ?? "roy",
427
+ sessionId: derive({ profile: input.profile, chatId: input.chatId }),
428
+ prompt: buildDispatchPrompt({
429
+ rawEvent: input.raw,
430
+ profile: input.profile,
431
+ recommendedAction: input.recommendedAction
432
+ }),
433
+ chatId: input.chatId,
434
+ messageId: input.messageId,
435
+ profile: input.profile
436
+ };
437
+ }
438
+ }
439
+
440
+ // ../cli/src/daemon/es-listener.ts
441
+ import { larkCliHandler } from "@ai-setting/roy-agent-core/env/event-source";
442
+ function createEventListener(config) {
443
+ const eventKeys = config.eventKeys?.length ? config.eventKeys : ["im.message.receive_v1"];
444
+ const sourceId = config.sourceId ?? "roy-task-es";
445
+ const instance = larkCliHandler.createInstance({
446
+ id: sourceId,
447
+ name: "roy-task event source",
448
+ type: "lark-cli",
449
+ enabled: true,
450
+ profile: config.profile,
451
+ eventKeys
452
+ });
453
+ let callback;
454
+ instance.onEvent((event) => {
455
+ const typed = event;
456
+ if (!callback)
457
+ return;
458
+ Promise.resolve(callback(typed)).catch((err) => {
459
+ console.error("[roy-task] event handler threw:", err instanceof Error ? err.message : err);
460
+ });
461
+ });
462
+ return {
463
+ onEvent(cb) {
464
+ callback = cb;
465
+ },
466
+ async start() {
467
+ await instance.start();
468
+ },
469
+ async stop() {
470
+ await instance.stop();
471
+ },
472
+ status() {
473
+ return instance.getStatus();
474
+ },
475
+ plannedCommands() {
476
+ const profileFlag = config.profile ? `--profile ${config.profile} ` : "";
477
+ return eventKeys.map((k) => `lark-cli ${profileFlag}event consume ${k} --as bot`);
478
+ }
479
+ };
480
+ }
481
+
482
+ // ../cli/src/daemon/ndjson.ts
483
+ class NdjsonAssembler {
484
+ tail = "";
485
+ texts = [];
486
+ error;
487
+ toolCalls = 0;
488
+ sessionId;
489
+ pushChunk(chunk) {
490
+ this.tail += chunk;
491
+ let idx = this.tail.indexOf(`
492
+ `);
493
+ while (idx !== -1) {
494
+ const line = this.tail.slice(0, idx);
495
+ this.tail = this.tail.slice(idx + 1);
496
+ this.parseLine(line);
497
+ idx = this.tail.indexOf(`
498
+ `);
499
+ }
500
+ }
501
+ pushLine(line) {
502
+ this.parseLine(line);
503
+ }
504
+ finishIncomplete() {
505
+ return this.tail.trim();
506
+ }
507
+ parseLine(line) {
508
+ const trimmed = line.trim();
509
+ if (!trimmed || trimmed[0] !== "{")
510
+ return;
511
+ let parsed;
512
+ try {
513
+ parsed = JSON.parse(trimmed);
514
+ } catch {
515
+ return;
516
+ }
517
+ if (parsed.sessionID)
518
+ this.sessionId = parsed.sessionID;
519
+ if (parsed.type === "text" && typeof parsed.part?.text === "string") {
520
+ this.texts.push(parsed.part.text);
521
+ return;
522
+ }
523
+ if (parsed.type === "tool_use") {
524
+ this.toolCalls += 1;
525
+ return;
526
+ }
527
+ if (parsed.type === "error") {
528
+ if (this.error === undefined) {
529
+ this.error = parsed.error?.message ?? parsed.error?.type ?? "unknown opencode error";
530
+ }
531
+ }
532
+ }
533
+ finish() {
534
+ if (this.tail.trim()) {
535
+ this.parseLine(this.tail);
536
+ this.tail = "";
537
+ }
538
+ const text = this.texts.join("").trim();
539
+ return {
540
+ text,
541
+ error: this.error,
542
+ toolCalls: this.toolCalls,
543
+ sessionId: this.sessionId
544
+ };
545
+ }
546
+ }
547
+
548
+ // ../cli/src/runtime/spawn.ts
549
+ import { spawn } from "node:child_process";
550
+ function runCapture(cmd, opts = {}) {
551
+ return new Promise((resolve, reject) => {
552
+ let child;
553
+ try {
554
+ child = spawn(cmd[0], cmd.slice(1), {
555
+ stdio: [assertEofStdin(opts.stdin), "pipe", "pipe"],
556
+ env: opts.env ? normalizeEnv(opts.env) : process.env,
557
+ cwd: opts.cwd,
558
+ windowsHide: true
559
+ });
560
+ } catch (err) {
561
+ reject(err);
562
+ return;
563
+ }
564
+ let stdout = "";
565
+ let stderr = "";
566
+ let timedOut = false;
567
+ child.stdout?.setEncoding("utf8");
568
+ child.stderr?.setEncoding("utf8");
569
+ child.stdout?.on("data", (chunk) => {
570
+ stdout += chunk;
571
+ });
572
+ child.stderr?.on("data", (chunk) => {
573
+ stderr += chunk;
574
+ });
575
+ const timer = opts.timeoutMs && opts.timeoutMs > 0 ? setTimeout(() => {
576
+ timedOut = true;
577
+ try {
578
+ child.kill("SIGTERM");
579
+ } catch {}
580
+ setTimeout(() => {
581
+ try {
582
+ child.kill("SIGKILL");
583
+ } catch {}
584
+ }, 2000).unref?.();
585
+ }, opts.timeoutMs) : undefined;
586
+ timer?.unref?.();
587
+ child.on("error", (err) => {
588
+ if (timer)
589
+ clearTimeout(timer);
590
+ reject(err);
591
+ });
592
+ child.on("close", (code, signal) => {
593
+ if (timer)
594
+ clearTimeout(timer);
595
+ if (timedOut) {
596
+ resolve({ code: 124, stdout, stderr });
597
+ return;
598
+ }
599
+ resolve({ code: code ?? (signal ? 1 : 0), stdout, stderr });
600
+ });
601
+ });
602
+ }
603
+ function spawnStreaming(cmd, opts = {}) {
604
+ const child = spawn(cmd[0], cmd.slice(1), {
605
+ stdio: [assertEofStdin(opts.stdin), "pipe", "pipe"],
606
+ env: opts.env ? normalizeEnv(opts.env) : process.env,
607
+ cwd: opts.cwd,
608
+ windowsHide: true
609
+ });
610
+ let timedOut = false;
611
+ const timer = opts.timeoutMs && opts.timeoutMs > 0 ? setTimeout(() => {
612
+ timedOut = true;
613
+ try {
614
+ child.kill("SIGTERM");
615
+ } catch {}
616
+ }, opts.timeoutMs) : undefined;
617
+ timer?.unref?.();
618
+ if (opts.onStdout || opts.onStderr) {
619
+ child.stdout?.setEncoding("utf8");
620
+ child.stderr?.setEncoding("utf8");
621
+ child.stdout?.on("data", (chunk) => opts.onStdout?.(chunk));
622
+ child.stderr?.on("data", (chunk) => opts.onStderr?.(chunk));
623
+ } else {
624
+ child.stdout?.resume();
625
+ child.stderr?.resume();
626
+ }
627
+ const exited = new Promise((resolve, reject) => {
628
+ child.on("error", (err) => {
629
+ if (timer)
630
+ clearTimeout(timer);
631
+ reject(err);
632
+ });
633
+ child.on("close", (code, signal) => {
634
+ if (timer)
635
+ clearTimeout(timer);
636
+ if (timedOut) {
637
+ resolve(124);
638
+ return;
639
+ }
640
+ resolve(code ?? (signal ? 1 : 0));
641
+ });
642
+ });
643
+ return {
644
+ exited,
645
+ pid: child.pid,
646
+ kill(signal = "SIGTERM") {
647
+ try {
648
+ child.kill(signal);
649
+ } catch {}
650
+ }
651
+ };
652
+ }
653
+ function assertEofStdin(value) {
654
+ if (value !== undefined && value !== "ignore") {
655
+ 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)');
656
+ }
657
+ return "ignore";
658
+ }
659
+ function normalizeEnv(env) {
660
+ const out = {};
661
+ for (const [k, v] of Object.entries(env)) {
662
+ if (typeof v === "string")
663
+ out[k] = v;
664
+ }
665
+ return out;
666
+ }
667
+ function isMissingBinary(err) {
668
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
669
+ }
670
+
671
+ // ../cli/src/daemon/opencode-runner.ts
672
+ var DEFAULT_OPENCODE_TIMEOUT_MS = 10 * 60 * 1000;
673
+ function makeOpencodeRunner(opts = {}) {
674
+ const bin = opts.bin ?? "opencode";
675
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_OPENCODE_TIMEOUT_MS;
676
+ return async function runAgent(input) {
677
+ const args = buildOpencodeArgs({
678
+ prompt: input.prompt,
679
+ agent: input.agent,
680
+ sessionId: input.sessionId
681
+ });
682
+ const assembler = new NdjsonAssembler;
683
+ let stderr = "";
684
+ let handle;
685
+ try {
686
+ handle = spawnStreaming([bin, ...args], {
687
+ stdin: OPENCODE_STDIO[0],
688
+ env: { ...process.env, NO_COLOR: "1" },
689
+ timeoutMs,
690
+ onStdout: (chunk) => assembler.pushChunk(chunk),
691
+ onStderr: (chunk) => {
692
+ if (stderr.length < 64 * 1024)
693
+ stderr += chunk;
694
+ }
695
+ });
696
+ } catch (err) {
697
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
698
+ }
699
+ let code;
700
+ try {
701
+ code = await handle.exited;
702
+ } catch (err) {
703
+ if (isMissingBinary(err)) {
704
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
705
+ }
706
+ return { text: "", error: errMsg(err) };
707
+ }
708
+ const result = assembler.finish();
709
+ if (code === 124) {
710
+ return {
711
+ text: result.text,
712
+ error: `opencode timed out after ${timeoutMs}ms`,
713
+ sessionId: result.sessionId,
714
+ toolCalls: result.toolCalls
715
+ };
716
+ }
717
+ if (result.error) {
718
+ return { text: result.text, error: result.error, sessionId: result.sessionId, toolCalls: result.toolCalls };
719
+ }
720
+ if (code !== 0 && !result.text) {
721
+ return {
722
+ text: "",
723
+ error: `opencode exited ${code}: ${(stderr || "no stderr").trim().slice(0, 1000)}`,
724
+ toolCalls: result.toolCalls
725
+ };
726
+ }
727
+ return {
728
+ text: result.text,
729
+ sessionId: result.sessionId,
730
+ toolCalls: result.toolCalls
731
+ };
732
+ };
733
+ }
734
+ function errMsg(err) {
735
+ return err instanceof Error ? err.message : String(err);
736
+ }
737
+
738
+ // ../cli/src/daemon/reply.ts
739
+ var MAX_POST_BYTES = 28 * 1024;
740
+
741
+ class EmptyReplyError extends Error {
742
+ constructor() {
743
+ super("refusing to send an empty reply");
744
+ this.name = "EmptyReplyError";
745
+ }
746
+ }
747
+ function fitToLimit(body, buildContent) {
748
+ const fits = (text) => Buffer.byteLength(buildContent(text), "utf8") <= MAX_POST_BYTES;
749
+ if (fits(body))
750
+ return body;
751
+ const marker = `
752
+
753
+ …(内容过长,已截断;truncated to fit Feishu post limit)`;
754
+ const chars = [...body];
755
+ let lo = 0;
756
+ let hi = chars.length;
757
+ while (lo < hi) {
758
+ const mid = Math.ceil((lo + hi) / 2);
759
+ if (fits(chars.slice(0, mid).join("") + marker))
760
+ lo = mid;
761
+ else
762
+ hi = mid - 1;
763
+ }
764
+ const candidate = chars.slice(0, lo).join("") + marker;
765
+ return fits(candidate) ? candidate : chars.slice(0, lo).join("");
766
+ }
767
+ function buildReplyInvocation(input) {
768
+ const text = input.text?.trim() ?? "";
769
+ if (!text)
770
+ throw new EmptyReplyError;
771
+ const rowsFor = (body2) => {
772
+ const rows = [];
773
+ if (input.atOpenId)
774
+ rows.push([{ tag: "at", user_id: input.atOpenId }]);
775
+ rows.push([{ tag: "md", text: body2 }]);
776
+ return rows;
777
+ };
778
+ const contentFor = (body2) => JSON.stringify({ zh_cn: { content: rowsFor(body2) } });
779
+ const body = fitToLimit(text, contentFor);
780
+ const content = contentFor(body);
781
+ const args = [
782
+ "im",
783
+ "+messages-reply",
784
+ "--message-id",
785
+ input.messageId,
786
+ "--as",
787
+ "bot",
788
+ "--msg-type",
789
+ "post",
790
+ "--content",
791
+ content
792
+ ];
793
+ if (input.profile)
794
+ args.push("--profile", input.profile);
795
+ return { command: "lark-cli", args };
796
+ }
797
+ async function defaultReplyRunner(input) {
798
+ let invocation;
799
+ try {
800
+ invocation = buildReplyInvocation(input);
801
+ } catch (err) {
802
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
803
+ }
804
+ const { code, stdout, stderr } = await runCapture([
805
+ invocation.command,
806
+ ...invocation.args
807
+ ]);
808
+ if (code !== 0) {
809
+ return { ok: false, error: (stderr || stdout || `exit ${code}`).trim().slice(0, 2000) };
810
+ }
811
+ try {
812
+ const parsed = JSON.parse(stdout);
813
+ if (parsed.ok === false) {
814
+ return { ok: false, error: parsed.error?.message ?? "lark-cli reported failure" };
815
+ }
816
+ return { ok: true, messageId: parsed.data?.message_id };
817
+ } catch {
818
+ return { ok: true };
819
+ }
820
+ }
821
+
822
+ // ../cli/src/cmd/serve.ts
823
+ function serve(options, deps = {}) {
824
+ return startServe(options, deps);
825
+ }
826
+ async function startServe(options, deps) {
827
+ const log = deps.log ?? ((line) => console.log(line));
828
+ const gate = new ConcurrencyGate({ maxConcurrency: options.maxConcurrency });
829
+ const deduper = new Deduper;
830
+ const dispatcher = new Dispatcher({
831
+ runAgent: deps.runAgent ?? makeOpencodeRunner({ bin: options.opencodeBin, timeoutMs: options.timeoutMs }),
832
+ reply: deps.reply ?? defaultReplyRunner,
833
+ gate,
834
+ deduper
835
+ }, {
836
+ dryRun: options.dryRun,
837
+ replyEnabled: options.replyEnabled,
838
+ agent: options.agent
839
+ });
840
+ const listener = (deps.createListener ?? createEventListener)({
841
+ profile: options.profile,
842
+ eventKeys: options.eventKeys
843
+ });
844
+ let eventCount = 0;
845
+ let stopping;
846
+ let resolveDone;
847
+ const done = new Promise((resolve) => {
848
+ resolveDone = resolve;
849
+ });
850
+ const stop = async () => {
851
+ if (stopping)
852
+ return stopping;
853
+ stopping = (async () => {
854
+ log("[roy-task] stopping event listener…");
855
+ try {
856
+ await listener.stop();
857
+ } catch (err) {
858
+ log(`[roy-task] listener.stop() failed: ${errMsg2(err)}`);
859
+ }
860
+ log("[roy-task] stopped");
861
+ resolveDone();
862
+ })();
863
+ return stopping;
864
+ };
865
+ const sweeper = setInterval(() => {
866
+ deduper.sweep();
867
+ }, 5 * 60 * 1000);
868
+ if (typeof sweeper.unref === "function")
869
+ sweeper.unref();
870
+ listener.onEvent(async (event) => {
871
+ eventCount += 1;
872
+ const label = `#${eventCount}`;
873
+ try {
874
+ const result = await dispatcher.handle(event);
875
+ log(formatResult(label, result));
876
+ if (options.once && result.status !== "duplicate") {
877
+ await stop();
878
+ }
879
+ } catch (err) {
880
+ log(`[roy-task] ${label} dispatch threw: ${errMsg2(err)}`);
881
+ if (options.once)
882
+ await stop();
883
+ }
884
+ });
885
+ const commands = listener.plannedCommands();
886
+ log(formatBanner(options, commands, gate.maxConcurrency));
887
+ try {
888
+ await listener.start();
889
+ } catch (err) {
890
+ log(`[roy-task] failed to start event listener: ${errMsg2(err)}`);
891
+ clearInterval(sweeper);
892
+ resolveDone();
893
+ throw err;
894
+ }
895
+ log(`[roy-task] event source status = ${listener.status()}`);
896
+ log("[roy-task] waiting for events… (Ctrl-C to stop)");
897
+ return { done, stop, get eventCount() {
898
+ return eventCount;
899
+ } };
900
+ }
901
+ function formatBanner(options, commands, maxConcurrency) {
902
+ const lines = [
903
+ "────────────────────────────────────────────────────────────",
904
+ "[roy-task] starting",
905
+ ` profile : ${options.profile}`,
906
+ ` agent : ${options.agent}`,
907
+ ` opencode bin : ${options.opencodeBin}`,
908
+ ` max concurrency : ${maxConcurrency}`,
909
+ ` reply enabled : ${options.replyEnabled}`,
910
+ ` dry run : ${options.dryRun}`,
911
+ ` once : ${options.once}`,
912
+ ` timeout : ${options.timeoutMs}ms`,
913
+ " consume commands :",
914
+ ...commands.map((c) => ` ${c}`),
915
+ "────────────────────────────────────────────────────────────"
916
+ ];
917
+ return lines.join(`
918
+ `);
919
+ }
920
+ function formatResult(label, result) {
921
+ switch (result.status) {
922
+ case "dispatched":
923
+ return `[roy-task] ${label} dispatched chat=${result.plan.chatId} session=${result.plan.sessionId} ` + `reply=${result.replyMessageId ?? "(no id)"} chars=${result.text.length}`;
924
+ case "dry-run":
925
+ 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`;
926
+ case "duplicate":
927
+ return `[roy-task] ${label} duplicate dropped key=${result.key ?? "(unknown)"}`;
928
+ case "no-output":
929
+ return `[roy-task] ${label} no-output (agent produced no text; not replying) toolCalls=${result.toolCalls}`;
930
+ case "no-reply":
931
+ return `[roy-task] ${label} no-reply (reply disabled) chars=${result.text.length}`;
932
+ case "agent-error":
933
+ return `[roy-task] ${label} agent-error: ${result.error}`;
934
+ case "reply-failed":
935
+ return `[roy-task] ${label} reply-failed: ${result.error}`;
936
+ case "no-target":
937
+ return `[roy-task] ${label} skipped: ${result.reason}`;
938
+ }
939
+ }
940
+ function errMsg2(err) {
941
+ return err instanceof Error ? err.message : String(err);
942
+ }
943
+
944
+ // ../cli/src/runtime/sleep.ts
945
+ function sleep(ms) {
946
+ return new Promise((resolve) => {
947
+ const timer = setTimeout(resolve, ms);
948
+ timer.unref?.();
949
+ });
950
+ }
951
+
952
+ // ../cli/src/cmd/status.ts
953
+ async function runCapture2(cmd) {
954
+ try {
955
+ return await runCapture(cmd);
956
+ } catch {
957
+ return null;
958
+ }
959
+ }
960
+ async function collectStatus(options) {
961
+ const warnings = [];
962
+ let appId;
963
+ const auth = await runCapture2(["lark-cli", "--profile", options.profile, "auth", "status"]);
964
+ if (auth !== null && auth.code === 0) {
965
+ try {
966
+ const parsed = JSON.parse(auth.stdout);
967
+ appId = parsed.appId;
968
+ } catch {}
969
+ }
970
+ const lark = await runCapture2(["lark-cli", "event", "status", "--json"]);
971
+ let larkCliOk = false;
972
+ let consumerRunning = false;
973
+ const consumers = [];
974
+ if (lark === null) {
975
+ warnings.push("lark-cli not found on PATH");
976
+ } else if (lark.code !== 0) {
977
+ warnings.push(`lark-cli event status failed: ${(lark.stderr || lark.stdout).trim().slice(0, 300)}`);
978
+ } else {
979
+ try {
980
+ const parsed = JSON.parse(lark.stdout);
981
+ larkCliOk = true;
982
+ if (!appId) {
983
+ warnings.push(`could not resolve the app_id for profile "${options.profile}"; the consumer check below ` + "covers all apps and may over-report.");
984
+ }
985
+ for (const app of parsed.apps ?? []) {
986
+ if (appId && app.app_id !== appId)
987
+ continue;
988
+ if (app.running)
989
+ consumerRunning = true;
990
+ for (const c of app.consumers ?? []) {
991
+ consumers.push({
992
+ pid: typeof c.pid === "number" ? c.pid : undefined,
993
+ eventKey: typeof c.event_key === "string" ? c.event_key : undefined,
994
+ received: typeof c.received === "number" ? c.received : undefined,
995
+ dropped: typeof c.dropped === "number" ? c.dropped : undefined
996
+ });
997
+ }
998
+ }
999
+ } catch {
1000
+ warnings.push("could not parse `lark-cli event status --json` output");
1001
+ }
1002
+ }
1003
+ if (consumerRunning) {
1004
+ 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.");
1005
+ }
1006
+ let oc = await runCapture2(["opencode", "debug", "agents"]);
1007
+ if (oc !== null && oc.code === 0 && oc.stdout.trim() === "[]") {
1008
+ await sleep(1200);
1009
+ oc = await runCapture2(["opencode", "debug", "agents"]);
1010
+ }
1011
+ let opencodeOk = false;
1012
+ let royAgentPresent = false;
1013
+ if (oc === null) {
1014
+ warnings.push("opencode not found on PATH");
1015
+ } else if (oc.code !== 0) {
1016
+ warnings.push(`opencode debug agents failed: ${(oc.stderr || oc.stdout).trim().slice(0, 300)}`);
1017
+ } else {
1018
+ opencodeOk = true;
1019
+ try {
1020
+ const parsed = JSON.parse(oc.stdout);
1021
+ const list = Array.isArray(parsed) ? parsed : parsed.agents ?? [];
1022
+ royAgentPresent = list.some((a) => a?.id === "roy" || a?.name === "roy");
1023
+ if (!royAgentPresent) {
1024
+ 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'.");
1025
+ }
1026
+ } catch {
1027
+ warnings.push("could not parse `opencode debug agents` output");
1028
+ }
1029
+ }
1030
+ return {
1031
+ profile: options.profile,
1032
+ appId,
1033
+ larkCliOk,
1034
+ consumerRunning,
1035
+ consumers,
1036
+ opencodeOk,
1037
+ royAgentPresent,
1038
+ warnings
1039
+ };
1040
+ }
1041
+ function formatStatus(report) {
1042
+ const lines = [
1043
+ `profile : ${report.profile}${report.appId ? ` (${report.appId})` : ""}`,
1044
+ `lark-cli usable : ${report.larkCliOk ? "yes" : "NO"}`,
1045
+ `consumer running : ${report.consumerRunning ? "YES (⚠️ see warnings)" : "no"}`
1046
+ ];
1047
+ if (report.consumers.length) {
1048
+ lines.push("consumers :");
1049
+ for (const c of report.consumers) {
1050
+ lines.push(` - pid=${c.pid ?? "?"} key=${c.eventKey ?? "?"} received=${c.received ?? "?"} dropped=${c.dropped ?? "?"}`);
1051
+ }
1052
+ }
1053
+ lines.push(`opencode usable : ${report.opencodeOk ? "yes" : "NO"}`);
1054
+ lines.push(`roy agent present : ${report.royAgentPresent ? "yes" : "NO"}`);
1055
+ if (report.warnings.length) {
1056
+ lines.push("", "warnings:");
1057
+ for (const w of report.warnings)
1058
+ lines.push(` ⚠️ ${w}`);
1059
+ }
1060
+ return lines.join(`
1061
+ `);
1062
+ }
1063
+
1064
+ // ../cli/src/main.ts
1065
+ var VERSION = "0.2.0";
1066
+ async function main(argv) {
1067
+ const parsed = parseArgs(argv);
1068
+ switch (parsed.command) {
1069
+ case "help":
1070
+ console.log(usage());
1071
+ return 0;
1072
+ case "version":
1073
+ console.log(`roy-task ${VERSION}`);
1074
+ return 0;
1075
+ case "unknown":
1076
+ console.error(`roy-task: ${parsed.error}
1077
+ `);
1078
+ console.error(usage());
1079
+ return 2;
1080
+ case "status": {
1081
+ const report = await collectStatus({ profile: parsed.profile });
1082
+ console.log(formatStatus(report));
1083
+ return report.consumerRunning || !report.larkCliOk || !report.royAgentPresent ? 1 : 0;
1084
+ }
1085
+ case "serve": {
1086
+ const handle = await serve(parsed.serve);
1087
+ const onSignal = (signal) => {
1088
+ console.log(`
1089
+ [roy-task] received ${signal}, shutting down…`);
1090
+ handle.stop();
1091
+ };
1092
+ process.on("SIGINT", () => onSignal("SIGINT"));
1093
+ process.on("SIGTERM", () => onSignal("SIGTERM"));
1094
+ await handle.done;
1095
+ return 0;
1096
+ }
1097
+ }
1098
+ }
1099
+ main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
1100
+ console.error("[roy-task] fatal:", err instanceof Error ? err.message : err);
1101
+ process.exit(1);
1102
+ });
1103
+ export {
1104
+ VERSION
1105
+ };