@ai-setting/roy-task-plugin 0.3.1 → 0.4.1

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 CHANGED
@@ -2,7 +2,18 @@
2
2
  import { createRequire } from "node:module";
3
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
- // ../cli/src/args.ts
5
+ // ../../../packages/cli/src/daemon/runners/types.ts
6
+ var RUNTIMES = Object.freeze(["opencode", "claude"]);
7
+ var DEFAULT_RUNTIME = "opencode";
8
+ var DEFAULT_RUNTIME_BIN = Object.freeze({
9
+ opencode: "opencode",
10
+ claude: "claude"
11
+ });
12
+ function isRuntimeName(value) {
13
+ return RUNTIMES.includes(value);
14
+ }
15
+
16
+ // ../../../packages/cli/src/args.ts
6
17
  var DEFAULT_PROFILE = "default";
7
18
  var DEFAULT_AGENT = "roy";
8
19
  var DEFAULT_EVENT_KEY = "im.message.receive_v1";
@@ -42,6 +53,7 @@ function parseServe(argv) {
42
53
  replyEnabled: true,
43
54
  cardEnabled: true,
44
55
  timeoutMs: DEFAULT_TIMEOUT_MS,
56
+ runtime: DEFAULT_RUNTIME,
45
57
  opencodeBin: "opencode"
46
58
  };
47
59
  for (let i = 0;i < argv.length; i++) {
@@ -82,6 +94,26 @@ function parseServe(argv) {
82
94
  serve.eventKeys.push(v);
83
95
  break;
84
96
  }
97
+ case "--runtime": {
98
+ const v = take();
99
+ if (v instanceof Error)
100
+ return { command: "unknown", error: v.message };
101
+ if (!isRuntimeName(v)) {
102
+ return {
103
+ command: "unknown",
104
+ error: `--runtime must be one of ${RUNTIMES.join("|")}, got "${v}"`
105
+ };
106
+ }
107
+ serve.runtime = v;
108
+ break;
109
+ }
110
+ case "--runtime-bin": {
111
+ const v = take();
112
+ if (v instanceof Error)
113
+ return { command: "unknown", error: v.message };
114
+ serve.runtimeBin = v;
115
+ break;
116
+ }
85
117
  case "--opencode-bin": {
86
118
  const v = take();
87
119
  if (v instanceof Error)
@@ -157,8 +189,16 @@ function readValue(argv, flag) {
157
189
  }
158
190
  return;
159
191
  }
192
+ function resolveRuntimeBin(options) {
193
+ if (options.runtimeBin !== undefined)
194
+ return options.runtimeBin;
195
+ if (options.runtime === "opencode" && options.opencodeBin !== "opencode") {
196
+ return options.opencodeBin;
197
+ }
198
+ return;
199
+ }
160
200
  function usage() {
161
- return `roy-task — bridge Feishu (Lark) messages to an OpenCode roy agent
201
+ return `roy-task — bridge Feishu (Lark) messages to a roy agent (opencode or claude)
162
202
 
163
203
  USAGE
164
204
  roy-task [serve] [options] start the daemon (default command)
@@ -174,13 +214,17 @@ SERVE OPTIONS
174
214
  consumer per (app, EventKey); a second one
175
215
  silently splits the event stream.
176
216
  --event-key <key> event key to consume (repeatable) (default: ${DEFAULT_EVENT_KEY})
177
- --agent <name> opencode agent to dispatch to (default: ${DEFAULT_AGENT})
178
- --opencode-bin <path> opencode binary (default: opencode)
217
+ --agent <name> agent to dispatch to (default: ${DEFAULT_AGENT})
218
+ --runtime <name> which harness runs the agent (default: ${DEFAULT_RUNTIME})
219
+ one of: ${RUNTIMES.join(" | ")}
220
+ --runtime-bin <path> binary for the selected runtime (default: per-runtime)
221
+ --opencode-bin <path> DEPRECATED alias for --runtime-bin
222
+ (only applies when --runtime opencode)
179
223
  --max-concurrency <n> max simultaneous agent runs (default: 1)
180
224
  ⚠️ >1 only safe across DIFFERENT chats;
181
225
  same-session concurrency cross-contaminates.
182
226
  --timeout-ms <n> per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
183
- --dry-run never spawn opencode, never reply
227
+ --dry-run never spawn the runtime, never reply
184
228
  --once stop after the first event (testing / CI)
185
229
  --reply / --no-reply send the reply back to Feishu (default: on)
186
230
  --card / --no-card render a roy-agent progress card (default: on)
@@ -190,8 +234,9 @@ SERVE OPTIONS
190
234
  never block the text reply.
191
235
 
192
236
  NOTES
193
- • stdin is forced to EOF for \`opencode run\` (stdio[0]="ignore"). Without it
194
- opencode's readStdin() waits for EOF on a socket and the run hangs forever.
237
+ • stdin is forced to EOF for the agent child (stdio[0]="ignore"). Without it
238
+ both \`opencode run\` and \`claude --print\` wait for EOF on a socket and the
239
+ run hangs forever.
195
240
  • \`lark-cli event consume\` needs the OPPOSITE: its stdin must stay open.
196
241
  That side is handled by roy-agent-core, which we import.
197
242
  • Replies are sent by the daemon (not by the agent) as a single structured
@@ -202,7 +247,7 @@ NOTES
202
247
  `;
203
248
  }
204
249
 
205
- // ../cli/src/daemon/concurrency.ts
250
+ // ../../../packages/cli/src/daemon/concurrency.ts
206
251
  class ConcurrencyGate {
207
252
  maxConcurrency;
208
253
  running = 0;
@@ -259,7 +304,7 @@ class ConcurrencyGate {
259
304
  }
260
305
  }
261
306
 
262
- // ../cli/src/daemon/dedupe.ts
307
+ // ../../../packages/cli/src/daemon/dedupe.ts
263
308
  var DEFAULT_TTL_MS = 60000;
264
309
  var DEFAULT_MAX_ENTRIES = 5000;
265
310
 
@@ -305,7 +350,7 @@ class Deduper {
305
350
  }
306
351
  }
307
352
 
308
- // ../cli/src/daemon/session-map.ts
353
+ // ../../../packages/cli/src/daemon/session-map.ts
309
354
  import { createHash } from "node:crypto";
310
355
  var DIGEST_LEN = 16;
311
356
  function deriveSessionId(input) {
@@ -316,7 +361,7 @@ function deriveSessionId(input) {
316
361
  return `ses_${digest.slice(0, DIGEST_LEN)}`;
317
362
  }
318
363
 
319
- // ../cli/src/daemon/prompt.ts
364
+ // ../../../packages/cli/src/daemon/prompt.ts
320
365
  var OPENCODE_STDIO = Object.freeze([
321
366
  "ignore",
322
367
  "pipe",
@@ -369,7 +414,7 @@ function buildOpencodeArgs(input) {
369
414
  return args;
370
415
  }
371
416
 
372
- // ../cli/src/daemon/task-card.ts
417
+ // ../../../packages/cli/src/daemon/task-card.ts
373
418
  var CARD_OP_TIMEOUT_MS = 15000;
374
419
 
375
420
  class CardTimeoutError extends Error {
@@ -506,7 +551,7 @@ function describeToolUse(tool) {
506
551
  };
507
552
  }
508
553
 
509
- // ../cli/src/daemon/dispatcher.ts
554
+ // ../../../packages/cli/src/daemon/dispatcher.ts
510
555
  function cardReport(handle) {
511
556
  const out = {};
512
557
  if (handle.messageId)
@@ -781,7 +826,7 @@ class Dispatcher {
781
826
  }
782
827
  }
783
828
 
784
- // ../cli/src/daemon/es-listener.ts
829
+ // ../../../packages/cli/src/daemon/es-listener.ts
785
830
  import { larkCliHandler } from "@ai-setting/roy-agent-core/env/event-source";
786
831
  function createEventListener(config) {
787
832
  const eventKeys = config.eventKeys?.length ? config.eventKeys : ["im.message.receive_v1"];
@@ -823,79 +868,10 @@ function createEventListener(config) {
823
868
  };
824
869
  }
825
870
 
826
- // ../cli/src/daemon/ndjson.ts
827
- class NdjsonAssembler {
828
- tail = "";
829
- texts = [];
830
- error;
831
- toolCalls = 0;
832
- sessionId;
833
- tools = [];
834
- pushChunk(chunk) {
835
- this.tail += chunk;
836
- let idx = this.tail.indexOf(`
837
- `);
838
- while (idx !== -1) {
839
- const line = this.tail.slice(0, idx);
840
- this.tail = this.tail.slice(idx + 1);
841
- this.parseLine(line);
842
- idx = this.tail.indexOf(`
843
- `);
844
- }
845
- }
846
- pushLine(line) {
847
- this.parseLine(line);
848
- }
849
- finishIncomplete() {
850
- return this.tail.trim();
851
- }
852
- parseLine(line) {
853
- const trimmed = line.trim();
854
- if (!trimmed || trimmed[0] !== "{")
855
- return;
856
- let parsed;
857
- try {
858
- parsed = JSON.parse(trimmed);
859
- } catch {
860
- return;
861
- }
862
- if (parsed.sessionID)
863
- this.sessionId = parsed.sessionID;
864
- if (parsed.type === "text" && typeof parsed.part?.text === "string") {
865
- this.texts.push(parsed.part.text);
866
- return;
867
- }
868
- if (parsed.type === "tool_use") {
869
- this.toolCalls += 1;
870
- const name = parsed.part?.tool ?? parsed.tool;
871
- if (typeof name === "string" && name) {
872
- this.tools.push(name);
873
- }
874
- return;
875
- }
876
- if (parsed.type === "error") {
877
- if (this.error === undefined) {
878
- this.error = parsed.error?.message ?? parsed.error?.type ?? "unknown opencode error";
879
- }
880
- }
881
- }
882
- finish() {
883
- if (this.tail.trim()) {
884
- this.parseLine(this.tail);
885
- this.tail = "";
886
- }
887
- const text = this.texts.join("").trim();
888
- return {
889
- text,
890
- error: this.error,
891
- toolCalls: this.toolCalls,
892
- sessionId: this.sessionId,
893
- tools: this.tools
894
- };
895
- }
896
- }
871
+ // ../../../packages/cli/src/daemon/runners/claude.ts
872
+ import { createHash as createHash2 } from "node:crypto";
897
873
 
898
- // ../cli/src/runtime/spawn.ts
874
+ // ../../../packages/cli/src/runtime/spawn.ts
899
875
  import { spawn } from "node:child_process";
900
876
  function runCapture(cmd, opts = {}) {
901
877
  return new Promise((resolve, reject) => {
@@ -1018,7 +994,444 @@ function isMissingBinary(err) {
1018
994
  return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1019
995
  }
1020
996
 
1021
- // ../cli/src/daemon/opencode-runner.ts
997
+ // ../../../packages/cli/src/daemon/runners/claude-agents.ts
998
+ var CLAUDE_ROY_AGENT_NAME = "roy";
999
+ var ROY_AGENT_DESCRIPTION = "Roy — task-as-first-class-citizen agent. Uses the roy-agent task lifecycle " + "(task_tree → task_create → task_update → task_operation_create → task_complete) " + "so every piece of work is tracked in the local task store.";
1000
+ var CLAUDE_ROY_TOOLS = Object.freeze([
1001
+ "Read",
1002
+ "Write",
1003
+ "Edit",
1004
+ "Bash",
1005
+ "Grep",
1006
+ "Glob",
1007
+ "TodoWrite",
1008
+ "WebFetch",
1009
+ "WebSearch"
1010
+ ]);
1011
+ var CLAUDE_ROY_SYSTEM_PROMPT = `# Roy — task-first-class-citizen agent
1012
+
1013
+ You are **Roy**, an agent whose defining rule is simple:
1014
+
1015
+ > **Task is a first-class citizen. No task, no work.**
1016
+
1017
+ You are not a chat assistant that occasionally writes notes. You are a worker
1018
+ whose output is a *visible, trackable* stream of tasks. Every non-trivial
1019
+ request (more than one tool call / more than a couple of minutes of work) MUST
1020
+ become a tracked task before you start doing it, and MUST be closed out
1021
+ explicitly when you finish.
1022
+
1023
+ ## The lifecycle (non-negotiable)
1024
+
1025
+ ### 1. Orient — \`task_tree(depth=1)\` (or \`depth=2\` for bigger trees)
1026
+ Before creating anything, look at the existing tree. Decide:
1027
+ - does this work belong to an existing root (attach via \`parent_task_id\`)?
1028
+ - or is it genuinely new?
1029
+
1030
+ Keep **root tasks ≤ 10**. Prefer attaching to an existing thematic root over
1031
+ spawning a new root. Never hardcode ids — read them from \`task_tree\`.
1032
+
1033
+ ### 2. Create — \`task_create\`
1034
+ Create the task **before** doing the work. Pass:
1035
+ - \`title\` (imperative, specific), \`description\`, \`goals_and_expected_deliverables\`
1036
+ - \`priority\` (low / medium / high), \`type\` (normal / cycle / longterm)
1037
+ - \`project_path\`, \`context\` (JSON string: worktree_path / branch / type …)
1038
+ - \`parent_task_id\` when the tree said so
1039
+
1040
+ The tool returns a **numeric task id** — that number is your handle for the
1041
+ rest of the conversation.
1042
+
1043
+ ### 3. Track — \`task_update\`
1044
+ Report progress as you go, not only at the end:
1045
+ - \`status\`: todo → active → (paused / cancelled) → completed
1046
+ - \`progress\`: 0–100
1047
+ - \`current_status\`: one human sentence about where you are right now
1048
+
1049
+ If the scope changes, \`task_update\` the description/goals instead of silently
1050
+ doing something else.
1051
+
1052
+ ### 4. Record milestones — \`task_operation_create\`
1053
+ This tool has **two orthogonal dimensions** and you must keep them distinct:
1054
+ - **state / milestone dimension** — \`milestone_type\` + \`milestone_title\` +
1055
+ \`milestone_description\`: *what* was reached (e.g. "GREEN: all tests pass").
1056
+ - **process dimension** — \`process_description\`: *how* you got there —
1057
+ strategy + tool steps, strictly **100–150 characters**.
1058
+ Too short = rejected (no value); too long = rejected (use the session log).
1059
+
1060
+ Rules: do **not** use the process field to restate the milestone, and do not
1061
+ use milestones as a substitute for progress. Milestones mark *state
1062
+ transitions*: RED→GREEN, blocker found, decision made, review passed.
1063
+
1064
+ ### 5. Close — \`task_complete\`
1065
+ Before completing:
1066
+ 1. \`task_get(task_id)\` and re-read goal + delivery.
1067
+ 2. Verify the goal and delivery actually match what you delivered. If they do
1068
+ not, either extend the task (\`task_update\`) or say so honestly.
1069
+ 3. Call \`task_complete\` with \`deliverable_storage_path\` — a locator for the
1070
+ work product: a file path, a git branch/worktree, a doc URL, a commit URL.
1071
+ The path is persisted into the task context and as an operation record.
1072
+
1073
+ \`task_complete\` cannot be used on \`cycle\` tasks (they are periodic by
1074
+ design) — use \`task_update\` to reset those.
1075
+
1076
+ ## Other tools
1077
+
1078
+ - \`task_get(task_id, include_operations)\` — full detail; use it before
1079
+ resuming or completing a task.
1080
+ - \`task_list(...)\` — flat listing with filters (status / priority / type).
1081
+ - \`task_search(keywords)\` — find by title / description / goals / tags.
1082
+ **Search before creating**, to avoid duplicates.
1083
+
1084
+ ## Where tasks live
1085
+
1086
+ All tasks and operations are persisted by the roy-task-plugin in the
1087
+ **single** project store — locally that is SQLite at
1088
+ \`~/.roy-task-plugin/tasks.db\` (or the remote service when the plugin is
1089
+ configured with \`persistence: "remote"\`). Do not create side databases, do
1090
+ not maintain your own to-do list in files, and do not track work in chat only:
1091
+ if it is not in the task store, it is not tracked.
1092
+
1093
+ ## Tool surface on Claude Code
1094
+
1095
+ The lifecycle above is expressed through the \`roy-agent tasks\` CLI (run it with
1096
+ the \`Bash\` tool), because Claude Code does not yet expose the \`task_*\` tools
1097
+ natively. The mapping is 1:1 — same store, same ids, same semantics:
1098
+
1099
+ | lifecycle step | Claude Code command |
1100
+ |---|---|
1101
+ | \`task_tree(depth=1)\` | \`roy-agent tasks tree\` |
1102
+ | \`task_create\` | \`roy-agent tasks create "<title>" -d "<description>" -g "<goals>" -p high --project-path "<path>"\` |
1103
+ | \`task_update\` | \`roy-agent tasks update <id> --status active --progress 50 --current-status "…"\` |
1104
+ | \`task_operation_create\` | \`roy-agent tasks operations <id>\` to read; record milestones via the CLI's update/ops surface |
1105
+ | \`task_complete\` | \`roy-agent tasks complete <id> --deliverable "<path>"\` |
1106
+ | \`task_get\` | \`roy-agent tasks get <id>\` |
1107
+ | \`task_list\` | \`roy-agent tasks list\` |
1108
+ | \`task_search\` | \`roy-agent tasks search <keywords>\` |
1109
+
1110
+ Prefer \`--json\` (\`-j\`) when you need to parse ids. The task id printed by
1111
+ \`create\` is your handle for the rest of the turn.
1112
+
1113
+ ## Style
1114
+
1115
+ - Be direct and concrete; the user is reading task titles and milestones.
1116
+ - Report the task id and its state whenever you finish a step.
1117
+ - If a task is blocked, mark it (\`task_update(status="paused")\`) and record a
1118
+ \`problem\` milestone explaining the blocker instead of going silent.`;
1119
+ function buildClaudeAgentsJson(opts = {}) {
1120
+ const def = {
1121
+ description: opts.description ?? ROY_AGENT_DESCRIPTION,
1122
+ prompt: opts.prompt ?? CLAUDE_ROY_SYSTEM_PROMPT,
1123
+ tools: [...opts.tools ?? CLAUDE_ROY_TOOLS]
1124
+ };
1125
+ if (opts.model)
1126
+ def.model = opts.model;
1127
+ return JSON.stringify({ [opts.name ?? CLAUDE_ROY_AGENT_NAME]: def });
1128
+ }
1129
+
1130
+ // ../../../packages/cli/src/daemon/runners/claude-sessions.ts
1131
+ function createClaudeSessionRegistry() {
1132
+ const sessions = new Map;
1133
+ return {
1134
+ get: (key) => sessions.get(key),
1135
+ set: (key, id) => {
1136
+ sessions.set(key, id);
1137
+ },
1138
+ delete: (key) => {
1139
+ sessions.delete(key);
1140
+ },
1141
+ size: () => sessions.size
1142
+ };
1143
+ }
1144
+
1145
+ // ../../../packages/cli/src/daemon/ndjson.ts
1146
+ class LineSplitter {
1147
+ tail = "";
1148
+ pushChunk(chunk) {
1149
+ this.tail += chunk;
1150
+ const out = [];
1151
+ let idx = this.tail.indexOf(`
1152
+ `);
1153
+ while (idx !== -1) {
1154
+ out.push(this.tail.slice(0, idx));
1155
+ this.tail = this.tail.slice(idx + 1);
1156
+ idx = this.tail.indexOf(`
1157
+ `);
1158
+ }
1159
+ return out;
1160
+ }
1161
+ finish() {
1162
+ if (!this.tail)
1163
+ return [];
1164
+ const line = this.tail;
1165
+ this.tail = "";
1166
+ return [line];
1167
+ }
1168
+ }
1169
+
1170
+ class NdjsonAssembler {
1171
+ tail = "";
1172
+ texts = [];
1173
+ error;
1174
+ toolCalls = 0;
1175
+ sessionId;
1176
+ tools = [];
1177
+ pushChunk(chunk) {
1178
+ this.tail += chunk;
1179
+ let idx = this.tail.indexOf(`
1180
+ `);
1181
+ while (idx !== -1) {
1182
+ const line = this.tail.slice(0, idx);
1183
+ this.tail = this.tail.slice(idx + 1);
1184
+ this.parseLine(line);
1185
+ idx = this.tail.indexOf(`
1186
+ `);
1187
+ }
1188
+ }
1189
+ pushLine(line) {
1190
+ this.parseLine(line);
1191
+ }
1192
+ finishIncomplete() {
1193
+ return this.tail.trim();
1194
+ }
1195
+ parseLine(line) {
1196
+ const trimmed = line.trim();
1197
+ if (!trimmed || trimmed[0] !== "{")
1198
+ return;
1199
+ let parsed;
1200
+ try {
1201
+ parsed = JSON.parse(trimmed);
1202
+ } catch {
1203
+ return;
1204
+ }
1205
+ if (parsed.sessionID)
1206
+ this.sessionId = parsed.sessionID;
1207
+ if (parsed.type === "text" && typeof parsed.part?.text === "string") {
1208
+ this.texts.push(parsed.part.text);
1209
+ return;
1210
+ }
1211
+ if (parsed.type === "tool_use") {
1212
+ this.toolCalls += 1;
1213
+ const name = parsed.part?.tool ?? parsed.tool;
1214
+ if (typeof name === "string" && name) {
1215
+ this.tools.push(name);
1216
+ }
1217
+ return;
1218
+ }
1219
+ if (parsed.type === "error") {
1220
+ if (this.error === undefined) {
1221
+ this.error = parsed.error?.message ?? parsed.error?.type ?? "unknown opencode error";
1222
+ }
1223
+ }
1224
+ }
1225
+ finish() {
1226
+ if (this.tail.trim()) {
1227
+ this.parseLine(this.tail);
1228
+ this.tail = "";
1229
+ }
1230
+ const text = this.texts.join("").trim();
1231
+ return {
1232
+ text,
1233
+ error: this.error,
1234
+ toolCalls: this.toolCalls,
1235
+ sessionId: this.sessionId,
1236
+ tools: this.tools
1237
+ };
1238
+ }
1239
+ }
1240
+
1241
+ // ../../../packages/cli/src/daemon/runners/claude-stream.ts
1242
+ function errorDetail(result) {
1243
+ if (typeof result.result === "string" && result.result.trim() !== "") {
1244
+ return result.result.trim();
1245
+ }
1246
+ if (Array.isArray(result.errors)) {
1247
+ const parts = result.errors.map((e) => typeof e === "string" ? e.trim() : "").filter((e) => e !== "");
1248
+ if (parts.length > 0)
1249
+ return parts.join("; ");
1250
+ }
1251
+ return result.subtype === undefined ? "claude reported is_error" : `claude reported ${result.subtype}`;
1252
+ }
1253
+
1254
+ class ClaudeStreamDecoder {
1255
+ lines = new LineSplitter;
1256
+ state = { assistantText: [], tools: [] };
1257
+ pushChunk(chunk) {
1258
+ for (const line of this.lines.pushChunk(chunk))
1259
+ this.consume(line);
1260
+ }
1261
+ finish() {
1262
+ for (const line of this.lines.finish())
1263
+ this.consume(line);
1264
+ return this.toResult();
1265
+ }
1266
+ snapshot() {
1267
+ return { ...this.state, assistantText: [...this.state.assistantText], tools: [...this.state.tools] };
1268
+ }
1269
+ consume(rawLine) {
1270
+ const line = rawLine.trim();
1271
+ if (!line.startsWith("{"))
1272
+ return;
1273
+ let event;
1274
+ try {
1275
+ event = JSON.parse(line);
1276
+ } catch {
1277
+ return;
1278
+ }
1279
+ if (typeof event !== "object" || event === null)
1280
+ return;
1281
+ const ev = event;
1282
+ const sessionId = typeof ev.session_id === "string" ? ev.session_id : undefined;
1283
+ if (sessionId && !this.state.sessionId)
1284
+ this.state.sessionId = sessionId;
1285
+ if (ev.type === "assistant") {
1286
+ this.consumeAssistant(ev);
1287
+ return;
1288
+ }
1289
+ if (ev.type === "result") {
1290
+ this.state.result = ev;
1291
+ }
1292
+ }
1293
+ consumeAssistant(ev) {
1294
+ const message = ev.message;
1295
+ const content = message?.content;
1296
+ if (!Array.isArray(content))
1297
+ return;
1298
+ for (const block of content) {
1299
+ if (typeof block !== "object" || block === null)
1300
+ continue;
1301
+ const b = block;
1302
+ if (b.type === "text" && typeof b.text === "string") {
1303
+ this.state.assistantText.push(b.text);
1304
+ } else if (b.type === "tool_use") {
1305
+ if (typeof b.name === "string")
1306
+ this.state.tools.push(b.name);
1307
+ }
1308
+ }
1309
+ }
1310
+ toResult() {
1311
+ const { result } = this.state;
1312
+ const toolCalls = this.state.tools.length > 0 ? this.state.tools.length : undefined;
1313
+ const tools = this.state.tools.length > 0 ? [...this.state.tools] : undefined;
1314
+ if (result?.is_error) {
1315
+ const detail = errorDetail(result);
1316
+ return { text: "", sessionId: this.state.sessionId, toolCalls, tools, error: detail.slice(0, 1000) };
1317
+ }
1318
+ if (typeof result?.result === "string" && result.result.trim() !== "") {
1319
+ return { text: result.result.trim(), sessionId: this.state.sessionId, toolCalls, tools };
1320
+ }
1321
+ const fallback = this.state.assistantText.join("").trim();
1322
+ return { text: fallback, sessionId: this.state.sessionId, toolCalls, tools };
1323
+ }
1324
+ }
1325
+
1326
+ // ../../../packages/cli/src/daemon/runners/claude.ts
1327
+ var DEFAULT_CLAUDE_TIMEOUT_MS = 10 * 60 * 1000;
1328
+ var CLAUDE_STDIO = Object.freeze([
1329
+ "ignore",
1330
+ "pipe",
1331
+ "pipe"
1332
+ ]);
1333
+ function buildClaudeArgs(input) {
1334
+ const args = ["--print", "--output-format", "stream-json", "--verbose"];
1335
+ if (input.agentsJson)
1336
+ args.push("--agents", input.agentsJson);
1337
+ args.push("--agent", input.agent ?? "roy");
1338
+ args.push("--dangerously-skip-permissions");
1339
+ if (input.sessionId)
1340
+ args.push("--resume", claudeSessionUuid(input.sessionId));
1341
+ args.push(input.prompt);
1342
+ return args;
1343
+ }
1344
+ function isClaudeSessionUuid(value) {
1345
+ if (!value)
1346
+ return false;
1347
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1348
+ }
1349
+ function claudeSessionUuid(sessionKey) {
1350
+ if (isClaudeSessionUuid(sessionKey))
1351
+ return sessionKey;
1352
+ const digest = createHash2("sha256").update(sessionKey).digest("hex");
1353
+ const h = digest.slice(0, 32);
1354
+ return [
1355
+ h.slice(0, 8),
1356
+ h.slice(8, 12),
1357
+ `4${h.slice(13, 16)}`,
1358
+ `8${h.slice(17, 20)}`,
1359
+ h.slice(20, 32)
1360
+ ].join("-");
1361
+ }
1362
+ function makeClaudeRunner(opts = {}) {
1363
+ const bin = opts.bin ?? "claude";
1364
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_CLAUDE_TIMEOUT_MS;
1365
+ const agentsJson = opts.agentsJson ?? buildClaudeAgentsJson();
1366
+ const sessions = opts.sessions ?? createClaudeSessionRegistry();
1367
+ return async function runAgent(input) {
1368
+ const known = input.sessionId ? sessions.get(input.sessionId) : undefined;
1369
+ const args = buildClaudeArgs({
1370
+ prompt: input.prompt,
1371
+ agent: input.agent,
1372
+ agentsJson,
1373
+ sessionId: known
1374
+ });
1375
+ const decoder = new ClaudeStreamDecoder;
1376
+ let stderr = "";
1377
+ let handle;
1378
+ try {
1379
+ handle = spawnStreaming([bin, ...args], {
1380
+ stdin: CLAUDE_STDIO[0],
1381
+ env: { ...process.env, NO_COLOR: "1" },
1382
+ timeoutMs,
1383
+ onStdout: (chunk) => decoder.pushChunk(chunk),
1384
+ onStderr: (chunk) => {
1385
+ if (stderr.length < 64 * 1024)
1386
+ stderr += chunk;
1387
+ }
1388
+ });
1389
+ } catch (err) {
1390
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1391
+ }
1392
+ let code;
1393
+ try {
1394
+ code = await handle.exited;
1395
+ } catch (err) {
1396
+ if (isMissingBinary(err)) {
1397
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1398
+ }
1399
+ return { text: "", error: errMsg(err) };
1400
+ }
1401
+ const result = decoder.finish();
1402
+ const stderrText = stderr.trim();
1403
+ const combined = result.error ? stderrText && !result.error.includes(stderrText) ? `${result.error} | ${stderrText}` : result.error : undefined;
1404
+ const effective = combined ? { ...result, error: combined.slice(0, 1000) } : result;
1405
+ if (input.sessionId && effective.sessionId) {
1406
+ sessions.set(input.sessionId, effective.sessionId);
1407
+ }
1408
+ if (code === 124) {
1409
+ return { ...effective, error: `claude timed out after ${timeoutMs}ms` };
1410
+ }
1411
+ if (effective.error) {
1412
+ if (input.sessionId && /No conversation found|not a UUID|session title/i.test(effective.error)) {
1413
+ sessions.delete(input.sessionId);
1414
+ }
1415
+ return effective;
1416
+ }
1417
+ if (code !== 0 && !effective.text) {
1418
+ return {
1419
+ text: "",
1420
+ toolCalls: effective.toolCalls,
1421
+ tools: effective.tools,
1422
+ error: `claude exited ${code}: ${(stderr || "no stderr").trim().slice(0, 1000)}`
1423
+ };
1424
+ }
1425
+ return effective;
1426
+ };
1427
+ }
1428
+ function errMsg(err) {
1429
+ if (err instanceof Error)
1430
+ return err.message;
1431
+ return String(err);
1432
+ }
1433
+
1434
+ // ../../../packages/cli/src/daemon/runners/opencode.ts
1022
1435
  var DEFAULT_OPENCODE_TIMEOUT_MS = 10 * 60 * 1000;
1023
1436
  function makeOpencodeRunner(opts = {}) {
1024
1437
  const bin = opts.bin ?? "opencode";
@@ -1044,16 +1457,16 @@ function makeOpencodeRunner(opts = {}) {
1044
1457
  }
1045
1458
  });
1046
1459
  } catch (err) {
1047
- return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1460
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg2(err)}` };
1048
1461
  }
1049
1462
  let code;
1050
1463
  try {
1051
1464
  code = await handle.exited;
1052
1465
  } catch (err) {
1053
1466
  if (isMissingBinary(err)) {
1054
- return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1467
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg2(err)}` };
1055
1468
  }
1056
- return { text: "", error: errMsg(err) };
1469
+ return { text: "", error: errMsg2(err) };
1057
1470
  }
1058
1471
  const result = assembler.finish();
1059
1472
  if (code === 124) {
@@ -1090,11 +1503,31 @@ function makeOpencodeRunner(opts = {}) {
1090
1503
  };
1091
1504
  };
1092
1505
  }
1093
- function errMsg(err) {
1094
- return err instanceof Error ? err.message : String(err);
1506
+ function errMsg2(err) {
1507
+ if (err instanceof Error)
1508
+ return err.message;
1509
+ return String(err);
1095
1510
  }
1096
1511
 
1097
- // ../cli/src/daemon/reply.ts
1512
+ // ../../../packages/cli/src/daemon/runners/registry.ts
1513
+ var FACTORIES = {
1514
+ opencode: (input) => makeOpencodeRunner({ bin: input.bin, timeoutMs: input.timeoutMs }),
1515
+ claude: (input) => makeClaudeRunner({
1516
+ bin: input.bin,
1517
+ timeoutMs: input.timeoutMs,
1518
+ agentsJson: input.claudeAgentsJson
1519
+ })
1520
+ };
1521
+ function makeRunner(input = {}) {
1522
+ const name = input.runtime ?? DEFAULT_RUNTIME;
1523
+ if (!isRuntimeName(name)) {
1524
+ throw new Error(`unknown runtime "${name}"`);
1525
+ }
1526
+ const factory = FACTORIES[name];
1527
+ return factory({ ...input, bin: input.bin ?? DEFAULT_RUNTIME_BIN[name] });
1528
+ }
1529
+
1530
+ // ../../../packages/cli/src/daemon/reply.ts
1098
1531
  var MAX_POST_BYTES = 28 * 1024;
1099
1532
 
1100
1533
  class EmptyReplyError extends Error {
@@ -1178,7 +1611,7 @@ async function defaultReplyRunner(input) {
1178
1611
  }
1179
1612
  }
1180
1613
 
1181
- // ../cli/src/cmd/serve.ts
1614
+ // ../../../packages/cli/src/cmd/serve.ts
1182
1615
  function serve(options, deps = {}) {
1183
1616
  return startServe(options, deps);
1184
1617
  }
@@ -1188,7 +1621,11 @@ async function startServe(options, deps) {
1188
1621
  const deduper = new Deduper;
1189
1622
  const card = deps.card ?? (options.cardEnabled && !options.dryRun ? createRoyAgentCardPort() : createNoopCardPort());
1190
1623
  const dispatcher = new Dispatcher({
1191
- runAgent: deps.runAgent ?? makeOpencodeRunner({ bin: options.opencodeBin, timeoutMs: options.timeoutMs }),
1624
+ runAgent: deps.runAgent ?? makeRunner({
1625
+ runtime: options.runtime,
1626
+ bin: resolveRuntimeBin(options),
1627
+ timeoutMs: options.timeoutMs
1628
+ }),
1192
1629
  reply: deps.reply ?? defaultReplyRunner,
1193
1630
  gate,
1194
1631
  deduper,
@@ -1216,7 +1653,7 @@ async function startServe(options, deps) {
1216
1653
  try {
1217
1654
  await listener.stop();
1218
1655
  } catch (err) {
1219
- log(`[roy-task] listener.stop() failed: ${errMsg2(err)}`);
1656
+ log(`[roy-task] listener.stop() failed: ${errMsg3(err)}`);
1220
1657
  }
1221
1658
  log("[roy-task] stopped");
1222
1659
  resolveDone();
@@ -1238,7 +1675,7 @@ async function startServe(options, deps) {
1238
1675
  await stop();
1239
1676
  }
1240
1677
  } catch (err) {
1241
- log(`[roy-task] ${label} dispatch threw: ${errMsg2(err)}`);
1678
+ log(`[roy-task] ${label} dispatch threw: ${errMsg3(err)}`);
1242
1679
  if (options.once)
1243
1680
  await stop();
1244
1681
  }
@@ -1248,7 +1685,7 @@ async function startServe(options, deps) {
1248
1685
  try {
1249
1686
  await listener.start();
1250
1687
  } catch (err) {
1251
- log(`[roy-task] failed to start event listener: ${errMsg2(err)}`);
1688
+ log(`[roy-task] failed to start event listener: ${errMsg3(err)}`);
1252
1689
  clearInterval(sweeper);
1253
1690
  resolveDone();
1254
1691
  throw err;
@@ -1265,7 +1702,8 @@ function formatBanner(options, commands, maxConcurrency) {
1265
1702
  "[roy-task] starting",
1266
1703
  ` profile : ${options.profile}`,
1267
1704
  ` agent : ${options.agent}`,
1268
- ` opencode bin : ${options.opencodeBin}`,
1705
+ ` runtime : ${options.runtime}`,
1706
+ ` runtime bin : ${resolveRuntimeBin(options) ?? "(default)"}`,
1269
1707
  ` max concurrency : ${maxConcurrency}`,
1270
1708
  ` reply enabled : ${options.replyEnabled}`,
1271
1709
  ` progress cards : ${options.cardEnabled && !options.dryRun}`,
@@ -1307,11 +1745,11 @@ function formatResult(label, result) {
1307
1745
  return `[roy-task] ${label} skipped: ${result.reason}`;
1308
1746
  }
1309
1747
  }
1310
- function errMsg2(err) {
1748
+ function errMsg3(err) {
1311
1749
  return err instanceof Error ? err.message : String(err);
1312
1750
  }
1313
1751
 
1314
- // ../cli/src/runtime/sleep.ts
1752
+ // ../../../packages/cli/src/runtime/sleep.ts
1315
1753
  function sleep(ms) {
1316
1754
  return new Promise((resolve) => {
1317
1755
  const timer = setTimeout(resolve, ms);
@@ -1319,7 +1757,7 @@ function sleep(ms) {
1319
1757
  });
1320
1758
  }
1321
1759
 
1322
- // ../cli/src/cmd/status.ts
1760
+ // ../../../packages/cli/src/cmd/status.ts
1323
1761
  async function runCapture2(cmd) {
1324
1762
  try {
1325
1763
  return await runCapture(cmd);
@@ -1431,8 +1869,8 @@ function formatStatus(report) {
1431
1869
  `);
1432
1870
  }
1433
1871
 
1434
- // ../cli/src/main.ts
1435
- var VERSION = "0.3.1";
1872
+ // ../../../packages/cli/src/main.ts
1873
+ var VERSION = "0.4.1";
1436
1874
  async function main(argv) {
1437
1875
  const parsed = parseArgs(argv);
1438
1876
  switch (parsed.command) {