@shipers-dev/multi 0.1.1 → 0.3.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 +175 -28
- package/package.json +1 -1
- package/src/client.ts +16 -10
- package/src/index.ts +133 -20
package/dist/index.js
CHANGED
|
@@ -71,21 +71,25 @@ async function getVersion(path) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// src/client.ts
|
|
74
|
+
var authToken = null;
|
|
75
|
+
function setAuthToken(token) {
|
|
76
|
+
authToken = token;
|
|
77
|
+
}
|
|
74
78
|
async function request(url, options) {
|
|
75
79
|
try {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
headers
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
const raw = await response.json();
|
|
80
|
+
const headers = {
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
...options?.headers
|
|
83
|
+
};
|
|
84
|
+
if (authToken && !headers["Authorization"])
|
|
85
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
86
|
+
const response = await fetch(url, { ...options, headers });
|
|
87
|
+
const raw = await response.json().catch(() => ({}));
|
|
84
88
|
if (!response.ok) {
|
|
85
|
-
return { success: false, error: raw?.error || `HTTP ${response.status}
|
|
89
|
+
return { success: false, error: raw?.error || `HTTP ${response.status}`, status: response.status };
|
|
86
90
|
}
|
|
87
91
|
const data = raw && typeof raw === "object" && "results" in raw ? raw.results : raw;
|
|
88
|
-
return { success: true, data };
|
|
92
|
+
return { success: true, data, status: response.status };
|
|
89
93
|
} catch (error) {
|
|
90
94
|
return { success: false, error: String(error) };
|
|
91
95
|
}
|
|
@@ -5364,7 +5368,7 @@ function extractText(content) {
|
|
|
5364
5368
|
|
|
5365
5369
|
// src/index.ts
|
|
5366
5370
|
import { parseArgs } from "util";
|
|
5367
|
-
import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, appendFileSync, unlinkSync } from "fs";
|
|
5371
|
+
import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, appendFileSync, unlinkSync, readdirSync, statSync } from "fs";
|
|
5368
5372
|
import { join, dirname as dirname2 } from "path";
|
|
5369
5373
|
var HOME = process.env.HOME || process.env.USERPROFILE || ".";
|
|
5370
5374
|
var MULTI_DIR = join(HOME, ".multi");
|
|
@@ -5413,9 +5417,11 @@ async function main() {
|
|
|
5413
5417
|
}
|
|
5414
5418
|
const apiUrl = args.values.api || process.env.MULTI_API_URL || "https://multi-api.adnb3r.workers.dev";
|
|
5415
5419
|
const config = loadConfig();
|
|
5420
|
+
if (config.token)
|
|
5421
|
+
setAuthToken(config.token);
|
|
5416
5422
|
switch (command) {
|
|
5417
5423
|
case "setup":
|
|
5418
|
-
await cmdSetup(args.values.name,
|
|
5424
|
+
await cmdSetup(args.values.name, apiUrl);
|
|
5419
5425
|
break;
|
|
5420
5426
|
case "connect":
|
|
5421
5427
|
case "start":
|
|
@@ -5466,12 +5472,8 @@ Examples:
|
|
|
5466
5472
|
multi-agent connect
|
|
5467
5473
|
`);
|
|
5468
5474
|
}
|
|
5469
|
-
async function cmdSetup(name,
|
|
5475
|
+
async function cmdSetup(name, apiUrl) {
|
|
5470
5476
|
ensureDirs();
|
|
5471
|
-
if (!workspaceId) {
|
|
5472
|
-
console.log("\u274C Workspace ID required. Run: multi-agent setup --workspace <id>");
|
|
5473
|
-
process.exit(1);
|
|
5474
|
-
}
|
|
5475
5477
|
console.log("\uD83D\uDD0D Detecting local agent runtimes...");
|
|
5476
5478
|
const detected = await detectAgents();
|
|
5477
5479
|
for (const a of detected)
|
|
@@ -5480,23 +5482,62 @@ async function cmdSetup(name, workspaceId, apiUrl) {
|
|
|
5480
5482
|
console.log(" (none detected \u2014 stub runtime will be used)");
|
|
5481
5483
|
const deviceName = name || process.env.HOSTNAME || "Unknown Device";
|
|
5482
5484
|
console.log(`
|
|
5483
|
-
\uD83D\
|
|
5484
|
-
const
|
|
5485
|
-
workspace_id: workspaceId,
|
|
5485
|
+
\uD83D\uDCE1 Requesting pairing code for "${deviceName}"...`);
|
|
5486
|
+
const start = await apiClient.post(`${apiUrl}/api/pair/start`, {
|
|
5486
5487
|
name: deviceName,
|
|
5487
5488
|
platform: process.platform,
|
|
5488
5489
|
arch: process.arch,
|
|
5489
5490
|
os_version: process.version,
|
|
5490
5491
|
detected_runtimes: detected.map((a) => a.type)
|
|
5491
5492
|
});
|
|
5492
|
-
if (!
|
|
5493
|
+
if (!start.success || !start.data) {
|
|
5494
|
+
console.log(`
|
|
5495
|
+
\u274C Failed to start pairing:`, start.error);
|
|
5496
|
+
process.exit(1);
|
|
5497
|
+
}
|
|
5498
|
+
const { code } = start.data;
|
|
5499
|
+
const pairUrl = `${apiUrl}/pair/${code}`;
|
|
5500
|
+
console.log(`
|
|
5501
|
+
\uD83D\uDC49 Open this URL in your browser to approve:
|
|
5502
|
+
|
|
5503
|
+
${pairUrl}
|
|
5504
|
+
`);
|
|
5505
|
+
console.log(` Or enter code manually: ${code}`);
|
|
5506
|
+
console.log(`
|
|
5507
|
+
\u23F3 Waiting for approval (10 min timeout)...`);
|
|
5508
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
5509
|
+
let approved = null;
|
|
5510
|
+
while (Date.now() < deadline) {
|
|
5511
|
+
await sleep(3000);
|
|
5512
|
+
const poll = await apiClient.get(`${apiUrl}/api/pair/poll/${code}`);
|
|
5513
|
+
if (!poll.success) {
|
|
5514
|
+
if (poll.status === 410) {
|
|
5515
|
+
console.log(`
|
|
5516
|
+
\u274C Pairing expired. Run setup again.`);
|
|
5517
|
+
process.exit(1);
|
|
5518
|
+
}
|
|
5519
|
+
continue;
|
|
5520
|
+
}
|
|
5521
|
+
if (poll.data?.status === "approved") {
|
|
5522
|
+
approved = { device_id: poll.data.device_id, token: poll.data.token };
|
|
5523
|
+
break;
|
|
5524
|
+
}
|
|
5525
|
+
}
|
|
5526
|
+
if (!approved) {
|
|
5493
5527
|
console.log(`
|
|
5494
|
-
\u274C
|
|
5528
|
+
\u274C Timed out.`);
|
|
5495
5529
|
process.exit(1);
|
|
5496
5530
|
}
|
|
5497
|
-
console.log(
|
|
5498
|
-
|
|
5499
|
-
|
|
5531
|
+
console.log(`
|
|
5532
|
+
\u2705 Device paired. ID: ${approved.device_id}`);
|
|
5533
|
+
saveConfig({ deviceId: approved.device_id, token: approved.token, apiUrl });
|
|
5534
|
+
setAuthToken(approved.token);
|
|
5535
|
+
const dev = await apiClient.get(`${apiUrl}/api/devices/${approved.device_id}`);
|
|
5536
|
+
const workspaceId = dev.data?.workspace_id;
|
|
5537
|
+
if (workspaceId) {
|
|
5538
|
+
saveConfig({ deviceId: approved.device_id, token: approved.token, workspaceId, apiUrl });
|
|
5539
|
+
await syncSkills(apiUrl, workspaceId);
|
|
5540
|
+
}
|
|
5500
5541
|
console.log(`
|
|
5501
5542
|
Next: link to an agent with: multi-agent link --agent <agentId>`);
|
|
5502
5543
|
console.log("Then: multi-agent connect");
|
|
@@ -5549,7 +5590,7 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5549
5590
|
log(`\uD83D\uDE80 Connecting device ${config.deviceId} (pid ${process.pid})`);
|
|
5550
5591
|
log(` runtimes: ${detected.map((d) => d.type).join(", ") || "stub"}`);
|
|
5551
5592
|
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online" });
|
|
5552
|
-
const wsUrl = apiUrl.replace(/^http/, "ws") + `/ws/devices/${config.deviceId}`;
|
|
5593
|
+
const wsUrl = apiUrl.replace(/^http/, "ws") + `/ws/devices/${config.deviceId}?token=${encodeURIComponent(config.token || "")}`;
|
|
5553
5594
|
let ws = null;
|
|
5554
5595
|
let running = true;
|
|
5555
5596
|
const shutdown = async (reason) => {
|
|
@@ -5608,9 +5649,19 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5608
5649
|
async function handleRunTask(apiUrl, deviceId, task, detected) {
|
|
5609
5650
|
const issueId = task.issue_id;
|
|
5610
5651
|
const isFollowup = !!task.followup;
|
|
5611
|
-
|
|
5652
|
+
const workingDir = task.working_dir && existsSync2(task.working_dir) ? task.working_dir : undefined;
|
|
5653
|
+
log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}]` : ""}`);
|
|
5612
5654
|
await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "in_progress" });
|
|
5613
5655
|
await postStream(apiUrl, issueId, "progress", { message: `Device ${deviceId} picked up ${isFollowup ? "follow-up" : "task"}` });
|
|
5656
|
+
let attachmentRefs = [];
|
|
5657
|
+
if (task.from_comment_id) {
|
|
5658
|
+
const baseDir = workingDir || join(MULTI_DIR, "tmp", issueId);
|
|
5659
|
+
const inDir = join(baseDir, ".multi-in", task.from_comment_id);
|
|
5660
|
+
attachmentRefs = await downloadCommentAttachments(apiUrl, task.from_comment_id, inDir);
|
|
5661
|
+
if (attachmentRefs.length)
|
|
5662
|
+
log(` fetched ${attachmentRefs.length} attachment(s) \u2192 ${inDir}`);
|
|
5663
|
+
}
|
|
5664
|
+
const outDir = join(workingDir || join(MULTI_DIR, "tmp", issueId), ".multi-out");
|
|
5614
5665
|
let liveCommentId;
|
|
5615
5666
|
let liveBody = "";
|
|
5616
5667
|
let hadError = false;
|
|
@@ -5814,6 +5865,22 @@ ${task.description || ""}`.trim();
|
|
|
5814
5865
|
---
|
|
5815
5866
|
Context (original task ${task.key}): ${task.title}` : base || task.title;
|
|
5816
5867
|
userPart = stripSelfMention(userPart, preferType);
|
|
5868
|
+
if (attachmentRefs.length) {
|
|
5869
|
+
const lines = attachmentRefs.map((a) => `- ${a.filename}: ${a.path}`).join(`
|
|
5870
|
+
`);
|
|
5871
|
+
userPart += `
|
|
5872
|
+
|
|
5873
|
+
---
|
|
5874
|
+
Attached files (on local disk, read them with your tools):
|
|
5875
|
+
${lines}
|
|
5876
|
+
|
|
5877
|
+
To return generated files, write them to: ${outDir}`;
|
|
5878
|
+
} else {
|
|
5879
|
+
userPart += `
|
|
5880
|
+
|
|
5881
|
+
---
|
|
5882
|
+
To return generated files, write them to: ${outDir}`;
|
|
5883
|
+
}
|
|
5817
5884
|
let preamble = "";
|
|
5818
5885
|
try {
|
|
5819
5886
|
const agentRes = await apiClient.get(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
@@ -5866,6 +5933,7 @@ ${userPart}` : userPart;
|
|
|
5866
5933
|
prompt,
|
|
5867
5934
|
sessionId: task.session_id || null,
|
|
5868
5935
|
adapterBin,
|
|
5936
|
+
cwd: workingDir,
|
|
5869
5937
|
onEvent: eventHandler,
|
|
5870
5938
|
onSession: async (sid) => {
|
|
5871
5939
|
try {
|
|
@@ -5885,6 +5953,11 @@ ${userPart}` : userPart;
|
|
|
5885
5953
|
for await (const event of runner(task))
|
|
5886
5954
|
await eventHandler(event);
|
|
5887
5955
|
}
|
|
5956
|
+
if (liveCommentId) {
|
|
5957
|
+
const n = await uploadOutputDir(apiUrl, liveCommentId, outDir);
|
|
5958
|
+
if (n > 0)
|
|
5959
|
+
log(` \uD83D\uDCCE uploaded ${n} output file(s)`);
|
|
5960
|
+
}
|
|
5888
5961
|
if (hadError) {
|
|
5889
5962
|
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
5890
5963
|
log(` \u2717 ${task.key} failed`);
|
|
@@ -5945,6 +6018,80 @@ async function resolveAcpAdapter(agentType, detectedPath) {
|
|
|
5945
6018
|
async function postStream(apiUrl, issueId, event_type, payload) {
|
|
5946
6019
|
await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
|
|
5947
6020
|
}
|
|
6021
|
+
async function downloadCommentAttachments(apiUrl, commentId, destDir) {
|
|
6022
|
+
try {
|
|
6023
|
+
const list = await apiClient.get(`${apiUrl}/api/attachments/comments/${commentId}`);
|
|
6024
|
+
const items = list.data?.results || list.data || [];
|
|
6025
|
+
if (!Array.isArray(items) || items.length === 0)
|
|
6026
|
+
return [];
|
|
6027
|
+
mkdirSync2(destDir, { recursive: true });
|
|
6028
|
+
const token = authTokenHeader();
|
|
6029
|
+
const out = [];
|
|
6030
|
+
for (const it of items) {
|
|
6031
|
+
const res = await fetch(`${apiUrl}/api/attachments/${it.id}`, { headers: token ? { Authorization: token } : {} });
|
|
6032
|
+
if (!res.ok)
|
|
6033
|
+
continue;
|
|
6034
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
6035
|
+
const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
6036
|
+
const p = join(destDir, safe);
|
|
6037
|
+
writeFileSync2(p, buf);
|
|
6038
|
+
out.push({ filename: it.filename, path: p });
|
|
6039
|
+
}
|
|
6040
|
+
return out;
|
|
6041
|
+
} catch {
|
|
6042
|
+
return [];
|
|
6043
|
+
}
|
|
6044
|
+
}
|
|
6045
|
+
function authTokenHeader() {
|
|
6046
|
+
const cfg = loadConfig();
|
|
6047
|
+
return cfg.token ? `Bearer ${cfg.token}` : null;
|
|
6048
|
+
}
|
|
6049
|
+
async function uploadOutputDir(apiUrl, commentId, dir) {
|
|
6050
|
+
if (!existsSync2(dir))
|
|
6051
|
+
return 0;
|
|
6052
|
+
const files = [];
|
|
6053
|
+
const walk = (d, depth = 0) => {
|
|
6054
|
+
if (depth > 3)
|
|
6055
|
+
return;
|
|
6056
|
+
for (const name of readdirSync(d)) {
|
|
6057
|
+
const p = join(d, name);
|
|
6058
|
+
try {
|
|
6059
|
+
const st = statSync(p);
|
|
6060
|
+
if (st.isDirectory())
|
|
6061
|
+
walk(p, depth + 1);
|
|
6062
|
+
else if (st.isFile())
|
|
6063
|
+
files.push(p);
|
|
6064
|
+
} catch {}
|
|
6065
|
+
}
|
|
6066
|
+
};
|
|
6067
|
+
walk(dir);
|
|
6068
|
+
if (!files.length)
|
|
6069
|
+
return 0;
|
|
6070
|
+
const token = authTokenHeader();
|
|
6071
|
+
let uploaded = 0;
|
|
6072
|
+
for (const f of files) {
|
|
6073
|
+
try {
|
|
6074
|
+
const data = readFileSync2(f);
|
|
6075
|
+
const form = new FormData;
|
|
6076
|
+
const blob = new Blob([data]);
|
|
6077
|
+
form.append("file", blob, f.split("/").pop() || "file");
|
|
6078
|
+
const res = await fetch(`${apiUrl}/api/attachments/comments/${commentId}`, {
|
|
6079
|
+
method: "POST",
|
|
6080
|
+
body: form,
|
|
6081
|
+
headers: token ? { Authorization: token } : {}
|
|
6082
|
+
});
|
|
6083
|
+
if (res.ok) {
|
|
6084
|
+
uploaded++;
|
|
6085
|
+
try {
|
|
6086
|
+
unlinkSync(f);
|
|
6087
|
+
} catch {}
|
|
6088
|
+
}
|
|
6089
|
+
} catch (e) {
|
|
6090
|
+
log(`upload failed for ${f}: ${String(e)}`);
|
|
6091
|
+
}
|
|
6092
|
+
}
|
|
6093
|
+
return uploaded;
|
|
6094
|
+
}
|
|
5948
6095
|
function pickRunner(detected, preferType) {
|
|
5949
6096
|
const forceStub = process.env.MULTI_STUB === "1";
|
|
5950
6097
|
if (forceStub || !detected.length)
|
|
@@ -5976,7 +6123,7 @@ Context (original task ${task.key}): ${task.title}` : base || task.title;
|
|
|
5976
6123
|
yield { event_type: "progress", payload: { message: `spawning ${agent.type}`, cmd: `${agent.path} ${args.slice(0, 2).join(" ")} \u2026` } };
|
|
5977
6124
|
let proc;
|
|
5978
6125
|
try {
|
|
5979
|
-
proc = Bun.spawn([agent.path, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
6126
|
+
proc = Bun.spawn([agent.path, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd: task?.working_dir || undefined });
|
|
5980
6127
|
} catch (e) {
|
|
5981
6128
|
yield { event_type: "error", payload: { message: `spawn failed: ${String(e)}` } };
|
|
5982
6129
|
return;
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -2,29 +2,35 @@ interface ApiResponse<T = unknown> {
|
|
|
2
2
|
success: boolean;
|
|
3
3
|
data?: T;
|
|
4
4
|
error?: string;
|
|
5
|
+
status?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let authToken: string | null = null;
|
|
9
|
+
|
|
10
|
+
export function setAuthToken(token: string | null) {
|
|
11
|
+
authToken = token;
|
|
5
12
|
}
|
|
6
13
|
|
|
7
14
|
async function request<T>(url: string, options?: RequestInit): Promise<ApiResponse<T>> {
|
|
8
15
|
try {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
headers
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
},
|
|
15
|
-
});
|
|
16
|
+
const headers: Record<string, string> = {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
...(options?.headers as Record<string, string> | undefined),
|
|
19
|
+
};
|
|
20
|
+
if (authToken && !headers['Authorization']) headers['Authorization'] = `Bearer ${authToken}`;
|
|
16
21
|
|
|
17
|
-
const
|
|
22
|
+
const response = await fetch(url, { ...options, headers });
|
|
23
|
+
const raw = await response.json().catch(() => ({}));
|
|
18
24
|
|
|
19
25
|
if (!response.ok) {
|
|
20
|
-
return { success: false, error: (raw as any)?.error || `HTTP ${response.status}
|
|
26
|
+
return { success: false, error: (raw as any)?.error || `HTTP ${response.status}`, status: response.status };
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
const data = (raw && typeof raw === 'object' && 'results' in (raw as any))
|
|
24
30
|
? (raw as any).results
|
|
25
31
|
: raw;
|
|
26
32
|
|
|
27
|
-
return { success: true, data };
|
|
33
|
+
return { success: true, data, status: response.status };
|
|
28
34
|
} catch (error) {
|
|
29
35
|
return { success: false, error: String(error) };
|
|
30
36
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { detectAgents } from './detect';
|
|
4
|
-
import { apiClient } from './client';
|
|
4
|
+
import { apiClient, setAuthToken } from './client';
|
|
5
5
|
import { runAcp } from './acp-runner';
|
|
6
6
|
import { parseArgs } from 'util';
|
|
7
|
-
import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync } from 'fs';
|
|
7
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
8
8
|
import { join, dirname } from 'path';
|
|
9
9
|
|
|
10
10
|
const HOME = process.env.HOME || process.env.USERPROFILE || '.';
|
|
@@ -61,10 +61,11 @@ async function main() {
|
|
|
61
61
|
|
|
62
62
|
const apiUrl = args.values.api || process.env.MULTI_API_URL || 'https://multi-api.adnb3r.workers.dev';
|
|
63
63
|
const config = loadConfig();
|
|
64
|
+
if (config.token) setAuthToken(config.token);
|
|
64
65
|
|
|
65
66
|
switch (command) {
|
|
66
67
|
case 'setup':
|
|
67
|
-
await cmdSetup(args.values.name,
|
|
68
|
+
await cmdSetup(args.values.name, apiUrl);
|
|
68
69
|
break;
|
|
69
70
|
case 'connect':
|
|
70
71
|
case 'start':
|
|
@@ -117,12 +118,8 @@ Examples:
|
|
|
117
118
|
`);
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
async function cmdSetup(name?: string,
|
|
121
|
+
async function cmdSetup(name?: string, apiUrl?: string) {
|
|
121
122
|
ensureDirs();
|
|
122
|
-
if (!workspaceId) {
|
|
123
|
-
console.log('❌ Workspace ID required. Run: multi-agent setup --workspace <id>');
|
|
124
|
-
process.exit(1);
|
|
125
|
-
}
|
|
126
123
|
|
|
127
124
|
console.log('🔍 Detecting local agent runtimes...');
|
|
128
125
|
const detected = await detectAgents();
|
|
@@ -130,25 +127,53 @@ async function cmdSetup(name?: string, workspaceId?: string, apiUrl?: string) {
|
|
|
130
127
|
if (!detected.length) console.log(' (none detected — stub runtime will be used)');
|
|
131
128
|
|
|
132
129
|
const deviceName = name || process.env.HOSTNAME || 'Unknown Device';
|
|
133
|
-
console.log(`\n
|
|
130
|
+
console.log(`\n📡 Requesting pairing code for "${deviceName}"...`);
|
|
134
131
|
|
|
135
|
-
const
|
|
136
|
-
workspace_id: workspaceId,
|
|
132
|
+
const start = await apiClient.post<{ code: string; expires_at: number }>(`${apiUrl}/api/pair/start`, {
|
|
137
133
|
name: deviceName,
|
|
138
134
|
platform: process.platform,
|
|
139
135
|
arch: process.arch,
|
|
140
136
|
os_version: process.version,
|
|
141
137
|
detected_runtimes: detected.map(a => a.type),
|
|
142
138
|
});
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
console.log('\n❌ Registration failed:', result.error);
|
|
139
|
+
if (!start.success || !start.data) {
|
|
140
|
+
console.log('\n❌ Failed to start pairing:', start.error);
|
|
146
141
|
process.exit(1);
|
|
147
142
|
}
|
|
148
143
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
const { code } = start.data;
|
|
145
|
+
const pairUrl = `${apiUrl}/pair/${code}`;
|
|
146
|
+
console.log(`\n👉 Open this URL in your browser to approve:\n\n ${pairUrl}\n`);
|
|
147
|
+
console.log(` Or enter code manually: ${code}`);
|
|
148
|
+
console.log('\n⏳ Waiting for approval (10 min timeout)...');
|
|
149
|
+
|
|
150
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
151
|
+
let approved: { device_id: string; token: string } | null = null;
|
|
152
|
+
while (Date.now() < deadline) {
|
|
153
|
+
await sleep(3000);
|
|
154
|
+
const poll = await apiClient.get<any>(`${apiUrl}/api/pair/poll/${code}`);
|
|
155
|
+
if (!poll.success) {
|
|
156
|
+
if (poll.status === 410) { console.log('\n❌ Pairing expired. Run setup again.'); process.exit(1); }
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (poll.data?.status === 'approved') {
|
|
160
|
+
approved = { device_id: poll.data.device_id, token: poll.data.token };
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!approved) { console.log('\n❌ Timed out.'); process.exit(1); }
|
|
165
|
+
|
|
166
|
+
console.log(`\n✅ Device paired. ID: ${approved.device_id}`);
|
|
167
|
+
saveConfig({ deviceId: approved.device_id, token: approved.token, apiUrl });
|
|
168
|
+
setAuthToken(approved.token);
|
|
169
|
+
|
|
170
|
+
// Fetch workspace_id from device (now authed)
|
|
171
|
+
const dev = await apiClient.get<any>(`${apiUrl}/api/devices/${approved.device_id}`);
|
|
172
|
+
const workspaceId = dev.data?.workspace_id;
|
|
173
|
+
if (workspaceId) {
|
|
174
|
+
saveConfig({ deviceId: approved.device_id, token: approved.token, workspaceId, apiUrl });
|
|
175
|
+
await syncSkills(apiUrl!, workspaceId);
|
|
176
|
+
}
|
|
152
177
|
console.log('\nNext: link to an agent with: multi-agent link --agent <agentId>');
|
|
153
178
|
console.log('Then: multi-agent connect');
|
|
154
179
|
}
|
|
@@ -204,7 +229,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
204
229
|
|
|
205
230
|
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online' });
|
|
206
231
|
|
|
207
|
-
const wsUrl = apiUrl.replace(/^http/, 'ws') + `/ws/devices/${config.deviceId}`;
|
|
232
|
+
const wsUrl = apiUrl.replace(/^http/, 'ws') + `/ws/devices/${config.deviceId}?token=${encodeURIComponent(config.token || '')}`;
|
|
208
233
|
let ws: WebSocket | null = null;
|
|
209
234
|
let running = true;
|
|
210
235
|
|
|
@@ -259,11 +284,22 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
259
284
|
async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[]) {
|
|
260
285
|
const issueId = task.issue_id;
|
|
261
286
|
const isFollowup = !!task.followup;
|
|
262
|
-
|
|
287
|
+
const workingDir = task.working_dir && existsSync(task.working_dir) ? task.working_dir : undefined;
|
|
288
|
+
log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}${workingDir ? ` [cwd: ${workingDir}]` : ''}`);
|
|
263
289
|
|
|
264
290
|
await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'in_progress' });
|
|
265
291
|
await postStream(apiUrl, issueId, 'progress', { message: `Device ${deviceId} picked up ${isFollowup ? 'follow-up' : 'task'}` });
|
|
266
292
|
|
|
293
|
+
// Fetch attachments from originating comment into a scratch dir under working_dir (or tmp)
|
|
294
|
+
let attachmentRefs: { filename: string; path: string }[] = [];
|
|
295
|
+
if (task.from_comment_id) {
|
|
296
|
+
const baseDir = workingDir || join(MULTI_DIR, 'tmp', issueId);
|
|
297
|
+
const inDir = join(baseDir, '.multi-in', task.from_comment_id);
|
|
298
|
+
attachmentRefs = await downloadCommentAttachments(apiUrl, task.from_comment_id, inDir);
|
|
299
|
+
if (attachmentRefs.length) log(` fetched ${attachmentRefs.length} attachment(s) → ${inDir}`);
|
|
300
|
+
}
|
|
301
|
+
const outDir = join(workingDir || join(MULTI_DIR, 'tmp', issueId), '.multi-out');
|
|
302
|
+
|
|
267
303
|
let liveCommentId: string | undefined;
|
|
268
304
|
let liveBody = '';
|
|
269
305
|
let hadError = false;
|
|
@@ -438,6 +474,12 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
438
474
|
? `${task.followup}\n\n---\nContext (original task ${task.key}): ${task.title}`
|
|
439
475
|
: (base || task.title);
|
|
440
476
|
userPart = stripSelfMention(userPart, preferType);
|
|
477
|
+
if (attachmentRefs.length) {
|
|
478
|
+
const lines = attachmentRefs.map(a => `- ${a.filename}: ${a.path}`).join('\n');
|
|
479
|
+
userPart += `\n\n---\nAttached files (on local disk, read them with your tools):\n${lines}\n\nTo return generated files, write them to: ${outDir}`;
|
|
480
|
+
} else {
|
|
481
|
+
userPart += `\n\n---\nTo return generated files, write them to: ${outDir}`;
|
|
482
|
+
}
|
|
441
483
|
|
|
442
484
|
// Pull agent + linked skills to construct system/context preamble
|
|
443
485
|
let preamble = '';
|
|
@@ -477,6 +519,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
477
519
|
apiUrl, issueId, deviceId, prompt,
|
|
478
520
|
sessionId: task.session_id || null,
|
|
479
521
|
adapterBin,
|
|
522
|
+
cwd: workingDir,
|
|
480
523
|
onEvent: eventHandler,
|
|
481
524
|
onSession: async (sid) => {
|
|
482
525
|
try { await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { session_id: sid } as any); } catch {}
|
|
@@ -494,6 +537,12 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
494
537
|
for await (const event of runner(task)) await eventHandler(event);
|
|
495
538
|
}
|
|
496
539
|
|
|
540
|
+
// Upload any files the agent wrote to .multi-out as attachments on the live comment
|
|
541
|
+
if (liveCommentId) {
|
|
542
|
+
const n = await uploadOutputDir(apiUrl, liveCommentId, outDir);
|
|
543
|
+
if (n > 0) log(` 📎 uploaded ${n} output file(s)`);
|
|
544
|
+
}
|
|
545
|
+
|
|
497
546
|
if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
|
|
498
547
|
else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
|
|
499
548
|
} catch (e) {
|
|
@@ -552,6 +601,69 @@ async function postStream(apiUrl: string, issueId: string, event_type: string, p
|
|
|
552
601
|
await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
|
|
553
602
|
}
|
|
554
603
|
|
|
604
|
+
// Download attachments of a comment to a local dir. Returns list of absolute paths.
|
|
605
|
+
async function downloadCommentAttachments(apiUrl: string, commentId: string, destDir: string): Promise<{ filename: string; path: string }[]> {
|
|
606
|
+
try {
|
|
607
|
+
const list = await apiClient.get<any>(`${apiUrl}/api/attachments/comments/${commentId}`);
|
|
608
|
+
const items = list.data?.results || list.data || [];
|
|
609
|
+
if (!Array.isArray(items) || items.length === 0) return [];
|
|
610
|
+
mkdirSync(destDir, { recursive: true });
|
|
611
|
+
const token = authTokenHeader();
|
|
612
|
+
const out: { filename: string; path: string }[] = [];
|
|
613
|
+
for (const it of items) {
|
|
614
|
+
const res = await fetch(`${apiUrl}/api/attachments/${it.id}`, { headers: token ? { Authorization: token } : {} });
|
|
615
|
+
if (!res.ok) continue;
|
|
616
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
617
|
+
const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
618
|
+
const p = join(destDir, safe);
|
|
619
|
+
writeFileSync(p, buf);
|
|
620
|
+
out.push({ filename: it.filename, path: p });
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
} catch { return []; }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function authTokenHeader(): string | null {
|
|
627
|
+
const cfg = loadConfig();
|
|
628
|
+
return cfg.token ? `Bearer ${cfg.token}` : null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Scan a directory for files (recursive, shallow cap) and upload each as an attachment to a comment.
|
|
632
|
+
async function uploadOutputDir(apiUrl: string, commentId: string, dir: string): Promise<number> {
|
|
633
|
+
if (!existsSync(dir)) return 0;
|
|
634
|
+
const files: string[] = [];
|
|
635
|
+
const walk = (d: string, depth = 0) => {
|
|
636
|
+
if (depth > 3) return;
|
|
637
|
+
for (const name of readdirSync(d)) {
|
|
638
|
+
const p = join(d, name);
|
|
639
|
+
try {
|
|
640
|
+
const st = statSync(p);
|
|
641
|
+
if (st.isDirectory()) walk(p, depth + 1);
|
|
642
|
+
else if (st.isFile()) files.push(p);
|
|
643
|
+
} catch {}
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
walk(dir);
|
|
647
|
+
if (!files.length) return 0;
|
|
648
|
+
const token = authTokenHeader();
|
|
649
|
+
let uploaded = 0;
|
|
650
|
+
for (const f of files) {
|
|
651
|
+
try {
|
|
652
|
+
const data = readFileSync(f);
|
|
653
|
+
const form = new FormData();
|
|
654
|
+
const blob = new Blob([data]);
|
|
655
|
+
form.append('file', blob, f.split('/').pop() || 'file');
|
|
656
|
+
const res = await fetch(`${apiUrl}/api/attachments/comments/${commentId}`, {
|
|
657
|
+
method: 'POST',
|
|
658
|
+
body: form,
|
|
659
|
+
headers: token ? { Authorization: token } : {},
|
|
660
|
+
});
|
|
661
|
+
if (res.ok) { uploaded++; try { unlinkSync(f); } catch {} }
|
|
662
|
+
} catch (e) { log(`upload failed for ${f}: ${String(e)}`); }
|
|
663
|
+
}
|
|
664
|
+
return uploaded;
|
|
665
|
+
}
|
|
666
|
+
|
|
555
667
|
type StreamEvent = { event_type: 'progress' | 'stdout' | 'stderr' | 'tool_call' | 'done' | 'error'; payload: any };
|
|
556
668
|
type Runner = (task: any) => AsyncGenerator<StreamEvent>;
|
|
557
669
|
|
|
@@ -585,7 +697,7 @@ function makeCliRunner(agent: { type: string; path: string }): Runner {
|
|
|
585
697
|
|
|
586
698
|
let proc: ReturnType<typeof Bun.spawn>;
|
|
587
699
|
try {
|
|
588
|
-
proc = Bun.spawn([agent.path, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
|
|
700
|
+
proc = Bun.spawn([agent.path, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', cwd: (task as any)?.working_dir || undefined });
|
|
589
701
|
} catch (e) {
|
|
590
702
|
yield { event_type: 'error', payload: { message: `spawn failed: ${String(e)}` } };
|
|
591
703
|
return;
|
|
@@ -777,6 +889,7 @@ interface Config {
|
|
|
777
889
|
deviceId?: string;
|
|
778
890
|
workspaceId?: string;
|
|
779
891
|
apiUrl?: string;
|
|
892
|
+
token?: string;
|
|
780
893
|
// legacy
|
|
781
894
|
agentId?: string;
|
|
782
895
|
}
|