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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js 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,429 @@ 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
+ class ClaudeStreamDecoder {
1243
+ lines = new LineSplitter;
1244
+ state = { assistantText: [], tools: [] };
1245
+ pushChunk(chunk) {
1246
+ for (const line of this.lines.pushChunk(chunk))
1247
+ this.consume(line);
1248
+ }
1249
+ finish() {
1250
+ for (const line of this.lines.finish())
1251
+ this.consume(line);
1252
+ return this.toResult();
1253
+ }
1254
+ snapshot() {
1255
+ return { ...this.state, assistantText: [...this.state.assistantText], tools: [...this.state.tools] };
1256
+ }
1257
+ consume(rawLine) {
1258
+ const line = rawLine.trim();
1259
+ if (!line.startsWith("{"))
1260
+ return;
1261
+ let event;
1262
+ try {
1263
+ event = JSON.parse(line);
1264
+ } catch {
1265
+ return;
1266
+ }
1267
+ if (typeof event !== "object" || event === null)
1268
+ return;
1269
+ const ev = event;
1270
+ const sessionId = typeof ev.session_id === "string" ? ev.session_id : undefined;
1271
+ if (sessionId && !this.state.sessionId)
1272
+ this.state.sessionId = sessionId;
1273
+ if (ev.type === "assistant") {
1274
+ this.consumeAssistant(ev);
1275
+ return;
1276
+ }
1277
+ if (ev.type === "result") {
1278
+ this.state.result = ev;
1279
+ }
1280
+ }
1281
+ consumeAssistant(ev) {
1282
+ const message = ev.message;
1283
+ const content = message?.content;
1284
+ if (!Array.isArray(content))
1285
+ return;
1286
+ for (const block of content) {
1287
+ if (typeof block !== "object" || block === null)
1288
+ continue;
1289
+ const b = block;
1290
+ if (b.type === "text" && typeof b.text === "string") {
1291
+ this.state.assistantText.push(b.text);
1292
+ } else if (b.type === "tool_use") {
1293
+ if (typeof b.name === "string")
1294
+ this.state.tools.push(b.name);
1295
+ }
1296
+ }
1297
+ }
1298
+ toResult() {
1299
+ const { result } = this.state;
1300
+ const toolCalls = this.state.tools.length > 0 ? this.state.tools.length : undefined;
1301
+ const tools = this.state.tools.length > 0 ? [...this.state.tools] : undefined;
1302
+ if (result?.is_error) {
1303
+ const detail = typeof result.result === "string" && result.result.trim() !== "" ? result.result.trim() : result.subtype === undefined ? "claude reported is_error" : `claude reported ${result.subtype}`;
1304
+ return { text: "", sessionId: this.state.sessionId, toolCalls, tools, error: detail.slice(0, 1000) };
1305
+ }
1306
+ if (typeof result?.result === "string" && result.result.trim() !== "") {
1307
+ return { text: result.result.trim(), sessionId: this.state.sessionId, toolCalls, tools };
1308
+ }
1309
+ const fallback = this.state.assistantText.join("").trim();
1310
+ return { text: fallback, sessionId: this.state.sessionId, toolCalls, tools };
1311
+ }
1312
+ }
1313
+
1314
+ // ../../../packages/cli/src/daemon/runners/claude.ts
1315
+ var DEFAULT_CLAUDE_TIMEOUT_MS = 10 * 60 * 1000;
1316
+ var CLAUDE_STDIO = Object.freeze([
1317
+ "ignore",
1318
+ "pipe",
1319
+ "pipe"
1320
+ ]);
1321
+ function buildClaudeArgs(input) {
1322
+ const args = ["--print", "--output-format", "stream-json", "--verbose"];
1323
+ if (input.agentsJson)
1324
+ args.push("--agents", input.agentsJson);
1325
+ args.push("--agent", input.agent ?? "roy");
1326
+ args.push("--dangerously-skip-permissions");
1327
+ if (input.sessionId)
1328
+ args.push("--resume", claudeSessionUuid(input.sessionId));
1329
+ args.push(input.prompt);
1330
+ return args;
1331
+ }
1332
+ function isClaudeSessionUuid(value) {
1333
+ if (!value)
1334
+ return false;
1335
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1336
+ }
1337
+ function claudeSessionUuid(sessionKey) {
1338
+ if (isClaudeSessionUuid(sessionKey))
1339
+ return sessionKey;
1340
+ const digest = createHash2("sha256").update(sessionKey).digest("hex");
1341
+ const h = digest.slice(0, 32);
1342
+ return [
1343
+ h.slice(0, 8),
1344
+ h.slice(8, 12),
1345
+ `4${h.slice(13, 16)}`,
1346
+ `8${h.slice(17, 20)}`,
1347
+ h.slice(20, 32)
1348
+ ].join("-");
1349
+ }
1350
+ function makeClaudeRunner(opts = {}) {
1351
+ const bin = opts.bin ?? "claude";
1352
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_CLAUDE_TIMEOUT_MS;
1353
+ const agentsJson = opts.agentsJson ?? buildClaudeAgentsJson();
1354
+ const sessions = opts.sessions ?? createClaudeSessionRegistry();
1355
+ return async function runAgent(input) {
1356
+ const known = input.sessionId ? sessions.get(input.sessionId) : undefined;
1357
+ const args = buildClaudeArgs({
1358
+ prompt: input.prompt,
1359
+ agent: input.agent,
1360
+ agentsJson,
1361
+ sessionId: known
1362
+ });
1363
+ const decoder = new ClaudeStreamDecoder;
1364
+ let stderr = "";
1365
+ let handle;
1366
+ try {
1367
+ handle = spawnStreaming([bin, ...args], {
1368
+ stdin: CLAUDE_STDIO[0],
1369
+ env: { ...process.env, NO_COLOR: "1" },
1370
+ timeoutMs,
1371
+ onStdout: (chunk) => decoder.pushChunk(chunk),
1372
+ onStderr: (chunk) => {
1373
+ if (stderr.length < 64 * 1024)
1374
+ stderr += chunk;
1375
+ }
1376
+ });
1377
+ } catch (err) {
1378
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1379
+ }
1380
+ let code;
1381
+ try {
1382
+ code = await handle.exited;
1383
+ } catch (err) {
1384
+ if (isMissingBinary(err)) {
1385
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1386
+ }
1387
+ return { text: "", error: errMsg(err) };
1388
+ }
1389
+ const result = decoder.finish();
1390
+ if (input.sessionId && result.sessionId && !result.error) {
1391
+ sessions.set(input.sessionId, result.sessionId);
1392
+ }
1393
+ if (code === 124) {
1394
+ return { ...result, error: `claude timed out after ${timeoutMs}ms` };
1395
+ }
1396
+ if (result.error) {
1397
+ if (input.sessionId && /No conversation found|not a UUID|session title/i.test(result.error)) {
1398
+ sessions.delete(input.sessionId);
1399
+ }
1400
+ return result;
1401
+ }
1402
+ if (code !== 0 && !result.text) {
1403
+ return {
1404
+ text: "",
1405
+ toolCalls: result.toolCalls,
1406
+ tools: result.tools,
1407
+ error: `claude exited ${code}: ${(stderr || "no stderr").trim().slice(0, 1000)}`
1408
+ };
1409
+ }
1410
+ return result;
1411
+ };
1412
+ }
1413
+ function errMsg(err) {
1414
+ if (err instanceof Error)
1415
+ return err.message;
1416
+ return String(err);
1417
+ }
1418
+
1419
+ // ../../../packages/cli/src/daemon/runners/opencode.ts
1022
1420
  var DEFAULT_OPENCODE_TIMEOUT_MS = 10 * 60 * 1000;
1023
1421
  function makeOpencodeRunner(opts = {}) {
1024
1422
  const bin = opts.bin ?? "opencode";
@@ -1044,16 +1442,16 @@ function makeOpencodeRunner(opts = {}) {
1044
1442
  }
1045
1443
  });
1046
1444
  } catch (err) {
1047
- return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1445
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg2(err)}` };
1048
1446
  }
1049
1447
  let code;
1050
1448
  try {
1051
1449
  code = await handle.exited;
1052
1450
  } catch (err) {
1053
1451
  if (isMissingBinary(err)) {
1054
- return { text: "", error: `failed to spawn ${bin}: ${errMsg(err)}` };
1452
+ return { text: "", error: `failed to spawn ${bin}: ${errMsg2(err)}` };
1055
1453
  }
1056
- return { text: "", error: errMsg(err) };
1454
+ return { text: "", error: errMsg2(err) };
1057
1455
  }
1058
1456
  const result = assembler.finish();
1059
1457
  if (code === 124) {
@@ -1090,11 +1488,31 @@ function makeOpencodeRunner(opts = {}) {
1090
1488
  };
1091
1489
  };
1092
1490
  }
1093
- function errMsg(err) {
1094
- return err instanceof Error ? err.message : String(err);
1491
+ function errMsg2(err) {
1492
+ if (err instanceof Error)
1493
+ return err.message;
1494
+ return String(err);
1095
1495
  }
1096
1496
 
1097
- // ../cli/src/daemon/reply.ts
1497
+ // ../../../packages/cli/src/daemon/runners/registry.ts
1498
+ var FACTORIES = {
1499
+ opencode: (input) => makeOpencodeRunner({ bin: input.bin, timeoutMs: input.timeoutMs }),
1500
+ claude: (input) => makeClaudeRunner({
1501
+ bin: input.bin,
1502
+ timeoutMs: input.timeoutMs,
1503
+ agentsJson: input.claudeAgentsJson
1504
+ })
1505
+ };
1506
+ function makeRunner(input = {}) {
1507
+ const name = input.runtime ?? DEFAULT_RUNTIME;
1508
+ if (!isRuntimeName(name)) {
1509
+ throw new Error(`unknown runtime "${name}"`);
1510
+ }
1511
+ const factory = FACTORIES[name];
1512
+ return factory({ ...input, bin: input.bin ?? DEFAULT_RUNTIME_BIN[name] });
1513
+ }
1514
+
1515
+ // ../../../packages/cli/src/daemon/reply.ts
1098
1516
  var MAX_POST_BYTES = 28 * 1024;
1099
1517
 
1100
1518
  class EmptyReplyError extends Error {
@@ -1178,7 +1596,7 @@ async function defaultReplyRunner(input) {
1178
1596
  }
1179
1597
  }
1180
1598
 
1181
- // ../cli/src/cmd/serve.ts
1599
+ // ../../../packages/cli/src/cmd/serve.ts
1182
1600
  function serve(options, deps = {}) {
1183
1601
  return startServe(options, deps);
1184
1602
  }
@@ -1188,7 +1606,11 @@ async function startServe(options, deps) {
1188
1606
  const deduper = new Deduper;
1189
1607
  const card = deps.card ?? (options.cardEnabled && !options.dryRun ? createRoyAgentCardPort() : createNoopCardPort());
1190
1608
  const dispatcher = new Dispatcher({
1191
- runAgent: deps.runAgent ?? makeOpencodeRunner({ bin: options.opencodeBin, timeoutMs: options.timeoutMs }),
1609
+ runAgent: deps.runAgent ?? makeRunner({
1610
+ runtime: options.runtime,
1611
+ bin: resolveRuntimeBin(options),
1612
+ timeoutMs: options.timeoutMs
1613
+ }),
1192
1614
  reply: deps.reply ?? defaultReplyRunner,
1193
1615
  gate,
1194
1616
  deduper,
@@ -1216,7 +1638,7 @@ async function startServe(options, deps) {
1216
1638
  try {
1217
1639
  await listener.stop();
1218
1640
  } catch (err) {
1219
- log(`[roy-task] listener.stop() failed: ${errMsg2(err)}`);
1641
+ log(`[roy-task] listener.stop() failed: ${errMsg3(err)}`);
1220
1642
  }
1221
1643
  log("[roy-task] stopped");
1222
1644
  resolveDone();
@@ -1238,7 +1660,7 @@ async function startServe(options, deps) {
1238
1660
  await stop();
1239
1661
  }
1240
1662
  } catch (err) {
1241
- log(`[roy-task] ${label} dispatch threw: ${errMsg2(err)}`);
1663
+ log(`[roy-task] ${label} dispatch threw: ${errMsg3(err)}`);
1242
1664
  if (options.once)
1243
1665
  await stop();
1244
1666
  }
@@ -1248,7 +1670,7 @@ async function startServe(options, deps) {
1248
1670
  try {
1249
1671
  await listener.start();
1250
1672
  } catch (err) {
1251
- log(`[roy-task] failed to start event listener: ${errMsg2(err)}`);
1673
+ log(`[roy-task] failed to start event listener: ${errMsg3(err)}`);
1252
1674
  clearInterval(sweeper);
1253
1675
  resolveDone();
1254
1676
  throw err;
@@ -1265,7 +1687,8 @@ function formatBanner(options, commands, maxConcurrency) {
1265
1687
  "[roy-task] starting",
1266
1688
  ` profile : ${options.profile}`,
1267
1689
  ` agent : ${options.agent}`,
1268
- ` opencode bin : ${options.opencodeBin}`,
1690
+ ` runtime : ${options.runtime}`,
1691
+ ` runtime bin : ${resolveRuntimeBin(options) ?? "(default)"}`,
1269
1692
  ` max concurrency : ${maxConcurrency}`,
1270
1693
  ` reply enabled : ${options.replyEnabled}`,
1271
1694
  ` progress cards : ${options.cardEnabled && !options.dryRun}`,
@@ -1307,11 +1730,11 @@ function formatResult(label, result) {
1307
1730
  return `[roy-task] ${label} skipped: ${result.reason}`;
1308
1731
  }
1309
1732
  }
1310
- function errMsg2(err) {
1733
+ function errMsg3(err) {
1311
1734
  return err instanceof Error ? err.message : String(err);
1312
1735
  }
1313
1736
 
1314
- // ../cli/src/runtime/sleep.ts
1737
+ // ../../../packages/cli/src/runtime/sleep.ts
1315
1738
  function sleep(ms) {
1316
1739
  return new Promise((resolve) => {
1317
1740
  const timer = setTimeout(resolve, ms);
@@ -1319,7 +1742,7 @@ function sleep(ms) {
1319
1742
  });
1320
1743
  }
1321
1744
 
1322
- // ../cli/src/cmd/status.ts
1745
+ // ../../../packages/cli/src/cmd/status.ts
1323
1746
  async function runCapture2(cmd) {
1324
1747
  try {
1325
1748
  return await runCapture(cmd);
@@ -1431,8 +1854,8 @@ function formatStatus(report) {
1431
1854
  `);
1432
1855
  }
1433
1856
 
1434
- // ../cli/src/main.ts
1435
- var VERSION = "0.2.0";
1857
+ // ../../../packages/cli/src/main.ts
1858
+ var VERSION = "0.4.0";
1436
1859
  async function main(argv) {
1437
1860
  const parsed = parseArgs(argv);
1438
1861
  switch (parsed.command) {