@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 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 response = await fetch(url, {
77
- ...options,
78
- headers: {
79
- "Content-Type": "application/json",
80
- ...options?.headers
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, args.values.workspace, apiUrl);
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, workspaceId, apiUrl) {
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\uDCDD Registering device "${deviceName}"...`);
5484
- const result = await apiClient.post(`${apiUrl}/api/devices/register`, {
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 (!result.success || !result.data) {
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 Registration failed:`, result.error);
5528
+ \u274C Timed out.`);
5495
5529
  process.exit(1);
5496
5530
  }
5497
- console.log(`\u2705 Device registered. ID: ${result.data.id}`);
5498
- saveConfig({ deviceId: result.data.id, workspaceId, apiUrl });
5499
- await syncSkills(apiUrl, workspaceId);
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
- log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
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 response = await fetch(url, {
10
- ...options,
11
- headers: {
12
- 'Content-Type': 'application/json',
13
- ...options?.headers,
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 raw = await response.json();
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, args.values.workspace, apiUrl);
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, workspaceId?: string, apiUrl?: 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📝 Registering device "${deviceName}"...`);
130
+ console.log(`\n📡 Requesting pairing code for "${deviceName}"...`);
134
131
 
135
- const result = await apiClient.post<{ id: string }>(`${apiUrl}/api/devices/register`, {
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
- if (!result.success || !result.data) {
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
- console.log(`✅ Device registered. ID: ${result.data.id}`);
150
- saveConfig({ deviceId: result.data.id, workspaceId, apiUrl });
151
- await syncSkills(apiUrl!, workspaceId);
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
- log(`▶ run_task ${task.key}: ${isFollowup ? '(follow-up) ' : ''}${task.title}`);
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
  }