@ai-setting/roy-task-plugin 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/cli.js +1105 -0
- package/dist/index.js +2625 -0
- package/dist/index.js.map +162 -0
- package/package.json +33 -4
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
|
+
};
|