@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 +110 -6
- package/package.json +1 -1
- package/src/index.ts +91 -4
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;
|
|
@@ -5764,9 +5774,7 @@ _${bits.join(" \xB7 ")}_`);
|
|
|
5764
5774
|
}
|
|
5765
5775
|
case "assistant_text": {
|
|
5766
5776
|
await ensureLiveComment();
|
|
5767
|
-
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
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;
|
|
@@ -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 +=
|
|
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;
|