@shipers-dev/multi 0.1.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/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@shipers-dev/multi",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "multi-agent": "./dist/index.js"
7
+ },
8
+ "scripts": {
9
+ "dev": "bun run src/index.ts",
10
+ "build": "bun build src/index.ts --outdir=dist --target=bun"
11
+ },
12
+ "dependencies": {
13
+ "@multi/lib": "workspace:*",
14
+ "@zed-industries/agent-client-protocol": "^0.4.5",
15
+ "@zed-industries/claude-code-acp": "^0.16.2"
16
+ }
17
+ }
@@ -0,0 +1,211 @@
1
+ // ACP runner — speaks Agent Client Protocol to an agent adapter (claude-code-acp etc).
2
+ // Spawns the adapter as a subprocess over stdio, converts ACP events → our StreamEvent shape
3
+ // and handles client-side callbacks (requestPermission forwarded to server, fs read/write local).
4
+
5
+ import { ClientSideConnection, ndJsonStream } from '@zed-industries/agent-client-protocol';
6
+ import type { Client, SessionNotification, RequestPermissionRequest, RequestPermissionResponse, ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest, WriteTextFileResponse } from '@zed-industries/agent-client-protocol';
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
8
+ import { dirname } from 'path';
9
+ import { apiClient } from './client';
10
+
11
+ export type AcpEvent =
12
+ | { event_type: 'progress'; payload: any }
13
+ | { event_type: 'assistant_text'; payload: { text: string } }
14
+ | { event_type: 'tool_call'; payload: { id: string; tool: string; kind?: string; status?: string; input?: any; locations?: any[] } }
15
+ | { event_type: 'tool_result'; payload: { tool_use_id: string; content: string } }
16
+ | { event_type: 'result'; payload: { result?: string; duration_ms?: number; total_cost_usd?: number; stopReason?: string } }
17
+ | { event_type: 'error'; payload: { message: string } };
18
+
19
+ export type AcpRunOpts = {
20
+ apiUrl: string;
21
+ issueId: string;
22
+ deviceId: string;
23
+ prompt: string;
24
+ cwd?: string;
25
+ sessionId?: string | null;
26
+ adapterBin: string | string[]; // command + args to spawn ACP agent
27
+ onEvent: (ev: AcpEvent) => void | Promise<void>;
28
+ onSession?: (sessionId: string) => void | Promise<void>;
29
+ };
30
+
31
+ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; sessionId: string }> {
32
+ const cleanEnv: Record<string, string> = {};
33
+ for (const [k, v] of Object.entries(process.env)) {
34
+ if (v === undefined) continue;
35
+ if (k === 'CLAUDECODE' || k.startsWith('CLAUDE_CODE_')) continue;
36
+ cleanEnv[k] = v;
37
+ }
38
+ const argv = Array.isArray(opts.adapterBin) ? opts.adapterBin : [opts.adapterBin];
39
+ const child = Bun.spawn(argv, {
40
+ stdio: ['pipe', 'pipe', 'inherit'],
41
+ cwd: opts.cwd || process.cwd(),
42
+ env: { ...cleanEnv, ACP_PERMISSION_MODE: 'default' },
43
+ });
44
+
45
+ // Adapter expects plain newline-delimited JSON on stdio. Wrap child streams as Web Streams.
46
+ const output = new WritableStream<Uint8Array>({
47
+ write(chunk) {
48
+ (child.stdin as any).write(chunk);
49
+ },
50
+ close() { try { (child.stdin as any).end?.(); } catch {} },
51
+ });
52
+ const input = child.stdout as ReadableStream<Uint8Array>;
53
+ const stream = ndJsonStream(output, input);
54
+
55
+ let activeSessionId: string | null = opts.sessionId || null;
56
+ let recording = false; // only forward events after prompt() starts
57
+
58
+ const client: Client = {
59
+ async sessionUpdate(params: SessionNotification): Promise<void> {
60
+ if (!recording) return; // drop replay chatter during loadSession
61
+ await handleSessionUpdate(params, opts);
62
+ },
63
+ async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
64
+ return await handleRequestPermission(params, opts);
65
+ },
66
+ async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
67
+ try {
68
+ const content = readFileSync(params.path, 'utf8');
69
+ const sliced = typeof params.line === 'number' && typeof params.limit === 'number'
70
+ ? content.split('\n').slice(params.line, params.line + params.limit).join('\n')
71
+ : content;
72
+ return { content: sliced };
73
+ } catch (e) {
74
+ throw new Error(`readTextFile failed: ${String(e)}`);
75
+ }
76
+ },
77
+ async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
78
+ try {
79
+ const dir = dirname(params.path);
80
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
81
+ writeFileSync(params.path, params.content, 'utf8');
82
+ return {};
83
+ } catch (e) {
84
+ throw new Error(`writeTextFile failed: ${String(e)}`);
85
+ }
86
+ },
87
+ };
88
+
89
+ const conn = new ClientSideConnection(() => client, stream);
90
+
91
+ try {
92
+ await conn.initialize({
93
+ protocolVersion: 1,
94
+ clientCapabilities: {
95
+ fs: { readTextFile: true, writeTextFile: true },
96
+ terminal: false,
97
+ },
98
+ } as any);
99
+
100
+ if (!activeSessionId) {
101
+ const { sessionId } = await conn.newSession({
102
+ cwd: opts.cwd || process.cwd(),
103
+ mcpServers: [],
104
+ } as any);
105
+ activeSessionId = sessionId;
106
+ if (opts.onSession) await opts.onSession(sessionId);
107
+ await opts.onEvent({ event_type: 'progress', payload: { message: `ACP session ${sessionId.slice(0, 8)} created` } });
108
+ } else {
109
+ try {
110
+ await conn.loadSession?.({ sessionId: activeSessionId, cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
111
+ await opts.onEvent({ event_type: 'progress', payload: { message: `ACP session ${activeSessionId.slice(0, 8)} resumed` } });
112
+ } catch (e) {
113
+ await opts.onEvent({ event_type: 'progress', payload: { message: `load_session failed; starting new. ${String(e)}` } });
114
+ const { sessionId } = await conn.newSession({ cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
115
+ activeSessionId = sessionId;
116
+ if (opts.onSession) await opts.onSession(sessionId);
117
+ }
118
+ }
119
+
120
+ recording = true;
121
+ const res = await conn.prompt({
122
+ sessionId: activeSessionId!,
123
+ prompt: [{ type: 'text', text: opts.prompt }],
124
+ } as any);
125
+
126
+ await opts.onEvent({ event_type: 'result', payload: { stopReason: (res as any).stopReason } });
127
+ return { stopReason: (res as any).stopReason, sessionId: activeSessionId! };
128
+ } finally {
129
+ try { child.kill(); } catch {}
130
+ }
131
+
132
+ async function handleSessionUpdate(params: SessionNotification, o: AcpRunOpts) {
133
+ const u: any = params.update;
134
+ const kind = u.sessionUpdate;
135
+ switch (kind) {
136
+ case 'agent_message_chunk':
137
+ case 'agent_thought_chunk': {
138
+ const text = extractText(u.content);
139
+ if (text) await o.onEvent({ event_type: 'assistant_text', payload: { text } });
140
+ break;
141
+ }
142
+ case 'tool_call':
143
+ case 'tool_call_update': {
144
+ await o.onEvent({ event_type: 'tool_call', payload: {
145
+ id: u.toolCallId || u.id, tool: u.title || u.toolName || 'tool', kind: u.kind, status: u.status, input: u.rawInput, locations: u.locations,
146
+ }});
147
+ // Surface tool_call content (e.g. diff/text/terminal) as tool_result if present
148
+ if (Array.isArray(u.content)) {
149
+ for (const c of u.content) {
150
+ if (c.type === 'content' && c.content?.type === 'text') {
151
+ await o.onEvent({ event_type: 'tool_result', payload: { tool_use_id: u.toolCallId || u.id, content: c.content.text.slice(0, 4000) } });
152
+ } else if (c.type === 'diff') {
153
+ const diff = `--- ${c.path}\n${c.oldText || ''}\n+++ ${c.path}\n${c.newText || ''}`.slice(0, 4000);
154
+ await o.onEvent({ event_type: 'tool_result', payload: { tool_use_id: u.toolCallId || u.id, content: diff } });
155
+ }
156
+ }
157
+ }
158
+ break;
159
+ }
160
+ case 'plan': {
161
+ const entries = (u.entries || []).map((e: any, i: number) => `${i + 1}. [${e.status || 'pending'}] ${e.content}`).join('\n');
162
+ if (entries) await o.onEvent({ event_type: 'assistant_text', payload: { text: `**Plan**\n\n${entries}` } });
163
+ break;
164
+ }
165
+ case 'current_mode_update': {
166
+ await o.onEvent({ event_type: 'progress', payload: { message: `mode=${u.currentModeId}` } });
167
+ break;
168
+ }
169
+ default: break;
170
+ }
171
+ }
172
+
173
+ async function handleRequestPermission(params: RequestPermissionRequest, o: AcpRunOpts): Promise<RequestPermissionResponse> {
174
+ const tc: any = params.toolCall || {};
175
+ const created = await apiClient.post<any>(`${o.apiUrl}/api/permissions`, {
176
+ issue_id: o.issueId,
177
+ device_id: o.deviceId,
178
+ tool_call_id: tc.id || tc.toolCallId,
179
+ tool_name: tc.title || tc.toolName,
180
+ summary: tc.title,
181
+ options: params.options.map((op: any) => ({ optionId: op.optionId, name: op.name, kind: op.kind })),
182
+ });
183
+ const permId = created.data?.id;
184
+ if (!permId) return { outcome: { outcome: 'cancelled' } as any };
185
+
186
+ // Poll every 500ms up to 5 min
187
+ const deadline = Date.now() + 5 * 60 * 1000;
188
+ while (Date.now() < deadline) {
189
+ await new Promise(r => setTimeout(r, 500));
190
+ const got = await apiClient.get<any>(`${o.apiUrl}/api/permissions/${permId}`);
191
+ const row = got.data;
192
+ if (!row) break;
193
+ if (row.status === 'resolved' && row.chosen) {
194
+ return { outcome: { outcome: 'selected', optionId: row.chosen } as any };
195
+ }
196
+ if (row.status === 'cancelled') {
197
+ return { outcome: { outcome: 'cancelled' } as any };
198
+ }
199
+ }
200
+ return { outcome: { outcome: 'cancelled' } as any };
201
+ }
202
+ }
203
+
204
+ function extractText(content: any): string {
205
+ if (!content) return '';
206
+ if (typeof content === 'string') return content;
207
+ if (Array.isArray(content)) return content.map(extractText).join('');
208
+ if (content.type === 'text' && typeof content.text === 'string') return content.text;
209
+ if (content.text) return content.text;
210
+ return '';
211
+ }
package/src/client.ts ADDED
@@ -0,0 +1,40 @@
1
+ interface ApiResponse<T = unknown> {
2
+ success: boolean;
3
+ data?: T;
4
+ error?: string;
5
+ }
6
+
7
+ async function request<T>(url: string, options?: RequestInit): Promise<ApiResponse<T>> {
8
+ try {
9
+ const response = await fetch(url, {
10
+ ...options,
11
+ headers: {
12
+ 'Content-Type': 'application/json',
13
+ ...options?.headers,
14
+ },
15
+ });
16
+
17
+ const raw = await response.json();
18
+
19
+ if (!response.ok) {
20
+ return { success: false, error: (raw as any)?.error || `HTTP ${response.status}` };
21
+ }
22
+
23
+ const data = (raw && typeof raw === 'object' && 'results' in (raw as any))
24
+ ? (raw as any).results
25
+ : raw;
26
+
27
+ return { success: true, data };
28
+ } catch (error) {
29
+ return { success: false, error: String(error) };
30
+ }
31
+ }
32
+
33
+ export const apiClient = {
34
+ get: <T = unknown>(url: string) => request<T>(url),
35
+ post: <T = unknown>(url: string, body: unknown) =>
36
+ request<T>(url, { method: 'POST', body: JSON.stringify(body) }),
37
+ patch: <T = unknown>(url: string, body: unknown) =>
38
+ request<T>(url, { method: 'PATCH', body: JSON.stringify(body) }),
39
+ delete: <T = unknown>(url: string) => request<T>(url, { method: 'DELETE' }),
40
+ };
package/src/detect.ts ADDED
@@ -0,0 +1,70 @@
1
+ export type AgentType = 'claude-code' | 'codex' | 'gemini-cli' | 'openclaw' | 'opencode' | 'pi' | 'custom';
2
+
3
+ export interface DetectedAgent {
4
+ type: AgentType;
5
+ path: string;
6
+ version?: string;
7
+ }
8
+
9
+ const AGENT_BINARIES: Record<AgentType, string[]> = {
10
+ 'claude-code': ['claude', 'claude-code', 'claude_code'],
11
+ 'codex': ['codex', 'openai-codex'],
12
+ 'gemini-cli': ['gemini', 'gemini-cli', 'google-gemini'],
13
+ 'openclaw': ['openclaw'],
14
+ 'opencode': ['opencode'],
15
+ 'pi': ['pi'],
16
+ 'custom': [],
17
+ };
18
+
19
+ export async function detectAgents(): Promise<DetectedAgent[]> {
20
+ const detected: DetectedAgent[] = [];
21
+
22
+ // Check PATH for known agent binaries
23
+ const path = Bun.env.PATH?.split(':') || [];
24
+
25
+ for (const [type, binaries] of Object.entries(AGENT_BINARIES)) {
26
+ if (type === 'custom') continue;
27
+
28
+ for (const binary of binaries) {
29
+ const found = await which(binary);
30
+ if (found) {
31
+ const version = await getVersion(found);
32
+ detected.push({ type: type as AgentType, path: found, version });
33
+ break;
34
+ }
35
+ }
36
+ }
37
+
38
+ return detected;
39
+ }
40
+
41
+ async function which(cmd: string): Promise<string | null> {
42
+ for (const dir of Bun.env.PATH?.split(':') || []) {
43
+ const fullPath = `${dir}/${cmd}`;
44
+ try {
45
+ const stat = await fileExists(fullPath);
46
+ if (stat) return fullPath;
47
+ } catch {
48
+ // Continue
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ async function fileExists(path: string): Promise<boolean> {
55
+ try {
56
+ return await Bun.file(path).exists();
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ async function getVersion(path: string): Promise<string | undefined> {
63
+ try {
64
+ const proc = Bun.spawn([path, '--version'], { stdout: 'pipe' });
65
+ const output = await new Response(proc.stdout).text();
66
+ return output.trim() || undefined;
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }