@shipers-dev/multi 0.17.2 → 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 +184 -10
  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.2",
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
@@ -17066,6 +17178,25 @@ async function markStopped(apiUrl, issueId, reason) {
17066
17178
  } catch {}
17067
17179
  await postStream(apiUrl, issueId, "stopped", { reason });
17068
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
+ }
17069
17200
  async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
17070
17201
  const issueId = task.issue_id;
17071
17202
  const isFollowup = !!task.followup;
@@ -17074,7 +17205,7 @@ async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
17074
17205
  let worktreeBranch = "";
17075
17206
  if (baseWorkingDir) {
17076
17207
  try {
17077
- const wt = await ensureWorktree(baseWorkingDir, task.key || issueId);
17208
+ const wt = await ensureWorktree(baseWorkingDir, task.key || issueId, issueId);
17078
17209
  workingDir = wt.path;
17079
17210
  worktreeBranch = wt.branch;
17080
17211
  await postStream(apiUrl, issueId, "worktree_created", { path: wt.path, branch: wt.branch, reused: !wt.created });
@@ -17540,9 +17671,13 @@ ${userPart}` : userPart;
17540
17671
  } else if (hadError) {
17541
17672
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
17542
17673
  log(` \u2717 ${task.key} failed`);
17674
+ if (baseWorkingDir)
17675
+ await gcWorktreeAfterTerminal(baseWorkingDir, task.key || issueId);
17543
17676
  } else {
17544
17677
  await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {});
17545
17678
  log(` \u2713 ${task.key} complete`);
17679
+ if (baseWorkingDir)
17680
+ await gcWorktreeAfterTerminal(baseWorkingDir, task.key || issueId);
17546
17681
  }
17547
17682
  } catch (e) {
17548
17683
  const msg = fmtError(e);
@@ -17714,6 +17849,11 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
17714
17849
  continue;
17715
17850
  }
17716
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
+ }
17717
17857
  } else if (a.type === "delegate") {
17718
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 });
17719
17859
  if (!res.success) {
@@ -18228,6 +18368,40 @@ async function cmdReset(issueId) {
18228
18368
  }
18229
18369
  console.log(body);
18230
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
+ }
18231
18405
  async function cmdRestart(apiUrl) {
18232
18406
  if (existsSync4(PID_PATH)) {
18233
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.2",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"