@shipers-dev/multi 0.17.0 → 0.18.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.
Files changed (2) hide show
  1. package/dist/index.js +187 -12
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16073,7 +16073,7 @@ var StreamEventInputSchema = exports_external.object({
16073
16073
  });
16074
16074
  // src/worktree.ts
16075
16075
  import { spawn } from "child_process";
16076
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, symlinkSync } from "fs";
16076
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, symlinkSync, rmSync } from "fs";
16077
16077
  import { dirname as dirname2, join as join3, resolve } from "path";
16078
16078
  async function run(cwd, cmd, args) {
16079
16079
  return await new Promise((resolve2) => {
@@ -16100,6 +16100,14 @@ async function branchExists(dir, branch) {
16100
16100
  const r = await run(dir, "git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
16101
16101
  return r.code === 0;
16102
16102
  }
16103
+ async function hasOriginRemote(dir) {
16104
+ const r = await run(dir, "git", ["remote", "get-url", "origin"]);
16105
+ return r.code === 0 && !!r.stdout;
16106
+ }
16107
+ async function isDirty(dir) {
16108
+ const r = await run(dir, "git", ["status", "--porcelain"]);
16109
+ return r.code === 0 && r.stdout.length > 0;
16110
+ }
16103
16111
  function ensureGitignoreEntry(workingDir, entry) {
16104
16112
  const gip = join3(workingDir, ".gitignore");
16105
16113
  let body = "";
@@ -16123,7 +16131,45 @@ function ensureGitignoreEntry(workingDir, entry) {
16123
16131
  function normalizeKey(issueKey) {
16124
16132
  return issueKey.toLowerCase().replace(/[^a-z0-9\-_\/]/g, "-");
16125
16133
  }
16126
- async function ensureWorktree(workingDir, issueKey) {
16134
+ function indexPath(workingDir) {
16135
+ return join3(workingDir, ".multi", "worktrees", "_index.json");
16136
+ }
16137
+ function readIndex(workingDir) {
16138
+ try {
16139
+ const p = indexPath(workingDir);
16140
+ if (!existsSync2(p))
16141
+ return {};
16142
+ return JSON.parse(readFileSync2(p, "utf8"));
16143
+ } catch {
16144
+ return {};
16145
+ }
16146
+ }
16147
+ function writeIndex(workingDir, idx) {
16148
+ try {
16149
+ const p = indexPath(workingDir);
16150
+ mkdirSync2(dirname2(p), { recursive: true });
16151
+ writeFileSync2(p, JSON.stringify(idx, null, 2), "utf8");
16152
+ } catch {}
16153
+ }
16154
+ function listWorktreeIndex(workingDir) {
16155
+ const idx = readIndex(workingDir);
16156
+ return Object.entries(idx).map(([key, issueId]) => ({ key, issueId }));
16157
+ }
16158
+ function removeWorktreeIndexEntry(workingDir, key) {
16159
+ const idx = readIndex(workingDir);
16160
+ if (key in idx) {
16161
+ delete idx[key];
16162
+ writeIndex(workingDir, idx);
16163
+ }
16164
+ }
16165
+ function setWorktreeIndexEntry(workingDir, key, issueId) {
16166
+ const idx = readIndex(workingDir);
16167
+ if (idx[key] === issueId)
16168
+ return;
16169
+ idx[key] = issueId;
16170
+ writeIndex(workingDir, idx);
16171
+ }
16172
+ async function ensureWorktree(workingDir, issueKey, issueId) {
16127
16173
  if (!await isGitRepo(workingDir)) {
16128
16174
  return { path: workingDir, branch: "", created: false };
16129
16175
  }
@@ -16133,6 +16179,8 @@ async function ensureWorktree(workingDir, issueKey) {
16133
16179
  const wtDir = join3(workingDir, ".multi", "worktrees");
16134
16180
  const wtPath = join3(wtDir, key);
16135
16181
  if (existsSync2(wtPath)) {
16182
+ if (issueId)
16183
+ setWorktreeIndexEntry(workingDir, key, issueId);
16136
16184
  return { path: wtPath, branch, created: false };
16137
16185
  }
16138
16186
  try {
@@ -16145,6 +16193,8 @@ async function ensureWorktree(workingDir, issueKey) {
16145
16193
  throw new Error(`git worktree add failed: ${r.stderr || r.stdout}`);
16146
16194
  }
16147
16195
  await linkIgnoredFiles(workingDir, wtPath);
16196
+ if (issueId)
16197
+ setWorktreeIndexEntry(workingDir, key, issueId);
16148
16198
  return { path: wtPath, branch, created: true };
16149
16199
  }
16150
16200
  async function linkIgnoredFiles(workingDir, wtPath) {
@@ -16177,9 +16227,61 @@ async function linkIgnoredFiles(workingDir, wtPath) {
16177
16227
  } catch {}
16178
16228
  }
16179
16229
  }
16230
+ async function removeWorktree(workingDir, issueKey, opts = {}) {
16231
+ const result = { removedPath: false, pushed: false, skipped: null, errors: [] };
16232
+ if (!await isGitRepo(workingDir)) {
16233
+ result.skipped = "not_git";
16234
+ return result;
16235
+ }
16236
+ const key = normalizeKey(issueKey);
16237
+ const branch = `multi/${key}`;
16238
+ const wtPath = join3(workingDir, ".multi", "worktrees", key);
16239
+ if (existsSync2(wtPath)) {
16240
+ const dirty = await isDirty(wtPath);
16241
+ if (dirty && !opts.force) {
16242
+ result.skipped = "dirty";
16243
+ return result;
16244
+ }
16245
+ }
16246
+ const noPush = opts.noPush || process.env.MULTI_GC_NO_PUSH === "1";
16247
+ if (!noPush) {
16248
+ if (await branchExists(workingDir, branch)) {
16249
+ if (await hasOriginRemote(workingDir)) {
16250
+ const push = await run(workingDir, "git", ["push", "--force-with-lease", "origin", `${branch}:${branch}`]);
16251
+ if (push.code === 0) {
16252
+ result.pushed = true;
16253
+ } else {
16254
+ result.errors.push(`push failed: ${push.stderr || push.stdout}`);
16255
+ }
16256
+ } else {
16257
+ result.errors.push("no origin remote, skipped push");
16258
+ }
16259
+ }
16260
+ }
16261
+ await run(workingDir, "git", ["worktree", "prune"]);
16262
+ if (existsSync2(wtPath)) {
16263
+ const rm = await run(workingDir, "git", ["worktree", "remove", "--force", wtPath]);
16264
+ if (rm.code === 0) {
16265
+ result.removedPath = true;
16266
+ } else {
16267
+ result.errors.push(`worktree remove failed: ${rm.stderr || rm.stdout}`);
16268
+ try {
16269
+ rmSync(wtPath, { recursive: true, force: true });
16270
+ result.removedPath = true;
16271
+ } catch (e) {
16272
+ result.errors.push(`fs rm failed: ${String(e)}`);
16273
+ }
16274
+ }
16275
+ await run(workingDir, "git", ["worktree", "prune"]);
16276
+ } else {
16277
+ result.removedPath = true;
16278
+ }
16279
+ removeWorktreeIndexEntry(workingDir, key);
16280
+ return result;
16281
+ }
16180
16282
 
16181
16283
  // src/materializer.ts
16182
- import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, rmSync, symlinkSync as symlinkSync2, lstatSync } from "fs";
16284
+ import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, rmSync as rmSync2, symlinkSync as symlinkSync2, lstatSync } from "fs";
16183
16285
  import { join as join4, dirname as dirname3 } from "path";
16184
16286
  var HOME2 = process.env.HOME || process.env.USERPROFILE || ".";
16185
16287
  var MULTI_DIR = join4(HOME2, ".multi");
@@ -16210,17 +16312,17 @@ function safeRmManaged(path) {
16210
16312
  try {
16211
16313
  const st = lstatSync(path);
16212
16314
  if (st.isSymbolicLink()) {
16213
- rmSync(path);
16315
+ rmSync2(path);
16214
16316
  return;
16215
16317
  }
16216
16318
  if (st.isDirectory() && existsSync3(join4(path, MARKER))) {
16217
- rmSync(path, { recursive: true, force: true });
16319
+ rmSync2(path, { recursive: true, force: true });
16218
16320
  return;
16219
16321
  }
16220
16322
  if (st.isFile() && path.endsWith(".md")) {
16221
16323
  const head = readFileSync3(path, "utf8").slice(0, 200);
16222
16324
  if (head.includes("multi-managed: true"))
16223
- rmSync(path);
16325
+ rmSync2(path);
16224
16326
  }
16225
16327
  } catch {}
16226
16328
  }
@@ -16324,7 +16426,7 @@ import { join as join5, dirname as dirname4 } from "path";
16324
16426
  // package.json
16325
16427
  var package_default = {
16326
16428
  name: "@shipers-dev/multi",
16327
- version: "0.17.0",
16429
+ version: "0.18.0",
16328
16430
  type: "module",
16329
16431
  bin: {
16330
16432
  "multi-agent": "./dist/index.js"
@@ -16365,7 +16467,8 @@ var COMMANDS = {
16365
16467
  stop: "Stop the running daemon",
16366
16468
  restart: "Stop and relaunch the daemon in background",
16367
16469
  logs: "View execution logs",
16368
- reset: "Reset acpx session for an issue (--issue <id>)"
16470
+ reset: "Reset acpx session for an issue (--issue <id>)",
16471
+ worktree: "Worktree maintenance (subcommands: gc)"
16369
16472
  };
16370
16473
  function ensureDirs() {
16371
16474
  for (const d of [MULTI_DIR2, join5(MULTI_DIR2, "logs"), SKILLS_DIR]) {
@@ -16396,7 +16499,10 @@ async function main() {
16396
16499
  workspace: { type: "string" },
16397
16500
  agent: { type: "string" },
16398
16501
  api: { type: "string" },
16399
- issue: { type: "string" }
16502
+ issue: { type: "string" },
16503
+ key: { type: "string" },
16504
+ force: { type: "boolean", default: false },
16505
+ "no-push": { type: "boolean", default: false }
16400
16506
  },
16401
16507
  allowPositionals: true,
16402
16508
  strict: false
@@ -16440,6 +16546,11 @@ async function main() {
16440
16546
  case "reset":
16441
16547
  await cmdReset(args.values.issue);
16442
16548
  break;
16549
+ case "worktree": {
16550
+ const sub = args.positionals.slice(3)[0] || "help";
16551
+ await cmdWorktree(sub, { force: !!args.values.force, noPush: !!args.values["no-push"], key: args.values.key });
16552
+ break;
16553
+ }
16443
16554
  default:
16444
16555
  console.error(`Unknown command: ${command}`);
16445
16556
  printHelp();
@@ -16461,6 +16572,7 @@ Commands:
16461
16572
  restart ${COMMANDS.restart}
16462
16573
  logs ${COMMANDS.logs}
16463
16574
  reset ${COMMANDS.reset}
16575
+ worktree ${COMMANDS.worktree}
16464
16576
 
16465
16577
  Options:
16466
16578
  --name <name> Device name
@@ -16753,8 +16865,9 @@ async function cmdConnect(apiUrl, config2) {
16753
16865
  try {
16754
16866
  entry.child?.kill("SIGKILL");
16755
16867
  } catch {}
16756
- }, 5000);
16868
+ }, 1500);
16757
16869
  }
16870
+ markStopped(apiUrl, issue_id, "user requested");
16758
16871
  return Response.json({ ok: true, state: "running-signalled" });
16759
16872
  } catch (e) {
16760
16873
  return Response.json({ error: String(e) }, { status: 400 });
@@ -17065,6 +17178,25 @@ async function markStopped(apiUrl, issueId, reason) {
17065
17178
  } catch {}
17066
17179
  await postStream(apiUrl, issueId, "stopped", { reason });
17067
17180
  }
17181
+ async function gcWorktreeAfterTerminal(baseWorkingDir, issueKey) {
17182
+ try {
17183
+ const res = await removeWorktree(baseWorkingDir, issueKey);
17184
+ if (res.skipped === "dirty") {
17185
+ log(` \uD83D\uDEB1 worktree gc ${issueKey}: skipped (dirty \u2014 uncommitted changes preserved)`);
17186
+ return;
17187
+ }
17188
+ if (res.skipped === "not_git")
17189
+ return;
17190
+ const errs = res.errors.length ? ` (warn: ${res.errors.join("; ")})` : "";
17191
+ if (res.removedPath) {
17192
+ log(` \uD83E\uDDF9 worktree gc ${issueKey}: pushed=${res.pushed} removed=true (branch kept)${errs}`);
17193
+ } else if (res.errors.length) {
17194
+ log(` \u26A0 worktree gc ${issueKey}: ${res.errors.join("; ")}`);
17195
+ }
17196
+ } catch (e) {
17197
+ log(` \u26A0 worktree gc ${issueKey} threw: ${fmtError(e)}`);
17198
+ }
17199
+ }
17068
17200
  async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
17069
17201
  const issueId = task.issue_id;
17070
17202
  const isFollowup = !!task.followup;
@@ -17073,7 +17205,7 @@ async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
17073
17205
  let worktreeBranch = "";
17074
17206
  if (baseWorkingDir) {
17075
17207
  try {
17076
- const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
17208
+ const wt = await ensureWorktree(baseWorkingDir, task.key || issueId, issueId);
17077
17209
  workingDir = wt.path;
17078
17210
  worktreeBranch = wt.branch;
17079
17211
  await postStream(apiUrl, issueId, "worktree_created", { path: wt.path, branch: wt.branch, reused: !wt.created });
@@ -17539,9 +17671,13 @@ ${userPart}` : userPart;
17539
17671
  } else if (hadError) {
17540
17672
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
17541
17673
  log(` \u2717 ${task.key} failed`);
17674
+ if (baseWorkingDir)
17675
+ await gcWorktreeAfterTerminal(baseWorkingDir, task.key || issueId);
17542
17676
  } else {
17543
17677
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {});
17544
17678
  log(` \u2713 ${task.key} complete`);
17679
+ if (baseWorkingDir)
17680
+ await gcWorktreeAfterTerminal(baseWorkingDir, task.key || issueId);
17545
17681
  }
17546
17682
  } catch (e) {
17547
17683
  const msg = fmtError(e);
@@ -17594,7 +17730,7 @@ Issue actions:
17594
17730
 
17595
17731
  \`\`\`multi-plan
17596
17732
  {"actions":[
17597
- {"type":"create","title":"...","description":"...","assignee_type":"agent","assignee_id":"<agent id>"},
17733
+ {"type":"create","title":"...","description":"... (required, non-empty: relays full task context to the assignee)","assignee_type":"agent","assignee_id":"<agent id>"},
17598
17734
  {"type":"update","id":"<issue id>","status":"done"},
17599
17735
  {"type":"delegate","id":"<issue id>","assignee_id":"<agent id>"}
17600
17736
  ]}
@@ -17713,6 +17849,11 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
17713
17849
  continue;
17714
17850
  }
17715
17851
  lines.push(`- \u2713 updated ${res.data.key}`);
17852
+ if ((a.status === "done" || a.status === "cancelled") && parentTask.working_dir && existsSync4(parentTask.working_dir)) {
17853
+ const targetKey = res.data?.key;
17854
+ if (targetKey)
17855
+ await gcWorktreeAfterTerminal(parentTask.working_dir, targetKey);
17856
+ }
17716
17857
  } else if (a.type === "delegate") {
17717
17858
  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 });
17718
17859
  if (!res.success) {
@@ -18227,6 +18368,40 @@ async function cmdReset(issueId) {
18227
18368
  }
18228
18369
  console.log(body);
18229
18370
  }
18371
+ async function cmdWorktree(sub, opts) {
18372
+ const config2 = loadConfig();
18373
+ const baseDir = process.cwd();
18374
+ if (sub === "gc") {
18375
+ const targets = opts.key ? [opts.key] : listWorktreeIndex(baseDir).map((e) => e.key);
18376
+ if (!targets.length) {
18377
+ console.log("No worktrees in index.");
18378
+ return;
18379
+ }
18380
+ let kept = 0, gone = 0, dirty = 0;
18381
+ for (const k of targets) {
18382
+ const r = await removeWorktree(baseDir, k, { force: opts.force, noPush: opts.noPush });
18383
+ if (r.skipped === "dirty") {
18384
+ dirty++;
18385
+ console.log(` \uD83D\uDEB1 ${k}: skipped (dirty)`);
18386
+ continue;
18387
+ }
18388
+ if (r.skipped === "not_git") {
18389
+ console.log(` \u26A0 ${k}: not a git repo`);
18390
+ continue;
18391
+ }
18392
+ if (r.removedPath) {
18393
+ gone++;
18394
+ console.log(` \uD83E\uDDF9 ${k}: pushed=${r.pushed} removed=true (branch kept)${r.errors.length ? " warn=" + r.errors.join("; ") : ""}`);
18395
+ } else {
18396
+ kept++;
18397
+ console.log(` \u26A0 ${k}: ${r.errors.join("; ") || "unchanged"}`);
18398
+ }
18399
+ }
18400
+ console.log(`Done. removed=${gone} dirty=${dirty} kept=${kept}`);
18401
+ return;
18402
+ }
18403
+ console.log("Usage: multi-agent worktree gc [--key <key>] [--force] [--no-push]");
18404
+ }
18230
18405
  async function cmdRestart(apiUrl) {
18231
18406
  if (existsSync4(PID_PATH)) {
18232
18407
  const pid = Number(readFileSync4(PID_PATH, "utf8").trim());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"