@shipers-dev/multi 0.2.0 → 0.3.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
@@ -5368,7 +5368,7 @@ function extractText(content) {
5368
5368
 
5369
5369
  // src/index.ts
5370
5370
  import { parseArgs } from "util";
5371
- 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";
5372
5372
  import { join, dirname as dirname2 } from "path";
5373
5373
  var HOME = process.env.HOME || process.env.USERPROFILE || ".";
5374
5374
  var MULTI_DIR = join(HOME, ".multi");
@@ -5649,9 +5649,19 @@ async function cmdConnect(apiUrl, config) {
5649
5649
  async function handleRunTask(apiUrl, deviceId, task, detected) {
5650
5650
  const issueId = task.issue_id;
5651
5651
  const isFollowup = !!task.followup;
5652
- 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}]` : ""}`);
5653
5654
  await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: "in_progress" });
5654
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");
5655
5665
  let liveCommentId;
5656
5666
  let liveBody = "";
5657
5667
  let hadError = false;
@@ -5764,9 +5774,7 @@ _${bits.join(" \xB7 ")}_`);
5764
5774
  }
5765
5775
  case "assistant_text": {
5766
5776
  await ensureLiveComment();
5767
- turn.text += (turn.text ? `
5768
-
5769
- ` : "") + p.text;
5777
+ turn.text += p.text;
5770
5778
  hasAssistantText = true;
5771
5779
  schedulePatch();
5772
5780
  break;
@@ -5855,6 +5863,22 @@ ${task.description || ""}`.trim();
5855
5863
  ---
5856
5864
  Context (original task ${task.key}): ${task.title}` : base || task.title;
5857
5865
  userPart = stripSelfMention(userPart, preferType);
5866
+ if (attachmentRefs.length) {
5867
+ const lines = attachmentRefs.map((a) => `- ${a.filename}: ${a.path}`).join(`
5868
+ `);
5869
+ userPart += `
5870
+
5871
+ ---
5872
+ Attached files (on local disk, read them with your tools):
5873
+ ${lines}
5874
+
5875
+ To return generated files, write them to: ${outDir}`;
5876
+ } else {
5877
+ userPart += `
5878
+
5879
+ ---
5880
+ To return generated files, write them to: ${outDir}`;
5881
+ }
5858
5882
  let preamble = "";
5859
5883
  try {
5860
5884
  const agentRes = await apiClient.get(`${apiUrl}/api/agents/${task.agent_id}`);
@@ -5907,6 +5931,7 @@ ${userPart}` : userPart;
5907
5931
  prompt,
5908
5932
  sessionId: task.session_id || null,
5909
5933
  adapterBin,
5934
+ cwd: workingDir,
5910
5935
  onEvent: eventHandler,
5911
5936
  onSession: async (sid) => {
5912
5937
  try {
@@ -5926,6 +5951,11 @@ ${userPart}` : userPart;
5926
5951
  for await (const event of runner(task))
5927
5952
  await eventHandler(event);
5928
5953
  }
5954
+ if (liveCommentId) {
5955
+ const n = await uploadOutputDir(apiUrl, liveCommentId, outDir);
5956
+ if (n > 0)
5957
+ log(` \uD83D\uDCCE uploaded ${n} output file(s)`);
5958
+ }
5929
5959
  if (hadError) {
5930
5960
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
5931
5961
  log(` \u2717 ${task.key} failed`);
@@ -5986,6 +6016,80 @@ async function resolveAcpAdapter(agentType, detectedPath) {
5986
6016
  async function postStream(apiUrl, issueId, event_type, payload) {
5987
6017
  await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
5988
6018
  }
6019
+ async function downloadCommentAttachments(apiUrl, commentId, destDir) {
6020
+ try {
6021
+ const list = await apiClient.get(`${apiUrl}/api/attachments/comments/${commentId}`);
6022
+ const items = list.data?.results || list.data || [];
6023
+ if (!Array.isArray(items) || items.length === 0)
6024
+ return [];
6025
+ mkdirSync2(destDir, { recursive: true });
6026
+ const token = authTokenHeader();
6027
+ const out = [];
6028
+ for (const it of items) {
6029
+ const res = await fetch(`${apiUrl}/api/attachments/${it.id}`, { headers: token ? { Authorization: token } : {} });
6030
+ if (!res.ok)
6031
+ continue;
6032
+ const buf = new Uint8Array(await res.arrayBuffer());
6033
+ const safe = it.filename.replace(/[^A-Za-z0-9._-]/g, "_");
6034
+ const p = join(destDir, safe);
6035
+ writeFileSync2(p, buf);
6036
+ out.push({ filename: it.filename, path: p });
6037
+ }
6038
+ return out;
6039
+ } catch {
6040
+ return [];
6041
+ }
6042
+ }
6043
+ function authTokenHeader() {
6044
+ const cfg = loadConfig();
6045
+ return cfg.token ? `Bearer ${cfg.token}` : null;
6046
+ }
6047
+ async function uploadOutputDir(apiUrl, commentId, dir) {
6048
+ if (!existsSync2(dir))
6049
+ return 0;
6050
+ const files = [];
6051
+ const walk = (d, depth = 0) => {
6052
+ if (depth > 3)
6053
+ return;
6054
+ for (const name of readdirSync(d)) {
6055
+ const p = join(d, name);
6056
+ try {
6057
+ const st = statSync(p);
6058
+ if (st.isDirectory())
6059
+ walk(p, depth + 1);
6060
+ else if (st.isFile())
6061
+ files.push(p);
6062
+ } catch {}
6063
+ }
6064
+ };
6065
+ walk(dir);
6066
+ if (!files.length)
6067
+ return 0;
6068
+ const token = authTokenHeader();
6069
+ let uploaded = 0;
6070
+ for (const f of files) {
6071
+ try {
6072
+ const data = readFileSync2(f);
6073
+ const form = new FormData;
6074
+ const blob = new Blob([data]);
6075
+ form.append("file", blob, f.split("/").pop() || "file");
6076
+ const res = await fetch(`${apiUrl}/api/attachments/comments/${commentId}`, {
6077
+ method: "POST",
6078
+ body: form,
6079
+ headers: token ? { Authorization: token } : {}
6080
+ });
6081
+ if (res.ok) {
6082
+ uploaded++;
6083
+ try {
6084
+ unlinkSync(f);
6085
+ } catch {}
6086
+ }
6087
+ } catch (e) {
6088
+ log(`upload failed for ${f}: ${String(e)}`);
6089
+ }
6090
+ }
6091
+ return uploaded;
6092
+ }
5989
6093
  function pickRunner(detected, preferType) {
5990
6094
  const forceStub = process.env.MULTI_STUB === "1";
5991
6095
  if (forceStub || !detected.length)
@@ -6017,7 +6121,7 @@ Context (original task ${task.key}): ${task.title}` : base || task.title;
6017
6121
  yield { event_type: "progress", payload: { message: `spawning ${agent.type}`, cmd: `${agent.path} ${args.slice(0, 2).join(" ")} \u2026` } };
6018
6122
  let proc;
6019
6123
  try {
6020
- proc = Bun.spawn([agent.path, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
6124
+ proc = Bun.spawn([agent.path, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd: task?.working_dir || undefined });
6021
6125
  } catch (e) {
6022
6126
  yield { event_type: "error", payload: { message: `spawn failed: ${String(e)}` } };
6023
6127
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import { detectAgents } from './detect';
4
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 || '.';
@@ -284,11 +284,22 @@ async function cmdConnect(apiUrl: string, config: Config) {
284
284
  async function handleRunTask(apiUrl: string, deviceId: string, task: any, detected: { type: string; path: string }[]) {
285
285
  const issueId = task.issue_id;
286
286
  const isFollowup = !!task.followup;
287
- 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}]` : ''}`);
288
289
 
289
290
  await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { status: 'in_progress' });
290
291
  await postStream(apiUrl, issueId, 'progress', { message: `Device ${deviceId} picked up ${isFollowup ? 'follow-up' : 'task'}` });
291
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
+
292
303
  let liveCommentId: string | undefined;
293
304
  let liveBody = '';
294
305
  let hadError = false;
@@ -379,7 +390,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
379
390
  }
380
391
  case 'assistant_text': {
381
392
  await ensureLiveComment();
382
- turn.text += (turn.text ? '\n\n' : '') + p.text;
393
+ turn.text += p.text;
383
394
  hasAssistantText = true;
384
395
  schedulePatch();
385
396
  break;
@@ -463,6 +474,12 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
463
474
  ? `${task.followup}\n\n---\nContext (original task ${task.key}): ${task.title}`
464
475
  : (base || task.title);
465
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
+ }
466
483
 
467
484
  // Pull agent + linked skills to construct system/context preamble
468
485
  let preamble = '';
@@ -502,6 +519,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
502
519
  apiUrl, issueId, deviceId, prompt,
503
520
  sessionId: task.session_id || null,
504
521
  adapterBin,
522
+ cwd: workingDir,
505
523
  onEvent: eventHandler,
506
524
  onSession: async (sid) => {
507
525
  try { await apiClient.patch(`${apiUrl}/api/issues/${issueId}`, { session_id: sid } as any); } catch {}
@@ -519,6 +537,12 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
519
537
  for await (const event of runner(task)) await eventHandler(event);
520
538
  }
521
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
+
522
546
  if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
523
547
  else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
524
548
  } catch (e) {
@@ -577,6 +601,69 @@ async function postStream(apiUrl: string, issueId: string, event_type: string, p
577
601
  await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
578
602
  }
579
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
+
580
667
  type StreamEvent = { event_type: 'progress' | 'stdout' | 'stderr' | 'tool_call' | 'done' | 'error'; payload: any };
581
668
  type Runner = (task: any) => AsyncGenerator<StreamEvent>;
582
669
 
@@ -610,7 +697,7 @@ function makeCliRunner(agent: { type: string; path: string }): Runner {
610
697
 
611
698
  let proc: ReturnType<typeof Bun.spawn>;
612
699
  try {
613
- 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 });
614
701
  } catch (e) {
615
702
  yield { event_type: 'error', payload: { message: `spawn failed: ${String(e)}` } };
616
703
  return;