@shipers-dev/multi 0.2.0 → 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 +109 -3
- package/package.json +1 -1
- package/src/index.ts +90 -3
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
|
-
|
|
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;
|
|
@@ -5855,6 +5865,22 @@ ${task.description || ""}`.trim();
|
|
|
5855
5865
|
---
|
|
5856
5866
|
Context (original task ${task.key}): ${task.title}` : base || task.title;
|
|
5857
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
|
+
}
|
|
5858
5884
|
let preamble = "";
|
|
5859
5885
|
try {
|
|
5860
5886
|
const agentRes = await apiClient.get(`${apiUrl}/api/agents/${task.agent_id}`);
|
|
@@ -5907,6 +5933,7 @@ ${userPart}` : userPart;
|
|
|
5907
5933
|
prompt,
|
|
5908
5934
|
sessionId: task.session_id || null,
|
|
5909
5935
|
adapterBin,
|
|
5936
|
+
cwd: workingDir,
|
|
5910
5937
|
onEvent: eventHandler,
|
|
5911
5938
|
onSession: async (sid) => {
|
|
5912
5939
|
try {
|
|
@@ -5926,6 +5953,11 @@ ${userPart}` : userPart;
|
|
|
5926
5953
|
for await (const event of runner(task))
|
|
5927
5954
|
await eventHandler(event);
|
|
5928
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
|
+
}
|
|
5929
5961
|
if (hadError) {
|
|
5930
5962
|
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
5931
5963
|
log(` \u2717 ${task.key} failed`);
|
|
@@ -5986,6 +6018,80 @@ async function resolveAcpAdapter(agentType, detectedPath) {
|
|
|
5986
6018
|
async function postStream(apiUrl, issueId, event_type, payload) {
|
|
5987
6019
|
await apiClient.post(`${apiUrl}/api/streams/${issueId}`, { event_type, payload });
|
|
5988
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
|
+
}
|
|
5989
6095
|
function pickRunner(detected, preferType) {
|
|
5990
6096
|
const forceStub = process.env.MULTI_STUB === "1";
|
|
5991
6097
|
if (forceStub || !detected.length)
|
|
@@ -6017,7 +6123,7 @@ Context (original task ${task.key}): ${task.title}` : base || task.title;
|
|
|
6017
6123
|
yield { event_type: "progress", payload: { message: `spawning ${agent.type}`, cmd: `${agent.path} ${args.slice(0, 2).join(" ")} \u2026` } };
|
|
6018
6124
|
let proc;
|
|
6019
6125
|
try {
|
|
6020
|
-
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 });
|
|
6021
6127
|
} catch (e) {
|
|
6022
6128
|
yield { event_type: "error", payload: { message: `spawn failed: ${String(e)}` } };
|
|
6023
6129
|
return;
|
package/package.json
CHANGED
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
|
-
|
|
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;
|
|
@@ -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;
|