@shipers-dev/multi 0.5.0 → 0.6.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/index.js CHANGED
@@ -5198,6 +5198,7 @@ async function runAcp(opts) {
5198
5198
  const stream2 = ndJsonStream(output, input);
5199
5199
  let activeSessionId = opts.sessionId || null;
5200
5200
  let recording = false;
5201
+ const alwaysAllow = new Set;
5201
5202
  const client = {
5202
5203
  async sessionUpdate(params) {
5203
5204
  if (!recording)
@@ -5205,7 +5206,7 @@ async function runAcp(opts) {
5205
5206
  await handleSessionUpdate(params, opts);
5206
5207
  },
5207
5208
  async requestPermission(params) {
5208
- return await handleRequestPermission(params, opts);
5209
+ return await handleRequestPermission(params, opts, alwaysAllow);
5209
5210
  },
5210
5211
  async readTextFile(params) {
5211
5212
  try {
@@ -5325,8 +5326,14 @@ ${entries}` } });
5325
5326
  break;
5326
5327
  }
5327
5328
  }
5328
- async function handleRequestPermission(params, o) {
5329
+ async function handleRequestPermission(params, o, allowCache) {
5329
5330
  const tc = params.toolCall || {};
5331
+ const toolKey = `${tc.toolName || tc.title || ""}|${tc.kind || ""}`.toLowerCase();
5332
+ if (toolKey && allowCache.has(toolKey)) {
5333
+ const allowOpt = params.options.find((op) => /allow/i.test(op.kind || "") || /allow/i.test(op.name || ""));
5334
+ if (allowOpt)
5335
+ return { outcome: { outcome: "selected", optionId: allowOpt.optionId } };
5336
+ }
5330
5337
  const created = await apiClient.post(`${o.apiUrl}/api/permissions`, {
5331
5338
  issue_id: o.issueId,
5332
5339
  device_id: o.deviceId,
@@ -5346,6 +5353,10 @@ ${entries}` } });
5346
5353
  if (!row)
5347
5354
  break;
5348
5355
  if (row.status === "resolved" && row.chosen) {
5356
+ const chosenOpt = params.options.find((op) => op.optionId === row.chosen);
5357
+ const isAlways = chosenOpt && (/always/i.test(chosenOpt.name || "") || /allow_always/i.test(chosenOpt.kind || ""));
5358
+ if (isAlways && toolKey)
5359
+ allowCache.add(toolKey);
5349
5360
  return { outcome: { outcome: "selected", optionId: row.chosen } };
5350
5361
  }
5351
5362
  if (row.status === "cancelled") {
@@ -5369,6 +5380,145 @@ function extractText(content) {
5369
5380
  return "";
5370
5381
  }
5371
5382
 
5383
+ // src/acpx-runner.ts
5384
+ async function runAcpx(opts) {
5385
+ const args = ["acpx", "--format", "json", "--json-strict", "--approve-all"];
5386
+ if (opts.cwd)
5387
+ args.push("--cwd", opts.cwd);
5388
+ args.push(opts.agentType);
5389
+ if (opts.sessionName) {
5390
+ const ensureArgs = ["acpx", ...opts.cwd ? ["--cwd", opts.cwd] : [], opts.agentType, "sessions", "ensure", "--name", opts.sessionName];
5391
+ try {
5392
+ const ensure = Bun.spawn(ensureArgs, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
5393
+ await ensure.exited;
5394
+ } catch {}
5395
+ args.push("prompt", "-s", opts.sessionName);
5396
+ } else {
5397
+ args.push("prompt");
5398
+ }
5399
+ args.push(opts.prompt);
5400
+ const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
5401
+ let stopReason = "end_turn";
5402
+ const reader = proc.stdout.getReader();
5403
+ const dec = new TextDecoder;
5404
+ let buf = "";
5405
+ while (true) {
5406
+ const { value, done } = await reader.read();
5407
+ if (done)
5408
+ break;
5409
+ buf += dec.decode(value, { stream: true });
5410
+ let nl;
5411
+ while ((nl = buf.indexOf(`
5412
+ `)) !== -1) {
5413
+ const line = buf.slice(0, nl).trim();
5414
+ buf = buf.slice(nl + 1);
5415
+ if (!line)
5416
+ continue;
5417
+ const events = parseAcpLine(line, (r) => {
5418
+ stopReason = r;
5419
+ });
5420
+ for (const ev of events)
5421
+ await opts.onEvent(ev);
5422
+ }
5423
+ }
5424
+ if (buf.trim()) {
5425
+ const events = parseAcpLine(buf.trim(), (r) => {
5426
+ stopReason = r;
5427
+ });
5428
+ for (const ev of events)
5429
+ await opts.onEvent(ev);
5430
+ }
5431
+ const code = await proc.exited;
5432
+ if (code !== 0 && code !== 130) {
5433
+ await opts.onEvent({ event_type: "error", payload: { message: `acpx exited with code ${code}` } });
5434
+ }
5435
+ return { stopReason };
5436
+ }
5437
+ function parseAcpLine(line, setStop) {
5438
+ let msg;
5439
+ try {
5440
+ msg = JSON.parse(line);
5441
+ } catch {
5442
+ return [];
5443
+ }
5444
+ const out = [];
5445
+ if (msg.method === "session/update" && msg.params?.sessionUpdate) {
5446
+ return handleSessionUpdate(msg.params);
5447
+ }
5448
+ if (msg.id && msg.result && typeof msg.result === "object" && "stopReason" in msg.result) {
5449
+ setStop(msg.result.stopReason);
5450
+ out.push({ event_type: "result", payload: { stopReason: msg.result.stopReason } });
5451
+ return out;
5452
+ }
5453
+ if (msg.error) {
5454
+ out.push({ event_type: "error", payload: { message: msg.error.message || JSON.stringify(msg.error) } });
5455
+ return out;
5456
+ }
5457
+ return out;
5458
+ }
5459
+ function handleSessionUpdate(params) {
5460
+ const u = params.update || {};
5461
+ const kind = u.sessionUpdate;
5462
+ const out = [];
5463
+ switch (kind) {
5464
+ case "agent_message_chunk":
5465
+ case "agent_thought_chunk": {
5466
+ const text = extractText2(u.content);
5467
+ if (text)
5468
+ out.push({ event_type: "assistant_text", payload: { text } });
5469
+ break;
5470
+ }
5471
+ case "tool_call":
5472
+ case "tool_call_update": {
5473
+ out.push({ event_type: "tool_call", payload: {
5474
+ id: u.toolCallId || u.id,
5475
+ tool: u.title || u.toolName || "tool",
5476
+ kind: u.kind,
5477
+ status: u.status,
5478
+ input: u.rawInput,
5479
+ locations: u.locations
5480
+ } });
5481
+ if (Array.isArray(u.content)) {
5482
+ for (const c of u.content) {
5483
+ if (c.type === "content" && c.content?.type === "text") {
5484
+ out.push({ event_type: "tool_result", payload: { tool_use_id: u.toolCallId || u.id, content: String(c.content.text).slice(0, 4000) } });
5485
+ } else if (c.type === "diff") {
5486
+ const diff = `--- ${c.path}
5487
+ ${c.oldText || ""}
5488
+ +++ ${c.path}
5489
+ ${c.newText || ""}`.slice(0, 4000);
5490
+ out.push({ event_type: "tool_result", payload: { tool_use_id: u.toolCallId || u.id, content: diff } });
5491
+ }
5492
+ }
5493
+ }
5494
+ break;
5495
+ }
5496
+ case "plan": {
5497
+ const entries = (u.entries || []).map((e, i) => `${i + 1}. [${e.status || "pending"}] ${e.content}`).join(`
5498
+ `);
5499
+ if (entries)
5500
+ out.push({ event_type: "assistant_text", payload: { text: `**Plan**
5501
+
5502
+ ${entries}` } });
5503
+ break;
5504
+ }
5505
+ }
5506
+ return out;
5507
+ }
5508
+ function extractText2(content) {
5509
+ if (!content)
5510
+ return "";
5511
+ if (typeof content === "string")
5512
+ return content;
5513
+ if (Array.isArray(content))
5514
+ return content.map(extractText2).join("");
5515
+ if (content.type === "text" && typeof content.text === "string")
5516
+ return content.text;
5517
+ if (content.text)
5518
+ return content.text;
5519
+ return "";
5520
+ }
5521
+
5372
5522
  // src/index.ts
5373
5523
  import { parseArgs } from "util";
5374
5524
  import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, appendFileSync, unlinkSync, readdirSync, statSync } from "fs";
@@ -5381,7 +5531,7 @@ var LOG_PATH = join(MULTI_DIR, "logs", "agent.log");
5381
5531
  var SKILLS_DIR = join(MULTI_DIR, "skills");
5382
5532
  var STOP_PATH = join(MULTI_DIR, "stop.flag");
5383
5533
  var TASKS_DB_PATH = join(MULTI_DIR, "tasks.db");
5384
- var VERSION = "0.5.0";
5534
+ var VERSION = "0.6.0";
5385
5535
  var COMMANDS = {
5386
5536
  setup: "Register this device with a workspace",
5387
5537
  connect: "Connect device to realtime hub and execute assigned tasks",
@@ -5833,29 +5983,71 @@ async function handleRunTask(apiUrl, deviceId, task, detected) {
5833
5983
  } catch {}
5834
5984
  };
5835
5985
  const turn = {
5836
- text: "",
5837
- toolOrder: [],
5986
+ blocks: [],
5838
5987
  tools: new Map,
5839
5988
  plans: [],
5840
5989
  progress: [],
5841
5990
  result: null,
5842
5991
  error: null
5843
5992
  };
5993
+ const appendText = (text) => {
5994
+ const last = turn.blocks[turn.blocks.length - 1];
5995
+ if (last && last.kind === "text")
5996
+ last.text += text;
5997
+ else
5998
+ turn.blocks.push({ kind: "text", text });
5999
+ };
6000
+ const upsertTool = (id, patch) => {
6001
+ const existing = turn.tools.get(id);
6002
+ if (existing) {
6003
+ if (patch.tool && patch.tool !== "tool")
6004
+ existing.tool = patch.tool;
6005
+ if (patch.kind)
6006
+ existing.kind = patch.kind;
6007
+ if (patch.status)
6008
+ existing.status = patch.status;
6009
+ if (patch.input !== undefined)
6010
+ existing.input = patch.input;
6011
+ } else {
6012
+ turn.tools.set(id, { id, tool: patch.tool || "tool", kind: patch.kind, status: patch.status, input: patch.input, results: [] });
6013
+ turn.blocks.push({ kind: "tool", id });
6014
+ }
6015
+ };
6016
+ const toolLabel = (t) => {
6017
+ const clean = stripMd(t.tool);
6018
+ if (clean && clean !== "tool")
6019
+ return clean;
6020
+ if (t.input && typeof t.input === "object") {
6021
+ if (t.input.command)
6022
+ return `Bash: ${String(t.input.command).split(`
6023
+ `)[0].slice(0, 60)}`;
6024
+ if (t.input.file_path)
6025
+ return `${t.kind === "edit" ? "Edit" : "Read"}: ${t.input.file_path}`;
6026
+ if (t.input.pattern)
6027
+ return `Grep: ${t.input.pattern}`;
6028
+ if (t.input.url)
6029
+ return `Fetch: ${t.input.url}`;
6030
+ }
6031
+ return t.kind ? `${t.kind}` : "tool";
6032
+ };
5844
6033
  const render = () => {
5845
6034
  const parts = [];
5846
- if (turn.text)
5847
- parts.push(turn.text);
5848
- for (const id of turn.toolOrder) {
5849
- const t = turn.tools.get(id);
6035
+ for (const b of turn.blocks) {
6036
+ if (b.kind === "text") {
6037
+ if (b.text)
6038
+ parts.push(b.text);
6039
+ continue;
6040
+ }
6041
+ const t = turn.tools.get(b.id);
5850
6042
  if (!t)
5851
6043
  continue;
5852
6044
  const icon = statusIcon(t.status);
5853
- const cleanTool = stripMd(t.tool);
5854
- const head = `${icon} ${cleanTool}${t.kind ? ` \xB7 ${t.kind}` : ""}${t.status && t.status !== "completed" ? ` \xB7 ${t.status}` : ""}`;
6045
+ const label = toolLabel(t);
6046
+ const head = `${icon} ${label}${t.status && t.status !== "completed" ? ` \xB7 ${t.status}` : ""}`;
5855
6047
  const body = [];
5856
6048
  if (t.input !== undefined && t.input !== null) {
5857
6049
  const inputStr = typeof t.input === "object" ? JSON.stringify(t.input, null, 2) : String(t.input);
5858
- body.push("```json\n" + inputStr + "\n```");
6050
+ body.push("```json\n" + inputStr.slice(0, 2000) + "\n```");
5859
6051
  }
5860
6052
  if (t.results.length) {
5861
6053
  const joined = t.results.join(`
@@ -5917,7 +6109,7 @@ _${bits.join(" \xB7 ")}_`);
5917
6109
  }
5918
6110
  case "assistant_text": {
5919
6111
  await ensureLiveComment();
5920
- turn.text += p.text;
6112
+ appendText(p.text);
5921
6113
  hasAssistantText = true;
5922
6114
  schedulePatch();
5923
6115
  break;
@@ -5925,8 +6117,8 @@ _${bits.join(" \xB7 ")}_`);
5925
6117
  case "stdout": {
5926
6118
  if (p.line) {
5927
6119
  await ensureLiveComment();
5928
- turn.text += (turn.text ? `
5929
- ` : "") + p.line;
6120
+ appendText((turn.blocks.length ? `
6121
+ ` : "") + p.line);
5930
6122
  hasAssistantText = true;
5931
6123
  schedulePatch();
5932
6124
  }
@@ -5934,36 +6126,24 @@ _${bits.join(" \xB7 ")}_`);
5934
6126
  }
5935
6127
  case "tool_call": {
5936
6128
  await ensureLiveComment();
5937
- const id = p.id || `anon-${turn.toolOrder.length}`;
5938
- const existing = turn.tools.get(id);
5939
- if (existing) {
5940
- if (p.tool)
5941
- existing.tool = p.tool;
5942
- if (p.kind)
5943
- existing.kind = p.kind;
5944
- if (p.status)
5945
- existing.status = p.status;
5946
- if (p.input !== undefined)
5947
- existing.input = p.input;
5948
- } else {
5949
- turn.toolOrder.push(id);
5950
- turn.tools.set(id, { id, tool: p.tool || "tool", kind: p.kind, status: p.status, input: p.input, results: [] });
5951
- }
6129
+ const id = p.id || `anon-${turn.tools.size}`;
6130
+ upsertTool(id, { tool: p.tool, kind: p.kind, status: p.status, input: p.input });
5952
6131
  schedulePatch();
5953
6132
  break;
5954
6133
  }
5955
6134
  case "tool_result": {
5956
6135
  await ensureLiveComment();
5957
- const id = p.tool_use_id || turn.toolOrder[turn.toolOrder.length - 1];
6136
+ const lastToolId = [...turn.blocks].reverse().find((b) => b.kind === "tool")?.id;
6137
+ const id = p.tool_use_id || lastToolId;
5958
6138
  const entry = id ? turn.tools.get(id) : undefined;
5959
6139
  const content = String(p.content ?? "").trim();
5960
6140
  if (entry && content) {
5961
6141
  entry.results.push(content);
5962
6142
  schedulePatch();
5963
6143
  } else if (content) {
5964
- const pid = `result-${turn.toolOrder.length}`;
5965
- turn.toolOrder.push(pid);
5966
- turn.tools.set(pid, { id: pid, tool: "tool result", results: [content] });
6144
+ const pid = `result-${turn.tools.size}`;
6145
+ upsertTool(pid, { tool: "tool result" });
6146
+ turn.tools.get(pid).results.push(content);
5967
6147
  schedulePatch();
5968
6148
  }
5969
6149
  break;
@@ -5973,7 +6153,7 @@ _${bits.join(" \xB7 ")}_`);
5973
6153
  if (p.is_error)
5974
6154
  hadError = true;
5975
6155
  if (!hasAssistantText && p.result) {
5976
- turn.text = p.result;
6156
+ appendText(p.result);
5977
6157
  hasAssistantText = true;
5978
6158
  }
5979
6159
  turn.result = { duration_ms: p.duration_ms, total_cost_usd: p.total_cost_usd, stopReason: p.stopReason, is_error: p.is_error };
@@ -5996,6 +6176,7 @@ _${bits.join(" \xB7 ")}_`);
5996
6176
  } catch {}
5997
6177
  const acpCapable = detected.filter((d) => d.type === "claude-code");
5998
6178
  const useAcp = preferType !== "pi" && acpCapable.length > 0 && process.env.MULTI_LEGACY !== "1";
6179
+ const useAcpx = !useAcp && preferType && ["pi", "codex", "openclaw"].includes(preferType) && process.env.MULTI_LEGACY !== "1";
5999
6180
  try {
6000
6181
  if (useAcp) {
6001
6182
  const base = `${task.title}
@@ -6083,14 +6264,72 @@ ${userPart}` : userPart;
6083
6264
  }
6084
6265
  });
6085
6266
  log(` acp session ${sessionId.slice(0, 8)}`);
6086
- } else {
6087
- let preferType2;
6267
+ } else if (useAcpx) {
6268
+ let preamble = "";
6088
6269
  try {
6089
- const a = await apiClient.get(`${apiUrl}/api/agents/${task.agent_id}`);
6090
- if (a.data?.type)
6091
- preferType2 = a.data.type;
6270
+ const agentRes = await apiClient.get(`${apiUrl}/api/agents/${task.agent_id}`);
6271
+ const agent = agentRes.data;
6272
+ if (agent?.prompt)
6273
+ preamble += `# Agent instructions
6274
+
6275
+ ${agent.prompt}
6276
+
6277
+ `;
6278
+ const skillsRes = await apiClient.get(`${apiUrl}/api/agents/${task.agent_id}/skills`);
6279
+ const skillList = Array.isArray(skillsRes.data) ? skillsRes.data : [];
6280
+ if (skillList.length) {
6281
+ preamble += `# Attached skills (${skillList.length})
6282
+
6283
+ `;
6284
+ for (const s of skillList) {
6285
+ const body = String(s.body || s.description || "").trim();
6286
+ if (body)
6287
+ preamble += `## ${s.name}${s.version ? ` v${s.version}` : ""}
6288
+
6289
+ ${body}
6290
+
6291
+ `;
6292
+ }
6293
+ }
6092
6294
  } catch {}
6093
- const runner = pickRunner(detected, preferType2);
6295
+ const base = `${task.title}
6296
+
6297
+ ${task.description || ""}`.trim();
6298
+ let userPart = task.followup ? `${task.followup}
6299
+
6300
+ ---
6301
+ Context (${task.key}): ${task.title}` : base || task.title;
6302
+ userPart = stripSelfMention(userPart, preferType);
6303
+ if (attachmentRefs.length) {
6304
+ const lines = attachmentRefs.map((a) => `- ${a.filename}: ${a.path}`).join(`
6305
+ `);
6306
+ userPart += `
6307
+
6308
+ ---
6309
+ Attached files:
6310
+ ${lines}
6311
+
6312
+ Write generated files to: ${outDir}`;
6313
+ } else {
6314
+ userPart += `
6315
+
6316
+ ---
6317
+ Write generated files to: ${outDir}`;
6318
+ }
6319
+ const full = preamble ? `${preamble}
6320
+ ---
6321
+
6322
+ ${userPart}` : userPart;
6323
+ log(` acpx runner: ${preferType}`);
6324
+ await runAcpx({
6325
+ agentType: preferType,
6326
+ prompt: full,
6327
+ cwd: workingDir,
6328
+ sessionName: `issue-${issueId}`,
6329
+ onEvent: eventHandler
6330
+ });
6331
+ } else {
6332
+ const runner = pickRunner(detected, preferType);
6094
6333
  for await (const event of runner(task))
6095
6334
  await eventHandler(event);
6096
6335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/acp-runner.ts CHANGED
@@ -54,6 +54,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
54
54
 
55
55
  let activeSessionId: string | null = opts.sessionId || null;
56
56
  let recording = false; // only forward events after prompt() starts
57
+ const alwaysAllow = new Set<string>(); // keys: toolName|kind that user said "always allow"
57
58
 
58
59
  const client: Client = {
59
60
  async sessionUpdate(params: SessionNotification): Promise<void> {
@@ -61,7 +62,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
61
62
  await handleSessionUpdate(params, opts);
62
63
  },
63
64
  async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
64
- return await handleRequestPermission(params, opts);
65
+ return await handleRequestPermission(params, opts, alwaysAllow);
65
66
  },
66
67
  async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
67
68
  try {
@@ -170,8 +171,16 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
170
171
  }
171
172
  }
172
173
 
173
- async function handleRequestPermission(params: RequestPermissionRequest, o: AcpRunOpts): Promise<RequestPermissionResponse> {
174
+ async function handleRequestPermission(params: RequestPermissionRequest, o: AcpRunOpts, allowCache: Set<string>): Promise<RequestPermissionResponse> {
174
175
  const tc: any = params.toolCall || {};
176
+ const toolKey = `${tc.toolName || tc.title || ''}|${tc.kind || ''}`.toLowerCase();
177
+
178
+ // Auto-approve if user previously chose "always allow" for same tool/kind
179
+ if (toolKey && allowCache.has(toolKey)) {
180
+ const allowOpt = (params.options as any[]).find(op => /allow/i.test(op.kind || '') || /allow/i.test(op.name || ''));
181
+ if (allowOpt) return { outcome: { outcome: 'selected', optionId: allowOpt.optionId } as any };
182
+ }
183
+
175
184
  const created = await apiClient.post<any>(`${o.apiUrl}/api/permissions`, {
176
185
  issue_id: o.issueId,
177
186
  device_id: o.deviceId,
@@ -183,7 +192,6 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
183
192
  const permId = created.data?.id;
184
193
  if (!permId) return { outcome: { outcome: 'cancelled' } as any };
185
194
 
186
- // Poll every 500ms up to 5 min
187
195
  const deadline = Date.now() + 5 * 60 * 1000;
188
196
  while (Date.now() < deadline) {
189
197
  await new Promise(r => setTimeout(r, 500));
@@ -191,6 +199,10 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
191
199
  const row = got.data;
192
200
  if (!row) break;
193
201
  if (row.status === 'resolved' && row.chosen) {
202
+ // If the chosen option is an "always" variant, cache it
203
+ const chosenOpt = (params.options as any[]).find(op => op.optionId === row.chosen);
204
+ const isAlways = chosenOpt && (/always/i.test(chosenOpt.name || '') || /allow_always/i.test(chosenOpt.kind || ''));
205
+ if (isAlways && toolKey) allowCache.add(toolKey);
194
206
  return { outcome: { outcome: 'selected', optionId: row.chosen } as any };
195
207
  }
196
208
  if (row.status === 'cancelled') {
@@ -0,0 +1,136 @@
1
+ // acpx-runner — delegate to `acpx` CLI for pi/codex/openclaw agents.
2
+ // acpx emits raw ACP JSON-RPC NDJSON on stdout with --format json; we reuse the
3
+ // same parse semantics as our direct ACP runner to produce AcpEvent stream.
4
+ //
5
+ // Requires: `npm install -g acpx` on device host (or npx acpx@latest).
6
+ // Runs with --approve-all for now (no permission UI forwarding).
7
+
8
+ import type { AcpEvent } from './acp-runner';
9
+
10
+ export interface AcpxRunOpts {
11
+ agentType: 'pi' | 'codex' | 'openclaw' | 'claude' | string;
12
+ prompt: string;
13
+ cwd?: string;
14
+ sessionName?: string; // reuse same session across follow-ups (e.g. "issue-<id>")
15
+ onEvent: (ev: AcpEvent) => void | Promise<void>;
16
+ }
17
+
18
+ export async function runAcpx(opts: AcpxRunOpts): Promise<{ stopReason: string }> {
19
+ const args = ['acpx', '--format', 'json', '--json-strict', '--approve-all'];
20
+ if (opts.cwd) args.push('--cwd', opts.cwd);
21
+ args.push(opts.agentType);
22
+ // Ensure a session exists for this name (idempotent). ensure = get-or-create.
23
+ // We run it as a separate invocation, then prompt.
24
+ if (opts.sessionName) {
25
+ const ensureArgs = ['acpx', ...(opts.cwd ? ['--cwd', opts.cwd] : []), opts.agentType, 'sessions', 'ensure', '--name', opts.sessionName];
26
+ try {
27
+ const ensure = Bun.spawn(ensureArgs, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
28
+ await ensure.exited;
29
+ } catch {}
30
+ args.push('prompt', '-s', opts.sessionName);
31
+ } else {
32
+ args.push('prompt');
33
+ }
34
+ args.push(opts.prompt);
35
+
36
+ const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
37
+ let stopReason = 'end_turn';
38
+
39
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
40
+ const dec = new TextDecoder();
41
+ let buf = '';
42
+
43
+ while (true) {
44
+ const { value, done } = await reader.read();
45
+ if (done) break;
46
+ buf += dec.decode(value, { stream: true });
47
+ let nl: number;
48
+ while ((nl = buf.indexOf('\n')) !== -1) {
49
+ const line = buf.slice(0, nl).trim();
50
+ buf = buf.slice(nl + 1);
51
+ if (!line) continue;
52
+ const events = parseAcpLine(line, (r) => { stopReason = r; });
53
+ for (const ev of events) await opts.onEvent(ev);
54
+ }
55
+ }
56
+ if (buf.trim()) {
57
+ const events = parseAcpLine(buf.trim(), (r) => { stopReason = r; });
58
+ for (const ev of events) await opts.onEvent(ev);
59
+ }
60
+
61
+ const code = await proc.exited;
62
+ if (code !== 0 && code !== 130) {
63
+ await opts.onEvent({ event_type: 'error', payload: { message: `acpx exited with code ${code}` } });
64
+ }
65
+ return { stopReason };
66
+ }
67
+
68
+ function parseAcpLine(line: string, setStop: (r: string) => void): AcpEvent[] {
69
+ let msg: any;
70
+ try { msg = JSON.parse(line); } catch { return []; }
71
+ const out: AcpEvent[] = [];
72
+
73
+ // session/update notification
74
+ if (msg.method === 'session/update' && msg.params?.sessionUpdate) {
75
+ return handleSessionUpdate(msg.params);
76
+ }
77
+ // prompt result
78
+ if (msg.id && msg.result && typeof msg.result === 'object' && 'stopReason' in msg.result) {
79
+ setStop(msg.result.stopReason);
80
+ out.push({ event_type: 'result', payload: { stopReason: msg.result.stopReason } });
81
+ return out;
82
+ }
83
+ // errors
84
+ if (msg.error) {
85
+ out.push({ event_type: 'error', payload: { message: msg.error.message || JSON.stringify(msg.error) } });
86
+ return out;
87
+ }
88
+ return out;
89
+ }
90
+
91
+ function handleSessionUpdate(params: any): AcpEvent[] {
92
+ const u = params.update || {};
93
+ const kind = u.sessionUpdate;
94
+ const out: AcpEvent[] = [];
95
+ switch (kind) {
96
+ case 'agent_message_chunk':
97
+ case 'agent_thought_chunk': {
98
+ const text = extractText(u.content);
99
+ if (text) out.push({ event_type: 'assistant_text', payload: { text } });
100
+ break;
101
+ }
102
+ case 'tool_call':
103
+ case 'tool_call_update': {
104
+ out.push({ event_type: 'tool_call', payload: {
105
+ id: u.toolCallId || u.id, tool: u.title || u.toolName || 'tool',
106
+ kind: u.kind, status: u.status, input: u.rawInput, locations: u.locations,
107
+ }});
108
+ if (Array.isArray(u.content)) {
109
+ for (const c of u.content) {
110
+ if (c.type === 'content' && c.content?.type === 'text') {
111
+ out.push({ event_type: 'tool_result', payload: { tool_use_id: u.toolCallId || u.id, content: String(c.content.text).slice(0, 4000) } });
112
+ } else if (c.type === 'diff') {
113
+ const diff = `--- ${c.path}\n${c.oldText || ''}\n+++ ${c.path}\n${c.newText || ''}`.slice(0, 4000);
114
+ out.push({ event_type: 'tool_result', payload: { tool_use_id: u.toolCallId || u.id, content: diff } });
115
+ }
116
+ }
117
+ }
118
+ break;
119
+ }
120
+ case 'plan': {
121
+ const entries = (u.entries || []).map((e: any, i: number) => `${i + 1}. [${e.status || 'pending'}] ${e.content}`).join('\n');
122
+ if (entries) out.push({ event_type: 'assistant_text', payload: { text: `**Plan**\n\n${entries}` } });
123
+ break;
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+
129
+ function extractText(content: any): string {
130
+ if (!content) return '';
131
+ if (typeof content === 'string') return content;
132
+ if (Array.isArray(content)) return content.map(extractText).join('');
133
+ if (content.type === 'text' && typeof content.text === 'string') return content.text;
134
+ if (content.text) return content.text;
135
+ return '';
136
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { detectAgents } from './detect';
4
4
  import { apiClient, setAuthToken } from './client';
5
5
  import { Database } from 'bun:sqlite';
6
6
  import { runAcp } from './acp-runner';
7
+ import { runAcpx } from './acpx-runner';
7
8
  import { parseArgs } from 'util';
8
9
  import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync, readdirSync, statSync } from 'fs';
9
10
  import { join, dirname } from 'path';
@@ -16,7 +17,7 @@ const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
16
17
  const SKILLS_DIR = join(MULTI_DIR, 'skills');
17
18
  const STOP_PATH = join(MULTI_DIR, 'stop.flag');
18
19
  const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
19
- const VERSION = '0.5.0';
20
+ const VERSION = '0.6.0';
20
21
 
21
22
  const COMMANDS = {
22
23
  setup: 'Register this device with a workspace',
@@ -448,9 +449,9 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
448
449
  };
449
450
 
450
451
  type ToolEntry = { id: string; tool: string; kind?: string; status?: string; input?: any; results: string[] };
452
+ type Block = { kind: 'text'; text: string } | { kind: 'tool'; id: string };
451
453
  const turn = {
452
- text: '' as string,
453
- toolOrder: [] as string[],
454
+ blocks: [] as Block[],
454
455
  tools: new Map<string, ToolEntry>(),
455
456
  plans: [] as string[],
456
457
  progress: [] as string[],
@@ -458,20 +459,54 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
458
459
  error: null as null | string,
459
460
  };
460
461
 
462
+ const appendText = (text: string) => {
463
+ const last = turn.blocks[turn.blocks.length - 1];
464
+ if (last && last.kind === 'text') last.text += text;
465
+ else turn.blocks.push({ kind: 'text', text });
466
+ };
467
+
468
+ const upsertTool = (id: string, patch: Partial<ToolEntry>) => {
469
+ const existing = turn.tools.get(id);
470
+ if (existing) {
471
+ if (patch.tool && patch.tool !== 'tool') existing.tool = patch.tool;
472
+ if (patch.kind) existing.kind = patch.kind;
473
+ if (patch.status) existing.status = patch.status;
474
+ if (patch.input !== undefined) existing.input = patch.input;
475
+ } else {
476
+ turn.tools.set(id, { id, tool: patch.tool || 'tool', kind: patch.kind, status: patch.status, input: patch.input, results: [] });
477
+ turn.blocks.push({ kind: 'tool', id });
478
+ }
479
+ };
480
+
481
+ const toolLabel = (t: ToolEntry): string => {
482
+ const clean = stripMd(t.tool);
483
+ if (clean && clean !== 'tool') return clean;
484
+ // Fallback from input shape
485
+ if (t.input && typeof t.input === 'object') {
486
+ if (t.input.command) return `Bash: ${String(t.input.command).split('\n')[0].slice(0, 60)}`;
487
+ if (t.input.file_path) return `${t.kind === 'edit' ? 'Edit' : 'Read'}: ${t.input.file_path}`;
488
+ if (t.input.pattern) return `Grep: ${t.input.pattern}`;
489
+ if (t.input.url) return `Fetch: ${t.input.url}`;
490
+ }
491
+ return (t.kind ? `${t.kind}` : 'tool');
492
+ };
493
+
461
494
  const render = (): string => {
462
495
  const parts: string[] = [];
463
- if (turn.text) parts.push(turn.text);
464
-
465
- for (const id of turn.toolOrder) {
466
- const t = turn.tools.get(id);
496
+ for (const b of turn.blocks) {
497
+ if (b.kind === 'text') {
498
+ if (b.text) parts.push(b.text);
499
+ continue;
500
+ }
501
+ const t = turn.tools.get(b.id);
467
502
  if (!t) continue;
468
503
  const icon = statusIcon(t.status);
469
- const cleanTool = stripMd(t.tool);
470
- const head = `${icon} ${cleanTool}${t.kind ? ` · ${t.kind}` : ''}${t.status && t.status !== 'completed' ? ` · ${t.status}` : ''}`;
504
+ const label = toolLabel(t);
505
+ const head = `${icon} ${label}${t.status && t.status !== 'completed' ? ` · ${t.status}` : ''}`;
471
506
  const body: string[] = [];
472
507
  if (t.input !== undefined && t.input !== null) {
473
508
  const inputStr = typeof t.input === 'object' ? JSON.stringify(t.input, null, 2) : String(t.input);
474
- body.push('```json\n' + inputStr + '\n```');
509
+ body.push('```json\n' + inputStr.slice(0, 2000) + '\n```');
475
510
  }
476
511
  if (t.results.length) {
477
512
  const joined = t.results.join('\n').slice(-2000);
@@ -481,7 +516,6 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
481
516
  }
482
517
 
483
518
  if (turn.plans.length) parts.push('**Plan**\n\n' + turn.plans[turn.plans.length - 1]);
484
-
485
519
  if (turn.error) parts.push(`> ❌ ${turn.error}`);
486
520
 
487
521
  if (turn.result) {
@@ -517,16 +551,15 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
517
551
  }
518
552
  case 'assistant_text': {
519
553
  await ensureLiveComment();
520
- turn.text += p.text;
554
+ appendText(p.text);
521
555
  hasAssistantText = true;
522
556
  schedulePatch();
523
557
  break;
524
558
  }
525
559
  case 'stdout': {
526
- // Legacy (non-ACP) runners emit stdout — route to live comment too.
527
560
  if (p.line) {
528
561
  await ensureLiveComment();
529
- turn.text += (turn.text ? '\n' : '') + p.line;
562
+ appendText((turn.blocks.length ? '\n' : '') + p.line);
530
563
  hasAssistantText = true;
531
564
  schedulePatch();
532
565
  }
@@ -534,33 +567,24 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
534
567
  }
535
568
  case 'tool_call': {
536
569
  await ensureLiveComment();
537
- const id = p.id || `anon-${turn.toolOrder.length}`;
538
- const existing = turn.tools.get(id);
539
- if (existing) {
540
- if (p.tool) existing.tool = p.tool;
541
- if (p.kind) existing.kind = p.kind;
542
- if (p.status) existing.status = p.status;
543
- if (p.input !== undefined) existing.input = p.input;
544
- } else {
545
- turn.toolOrder.push(id);
546
- turn.tools.set(id, { id, tool: p.tool || 'tool', kind: p.kind, status: p.status, input: p.input, results: [] });
547
- }
570
+ const id = p.id || `anon-${turn.tools.size}`;
571
+ upsertTool(id, { tool: p.tool, kind: p.kind, status: p.status, input: p.input });
548
572
  schedulePatch();
549
573
  break;
550
574
  }
551
575
  case 'tool_result': {
552
576
  await ensureLiveComment();
553
- const id = p.tool_use_id || turn.toolOrder[turn.toolOrder.length - 1];
577
+ const lastToolId = [...turn.blocks].reverse().find(b => b.kind === 'tool')?.id;
578
+ const id = p.tool_use_id || lastToolId;
554
579
  const entry = id ? turn.tools.get(id) : undefined;
555
580
  const content = String(p.content ?? '').trim();
556
581
  if (entry && content) {
557
582
  entry.results.push(content);
558
583
  schedulePatch();
559
584
  } else if (content) {
560
- // orphan result — create a pseudo tool
561
- const pid = `result-${turn.toolOrder.length}`;
562
- turn.toolOrder.push(pid);
563
- turn.tools.set(pid, { id: pid, tool: 'tool result', results: [content] });
585
+ const pid = `result-${turn.tools.size}`;
586
+ upsertTool(pid, { tool: 'tool result' });
587
+ turn.tools.get(pid)!.results.push(content);
564
588
  schedulePatch();
565
589
  }
566
590
  break;
@@ -569,7 +593,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
569
593
  await ensureLiveComment();
570
594
  if (p.is_error) hadError = true;
571
595
  if (!hasAssistantText && p.result) {
572
- turn.text = p.result;
596
+ appendText(p.result);
573
597
  hasAssistantText = true;
574
598
  }
575
599
  turn.result = { duration_ms: p.duration_ms, total_cost_usd: p.total_cost_usd, stopReason: p.stopReason, is_error: p.is_error };
@@ -593,6 +617,8 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
593
617
  } catch {}
594
618
  const acpCapable = detected.filter(d => d.type === 'claude-code');
595
619
  const useAcp = preferType !== 'pi' && acpCapable.length > 0 && process.env.MULTI_LEGACY !== '1';
620
+ // Route pi/codex/openclaw through acpx (handles these agent types natively)
621
+ const useAcpx = !useAcp && preferType && ['pi', 'codex', 'openclaw'].includes(preferType) && process.env.MULTI_LEGACY !== '1';
596
622
 
597
623
  try {
598
624
  if (useAcp) {
@@ -653,13 +679,42 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
653
679
  },
654
680
  });
655
681
  log(` acp session ${sessionId.slice(0, 8)}`);
656
- } else {
657
- // Determine preferred type from agent (e.g. pi)
658
- let preferType: string | undefined;
682
+ } else if (useAcpx) {
683
+ // Build prompt with preamble (same logic as ACP path, but as one string)
684
+ let preamble = '';
659
685
  try {
660
- const a = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
661
- if (a.data?.type) preferType = a.data.type;
686
+ const agentRes = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
687
+ const agent = agentRes.data;
688
+ if (agent?.prompt) preamble += `# Agent instructions\n\n${agent.prompt}\n\n`;
689
+ const skillsRes = await apiClient.get<any[]>(`${apiUrl}/api/agents/${task.agent_id}/skills`);
690
+ const skillList = Array.isArray(skillsRes.data) ? skillsRes.data : [];
691
+ if (skillList.length) {
692
+ preamble += `# Attached skills (${skillList.length})\n\n`;
693
+ for (const s of skillList) {
694
+ const body = String(s.body || s.description || '').trim();
695
+ if (body) preamble += `## ${s.name}${s.version ? ` v${s.version}` : ''}\n\n${body}\n\n`;
696
+ }
697
+ }
662
698
  } catch {}
699
+ const base = `${task.title}\n\n${task.description || ''}`.trim();
700
+ let userPart = task.followup ? `${task.followup}\n\n---\nContext (${task.key}): ${task.title}` : (base || task.title);
701
+ userPart = stripSelfMention(userPart, preferType);
702
+ if (attachmentRefs.length) {
703
+ const lines = attachmentRefs.map(a => `- ${a.filename}: ${a.path}`).join('\n');
704
+ userPart += `\n\n---\nAttached files:\n${lines}\n\nWrite generated files to: ${outDir}`;
705
+ } else {
706
+ userPart += `\n\n---\nWrite generated files to: ${outDir}`;
707
+ }
708
+ const full = preamble ? `${preamble}\n---\n\n${userPart}` : userPart;
709
+ log(` acpx runner: ${preferType}`);
710
+ await runAcpx({
711
+ agentType: preferType!,
712
+ prompt: full,
713
+ cwd: workingDir,
714
+ sessionName: `issue-${issueId}`,
715
+ onEvent: eventHandler,
716
+ });
717
+ } else {
663
718
  const runner = pickRunner(detected, preferType);
664
719
  for await (const event of runner(task)) await eventHandler(event);
665
720
  }