@shipers-dev/multi 0.17.2 → 0.20.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/README.md +47 -0
- package/dist/index.js +377 -38
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @shipers-dev/multi
|
|
2
|
+
|
|
3
|
+
CLI daemon (`multi-agent`) that pairs a device with a Multi workspace and runs assigned issues via ACP / acpx adapters.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add -g @shipers-dev/multi
|
|
9
|
+
# or
|
|
10
|
+
npm i -g @shipers-dev/multi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
multi-agent setup # pair the device against prod
|
|
17
|
+
multi-agent start # foreground daemon
|
|
18
|
+
multi-agent start -d # detached
|
|
19
|
+
multi-agent status
|
|
20
|
+
multi-agent logs
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Pointing at a local API worker
|
|
24
|
+
|
|
25
|
+
For local development against a wrangler-dev API worker, set `MULTI_API` to its base URL:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
MULTI_API=http://localhost:8787 multi-agent setup
|
|
29
|
+
MULTI_API=http://localhost:8787 multi-agent start
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The resolved API base URL is printed at start. When the host is `localhost` / `127.0.0.1` / `[::1]`, the daemon:
|
|
33
|
+
|
|
34
|
+
- skips spawning `cloudflared` (no quick-tunnel),
|
|
35
|
+
- advertises `http://127.0.0.1:<port>` as its push endpoint, so the local worker can reach the daemon directly over loopback,
|
|
36
|
+
- skips the tunnel self-heal + DNS probe loops.
|
|
37
|
+
|
|
38
|
+
`MULTI_API_URL` is still accepted as a fallback alias. CLI flag `--api <url>` overrides both.
|
|
39
|
+
|
|
40
|
+
## Common env vars
|
|
41
|
+
|
|
42
|
+
| Var | Purpose |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `MULTI_API` | API base URL (default `https://multi-api.adnb3r.workers.dev`) |
|
|
45
|
+
| `MULTI_MAX_CONCURRENT` | Max concurrent tasks per device (default 3) |
|
|
46
|
+
| `MULTI_TUNNEL_NAME` | Use a named cloudflared tunnel instead of quick tunnel |
|
|
47
|
+
| `MULTI_TUNNEL_HOSTNAME` | Public hostname routed to the named tunnel |
|
package/dist/index.js
CHANGED
|
@@ -16071,9 +16071,86 @@ var StreamEventInputSchema = exports_external.object({
|
|
|
16071
16071
|
event_type: StreamEventTypeSchema,
|
|
16072
16072
|
payload: exports_external.unknown().optional()
|
|
16073
16073
|
});
|
|
16074
|
+
// ../lib/memory.ts
|
|
16075
|
+
var MEMORY_ENTRY_KINDS = ["fact", "event", "instruction", "task"];
|
|
16076
|
+
var MemoryEntryKindSchema = exports_external.enum(MEMORY_ENTRY_KINDS);
|
|
16077
|
+
var MemoryEntrySchema = exports_external.object({
|
|
16078
|
+
id: exports_external.string(),
|
|
16079
|
+
project_id: exports_external.string(),
|
|
16080
|
+
issue_id: exports_external.string().nullable().optional(),
|
|
16081
|
+
kind: MemoryEntryKindSchema,
|
|
16082
|
+
text: exports_external.string(),
|
|
16083
|
+
source_event_id: exports_external.string().nullable().optional(),
|
|
16084
|
+
source_comment_id: exports_external.string().nullable().optional(),
|
|
16085
|
+
score: exports_external.number().optional(),
|
|
16086
|
+
created_at: exports_external.number()
|
|
16087
|
+
});
|
|
16088
|
+
var MemoryCitationSchema = exports_external.object({
|
|
16089
|
+
entry_id: exports_external.string(),
|
|
16090
|
+
kind: MemoryEntryKindSchema,
|
|
16091
|
+
issue_id: exports_external.string().nullable().optional(),
|
|
16092
|
+
comment_id: exports_external.string().nullable().optional(),
|
|
16093
|
+
snippet: exports_external.string(),
|
|
16094
|
+
score: exports_external.number().optional()
|
|
16095
|
+
});
|
|
16096
|
+
var MemoryRecallRequestSchema = exports_external.object({
|
|
16097
|
+
project_id: exports_external.string().min(1),
|
|
16098
|
+
issue_id: exports_external.string().optional(),
|
|
16099
|
+
query: exports_external.string().min(1),
|
|
16100
|
+
k: exports_external.number().int().min(1).max(50).optional()
|
|
16101
|
+
});
|
|
16102
|
+
var MemoryRecallResponseSchema = exports_external.object({
|
|
16103
|
+
synthesis: exports_external.string(),
|
|
16104
|
+
citations: exports_external.array(MemoryCitationSchema),
|
|
16105
|
+
entries: exports_external.array(MemoryEntrySchema)
|
|
16106
|
+
});
|
|
16107
|
+
var StreamSourceKind = exports_external.enum(["assistant_text", "result"]);
|
|
16108
|
+
var IngestBase = exports_external.object({
|
|
16109
|
+
project_id: exports_external.string().min(1),
|
|
16110
|
+
issue_id: exports_external.string().min(1),
|
|
16111
|
+
ts: exports_external.number().int()
|
|
16112
|
+
});
|
|
16113
|
+
var MemoryIngestAssistantTextSchema = IngestBase.extend({
|
|
16114
|
+
kind: exports_external.literal("assistant_text"),
|
|
16115
|
+
event_id: exports_external.string(),
|
|
16116
|
+
text: exports_external.string(),
|
|
16117
|
+
final: exports_external.literal(true)
|
|
16118
|
+
});
|
|
16119
|
+
var MemoryIngestResultSchema = IngestBase.extend({
|
|
16120
|
+
kind: exports_external.literal("result"),
|
|
16121
|
+
event_id: exports_external.string(),
|
|
16122
|
+
text: exports_external.string(),
|
|
16123
|
+
status: exports_external.enum(["success", "error", "stopped"]).optional()
|
|
16124
|
+
});
|
|
16125
|
+
var MemoryIngestHumanCommentSchema = IngestBase.extend({
|
|
16126
|
+
kind: exports_external.literal("human_comment"),
|
|
16127
|
+
comment_id: exports_external.string(),
|
|
16128
|
+
author_id: exports_external.string(),
|
|
16129
|
+
body: exports_external.string()
|
|
16130
|
+
});
|
|
16131
|
+
var MemoryIngestIssueChangedSchema = IngestBase.extend({
|
|
16132
|
+
kind: exports_external.literal("issue_changed"),
|
|
16133
|
+
field: exports_external.enum(["title", "description"]),
|
|
16134
|
+
before: exports_external.string().nullable(),
|
|
16135
|
+
after: exports_external.string().nullable(),
|
|
16136
|
+
actor_id: exports_external.string()
|
|
16137
|
+
});
|
|
16138
|
+
var MemoryIngestEventSchema = exports_external.discriminatedUnion("kind", [
|
|
16139
|
+
MemoryIngestAssistantTextSchema,
|
|
16140
|
+
MemoryIngestResultSchema,
|
|
16141
|
+
MemoryIngestHumanCommentSchema,
|
|
16142
|
+
MemoryIngestIssueChangedSchema
|
|
16143
|
+
]);
|
|
16144
|
+
var MemoryIngestRequestSchema = exports_external.object({
|
|
16145
|
+
events: exports_external.array(MemoryIngestEventSchema).min(1).max(100)
|
|
16146
|
+
});
|
|
16147
|
+
var MemoryIngestResponseSchema = exports_external.object({
|
|
16148
|
+
accepted: exports_external.number().int().nonnegative(),
|
|
16149
|
+
rejected: exports_external.number().int().nonnegative()
|
|
16150
|
+
});
|
|
16074
16151
|
// src/worktree.ts
|
|
16075
16152
|
import { spawn } from "child_process";
|
|
16076
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, symlinkSync } from "fs";
|
|
16153
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, symlinkSync, rmSync } from "fs";
|
|
16077
16154
|
import { dirname as dirname2, join as join3, resolve } from "path";
|
|
16078
16155
|
async function run(cwd, cmd, args) {
|
|
16079
16156
|
return await new Promise((resolve2) => {
|
|
@@ -16100,6 +16177,14 @@ async function branchExists(dir, branch) {
|
|
|
16100
16177
|
const r = await run(dir, "git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
16101
16178
|
return r.code === 0;
|
|
16102
16179
|
}
|
|
16180
|
+
async function hasOriginRemote(dir) {
|
|
16181
|
+
const r = await run(dir, "git", ["remote", "get-url", "origin"]);
|
|
16182
|
+
return r.code === 0 && !!r.stdout;
|
|
16183
|
+
}
|
|
16184
|
+
async function isDirty(dir) {
|
|
16185
|
+
const r = await run(dir, "git", ["status", "--porcelain"]);
|
|
16186
|
+
return r.code === 0 && r.stdout.length > 0;
|
|
16187
|
+
}
|
|
16103
16188
|
function ensureGitignoreEntry(workingDir, entry) {
|
|
16104
16189
|
const gip = join3(workingDir, ".gitignore");
|
|
16105
16190
|
let body = "";
|
|
@@ -16123,7 +16208,45 @@ function ensureGitignoreEntry(workingDir, entry) {
|
|
|
16123
16208
|
function normalizeKey(issueKey) {
|
|
16124
16209
|
return issueKey.toLowerCase().replace(/[^a-z0-9\-_\/]/g, "-");
|
|
16125
16210
|
}
|
|
16126
|
-
|
|
16211
|
+
function indexPath(workingDir) {
|
|
16212
|
+
return join3(workingDir, ".multi", "worktrees", "_index.json");
|
|
16213
|
+
}
|
|
16214
|
+
function readIndex(workingDir) {
|
|
16215
|
+
try {
|
|
16216
|
+
const p = indexPath(workingDir);
|
|
16217
|
+
if (!existsSync2(p))
|
|
16218
|
+
return {};
|
|
16219
|
+
return JSON.parse(readFileSync2(p, "utf8"));
|
|
16220
|
+
} catch {
|
|
16221
|
+
return {};
|
|
16222
|
+
}
|
|
16223
|
+
}
|
|
16224
|
+
function writeIndex(workingDir, idx) {
|
|
16225
|
+
try {
|
|
16226
|
+
const p = indexPath(workingDir);
|
|
16227
|
+
mkdirSync2(dirname2(p), { recursive: true });
|
|
16228
|
+
writeFileSync2(p, JSON.stringify(idx, null, 2), "utf8");
|
|
16229
|
+
} catch {}
|
|
16230
|
+
}
|
|
16231
|
+
function listWorktreeIndex(workingDir) {
|
|
16232
|
+
const idx = readIndex(workingDir);
|
|
16233
|
+
return Object.entries(idx).map(([key, issueId]) => ({ key, issueId }));
|
|
16234
|
+
}
|
|
16235
|
+
function removeWorktreeIndexEntry(workingDir, key) {
|
|
16236
|
+
const idx = readIndex(workingDir);
|
|
16237
|
+
if (key in idx) {
|
|
16238
|
+
delete idx[key];
|
|
16239
|
+
writeIndex(workingDir, idx);
|
|
16240
|
+
}
|
|
16241
|
+
}
|
|
16242
|
+
function setWorktreeIndexEntry(workingDir, key, issueId) {
|
|
16243
|
+
const idx = readIndex(workingDir);
|
|
16244
|
+
if (idx[key] === issueId)
|
|
16245
|
+
return;
|
|
16246
|
+
idx[key] = issueId;
|
|
16247
|
+
writeIndex(workingDir, idx);
|
|
16248
|
+
}
|
|
16249
|
+
async function ensureWorktree(workingDir, issueKey, issueId) {
|
|
16127
16250
|
if (!await isGitRepo(workingDir)) {
|
|
16128
16251
|
return { path: workingDir, branch: "", created: false };
|
|
16129
16252
|
}
|
|
@@ -16133,6 +16256,8 @@ async function ensureWorktree(workingDir, issueKey) {
|
|
|
16133
16256
|
const wtDir = join3(workingDir, ".multi", "worktrees");
|
|
16134
16257
|
const wtPath = join3(wtDir, key);
|
|
16135
16258
|
if (existsSync2(wtPath)) {
|
|
16259
|
+
if (issueId)
|
|
16260
|
+
setWorktreeIndexEntry(workingDir, key, issueId);
|
|
16136
16261
|
return { path: wtPath, branch, created: false };
|
|
16137
16262
|
}
|
|
16138
16263
|
try {
|
|
@@ -16145,6 +16270,8 @@ async function ensureWorktree(workingDir, issueKey) {
|
|
|
16145
16270
|
throw new Error(`git worktree add failed: ${r.stderr || r.stdout}`);
|
|
16146
16271
|
}
|
|
16147
16272
|
await linkIgnoredFiles(workingDir, wtPath);
|
|
16273
|
+
if (issueId)
|
|
16274
|
+
setWorktreeIndexEntry(workingDir, key, issueId);
|
|
16148
16275
|
return { path: wtPath, branch, created: true };
|
|
16149
16276
|
}
|
|
16150
16277
|
async function linkIgnoredFiles(workingDir, wtPath) {
|
|
@@ -16177,9 +16304,61 @@ async function linkIgnoredFiles(workingDir, wtPath) {
|
|
|
16177
16304
|
} catch {}
|
|
16178
16305
|
}
|
|
16179
16306
|
}
|
|
16307
|
+
async function removeWorktree(workingDir, issueKey, opts = {}) {
|
|
16308
|
+
const result = { removedPath: false, pushed: false, skipped: null, errors: [] };
|
|
16309
|
+
if (!await isGitRepo(workingDir)) {
|
|
16310
|
+
result.skipped = "not_git";
|
|
16311
|
+
return result;
|
|
16312
|
+
}
|
|
16313
|
+
const key = normalizeKey(issueKey);
|
|
16314
|
+
const branch = `multi/${key}`;
|
|
16315
|
+
const wtPath = join3(workingDir, ".multi", "worktrees", key);
|
|
16316
|
+
if (existsSync2(wtPath)) {
|
|
16317
|
+
const dirty = await isDirty(wtPath);
|
|
16318
|
+
if (dirty && !opts.force) {
|
|
16319
|
+
result.skipped = "dirty";
|
|
16320
|
+
return result;
|
|
16321
|
+
}
|
|
16322
|
+
}
|
|
16323
|
+
const noPush = opts.noPush || process.env.MULTI_GC_NO_PUSH === "1";
|
|
16324
|
+
if (!noPush) {
|
|
16325
|
+
if (await branchExists(workingDir, branch)) {
|
|
16326
|
+
if (await hasOriginRemote(workingDir)) {
|
|
16327
|
+
const push = await run(workingDir, "git", ["push", "--force-with-lease", "origin", `${branch}:${branch}`]);
|
|
16328
|
+
if (push.code === 0) {
|
|
16329
|
+
result.pushed = true;
|
|
16330
|
+
} else {
|
|
16331
|
+
result.errors.push(`push failed: ${push.stderr || push.stdout}`);
|
|
16332
|
+
}
|
|
16333
|
+
} else {
|
|
16334
|
+
result.errors.push("no origin remote, skipped push");
|
|
16335
|
+
}
|
|
16336
|
+
}
|
|
16337
|
+
}
|
|
16338
|
+
await run(workingDir, "git", ["worktree", "prune"]);
|
|
16339
|
+
if (existsSync2(wtPath)) {
|
|
16340
|
+
const rm = await run(workingDir, "git", ["worktree", "remove", "--force", wtPath]);
|
|
16341
|
+
if (rm.code === 0) {
|
|
16342
|
+
result.removedPath = true;
|
|
16343
|
+
} else {
|
|
16344
|
+
result.errors.push(`worktree remove failed: ${rm.stderr || rm.stdout}`);
|
|
16345
|
+
try {
|
|
16346
|
+
rmSync(wtPath, { recursive: true, force: true });
|
|
16347
|
+
result.removedPath = true;
|
|
16348
|
+
} catch (e) {
|
|
16349
|
+
result.errors.push(`fs rm failed: ${String(e)}`);
|
|
16350
|
+
}
|
|
16351
|
+
}
|
|
16352
|
+
await run(workingDir, "git", ["worktree", "prune"]);
|
|
16353
|
+
} else {
|
|
16354
|
+
result.removedPath = true;
|
|
16355
|
+
}
|
|
16356
|
+
removeWorktreeIndexEntry(workingDir, key);
|
|
16357
|
+
return result;
|
|
16358
|
+
}
|
|
16180
16359
|
|
|
16181
16360
|
// src/materializer.ts
|
|
16182
|
-
import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, rmSync, symlinkSync as symlinkSync2, lstatSync } from "fs";
|
|
16361
|
+
import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, rmSync as rmSync2, symlinkSync as symlinkSync2, lstatSync } from "fs";
|
|
16183
16362
|
import { join as join4, dirname as dirname3 } from "path";
|
|
16184
16363
|
var HOME2 = process.env.HOME || process.env.USERPROFILE || ".";
|
|
16185
16364
|
var MULTI_DIR = join4(HOME2, ".multi");
|
|
@@ -16210,17 +16389,17 @@ function safeRmManaged(path) {
|
|
|
16210
16389
|
try {
|
|
16211
16390
|
const st = lstatSync(path);
|
|
16212
16391
|
if (st.isSymbolicLink()) {
|
|
16213
|
-
|
|
16392
|
+
rmSync2(path);
|
|
16214
16393
|
return;
|
|
16215
16394
|
}
|
|
16216
16395
|
if (st.isDirectory() && existsSync3(join4(path, MARKER))) {
|
|
16217
|
-
|
|
16396
|
+
rmSync2(path, { recursive: true, force: true });
|
|
16218
16397
|
return;
|
|
16219
16398
|
}
|
|
16220
16399
|
if (st.isFile() && path.endsWith(".md")) {
|
|
16221
16400
|
const head = readFileSync3(path, "utf8").slice(0, 200);
|
|
16222
16401
|
if (head.includes("multi-managed: true"))
|
|
16223
|
-
|
|
16402
|
+
rmSync2(path);
|
|
16224
16403
|
}
|
|
16225
16404
|
} catch {}
|
|
16226
16405
|
}
|
|
@@ -16324,7 +16503,7 @@ import { join as join5, dirname as dirname4 } from "path";
|
|
|
16324
16503
|
// package.json
|
|
16325
16504
|
var package_default = {
|
|
16326
16505
|
name: "@shipers-dev/multi",
|
|
16327
|
-
version: "0.
|
|
16506
|
+
version: "0.20.0",
|
|
16328
16507
|
type: "module",
|
|
16329
16508
|
bin: {
|
|
16330
16509
|
"multi-agent": "./dist/index.js"
|
|
@@ -16365,7 +16544,8 @@ var COMMANDS = {
|
|
|
16365
16544
|
stop: "Stop the running daemon",
|
|
16366
16545
|
restart: "Stop and relaunch the daemon in background",
|
|
16367
16546
|
logs: "View execution logs",
|
|
16368
|
-
reset: "Reset acpx session for an issue (--issue <id>)"
|
|
16547
|
+
reset: "Reset acpx session for an issue (--issue <id>)",
|
|
16548
|
+
worktree: "Worktree maintenance (subcommands: gc)"
|
|
16369
16549
|
};
|
|
16370
16550
|
function ensureDirs() {
|
|
16371
16551
|
for (const d of [MULTI_DIR2, join5(MULTI_DIR2, "logs"), SKILLS_DIR]) {
|
|
@@ -16373,6 +16553,14 @@ function ensureDirs() {
|
|
|
16373
16553
|
mkdirSync4(d, { recursive: true });
|
|
16374
16554
|
}
|
|
16375
16555
|
}
|
|
16556
|
+
function isLocalApi(url2) {
|
|
16557
|
+
try {
|
|
16558
|
+
const h = new URL(url2).hostname;
|
|
16559
|
+
return h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0" || h === "[::1]" || h === "::1";
|
|
16560
|
+
} catch {
|
|
16561
|
+
return false;
|
|
16562
|
+
}
|
|
16563
|
+
}
|
|
16376
16564
|
function log(msg) {
|
|
16377
16565
|
ensureDirs();
|
|
16378
16566
|
const line = `[${new Date().toISOString()}] ${msg}
|
|
@@ -16396,7 +16584,10 @@ async function main() {
|
|
|
16396
16584
|
workspace: { type: "string" },
|
|
16397
16585
|
agent: { type: "string" },
|
|
16398
16586
|
api: { type: "string" },
|
|
16399
|
-
issue: { type: "string" }
|
|
16587
|
+
issue: { type: "string" },
|
|
16588
|
+
key: { type: "string" },
|
|
16589
|
+
force: { type: "boolean", default: false },
|
|
16590
|
+
"no-push": { type: "boolean", default: false }
|
|
16400
16591
|
},
|
|
16401
16592
|
allowPositionals: true,
|
|
16402
16593
|
strict: false
|
|
@@ -16406,7 +16597,7 @@ async function main() {
|
|
|
16406
16597
|
printHelp();
|
|
16407
16598
|
process.exit(0);
|
|
16408
16599
|
}
|
|
16409
|
-
const apiUrl = args.values.api || process.env.MULTI_API_URL || "https://multi-api.adnb3r.workers.dev";
|
|
16600
|
+
const apiUrl = args.values.api || process.env.MULTI_API || process.env.MULTI_API_URL || "https://multi-api.adnb3r.workers.dev";
|
|
16410
16601
|
const config2 = loadConfig();
|
|
16411
16602
|
if (config2.token)
|
|
16412
16603
|
setAuthToken(config2.token);
|
|
@@ -16440,6 +16631,11 @@ async function main() {
|
|
|
16440
16631
|
case "reset":
|
|
16441
16632
|
await cmdReset(args.values.issue);
|
|
16442
16633
|
break;
|
|
16634
|
+
case "worktree": {
|
|
16635
|
+
const sub = args.positionals.slice(3)[0] || "help";
|
|
16636
|
+
await cmdWorktree(sub, { force: !!args.values.force, noPush: !!args.values["no-push"], key: args.values.key });
|
|
16637
|
+
break;
|
|
16638
|
+
}
|
|
16443
16639
|
default:
|
|
16444
16640
|
console.error(`Unknown command: ${command}`);
|
|
16445
16641
|
printHelp();
|
|
@@ -16461,6 +16657,7 @@ Commands:
|
|
|
16461
16657
|
restart ${COMMANDS.restart}
|
|
16462
16658
|
logs ${COMMANDS.logs}
|
|
16463
16659
|
reset ${COMMANDS.reset}
|
|
16660
|
+
worktree ${COMMANDS.worktree}
|
|
16464
16661
|
|
|
16465
16662
|
Options:
|
|
16466
16663
|
--name <name> Device name
|
|
@@ -16588,12 +16785,15 @@ async function cmdConnect(apiUrl, config2) {
|
|
|
16588
16785
|
if (existsSync4(STOP_PATH))
|
|
16589
16786
|
unlinkSync(STOP_PATH);
|
|
16590
16787
|
const detected = await detectAgents();
|
|
16788
|
+
const localMode = isLocalApi(apiUrl);
|
|
16591
16789
|
log(`\uD83D\uDE80 Starting daemon for device ${config2.deviceId} (pid ${process.pid})`);
|
|
16790
|
+
log(` API: ${apiUrl}${localMode ? " (local \u2014 cloudflared skipped)" : ""}`);
|
|
16592
16791
|
log(` runtimes: ${detected.map((d) => d.type).join(", ") || "stub"}`);
|
|
16593
16792
|
const db = openTasksDb();
|
|
16594
16793
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
|
|
16595
16794
|
const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
|
|
16596
16795
|
const running = new Map;
|
|
16796
|
+
let triggerHeartbeat = () => {};
|
|
16597
16797
|
function resolvePayloadIds(row) {
|
|
16598
16798
|
let agent_id = row.agent_id;
|
|
16599
16799
|
let issue_id = row.issue_id;
|
|
@@ -16636,6 +16836,7 @@ async function cmdConnect(apiUrl, config2) {
|
|
|
16636
16836
|
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
16637
16837
|
const entry = { agentId: ids.agent_id || "", issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: "" };
|
|
16638
16838
|
running.set(row.id, entry);
|
|
16839
|
+
triggerHeartbeat();
|
|
16639
16840
|
(async () => {
|
|
16640
16841
|
try {
|
|
16641
16842
|
const task = JSON.parse(row.payload);
|
|
@@ -16646,6 +16847,7 @@ async function cmdConnect(apiUrl, config2) {
|
|
|
16646
16847
|
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
16647
16848
|
} finally {
|
|
16648
16849
|
running.delete(row.id);
|
|
16850
|
+
triggerHeartbeat();
|
|
16649
16851
|
queueMicrotask(() => schedule());
|
|
16650
16852
|
}
|
|
16651
16853
|
})();
|
|
@@ -16769,17 +16971,28 @@ async function cmdConnect(apiUrl, config2) {
|
|
|
16769
16971
|
try {
|
|
16770
16972
|
writeFileSync4(PORT_PATH, String(port));
|
|
16771
16973
|
} catch {}
|
|
16772
|
-
let tunnel =
|
|
16773
|
-
if (
|
|
16774
|
-
|
|
16775
|
-
|
|
16776
|
-
|
|
16777
|
-
|
|
16778
|
-
|
|
16974
|
+
let tunnel = null;
|
|
16975
|
+
if (localMode) {
|
|
16976
|
+
tunnel = { child: null, url: `http://127.0.0.1:${port}` };
|
|
16977
|
+
log(`\uD83C\uDFE0 Local tunnel: ${tunnel.url}`);
|
|
16978
|
+
} else {
|
|
16979
|
+
tunnel = await startTunnel(port, log);
|
|
16980
|
+
if (!tunnel) {
|
|
16981
|
+
log("\u274C cloudflared did not emit a tunnel URL \u2014 is `cloudflared` installed? (`brew install cloudflared`)");
|
|
16982
|
+
try {
|
|
16983
|
+
server.stop();
|
|
16984
|
+
} catch {}
|
|
16985
|
+
process.exit(1);
|
|
16986
|
+
}
|
|
16987
|
+
log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
|
|
16779
16988
|
}
|
|
16780
|
-
log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
|
|
16781
16989
|
const heartbeat = async () => {
|
|
16782
|
-
const res = await apiClient.post(`${apiUrl}/api/devices/${config2.deviceId}/heartbeat`, {
|
|
16990
|
+
const res = await apiClient.post(`${apiUrl}/api/devices/${config2.deviceId}/heartbeat`, {
|
|
16991
|
+
status: "online",
|
|
16992
|
+
tunnel_url: tunnel?.url,
|
|
16993
|
+
running_count: running.size,
|
|
16994
|
+
max_concurrent: MAX_DEVICE
|
|
16995
|
+
});
|
|
16783
16996
|
if (res.success && res.data) {
|
|
16784
16997
|
const remoteRev = Number(res.data.agent_skill_revision ?? 0);
|
|
16785
16998
|
if (remoteRev > 0 && remoteRev !== lastMaterializedRevision()) {
|
|
@@ -16792,6 +17005,15 @@ async function cmdConnect(apiUrl, config2) {
|
|
|
16792
17005
|
}
|
|
16793
17006
|
return res.success && res.data?.pending_dispatches || 0;
|
|
16794
17007
|
};
|
|
17008
|
+
let beatTimer = null;
|
|
17009
|
+
triggerHeartbeat = () => {
|
|
17010
|
+
if (beatTimer)
|
|
17011
|
+
return;
|
|
17012
|
+
beatTimer = setTimeout(() => {
|
|
17013
|
+
beatTimer = null;
|
|
17014
|
+
heartbeat().catch(() => {});
|
|
17015
|
+
}, 200);
|
|
17016
|
+
};
|
|
16795
17017
|
{
|
|
16796
17018
|
const pending = await heartbeat();
|
|
16797
17019
|
if (pending > 0)
|
|
@@ -16828,7 +17050,7 @@ async function cmdConnect(apiUrl, config2) {
|
|
|
16828
17050
|
schedule();
|
|
16829
17051
|
let restarting = false;
|
|
16830
17052
|
const restartTunnel = async (reason) => {
|
|
16831
|
-
if (!alive || restarting)
|
|
17053
|
+
if (!alive || restarting || localMode)
|
|
16832
17054
|
return;
|
|
16833
17055
|
restarting = true;
|
|
16834
17056
|
try {
|
|
@@ -16860,20 +17082,21 @@ async function cmdConnect(apiUrl, config2) {
|
|
|
16860
17082
|
restarting = false;
|
|
16861
17083
|
}
|
|
16862
17084
|
};
|
|
16863
|
-
|
|
16864
|
-
|
|
16865
|
-
|
|
16866
|
-
|
|
16867
|
-
|
|
16868
|
-
|
|
17085
|
+
if (!localMode)
|
|
17086
|
+
(async () => {
|
|
17087
|
+
while (alive) {
|
|
17088
|
+
const t = tunnel;
|
|
17089
|
+
if (!t || !t.child) {
|
|
17090
|
+
await sleep(1000);
|
|
17091
|
+
continue;
|
|
17092
|
+
}
|
|
17093
|
+
const code = await t.child.exited;
|
|
17094
|
+
if (!alive)
|
|
17095
|
+
return;
|
|
17096
|
+
if (tunnel === t)
|
|
17097
|
+
await restartTunnel(`cloudflared exited code=${code}`);
|
|
16869
17098
|
}
|
|
16870
|
-
|
|
16871
|
-
if (!alive)
|
|
16872
|
-
return;
|
|
16873
|
-
if (tunnel === t)
|
|
16874
|
-
await restartTunnel(`cloudflared exited code=${code}`);
|
|
16875
|
-
}
|
|
16876
|
-
})();
|
|
17099
|
+
})();
|
|
16877
17100
|
let probeFailures = 0;
|
|
16878
17101
|
let tick = 0;
|
|
16879
17102
|
const PROBE_EVERY = 6;
|
|
@@ -16885,7 +17108,7 @@ async function cmdConnect(apiUrl, config2) {
|
|
|
16885
17108
|
}
|
|
16886
17109
|
tick++;
|
|
16887
17110
|
const currentUrl = tunnel?.url;
|
|
16888
|
-
if (currentUrl && tick % PROBE_EVERY === 0) {
|
|
17111
|
+
if (!localMode && currentUrl && tick % PROBE_EVERY === 0) {
|
|
16889
17112
|
const ok = await probeTunnel(currentUrl);
|
|
16890
17113
|
if (!ok) {
|
|
16891
17114
|
probeFailures++;
|
|
@@ -17066,6 +17289,25 @@ async function markStopped(apiUrl, issueId, reason) {
|
|
|
17066
17289
|
} catch {}
|
|
17067
17290
|
await postStream(apiUrl, issueId, "stopped", { reason });
|
|
17068
17291
|
}
|
|
17292
|
+
async function gcWorktreeAfterTerminal(baseWorkingDir, issueKey) {
|
|
17293
|
+
try {
|
|
17294
|
+
const res = await removeWorktree(baseWorkingDir, issueKey);
|
|
17295
|
+
if (res.skipped === "dirty") {
|
|
17296
|
+
log(` \uD83D\uDEB1 worktree gc ${issueKey}: skipped (dirty \u2014 uncommitted changes preserved)`);
|
|
17297
|
+
return;
|
|
17298
|
+
}
|
|
17299
|
+
if (res.skipped === "not_git")
|
|
17300
|
+
return;
|
|
17301
|
+
const errs = res.errors.length ? ` (warn: ${res.errors.join("; ")})` : "";
|
|
17302
|
+
if (res.removedPath) {
|
|
17303
|
+
log(` \uD83E\uDDF9 worktree gc ${issueKey}: pushed=${res.pushed} removed=true (branch kept)${errs}`);
|
|
17304
|
+
} else if (res.errors.length) {
|
|
17305
|
+
log(` \u26A0 worktree gc ${issueKey}: ${res.errors.join("; ")}`);
|
|
17306
|
+
}
|
|
17307
|
+
} catch (e) {
|
|
17308
|
+
log(` \u26A0 worktree gc ${issueKey} threw: ${fmtError(e)}`);
|
|
17309
|
+
}
|
|
17310
|
+
}
|
|
17069
17311
|
async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
|
|
17070
17312
|
const issueId = task.issue_id;
|
|
17071
17313
|
const isFollowup = !!task.followup;
|
|
@@ -17074,7 +17316,7 @@ async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
|
|
|
17074
17316
|
let worktreeBranch = "";
|
|
17075
17317
|
if (baseWorkingDir) {
|
|
17076
17318
|
try {
|
|
17077
|
-
const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
|
|
17319
|
+
const wt = await ensureWorktree(baseWorkingDir, task.key || issueId, issueId);
|
|
17078
17320
|
workingDir = wt.path;
|
|
17079
17321
|
worktreeBranch = wt.branch;
|
|
17080
17322
|
await postStream(apiUrl, issueId, "worktree_created", { path: wt.path, branch: wt.branch, reused: !wt.created });
|
|
@@ -17387,6 +17629,7 @@ ${body}
|
|
|
17387
17629
|
} catch (e) {
|
|
17388
17630
|
log(`preamble fetch failed: ${String(e)}`);
|
|
17389
17631
|
}
|
|
17632
|
+
preamble += await fetchMemoryPreamble(apiUrl, task);
|
|
17390
17633
|
preamble += await buildPlanningPreamble(apiUrl, task);
|
|
17391
17634
|
const prompt = preamble ? `${preamble}
|
|
17392
17635
|
---
|
|
@@ -17452,6 +17695,7 @@ ${body}
|
|
|
17452
17695
|
}
|
|
17453
17696
|
}
|
|
17454
17697
|
} catch {}
|
|
17698
|
+
preamble += await fetchMemoryPreamble(apiUrl, task);
|
|
17455
17699
|
preamble += await buildPlanningPreamble(apiUrl, task);
|
|
17456
17700
|
const issueContext = `## Issue ${task.key}: ${task.title}${task.description ? `
|
|
17457
17701
|
|
|
@@ -17512,7 +17756,7 @@ ${userPart}` : userPart;
|
|
|
17512
17756
|
await eventHandler(event);
|
|
17513
17757
|
}
|
|
17514
17758
|
if (liveCommentId) {
|
|
17515
|
-
const n = await uploadOutputDir(apiUrl, liveCommentId, outDir);
|
|
17759
|
+
const n = await uploadOutputDir(apiUrl, liveCommentId, outDir, task.issue_id);
|
|
17516
17760
|
if (n > 0)
|
|
17517
17761
|
log(` \uD83D\uDCCE uploaded ${n} output file(s)`);
|
|
17518
17762
|
}
|
|
@@ -17540,9 +17784,13 @@ ${userPart}` : userPart;
|
|
|
17540
17784
|
} else if (hadError) {
|
|
17541
17785
|
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
17542
17786
|
log(` \u2717 ${task.key} failed`);
|
|
17787
|
+
if (baseWorkingDir)
|
|
17788
|
+
await gcWorktreeAfterTerminal(baseWorkingDir, task.key || issueId);
|
|
17543
17789
|
} else {
|
|
17544
17790
|
await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {});
|
|
17545
17791
|
log(` \u2713 ${task.key} complete`);
|
|
17792
|
+
if (baseWorkingDir)
|
|
17793
|
+
await gcWorktreeAfterTerminal(baseWorkingDir, task.key || issueId);
|
|
17546
17794
|
}
|
|
17547
17795
|
} catch (e) {
|
|
17548
17796
|
const msg = fmtError(e);
|
|
@@ -17557,6 +17805,53 @@ ${userPart}` : userPart;
|
|
|
17557
17805
|
}
|
|
17558
17806
|
}
|
|
17559
17807
|
}
|
|
17808
|
+
async function fetchMemoryPreamble(apiUrl, task) {
|
|
17809
|
+
if (process.env.MULTI_MEMORY_ENABLED !== "1")
|
|
17810
|
+
return "";
|
|
17811
|
+
try {
|
|
17812
|
+
const issueRes = await apiClient.get(`${apiUrl}/api/issues/${task.issue_id}`);
|
|
17813
|
+
const projectId = issueRes.data?.project_id;
|
|
17814
|
+
if (!projectId)
|
|
17815
|
+
return "";
|
|
17816
|
+
const title = String(task.title || "").trim();
|
|
17817
|
+
const desc = String(task.description || "").trim();
|
|
17818
|
+
const followup = String(task.followup || "").trim();
|
|
17819
|
+
const query = [title, desc, followup].filter(Boolean).join(`
|
|
17820
|
+
`).slice(0, 4000);
|
|
17821
|
+
if (!query)
|
|
17822
|
+
return "";
|
|
17823
|
+
const res = await apiClient.post(`${apiUrl}/api/memory/recall`, {
|
|
17824
|
+
project_id: projectId,
|
|
17825
|
+
issue_id: task.issue_id,
|
|
17826
|
+
query,
|
|
17827
|
+
k: 10
|
|
17828
|
+
});
|
|
17829
|
+
if (!res.success)
|
|
17830
|
+
return "";
|
|
17831
|
+
const synthesis = String(res.data?.synthesis || "").trim();
|
|
17832
|
+
if (!synthesis)
|
|
17833
|
+
return "";
|
|
17834
|
+
const cites = Array.isArray(res.data?.citations) ? res.data.citations : [];
|
|
17835
|
+
let block = `## Project memory
|
|
17836
|
+
|
|
17837
|
+
${synthesis}
|
|
17838
|
+
`;
|
|
17839
|
+
if (cites.length) {
|
|
17840
|
+
block += `
|
|
17841
|
+
`;
|
|
17842
|
+
for (let i = 0;i < cites.length; i++) {
|
|
17843
|
+
const c = cites[i];
|
|
17844
|
+
block += `[${i + 1}] ${String(c.snippet || "").replace(/\s+/g, " ").trim()}
|
|
17845
|
+
`;
|
|
17846
|
+
}
|
|
17847
|
+
}
|
|
17848
|
+
return `${block}
|
|
17849
|
+
`;
|
|
17850
|
+
} catch (e) {
|
|
17851
|
+
log(`memory recall failed: ${String(e?.message || e)}`);
|
|
17852
|
+
return "";
|
|
17853
|
+
}
|
|
17854
|
+
}
|
|
17560
17855
|
async function buildPlanningPreamble(apiUrl, task) {
|
|
17561
17856
|
const depth = typeof task.planning_depth === "number" ? task.planning_depth : 0;
|
|
17562
17857
|
if (depth >= PLANNING_DEPTH_LIMIT) {
|
|
@@ -17714,6 +18009,11 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
|
|
|
17714
18009
|
continue;
|
|
17715
18010
|
}
|
|
17716
18011
|
lines.push(`- \u2713 updated ${res.data.key}`);
|
|
18012
|
+
if ((a.status === "done" || a.status === "cancelled") && parentTask.working_dir && existsSync4(parentTask.working_dir)) {
|
|
18013
|
+
const targetKey = res.data?.key;
|
|
18014
|
+
if (targetKey)
|
|
18015
|
+
await gcWorktreeAfterTerminal(parentTask.working_dir, targetKey);
|
|
18016
|
+
}
|
|
17717
18017
|
} else if (a.type === "delegate") {
|
|
17718
18018
|
const res = await apiClient.post(`${apiUrl}/api/issues/agent/mutate`, { action: "update", id: a.id, assignee_type: "agent", assignee_id: a.assignee_id, status: "todo" }, { headers });
|
|
17719
18019
|
if (!res.success) {
|
|
@@ -17918,7 +18218,7 @@ function authTokenHeader() {
|
|
|
17918
18218
|
const cfg = loadConfig();
|
|
17919
18219
|
return cfg.token ? `Bearer ${cfg.token}` : null;
|
|
17920
18220
|
}
|
|
17921
|
-
async function uploadOutputDir(apiUrl, commentId, dir) {
|
|
18221
|
+
async function uploadOutputDir(apiUrl, commentId, dir, issueId) {
|
|
17922
18222
|
if (!existsSync4(dir))
|
|
17923
18223
|
return 0;
|
|
17924
18224
|
const files = [];
|
|
@@ -17947,10 +18247,15 @@ async function uploadOutputDir(apiUrl, commentId, dir) {
|
|
|
17947
18247
|
const form = new FormData;
|
|
17948
18248
|
const blob = new Blob([data]);
|
|
17949
18249
|
form.append("file", blob, f.split("/").pop() || "file");
|
|
18250
|
+
const headers = {};
|
|
18251
|
+
if (token)
|
|
18252
|
+
headers.Authorization = token;
|
|
18253
|
+
if (issueId)
|
|
18254
|
+
headers["x-issue-id"] = issueId;
|
|
17950
18255
|
const res = await fetch(`${apiUrl}/api/attachments/comments/${commentId}`, {
|
|
17951
18256
|
method: "POST",
|
|
17952
18257
|
body: form,
|
|
17953
|
-
headers
|
|
18258
|
+
headers
|
|
17954
18259
|
});
|
|
17955
18260
|
if (res.ok) {
|
|
17956
18261
|
uploaded++;
|
|
@@ -18228,6 +18533,40 @@ async function cmdReset(issueId) {
|
|
|
18228
18533
|
}
|
|
18229
18534
|
console.log(body);
|
|
18230
18535
|
}
|
|
18536
|
+
async function cmdWorktree(sub, opts) {
|
|
18537
|
+
const config2 = loadConfig();
|
|
18538
|
+
const baseDir = process.cwd();
|
|
18539
|
+
if (sub === "gc") {
|
|
18540
|
+
const targets = opts.key ? [opts.key] : listWorktreeIndex(baseDir).map((e) => e.key);
|
|
18541
|
+
if (!targets.length) {
|
|
18542
|
+
console.log("No worktrees in index.");
|
|
18543
|
+
return;
|
|
18544
|
+
}
|
|
18545
|
+
let kept = 0, gone = 0, dirty = 0;
|
|
18546
|
+
for (const k of targets) {
|
|
18547
|
+
const r = await removeWorktree(baseDir, k, { force: opts.force, noPush: opts.noPush });
|
|
18548
|
+
if (r.skipped === "dirty") {
|
|
18549
|
+
dirty++;
|
|
18550
|
+
console.log(` \uD83D\uDEB1 ${k}: skipped (dirty)`);
|
|
18551
|
+
continue;
|
|
18552
|
+
}
|
|
18553
|
+
if (r.skipped === "not_git") {
|
|
18554
|
+
console.log(` \u26A0 ${k}: not a git repo`);
|
|
18555
|
+
continue;
|
|
18556
|
+
}
|
|
18557
|
+
if (r.removedPath) {
|
|
18558
|
+
gone++;
|
|
18559
|
+
console.log(` \uD83E\uDDF9 ${k}: pushed=${r.pushed} removed=true (branch kept)${r.errors.length ? " warn=" + r.errors.join("; ") : ""}`);
|
|
18560
|
+
} else {
|
|
18561
|
+
kept++;
|
|
18562
|
+
console.log(` \u26A0 ${k}: ${r.errors.join("; ") || "unchanged"}`);
|
|
18563
|
+
}
|
|
18564
|
+
}
|
|
18565
|
+
console.log(`Done. removed=${gone} dirty=${dirty} kept=${kept}`);
|
|
18566
|
+
return;
|
|
18567
|
+
}
|
|
18568
|
+
console.log("Usage: multi-agent worktree gc [--key <key>] [--force] [--no-push]");
|
|
18569
|
+
}
|
|
18231
18570
|
async function cmdRestart(apiUrl) {
|
|
18232
18571
|
if (existsSync4(PID_PATH)) {
|
|
18233
18572
|
const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
|