@ai-setting/roy-task-plugin 0.2.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.
Files changed (2) hide show
  1. package/dist/cli.js +386 -16
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
2
4
 
3
5
  // ../cli/src/args.ts
4
6
  var DEFAULT_PROFILE = "default";
@@ -38,6 +40,7 @@ function parseServe(argv) {
38
40
  dryRun: false,
39
41
  once: false,
40
42
  replyEnabled: true,
43
+ cardEnabled: true,
41
44
  timeoutMs: DEFAULT_TIMEOUT_MS,
42
45
  opencodeBin: "opencode"
43
46
  };
@@ -120,6 +123,12 @@ function parseServe(argv) {
120
123
  case "--reply":
121
124
  serve.replyEnabled = true;
122
125
  break;
126
+ case "--card":
127
+ serve.cardEnabled = true;
128
+ break;
129
+ case "--no-card":
130
+ serve.cardEnabled = false;
131
+ break;
123
132
  default:
124
133
  return { command: "unknown", error: `unknown flag "${flag}"` };
125
134
  }
@@ -174,6 +183,11 @@ SERVE OPTIONS
174
183
  --dry-run never spawn opencode, never reply
175
184
  --once stop after the first event (testing / CI)
176
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.
177
191
 
178
192
  NOTES
179
193
  • stdin is forced to EOF for \`opencode run\` (stdio[0]="ignore"). Without it
@@ -182,6 +196,9 @@ NOTES
182
196
  That side is handled by roy-agent-core, which we import.
183
197
  • Replies are sent by the daemon (not by the agent) as a single structured
184
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.
185
202
  `;
186
203
  }
187
204
 
@@ -352,7 +369,207 @@ function buildOpencodeArgs(input) {
352
369
  return args;
353
370
  }
354
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
+
355
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
+
356
573
  class Dispatcher {
357
574
  deps;
358
575
  opts;
@@ -392,19 +609,52 @@ class Dispatcher {
392
609
  return { status: "dry-run", plan };
393
610
  }
394
611
  const run = async () => {
612
+ const cardPromise = this.openCard({ chatId, messageId, profile, raw });
395
613
  const result = await this.deps.runAgent({
396
614
  prompt: plan.prompt,
397
615
  agent: plan.agent,
398
616
  sessionId: plan.sessionId
399
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
+ }
400
627
  if (result.error) {
401
- return { status: "agent-error", plan, error: 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) };
402
635
  }
403
636
  if (!result.text.trim()) {
404
- return { status: "no-output", plan, toolCalls: result.toolCalls ?? 0 };
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
+ };
405
649
  }
406
650
  if (this.opts.replyEnabled === false) {
407
- return { status: "no-reply", plan, text: result.text };
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) };
408
658
  }
409
659
  const sent = await this.deps.reply({
410
660
  messageId,
@@ -414,12 +664,106 @@ class Dispatcher {
414
664
  atOpenId: senderOpenId
415
665
  });
416
666
  if (!sent.ok) {
417
- return { status: "reply-failed", plan, error: sent.error ?? "unknown", text: result.text };
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
+ };
418
680
  }
419
- return { status: "dispatched", plan, replyMessageId: sent.messageId, text: result.text };
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
+ };
420
694
  };
421
695
  return this.deps.gate ? this.deps.gate.run(plan.sessionId, run) : run();
422
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
+ }
423
767
  buildPlan(input) {
424
768
  const derive = this.opts.sessionIdFor ?? deriveSessionId;
425
769
  return {
@@ -486,6 +830,7 @@ class NdjsonAssembler {
486
830
  error;
487
831
  toolCalls = 0;
488
832
  sessionId;
833
+ tools = [];
489
834
  pushChunk(chunk) {
490
835
  this.tail += chunk;
491
836
  let idx = this.tail.indexOf(`
@@ -522,6 +867,10 @@ class NdjsonAssembler {
522
867
  }
523
868
  if (parsed.type === "tool_use") {
524
869
  this.toolCalls += 1;
870
+ const name = parsed.part?.tool ?? parsed.tool;
871
+ if (typeof name === "string" && name) {
872
+ this.tools.push(name);
873
+ }
525
874
  return;
526
875
  }
527
876
  if (parsed.type === "error") {
@@ -540,7 +889,8 @@ class NdjsonAssembler {
540
889
  text,
541
890
  error: this.error,
542
891
  toolCalls: this.toolCalls,
543
- sessionId: this.sessionId
892
+ sessionId: this.sessionId,
893
+ tools: this.tools
544
894
  };
545
895
  }
546
896
  }
@@ -711,23 +1061,32 @@ function makeOpencodeRunner(opts = {}) {
711
1061
  text: result.text,
712
1062
  error: `opencode timed out after ${timeoutMs}ms`,
713
1063
  sessionId: result.sessionId,
714
- toolCalls: result.toolCalls
1064
+ toolCalls: result.toolCalls,
1065
+ tools: result.tools
715
1066
  };
716
1067
  }
717
1068
  if (result.error) {
718
- return { text: result.text, error: result.error, sessionId: result.sessionId, toolCalls: result.toolCalls };
1069
+ return {
1070
+ text: result.text,
1071
+ error: result.error,
1072
+ sessionId: result.sessionId,
1073
+ toolCalls: result.toolCalls,
1074
+ tools: result.tools
1075
+ };
719
1076
  }
720
1077
  if (code !== 0 && !result.text) {
721
1078
  return {
722
1079
  text: "",
723
1080
  error: `opencode exited ${code}: ${(stderr || "no stderr").trim().slice(0, 1000)}`,
724
- toolCalls: result.toolCalls
1081
+ toolCalls: result.toolCalls,
1082
+ tools: result.tools
725
1083
  };
726
1084
  }
727
1085
  return {
728
1086
  text: result.text,
729
1087
  sessionId: result.sessionId,
730
- toolCalls: result.toolCalls
1088
+ toolCalls: result.toolCalls,
1089
+ tools: result.tools
731
1090
  };
732
1091
  };
733
1092
  }
@@ -827,11 +1186,13 @@ async function startServe(options, deps) {
827
1186
  const log = deps.log ?? ((line) => console.log(line));
828
1187
  const gate = new ConcurrencyGate({ maxConcurrency: options.maxConcurrency });
829
1188
  const deduper = new Deduper;
1189
+ const card = deps.card ?? (options.cardEnabled && !options.dryRun ? createRoyAgentCardPort() : createNoopCardPort());
830
1190
  const dispatcher = new Dispatcher({
831
1191
  runAgent: deps.runAgent ?? makeOpencodeRunner({ bin: options.opencodeBin, timeoutMs: options.timeoutMs }),
832
1192
  reply: deps.reply ?? defaultReplyRunner,
833
1193
  gate,
834
- deduper
1194
+ deduper,
1195
+ card
835
1196
  }, {
836
1197
  dryRun: options.dryRun,
837
1198
  replyEnabled: options.replyEnabled,
@@ -907,6 +1268,7 @@ function formatBanner(options, commands, maxConcurrency) {
907
1268
  ` opencode bin : ${options.opencodeBin}`,
908
1269
  ` max concurrency : ${maxConcurrency}`,
909
1270
  ` reply enabled : ${options.replyEnabled}`,
1271
+ ` progress cards : ${options.cardEnabled && !options.dryRun}`,
910
1272
  ` dry run : ${options.dryRun}`,
911
1273
  ` once : ${options.once}`,
912
1274
  ` timeout : ${options.timeoutMs}ms`,
@@ -917,22 +1279,30 @@ function formatBanner(options, commands, maxConcurrency) {
917
1279
  return lines.join(`
918
1280
  `);
919
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
+ }
920
1289
  function formatResult(label, result) {
1290
+ const card = cardSuffix(result);
921
1291
  switch (result.status) {
922
1292
  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}`;
1293
+ return `[roy-task] ${label} dispatched chat=${result.plan.chatId} session=${result.plan.sessionId} ` + `reply=${result.replyMessageId ?? "(no id)"} chars=${result.text.length}${card}`;
924
1294
  case "dry-run":
925
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`;
926
1296
  case "duplicate":
927
1297
  return `[roy-task] ${label} duplicate dropped key=${result.key ?? "(unknown)"}`;
928
1298
  case "no-output":
929
- return `[roy-task] ${label} no-output (agent produced no text; not replying) toolCalls=${result.toolCalls}`;
1299
+ return `[roy-task] ${label} no-output (agent produced no text; not replying) ` + `toolCalls=${result.toolCalls}${card}`;
930
1300
  case "no-reply":
931
- return `[roy-task] ${label} no-reply (reply disabled) chars=${result.text.length}`;
1301
+ return `[roy-task] ${label} no-reply (reply disabled) chars=${result.text.length}${card}`;
932
1302
  case "agent-error":
933
- return `[roy-task] ${label} agent-error: ${result.error}`;
1303
+ return `[roy-task] ${label} agent-error: ${result.error}${card}`;
934
1304
  case "reply-failed":
935
- return `[roy-task] ${label} reply-failed: ${result.error}`;
1305
+ return `[roy-task] ${label} reply-failed: ${result.error}${card}`;
936
1306
  case "no-target":
937
1307
  return `[roy-task] ${label} skipped: ${result.reason}`;
938
1308
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-setting/roy-task-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {