@shipers-dev/multi 0.5.1 → 0.6.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/index.js CHANGED
@@ -5380,6 +5380,145 @@ function extractText(content) {
5380
5380
  return "";
5381
5381
  }
5382
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
+
5383
5522
  // src/index.ts
5384
5523
  import { parseArgs } from "util";
5385
5524
  import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, appendFileSync, unlinkSync, readdirSync, statSync } from "fs";
@@ -5392,7 +5531,7 @@ var LOG_PATH = join(MULTI_DIR, "logs", "agent.log");
5392
5531
  var SKILLS_DIR = join(MULTI_DIR, "skills");
5393
5532
  var STOP_PATH = join(MULTI_DIR, "stop.flag");
5394
5533
  var TASKS_DB_PATH = join(MULTI_DIR, "tasks.db");
5395
- var VERSION = "0.5.1";
5534
+ var VERSION = "0.6.1";
5396
5535
  var COMMANDS = {
5397
5536
  setup: "Register this device with a workspace",
5398
5537
  connect: "Connect device to realtime hub and execute assigned tasks",
@@ -6037,6 +6176,7 @@ _${bits.join(" \xB7 ")}_`);
6037
6176
  } catch {}
6038
6177
  const acpCapable = detected.filter((d) => d.type === "claude-code");
6039
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";
6040
6180
  try {
6041
6181
  if (useAcp) {
6042
6182
  const base = `${task.title}
@@ -6053,15 +6193,15 @@ Context (original task ${task.key}): ${task.title}` : base || task.title;
6053
6193
  userPart += `
6054
6194
 
6055
6195
  ---
6056
- Attached files (on local disk, read them with your tools):
6196
+ Attached input files (read them with your tools if useful):
6057
6197
  ${lines}
6058
6198
 
6059
- To return generated files, write them to: ${outDir}`;
6199
+ Note: if (and only if) you produce binary or large artifact outputs (screenshots, data exports, generated source files), write them under ${outDir}. Always put your human-facing reply in the chat response itself \u2014 do NOT write your answer as a file.`;
6060
6200
  } else {
6061
6201
  userPart += `
6062
6202
 
6063
6203
  ---
6064
- To return generated files, write them to: ${outDir}`;
6204
+ Respond in the chat. Only if you produce large artifact files (screenshots, data exports, generated source code), write them under ${outDir}. Do not put your answer in a file.`;
6065
6205
  }
6066
6206
  let preamble = "";
6067
6207
  try {
@@ -6124,14 +6264,72 @@ ${userPart}` : userPart;
6124
6264
  }
6125
6265
  });
6126
6266
  log(` acp session ${sessionId.slice(0, 8)}`);
6127
- } else {
6128
- let preferType2;
6267
+ } else if (useAcpx) {
6268
+ let preamble = "";
6129
6269
  try {
6130
- const a = await apiClient.get(`${apiUrl}/api/agents/${task.agent_id}`);
6131
- if (a.data?.type)
6132
- 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
+ }
6133
6294
  } catch {}
6134
- 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);
6135
6333
  for await (const event of runner(task))
6136
6334
  await eventHandler(event);
6137
6335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
@@ -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.1';
20
+ const VERSION = '0.6.1';
20
21
 
21
22
  const COMMANDS = {
22
23
  setup: 'Register this device with a workspace',
@@ -616,6 +617,8 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
616
617
  } catch {}
617
618
  const acpCapable = detected.filter(d => d.type === 'claude-code');
618
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';
619
622
 
620
623
  try {
621
624
  if (useAcp) {
@@ -626,9 +629,9 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
626
629
  userPart = stripSelfMention(userPart, preferType);
627
630
  if (attachmentRefs.length) {
628
631
  const lines = attachmentRefs.map(a => `- ${a.filename}: ${a.path}`).join('\n');
629
- userPart += `\n\n---\nAttached files (on local disk, read them with your tools):\n${lines}\n\nTo return generated files, write them to: ${outDir}`;
632
+ userPart += `\n\n---\nAttached input files (read them with your tools if useful):\n${lines}\n\nNote: if (and only if) you produce binary or large artifact outputs (screenshots, data exports, generated source files), write them under ${outDir}. Always put your human-facing reply in the chat response itself — do NOT write your answer as a file.`;
630
633
  } else {
631
- userPart += `\n\n---\nTo return generated files, write them to: ${outDir}`;
634
+ userPart += `\n\n---\nRespond in the chat. Only if you produce large artifact files (screenshots, data exports, generated source code), write them under ${outDir}. Do not put your answer in a file.`;
632
635
  }
633
636
 
634
637
  // Pull agent + linked skills to construct system/context preamble
@@ -676,13 +679,42 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
676
679
  },
677
680
  });
678
681
  log(` acp session ${sessionId.slice(0, 8)}`);
679
- } else {
680
- // Determine preferred type from agent (e.g. pi)
681
- 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 = '';
682
685
  try {
683
- const a = await apiClient.get<any>(`${apiUrl}/api/agents/${task.agent_id}`);
684
- 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
+ }
685
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 {
686
718
  const runner = pickRunner(detected, preferType);
687
719
  for await (const event of runner(task)) await eventHandler(event);
688
720
  }