@mu-crew/mu 3.8.0 → 3.9.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 CHANGED
@@ -151,7 +151,7 @@ import { dirname as dirname2, join as join2 } from "path";
151
151
  import { randomUUID } from "crypto";
152
152
  import { mkdirSync } from "fs";
153
153
  import { homedir, hostname } from "os";
154
- import { dirname, join, resolve } from "path";
154
+ import { dirname, isAbsolute, join, resolve } from "path";
155
155
  import Database from "better-sqlite3";
156
156
 
157
157
  // src/capture.ts
@@ -257,6 +257,23 @@ var CAPTURED_COLUMNS = {
257
257
  task_notes: ["author", "content", "created_at"],
258
258
  task_edges: ["created_at"]
259
259
  };
260
+ var KEY_COLUMNS = {
261
+ workstreams: ["name"],
262
+ tasks: ["workstream_id", "local_id"],
263
+ task_notes: ["id", "task_id"],
264
+ task_edges: ["from_task_id", "to_task_id"]
265
+ };
266
+ function keyGuard(table) {
267
+ const cols = KEY_COLUMNS[table];
268
+ const msg = `mu: ${table}.${cols.join("/")} is part of the natural key that every op is filed under; changing it would desync the ops log, sync and undo. Add a new row (mu task add / mu workstream init) instead of moving or renaming one.`;
269
+ return `
270
+ CREATE TEMP TRIGGER IF NOT EXISTS _cap_${table}_key
271
+ BEFORE UPDATE OF ${cols.join(", ")} ON ${table}
272
+ WHEN ${anyChanged(cols)}
273
+ BEGIN
274
+ SELECT RAISE(ABORT, '${msg.replaceAll("'", "''")}');
275
+ END;`;
276
+ }
260
277
  var DYING_STASH = (kind, keyExpr) => `INSERT OR REPLACE INTO _op_dying (kind, id, key) VALUES ('${kind}', OLD.id, ${keyExpr});`;
261
278
  function buildTriggerDdl() {
262
279
  const wsCols = CAPTURED_COLUMNS.workstreams;
@@ -268,6 +285,12 @@ function buildTriggerDdl() {
268
285
  const edgeKeyNew = `${taskKey("NEW.from_task_id")} || '->' || ${taskKey("NEW.to_task_id")}`;
269
286
  const edgeKeyOld = `${taskKey("OLD.from_task_id")} || '->' || ${taskKey("OLD.to_task_id")}`;
270
287
  return `
288
+ -- \u2500\u2500\u2500 natural-key guards (see KEY_COLUMNS) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
289
+ ${keyGuard("workstreams")}
290
+ ${keyGuard("tasks")}
291
+ ${keyGuard("task_notes")}
292
+ ${keyGuard("task_edges")}
293
+
271
294
  -- \u2500\u2500\u2500 workstreams \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
272
295
  CREATE TEMP TRIGGER IF NOT EXISTS _cap_workstreams_ins
273
296
  AFTER INSERT ON workstreams WHEN ${NOT_APPLYING}
@@ -403,8 +426,11 @@ function formatPair(pair) {
403
426
  // src/db.ts
404
427
  function defaultStateDir() {
405
428
  if (process.env.MU_STATE_DIR) return process.env.MU_STATE_DIR;
406
- const stateHome = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
407
- return join(stateHome, "mu");
429
+ return join(xdgStateHome(homedir()), "mu");
430
+ }
431
+ function xdgStateHome(home) {
432
+ const xdg = process.env.XDG_STATE_HOME;
433
+ return xdg !== void 0 && isAbsolute(xdg) ? xdg : join(home, ".local", "state");
408
434
  }
409
435
  function defaultDbPath() {
410
436
  if (process.env.MU_DB_PATH) return process.env.MU_DB_PATH;
@@ -416,8 +442,6 @@ function openDb(options = {}) {
416
442
  mkdirSync(dirname(path), { recursive: true });
417
443
  const db = new Database(path, { readonly: options.readonly ?? false });
418
444
  if (!options.readonly) {
419
- db.pragma("journal_mode = WAL");
420
- db.pragma("foreign_keys = ON");
421
445
  db.pragma("busy_timeout = 5000");
422
446
  const detectedVersion = detectExistingSchemaVersion(db);
423
447
  if (detectedVersion !== null && detectedVersion > CURRENT_SCHEMA_VERSION) {
@@ -434,6 +458,8 @@ function openDb(options = {}) {
434
458
  }
435
459
  throw new SchemaTooOldError(detectedVersion, MIN_ACCEPTED_SCHEMA_VERSION);
436
460
  }
461
+ db.pragma("journal_mode = WAL");
462
+ db.pragma("foreign_keys = ON");
437
463
  applySchema(db);
438
464
  seedMachineIdentity(db);
439
465
  installCapture(db);
@@ -446,8 +472,7 @@ function refuseUserDbDuringTests(path) {
446
472
  const inTest = process.env.VITEST !== void 0 || process.env.NODE_ENV === "test";
447
473
  if (!inTest) return;
448
474
  const home = process.env.HOME ?? homedir();
449
- const xdg = process.env.XDG_STATE_HOME ?? join(home, ".local", "state");
450
- const realDb = resolve(join(xdg, "mu", "mu.db"));
475
+ const realDb = resolve(join(xdgStateHome(home), "mu", "mu.db"));
451
476
  if (resolve(path) === realDb) {
452
477
  throw new Error(
453
478
  `openDb refused: tests must NEVER write to the user DB (${realDb}). Set MU_DB_PATH to a per-test temp path (test/_runCli.ts does this automatically) or pass an explicit { path } argument. The leak source is the call site of openDb in this stack frame.`
@@ -502,7 +527,7 @@ var SchemaTooOldError = class extends Error {
502
527
  command: `mv "\${MU_DB_PATH:-$HOME/.local/state/mu/mu.db}" "\${MU_DB_PATH:-$HOME/.local/state/mu/mu.db}.old"`
503
528
  },
504
529
  {
505
- intent: `Migrate the old DB into a fresh v${this.requiredVersion} DB`,
530
+ intent: `Migrate the old DB into a fresh v${this.requiredVersion} DB (run in a mu git checkout: the npm package does not ship scripts/)`,
506
531
  command: `npx tsx scripts/migrate.ts "\${MU_DB_PATH:-$HOME/.local/state/mu/mu.db}.old" --out /tmp/mu-v${this.requiredVersion}.db`
507
532
  },
508
533
  {
@@ -810,6 +835,7 @@ CREATE INDEX IF NOT EXISTS idx_ops_group ON ops (group_id);
810
835
  -- into that peer's segment we have applied. One integer suffices
811
836
  -- because segments are append-only and ordered. Rows are created on
812
837
  -- demand at first ingest; there is no membership list to configure.
838
+ -- A Syncthing conflict copy gets its own row, keyed by its file stem.
813
839
  CREATE TABLE IF NOT EXISTS sync_peers (
814
840
  machine_id TEXT PRIMARY KEY,
815
841
  last_applied_seq INTEGER NOT NULL DEFAULT 0,
@@ -976,6 +1002,9 @@ var LEGACY_LOG_ONLY_INTENTS = /* @__PURE__ */ new Set(["workstream.export"]);
976
1002
  function isLegacyLogOnlyIntent(intent) {
977
1003
  return intent !== null && LEGACY_LOG_ONLY_INTENTS.has(intent);
978
1004
  }
1005
+ var LEGACY_LOG_ONLY_SQL_EXCLUSION = `(intent IS NULL OR intent NOT IN (${[
1006
+ ...LEGACY_LOG_ONLY_INTENTS
1007
+ ].map((i) => `'${i}'`).join(", ")}))`;
979
1008
  var LEGACY_INTENT_SYNONYMS = /* @__PURE__ */ new Map([
980
1009
  ["workstream.teardown", ["workstream.destroy"]]
981
1010
  ]);
@@ -983,9 +1012,6 @@ function intentSpellings(intent) {
983
1012
  const legacy = LEGACY_INTENT_SYNONYMS.get(intent);
984
1013
  return legacy === void 0 ? [intent] : [intent, ...legacy];
985
1014
  }
986
- var LEGACY_LOG_ONLY_SQL_EXCLUSION = `(intent IS NULL OR intent NOT IN (${[
987
- ...LEGACY_LOG_ONLY_INTENTS
988
- ].map((i) => `'${i}'`).join(", ")}))`;
989
1015
 
990
1016
  // src/logs.ts
991
1017
  var SELECT_LOG_COLS = `
@@ -1068,8 +1094,30 @@ function rowFromDb(row) {
1068
1094
  createdAt: row.created_at
1069
1095
  };
1070
1096
  }
1097
+ var RESERVED_LOG_KINDS = new Set(
1098
+ SYNCED_ENTITIES.filter((e) => e !== "message")
1099
+ );
1100
+ var LogKindReservedError = class extends Error {
1101
+ constructor(kind) {
1102
+ super(
1103
+ `--kind ${JSON.stringify(kind)} is reserved: ${[...RESERVED_LOG_KINDS].join(", ")} name projectable op entities, and a log line under one would be synced and replayed as a real ${kind} change`
1104
+ );
1105
+ this.kind = kind;
1106
+ }
1107
+ kind;
1108
+ name = "LogKindReservedError";
1109
+ errorNextSteps() {
1110
+ return [
1111
+ {
1112
+ intent: "Use a channel tag that names your ledger",
1113
+ command: `mu log --kind ${this.kind}-log '<text>'`
1114
+ }
1115
+ ];
1116
+ }
1117
+ };
1071
1118
  function appendLog(db, opts) {
1072
1119
  const kind = opts.kind ?? "message";
1120
+ if (RESERVED_LOG_KINDS.has(kind)) throw new LogKindReservedError(kind);
1073
1121
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1074
1122
  const key = opts.workstream ?? MACHINE_WIDE_KEY;
1075
1123
  const group = randomUUID2();
@@ -1113,7 +1161,10 @@ function listLogs(db, opts = {}) {
1113
1161
  conditions.push("l.seq > ?");
1114
1162
  params.push(opts.since);
1115
1163
  }
1116
- if (opts.source !== void 0) {
1164
+ if (opts.source === "system") {
1165
+ conditions.push("(l.actor = ? OR l.actor IS NULL)");
1166
+ params.push(opts.source);
1167
+ } else if (opts.source !== void 0) {
1117
1168
  conditions.push("l.actor = ?");
1118
1169
  params.push(opts.source);
1119
1170
  }
@@ -1355,19 +1406,26 @@ function resetSleep() {
1355
1406
  function sleep(ms) {
1356
1407
  return currentSleep(ms);
1357
1408
  }
1409
+ function exactSessionTarget(name) {
1410
+ return `=${name}:`;
1411
+ }
1412
+ var NO_SERVER_RE = /no server running|error connecting to .*\(No such file or directory\)/i;
1413
+ function isPaneGoneError(err) {
1414
+ return err instanceof TmuxError && (/can't find pane|pane not found|no current target/i.test(err.stderr) || NO_SERVER_RE.test(err.stderr));
1415
+ }
1358
1416
  async function listSessions() {
1359
1417
  try {
1360
1418
  const out = await tmux(["list-sessions", "-F", "#{session_name}"]);
1361
1419
  return out.split("\n").filter((line) => line.length > 0).map((name) => ({ name }));
1362
1420
  } catch (err) {
1363
- if (err instanceof TmuxError && /no server running|no sessions/i.test(err.stderr)) {
1421
+ if (err instanceof TmuxError && (NO_SERVER_RE.test(err.stderr) || /no sessions/i.test(err.stderr))) {
1364
1422
  return [];
1365
1423
  }
1366
1424
  throw err;
1367
1425
  }
1368
1426
  }
1369
1427
  async function sessionExists(name) {
1370
- const result = await currentExecutor(["has-session", "-t", name]);
1428
+ const result = await currentExecutor(["has-session", "-t", exactSessionTarget(name)]);
1371
1429
  return result.exitCode === 0;
1372
1430
  }
1373
1431
  async function newSession(name, opts = {}) {
@@ -1392,19 +1450,21 @@ async function newSessionWithPane(name, opts) {
1392
1450
  return out;
1393
1451
  }
1394
1452
  async function killSession(name) {
1395
- const result = await currentExecutor(["kill-session", "-t", name]);
1396
- if (result.exitCode !== 0 && !/can't find session|session not found|no server running|no current target/i.test(result.stderr)) {
1397
- throw new TmuxError(
1398
- ["kill-session", "-t", name],
1399
- result.stderr,
1400
- result.stdout,
1401
- result.exitCode
1402
- );
1453
+ const args = ["kill-session", "-t", exactSessionTarget(name)];
1454
+ const result = await currentExecutor(args);
1455
+ if (result.exitCode !== 0 && !/can't find session|session not found|no current target/i.test(result.stderr) && !NO_SERVER_RE.test(result.stderr)) {
1456
+ throw new TmuxError(args, result.stderr, result.stdout, result.exitCode);
1403
1457
  }
1404
1458
  }
1405
1459
  async function listWindows(session) {
1406
1460
  if (session) {
1407
- const out2 = await tmux(["list-windows", "-t", session, "-F", "#{window_id} #{window_name}"]);
1461
+ const out2 = await tmux([
1462
+ "list-windows",
1463
+ "-t",
1464
+ exactSessionTarget(session),
1465
+ "-F",
1466
+ "#{window_id} #{window_name}"
1467
+ ]);
1408
1468
  return parseWindows(out2);
1409
1469
  }
1410
1470
  const out = await tmux([
@@ -1435,7 +1495,7 @@ function parseWindows(output) {
1435
1495
  async function newWindow(opts) {
1436
1496
  const args = ["new-window"];
1437
1497
  if (opts.detached !== false) args.push("-d");
1438
- if (opts.session) args.push("-t", opts.session);
1498
+ if (opts.session) args.push("-t", exactSessionTarget(opts.session));
1439
1499
  args.push("-n", opts.name);
1440
1500
  if (opts.cwd) args.push("-c", opts.cwd);
1441
1501
  appendEnvFlags(args, opts.env);
@@ -1449,15 +1509,13 @@ async function listPanesInSession(session) {
1449
1509
  "list-panes",
1450
1510
  "-s",
1451
1511
  "-t",
1452
- session,
1512
+ exactSessionTarget(session),
1453
1513
  "-F",
1454
1514
  "#{window_id} #{pane_id} #{pane_title} #{pane_current_command}"
1455
1515
  ];
1456
1516
  const result = await currentExecutor(args);
1457
1517
  if (result.exitCode !== 0) {
1458
- if (/can't find (session|window)|no server running|no sessions|no current target/i.test(
1459
- result.stderr
1460
- )) {
1518
+ if (/can't find (session|window)|no sessions|no current target/i.test(result.stderr) || NO_SERVER_RE.test(result.stderr)) {
1461
1519
  return [];
1462
1520
  }
1463
1521
  throw new TmuxError(args, result.stderr, result.stdout, result.exitCode);
@@ -1528,7 +1586,7 @@ function appendEnvFlags(args, env) {
1528
1586
  async function killPane(paneId) {
1529
1587
  assertValidPaneId(paneId);
1530
1588
  const result = await currentExecutor(["kill-pane", "-t", paneId]);
1531
- if (result.exitCode !== 0 && !/can't find pane/i.test(result.stderr)) {
1589
+ if (result.exitCode !== 0 && !/can't find pane/i.test(result.stderr) && !NO_SERVER_RE.test(result.stderr)) {
1532
1590
  throw new TmuxError(["kill-pane", "-t", paneId], result.stderr, result.stdout, result.exitCode);
1533
1591
  }
1534
1592
  }
@@ -1582,7 +1640,7 @@ async function paneTTY(paneId) {
1582
1640
  assertValidPaneId(paneId);
1583
1641
  const result = await currentExecutor(["display-message", "-t", paneId, "-p", "#{pane_tty}"]);
1584
1642
  if (result.exitCode !== 0) {
1585
- if (/can't find pane|pane not found/i.test(result.stderr)) {
1643
+ if (/can't find pane|pane not found/i.test(result.stderr) || NO_SERVER_RE.test(result.stderr)) {
1586
1644
  throw new PaneNotFoundError(paneId, tmuxBackend);
1587
1645
  }
1588
1646
  throw new TmuxError(
@@ -1645,7 +1703,10 @@ async function awaitPaneQuiescence(paneId, budgetMs) {
1645
1703
  const deadline = Date.now() + budgetMs;
1646
1704
  let calm = 0;
1647
1705
  for (; ; ) {
1648
- const scrollback = await capturePane(paneId, { lines: 50 }).catch(() => void 0);
1706
+ const scrollback = await capturePane(paneId, { lines: 50 }).catch((err) => {
1707
+ if (isPaneGoneError(err)) throw err;
1708
+ return void 0;
1709
+ });
1649
1710
  const busy = scrollback === void 0 || paneLooksBusy(scrollback);
1650
1711
  if (busy && scrollback !== void 0 && hasWorkMarker(scrollback)) return true;
1651
1712
  if (!busy) {
@@ -1730,9 +1791,19 @@ async function capturePane(paneId, opts = {}) {
1730
1791
  args.push("-S", "-", "-E", "-");
1731
1792
  } else if (opts.lines > 0) {
1732
1793
  args.push("-S", `-${opts.lines}`);
1794
+ return lastLines(await tmux(args), opts.lines);
1733
1795
  }
1734
1796
  return tmux(args);
1735
1797
  }
1798
+ function lastLines(text, n) {
1799
+ const lines = text.split("\n");
1800
+ let end = lines.length;
1801
+ while (end > 0 && (lines[end - 1] ?? "").trim() === "") end--;
1802
+ if (end === 0) return "";
1803
+ const kept = lines.slice(Math.max(0, end - n), end).join("\n");
1804
+ return text.endsWith("\n") ? `${kept}
1805
+ ` : kept;
1806
+ }
1736
1807
  async function tmuxAvailable() {
1737
1808
  try {
1738
1809
  await tmux(["-V"]);
@@ -1827,7 +1898,7 @@ var tmuxBackend = Object.freeze({
1827
1898
  // src/mux/herdr.ts
1828
1899
  var HerdrError = class extends MuxError {
1829
1900
  constructor(args, stderr, stdout, exitCode, code) {
1830
- const detail = parseErrorEnvelope(stderr)?.message ?? stderr.trim() ?? stdout.trim();
1901
+ const detail = parseErrorEnvelope(stderr)?.message ?? (stderr.trim() || stdout.trim());
1831
1902
  super(`herdr ${args.join(" ")} failed (exit ${exitCode}): ${detail || "no output"}`);
1832
1903
  this.args = args;
1833
1904
  this.stderr = stderr;
@@ -1958,8 +2029,10 @@ var HerdrWorkspaceGroupCloseError = class extends Error {
1958
2029
  };
1959
2030
  var HerdrNotImplementedError = class extends Error {
1960
2031
  name = "HerdrNotImplementedError";
1961
- constructor(method, owner) {
1962
- super(`herdr backend: ${method} is not implemented yet (owned by task ${owner})`);
2032
+ constructor(method) {
2033
+ super(
2034
+ `herdr backend: ${method} is not supported: herdr creation verbs always start a plain shell; create the pane without a command, then start the agent with startAgentInPane`
2035
+ );
1963
2036
  }
1964
2037
  };
1965
2038
  var HERDR_ORDINAL = "[0-9A-Z]+";
@@ -2078,10 +2151,12 @@ function readPane(v) {
2078
2151
  // herdr's optional `label` is what `pane rename` writes and is
2079
2152
  // therefore mu's pane-title equivalent.
2080
2153
  title: asString(v.label) ?? "",
2081
- // herdr does not report a foreground command on `pane list`; the
2082
- // authoritative answer costs a `pane process-info` round trip per
2083
- // pane. Callers that need it use `paneCommand()`.
2084
- command: "",
2154
+ // herdr reports no foreground command on `pane list` (that costs a
2155
+ // `pane process-info` round trip per pane), but it does report the
2156
+ // agent kind it detected (`pi`, `claude`, `codex`, ...). That is
2157
+ // what `command` is used for: reconcile's orphan recogniser matches
2158
+ // it against the agent CLIs. Empty for a plain shell.
2159
+ command: asString(v.agent) ?? "",
2085
2160
  windowId: asString(v.tab_id)
2086
2161
  };
2087
2162
  }
@@ -2128,7 +2203,7 @@ async function newSession2(name, opts = {}) {
2128
2203
  const args = ["workspace", "create", "--label", name, "--no-focus"];
2129
2204
  if (opts.cwd) args.push("--cwd", opts.cwd);
2130
2205
  appendEnvFlags2(args, opts.env);
2131
- await herdr(args);
2206
+ await labelRootTab(await herdr(args), opts.windowName);
2132
2207
  }
2133
2208
  async function newSessionWithPane2(name, opts) {
2134
2209
  rejectCommand("newSessionWithPane", opts.command);
@@ -2136,7 +2211,39 @@ async function newSessionWithPane2(name, opts) {
2136
2211
  if (opts.cwd) args.push("--cwd", opts.cwd);
2137
2212
  appendEnvFlags2(args, opts.env);
2138
2213
  const result = await herdr(args);
2139
- return readCreatedPaneId(result, "root_pane", args);
2214
+ const paneId = readCreatedPaneId(result, "root_pane", args);
2215
+ await labelRootTab(result, opts.windowName);
2216
+ return paneId;
2217
+ }
2218
+ async function labelRootTab(created, windowName) {
2219
+ if (windowName === void 0 || windowName.length === 0) return;
2220
+ const root = created.root_pane;
2221
+ const tab = created.tab;
2222
+ const tabId = (isRecord(root) ? asString(root.tab_id) : void 0) ?? (isRecord(tab) ? asString(tab.tab_id) : void 0);
2223
+ try {
2224
+ if (tabId === void 0) {
2225
+ throw new HerdrError(
2226
+ ["workspace", "create"],
2227
+ `herdr response had no root tab id: ${JSON.stringify(created)}`,
2228
+ "",
2229
+ 0
2230
+ );
2231
+ }
2232
+ await herdr(["tab", "rename", tabId, windowName]);
2233
+ } catch (err) {
2234
+ await closeCreatedWorkspace(created);
2235
+ throw err;
2236
+ }
2237
+ }
2238
+ async function closeCreatedWorkspace(created) {
2239
+ const ws = created.workspace;
2240
+ const root = created.root_pane;
2241
+ const id = (isRecord(ws) ? asString(ws.workspace_id) : void 0) ?? (isRecord(root) ? asString(root.workspace_id) : void 0);
2242
+ if (id === void 0) return;
2243
+ try {
2244
+ await herdrTolerating(["workspace", "close", id], ["workspace_not_found"]);
2245
+ } catch {
2246
+ }
2140
2247
  }
2141
2248
  async function killSession2(name) {
2142
2249
  const id = await resolveWorkspaceId(name);
@@ -2265,10 +2372,10 @@ async function paneTTY2(paneId) {
2265
2372
  ["pane", "process-info", "--pane", paneId],
2266
2373
  ["pane_not_found", "workspace_not_found"]
2267
2374
  );
2268
- if (result === void 0) throw new PaneNotFoundError(paneId);
2375
+ if (result === void 0) throw new PaneNotFoundError(paneId, herdrBackend);
2269
2376
  const info = result.process_info;
2270
2377
  const pid = isRecord(info) ? info.shell_pid : void 0;
2271
- if (typeof pid !== "number") throw new PaneNotFoundError(paneId);
2378
+ if (typeof pid !== "number") throw new PaneNotFoundError(paneId, herdrBackend);
2272
2379
  const { readlink } = await import("fs/promises");
2273
2380
  try {
2274
2381
  return await readlink(`/proc/${pid}/fd/0`);
@@ -2426,7 +2533,7 @@ function appendEnvFlags2(args, env) {
2426
2533
  }
2427
2534
  function rejectCommand(method, command) {
2428
2535
  if (command === void 0 || command.trim().length === 0) return;
2429
- throw new HerdrNotImplementedError(`${method} with a command`, "mux-herdr-spawn");
2536
+ throw new HerdrNotImplementedError(`${method} with a command`);
2430
2537
  }
2431
2538
  function readCreatedPaneId(result, key, args) {
2432
2539
  const pane = readPane(result[key]);
@@ -2451,14 +2558,32 @@ async function herdrAvailable() {
2451
2558
  if (result === void 0 || result.exitCode !== 0) return false;
2452
2559
  return isHerdrStatusUsable(result.stdout);
2453
2560
  }
2454
- function attachHint2(target) {
2561
+ async function attachFocusArgs(workspaceId, target) {
2455
2562
  if (target.window !== void 0) {
2456
- return `herdr session attach ${target.session} # then focus tab ${target.window}`;
2563
+ const tabs = readTabs(await herdr(["tab", "list", "--workspace", workspaceId]));
2564
+ const tab = tabs.find((t) => t.name === target.window);
2565
+ if (tab !== void 0) return ["tab", "focus", tab.id];
2566
+ }
2567
+ return ["workspace", "focus", workspaceId];
2568
+ }
2569
+ function insideHerdrClient() {
2570
+ return process.env.HERDR_ENV === "1";
2571
+ }
2572
+ async function attachHint2(target) {
2573
+ const base = ["herdr", ...herdrGlobalFlags()].join(" ");
2574
+ const focus = await resolveWorkspaceId(target.session).then((id) => id === void 0 ? void 0 : attachFocusArgs(id, target)).catch(() => void 0);
2575
+ if (focus === void 0) {
2576
+ return `${base} workspace list # then: ${base} workspace focus <id of ${target.session}>`;
2457
2577
  }
2458
- return `herdr session attach ${target.session}`;
2578
+ const line = `${base} ${focus.join(" ")}`;
2579
+ return insideHerdrClient() ? line : `${line} && ${base}`;
2459
2580
  }
2460
- function attachCommands2(target) {
2461
- return [{ command: "herdr", args: ["session", "attach", target.session] }];
2581
+ async function attachCommands2(target) {
2582
+ const flags = herdrGlobalFlags();
2583
+ const focus = await attachFocusArgs(await requireWorkspaceId(target.session), target);
2584
+ const steps = [{ command: "herdr", args: [...flags, ...focus] }];
2585
+ if (!insideHerdrClient()) steps.push({ command: "herdr", args: [...flags] });
2586
+ return steps;
2462
2587
  }
2463
2588
  function paneNotFoundNextSteps2(paneId) {
2464
2589
  return [
@@ -2815,16 +2940,11 @@ var WorkspacePathNotEmptyError = class extends Error {
2815
2940
  command: `mu workspace orphans -w ${this.workstream}`
2816
2941
  },
2817
2942
  {
2818
- intent: "If the dir is intentional (orphan from older mu), free it via mu first",
2819
- command: `mu workspace free ${this.agent} -w ${this.workstream} # also runs backend cleanup if a row remains`
2820
- },
2821
- {
2822
- intent: "Or delete it manually if the registry has no row",
2823
- command: `rm -rf ${this.workspacePath}`
2824
- },
2825
- {
2826
- intent: "For git workspaces specifically: also prune the worktree registration",
2827
- command: "cd <project-root> && git worktree prune"
2943
+ // No row points at the dir (createWorkspace throws
2944
+ // WorkspaceExistsError first when one does), so `mu workspace
2945
+ // free` has nothing to free. Same recipe as `mu workspace orphans`.
2946
+ intent: "Inspect it, then remove it by hand (git: also drops the worktree registration)",
2947
+ command: `(cd <project-root> && git worktree remove --force ${this.workspacePath}) || rm -rf ${this.workspacePath}`
2828
2948
  }
2829
2949
  ];
2830
2950
  }
@@ -2937,22 +3057,44 @@ var WorkspaceDirtyError = class extends Error {
2937
3057
  }
2938
3058
  };
2939
3059
  var WorkspaceConflictError = class extends Error {
2940
- constructor(workspacePath2, fromRef, conflicts) {
2941
- super(`rebase onto ${fromRef} produced ${conflicts.length} conflict(s): ${workspacePath2}`);
3060
+ constructor(workspacePath2, fromRef, conflicts, aborted = true) {
3061
+ super(
3062
+ `rebase onto ${fromRef} produced ${conflicts.length} conflict(s)${aborted ? "; rebase aborted, workspace unchanged" : "; conflicts left in place"}: ${workspacePath2}`
3063
+ );
2942
3064
  this.workspacePath = workspacePath2;
2943
3065
  this.fromRef = fromRef;
2944
3066
  this.conflicts = conflicts;
3067
+ this.aborted = aborted;
2945
3068
  }
2946
3069
  workspacePath;
2947
3070
  fromRef;
2948
3071
  conflicts;
3072
+ aborted;
2949
3073
  name = "WorkspaceConflictError";
2950
3074
  errorNextSteps() {
3075
+ const resync = {
3076
+ intent: "After resolving, record the new base (a clean refresh updates the fork point)",
3077
+ command: `mu workspace refresh <agent> --from '${this.fromRef}'`
3078
+ };
3079
+ if (this.aborted) {
3080
+ return [
3081
+ {
3082
+ intent: "The rebase was aborted. Rebase by hand in the workspace and resolve",
3083
+ command: `cd ${this.workspacePath} # then: git rebase ${this.fromRef} (sl: sl rebase -d '${this.fromRef}')`
3084
+ },
3085
+ resync,
3086
+ {
3087
+ intent: "Or DISCARD the workspace entirely (the lossy escape)",
3088
+ command: "mu workspace free <agent>"
3089
+ }
3090
+ ];
3091
+ }
2951
3092
  return [
2952
3093
  {
2953
- intent: "cd into the workspace and resolve",
2954
- command: `cd ${this.workspacePath} # then resolve & commit; or: git rebase --abort / jj abandon / sl rebase --abort`
2955
- }
3094
+ intent: "Resolve the conflicted commits in place",
3095
+ command: `cd ${this.workspacePath} # then: jj resolve; or undo the rebase: jj op undo`
3096
+ },
3097
+ resync
2956
3098
  ];
2957
3099
  }
2958
3100
  };
@@ -2992,26 +3134,38 @@ async function run(bin, args, cwd) {
2992
3134
  }
2993
3135
  }
2994
3136
  async function runShow(bin, args, cwd) {
3137
+ let stdout;
3138
+ let overflowed = false;
2995
3139
  try {
2996
- const { stdout } = await exec(bin, [...args], {
2997
- cwd,
2998
- maxBuffer: SHOW_COMMIT_MAX_CHARS * 2
2999
- });
3000
- if (stdout.length > SHOW_COMMIT_MAX_CHARS) {
3140
+ ({ stdout } = await exec(bin, [...args], { cwd, maxBuffer: SHOW_COMMIT_MAX_CHARS * 2 }));
3141
+ } catch (err) {
3142
+ const partial = stdoutOverflow(err);
3143
+ if (partial === void 0) {
3001
3144
  return {
3002
- text: `${stdout.slice(0, SHOW_COMMIT_MAX_CHARS)}
3003
- \u2026(truncated at ${SHOW_COMMIT_MAX_CHARS} chars)`,
3004
- truncated: true
3145
+ text: "",
3146
+ truncated: false,
3147
+ error: err instanceof Error ? err.message : String(err)
3005
3148
  };
3006
3149
  }
3007
- return { text: stdout, truncated: false };
3008
- } catch (err) {
3150
+ stdout = partial;
3151
+ overflowed = true;
3152
+ }
3153
+ if (overflowed || stdout.length > SHOW_COMMIT_MAX_CHARS) {
3009
3154
  return {
3010
- text: "",
3011
- truncated: false,
3012
- error: err instanceof Error ? err.message : String(err)
3155
+ text: `${stdout.slice(0, SHOW_COMMIT_MAX_CHARS)}
3156
+ \u2026(truncated at ${SHOW_COMMIT_MAX_CHARS} chars)`,
3157
+ truncated: true
3013
3158
  };
3014
3159
  }
3160
+ return { text: stdout, truncated: false };
3161
+ }
3162
+ function stdoutOverflow(err) {
3163
+ if (!(err instanceof Error)) return void 0;
3164
+ const e = err;
3165
+ if (e.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || typeof e.stdout !== "string") {
3166
+ return void 0;
3167
+ }
3168
+ return e.message.startsWith("stdout ") ? e.stdout : void 0;
3015
3169
  }
3016
3170
  function relTimeFromIso(iso) {
3017
3171
  const t = Date.parse(iso);
@@ -3104,15 +3258,15 @@ var gitBackend = {
3104
3258
  },
3105
3259
  // Rebase the worktree onto `fromRef` (default = origin/HEAD via
3106
3260
  // resolveGitMainRef). Refuses on a dirty WC; returns the replayed
3107
- // commit subjects oldest-first; on conflict aborts the rebase and
3108
- // throws WorkspaceConflictError so the operator never inherits a
3109
- // half-rebased worktree from us.
3261
+ // commit subjects oldest-first and the new fork point (parentRef);
3262
+ // on conflict aborts the rebase and throws WorkspaceConflictError so
3263
+ // the operator never inherits a half-rebased worktree from us.
3110
3264
  //
3111
3265
  // We DO `git fetch` first — otherwise the rebase target would only
3112
3266
  // be as fresh as the local refs cache, and the operator running
3113
3267
  // `mu workspace refresh` is explicitly asking for the latest. This
3114
3268
  // is the one-and-only place mu fetches; commitsBehind() stays pure.
3115
- async rebaseTo(workspacePath2, fromRef) {
3269
+ async rebaseTo(workspacePath2, fromRef, previousParentRef) {
3116
3270
  if (!existsSync2(workspacePath2)) {
3117
3271
  throw new Error(`vcs git: workspace path missing: ${workspacePath2}`);
3118
3272
  }
@@ -3134,7 +3288,7 @@ var gitBackend = {
3134
3288
  }
3135
3289
  resolvedRef = main;
3136
3290
  }
3137
- const preHead = await run("git", ["rev-parse", "HEAD"], workspacePath2);
3291
+ const headBefore = await run("git", ["rev-parse", "HEAD"], workspacePath2);
3138
3292
  try {
3139
3293
  await run(
3140
3294
  "git",
@@ -3150,16 +3304,21 @@ var gitBackend = {
3150
3304
  }
3151
3305
  throw err;
3152
3306
  }
3153
- const mergeBase = await run("git", ["merge-base", "HEAD", resolvedRef], workspacePath2).catch(
3154
- () => preHead
3307
+ const target = await run(
3308
+ "git",
3309
+ ["rev-parse", "--verify", `${resolvedRef}^{commit}`],
3310
+ workspacePath2
3155
3311
  );
3156
- const logOut = await run(
3312
+ const keepPrevious = previousParentRef !== void 0 && await gitIsAncestor(workspacePath2, target, previousParentRef) && await gitIsAncestor(workspacePath2, previousParentRef, "HEAD");
3313
+ const parentRef = keepPrevious ? previousParentRef : target;
3314
+ const headAfter = await run("git", ["rev-parse", "HEAD"], workspacePath2);
3315
+ const logOut = headAfter === headBefore ? "" : await run(
3157
3316
  "git",
3158
- ["log", "--reverse", "--format=%s", `${mergeBase}..HEAD`],
3317
+ ["log", "--reverse", "--format=%s", `${parentRef}..HEAD`],
3159
3318
  workspacePath2
3160
3319
  );
3161
3320
  const replayed = logOut.length === 0 ? [] : logOut.split("\n");
3162
- return { fromRef: resolvedRef, replayed, conflicts: [] };
3321
+ return { fromRef: resolvedRef, parentRef, replayed, conflicts: [] };
3163
3322
  },
3164
3323
  // List commits in (baseRef..HEAD), oldest-first. The format string
3165
3324
  // packs four NUL-delimited fields per record, then a record
@@ -3210,6 +3369,7 @@ var gitBackend = {
3210
3369
  return { removed: false };
3211
3370
  }
3212
3371
  let committedRef;
3372
+ let branch;
3213
3373
  if (opts.commit) {
3214
3374
  const dirty = (await listGitDirtyFiles(opts.workspacePath)).length > 0;
3215
3375
  if (dirty) {
@@ -3229,6 +3389,7 @@ var gitBackend = {
3229
3389
  );
3230
3390
  committedRef = await run("git", ["rev-parse", "HEAD"], opts.workspacePath);
3231
3391
  }
3392
+ branch = await keepGitHeadReachable(opts.workspacePath);
3232
3393
  }
3233
3394
  const projectRoot = await resolveGitProjectRoot(opts.workspacePath);
3234
3395
  if (projectRoot) {
@@ -3238,6 +3399,7 @@ var gitBackend = {
3238
3399
  }
3239
3400
  const result = { removed: true };
3240
3401
  if (committedRef !== void 0) result.committedRef = committedRef;
3402
+ if (branch !== void 0) result.branch = branch;
3241
3403
  return result;
3242
3404
  },
3243
3405
  async listDirtyFiles(workspacePath2) {
@@ -3245,6 +3407,14 @@ var gitBackend = {
3245
3407
  return listGitDirtyFiles(workspacePath2);
3246
3408
  }
3247
3409
  };
3410
+ async function gitIsAncestor(cwd, ancestor, descendant) {
3411
+ try {
3412
+ await run("git", ["merge-base", "--is-ancestor", ancestor, descendant], cwd);
3413
+ return true;
3414
+ } catch {
3415
+ return false;
3416
+ }
3417
+ }
3248
3418
  async function resolveGitMainRef(workspacePath2) {
3249
3419
  for (const candidate of [
3250
3420
  "refs/remotes/origin/HEAD",
@@ -3272,6 +3442,21 @@ async function listGitUnmergedPaths(workspacePath2) {
3272
3442
  return [];
3273
3443
  }
3274
3444
  }
3445
+ async function keepGitHeadReachable(workspacePath2) {
3446
+ const containing = await run(
3447
+ "git",
3448
+ ["for-each-ref", "--count=1", "--contains", "HEAD", "refs/heads", "refs/remotes", "refs/tags"],
3449
+ workspacePath2
3450
+ );
3451
+ if (containing.length > 0) return void 0;
3452
+ const sha = await run("git", ["rev-parse", "HEAD"], workspacePath2);
3453
+ const parts = workspacePath2.replace(/\/+$/, "").split("/");
3454
+ const agent = parts.at(-1) ?? "workspace";
3455
+ const workstream = parts.at(-2) ?? "mu";
3456
+ const branch = `mu/${workstream}/${agent}-${sha.slice(0, 12)}`;
3457
+ await run("git", ["branch", branch, sha], workspacePath2);
3458
+ return branch;
3459
+ }
3275
3460
  async function resolveGitProjectRoot(workspacePath2) {
3276
3461
  try {
3277
3462
  const { stdout } = await exec(
@@ -3317,6 +3502,7 @@ var jjBackend = {
3317
3502
  }
3318
3503
  await ensureParent(opts.workspacePath);
3319
3504
  const name = jjWorkspaceName(opts.workspacePath);
3505
+ await forgetIfRootMissing(name, opts.projectRoot);
3320
3506
  const args = ["workspace", "add", "--name", name];
3321
3507
  if (opts.parentRef) args.push("--revision", opts.parentRef);
3322
3508
  args.push(opts.workspacePath);
@@ -3328,7 +3514,6 @@ var jjBackend = {
3328
3514
  if (!existsSync3(opts.workspacePath)) {
3329
3515
  return { removed: false };
3330
3516
  }
3331
- const name = jjWorkspaceName(opts.workspacePath);
3332
3517
  let committedRef;
3333
3518
  if (opts.commit) {
3334
3519
  const desc = await run(
@@ -3351,7 +3536,7 @@ var jjBackend = {
3351
3536
  }
3352
3537
  committedRef = await jjCommitId(opts.workspacePath);
3353
3538
  }
3354
- await run("jj", ["workspace", "forget", name], opts.workspacePath);
3539
+ await run("jj", ["workspace", "forget"], opts.workspacePath);
3355
3540
  rmDirSync(opts.workspacePath);
3356
3541
  const result = { removed: true };
3357
3542
  if (committedRef !== void 0) result.committedRef = committedRef;
@@ -3411,18 +3596,15 @@ var jjBackend = {
3411
3596
  // doesn't auto-abort on conflicts (they materialise as commits with
3412
3597
  // conflict markers), so the workspace is left in a state the
3413
3598
  // operator can resolve in-place.
3414
- async rebaseTo(workspacePath2, fromRef) {
3599
+ async rebaseTo(workspacePath2, fromRef, previousParentRef) {
3415
3600
  if (!existsSync3(workspacePath2)) {
3416
3601
  throw new Error(`vcs jj: workspace path missing: ${workspacePath2}`);
3417
3602
  }
3418
3603
  const target = fromRef ?? "trunk()";
3419
- const preRev = await run(
3420
- "jj",
3421
- ["log", "-r", "@", "--no-graph", "--no-pager", "--color", "never", "--template", "change_id"],
3422
- workspacePath2
3423
- );
3604
+ const headBefore = await jjCommitId(workspacePath2);
3424
3605
  await run("jj", ["rebase", "-d", target], workspacePath2);
3425
- const replayedRaw = await run(
3606
+ const moved = await jjCommitId(workspacePath2) !== headBefore;
3607
+ const replayedRaw = moved ? await run(
3426
3608
  "jj",
3427
3609
  [
3428
3610
  "log",
@@ -3437,7 +3619,7 @@ var jjBackend = {
3437
3619
  'description.first_line() ++ "\\n"'
3438
3620
  ],
3439
3621
  workspacePath2
3440
- ).catch(() => "");
3622
+ ).catch(() => "") : "";
3441
3623
  const replayed = replayedRaw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
3442
3624
  const conflictRaw = await run(
3443
3625
  "jj",
@@ -3456,12 +3638,17 @@ var jjBackend = {
3456
3638
  ).catch(() => "");
3457
3639
  const conflicts = conflictRaw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
3458
3640
  if (conflicts.length > 0) {
3459
- throw new WorkspaceConflictError(workspacePath2, target, conflicts);
3641
+ throw new WorkspaceConflictError(workspacePath2, target, conflicts, false);
3460
3642
  }
3461
- void preRev;
3462
- return { fromRef: target, replayed, conflicts: [] };
3643
+ const targetId = await jjSingleCommitId(workspacePath2, target);
3644
+ if (targetId === void 0) return { fromRef: target, replayed, conflicts: [] };
3645
+ const keepPrevious = previousParentRef !== void 0 && await jjSingleCommitId(workspacePath2, `${previousParentRef} & ${targetId}:: & ::@`) !== void 0;
3646
+ const parentRef = keepPrevious ? previousParentRef : targetId;
3647
+ return { fromRef: target, parentRef, replayed, conflicts: [] };
3463
3648
  },
3464
- // List jj commits in (baseRef..@), oldest-first. jj's templating
3649
+ // List jj commits in (baseRef..@), oldest-first, minus @ itself when
3650
+ // it is the empty, undescribed working-copy commit jj snapshots on
3651
+ // every command: that is "no work yet", not a commit. jj's templating
3465
3652
  // gives us per-field strings; we glue them with NUL field-separators
3466
3653
  // and \x1e record-separators so multi-line descriptions/bodies
3467
3654
  // round-trip cleanly. The author timestamp template is
@@ -3476,7 +3663,7 @@ var jjBackend = {
3476
3663
  [
3477
3664
  "log",
3478
3665
  "-r",
3479
- `${baseRef}..@`,
3666
+ `(${baseRef}..@) ~ (@ & empty() & description(exact:""))`,
3480
3667
  "--no-graph",
3481
3668
  "--no-pager",
3482
3669
  "--color",
@@ -3527,7 +3714,46 @@ var jjBackend = {
3527
3714
  };
3528
3715
  var jjCommitSummaryTemplate = 'commit_id ++ "\\x00" ++ description.first_line() ++ "\\x00" ++ description ++ "\\x00" ++ author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z") ++ "\\x00" ++ author.name() ++ "\\x1e"';
3529
3716
  function jjWorkspaceName(workspacePath2) {
3530
- return workspacePath2.replace(/\/+$/, "").split("/").pop() ?? workspacePath2;
3717
+ const parts = workspacePath2.split("/").filter((p) => p.length > 0);
3718
+ return parts.slice(-2).join("/") || workspacePath2;
3719
+ }
3720
+ async function forgetIfRootMissing(name, projectRoot) {
3721
+ try {
3722
+ const out = await run(
3723
+ "jj",
3724
+ [
3725
+ "workspace",
3726
+ "list",
3727
+ "--color",
3728
+ "never",
3729
+ "--template",
3730
+ 'name ++ "\\x00" ++ self.root() ++ "\\x1e"'
3731
+ ],
3732
+ projectRoot
3733
+ );
3734
+ const stale = out.split("").map((r) => r.trim().split("\0")).some(([n, root]) => n === name && root === "");
3735
+ if (stale) await run("jj", ["workspace", "forget", name], projectRoot);
3736
+ } catch {
3737
+ }
3738
+ }
3739
+ async function jjSingleCommitId(workspacePath2, revset) {
3740
+ const out = await run(
3741
+ "jj",
3742
+ [
3743
+ "log",
3744
+ "-r",
3745
+ revset,
3746
+ "--no-graph",
3747
+ "--no-pager",
3748
+ "--color",
3749
+ "never",
3750
+ "--template",
3751
+ 'commit_id ++ "\\n"'
3752
+ ],
3753
+ workspacePath2
3754
+ ).catch(() => "");
3755
+ const ids = out.split("\n").filter((l) => l.length > 0);
3756
+ return ids.length === 1 ? ids[0] : void 0;
3531
3757
  }
3532
3758
  async function jjCommitId(workspacePath2) {
3533
3759
  return run(
@@ -3671,12 +3897,15 @@ var slBackend = {
3671
3897
  }
3672
3898
  },
3673
3899
  // Rebase the active draft chain onto `fromRef` (default = `trunk()`).
3674
- // Sapling refuses on dirty WC by default — we pre-check and convert
3675
- // its error into the typed WorkspaceDirtyError. Conflict surface
3676
- // post-rebase via `sl resolve --list --tool=internal:dumpjson` is
3677
- // brittle across versions, so we use the textual `sl resolve --list`
3678
- // output and look for the U-prefixed lines (unresolved).
3679
- async rebaseTo(workspacePath2, fromRef) {
3900
+ // We pre-check the WC and refuse with the typed WorkspaceDirtyError.
3901
+ // `sl rebase` exits 0 on success (also when there is nothing to
3902
+ // rebase) and 1 on unresolved conflicts. Conflict surface via
3903
+ // `sl resolve --list --tool=internal:dumpjson` is brittle across
3904
+ // versions, so on failure we read the textual `sl resolve --list`
3905
+ // and keep the U-prefixed (unresolved) lines. A failure with no
3906
+ // unresolved files (bad ref, unresolvable trunk()) is rethrown, as
3907
+ // the git impl does.
3908
+ async rebaseTo(workspacePath2, fromRef, previousParentRef) {
3680
3909
  if (!existsSync5(workspacePath2)) {
3681
3910
  throw new Error(`vcs sl: workspace path missing: ${workspacePath2}`);
3682
3911
  }
@@ -3685,25 +3914,41 @@ var slBackend = {
3685
3914
  if (dirtyFiles.length > 0) {
3686
3915
  throw new WorkspaceDirtyError(workspacePath2, dirtyFiles);
3687
3916
  }
3688
- await run(
3689
- "sl",
3690
- ["--config", "ui.username=mu <mu@local>", "rebase", "-d", target],
3691
- workspacePath2
3692
- ).catch(() => {
3693
- });
3694
- const conflicts = await listSlUnresolved(workspacePath2);
3695
- if (conflicts.length > 0) {
3917
+ const headBefore = await slCommitId(workspacePath2);
3918
+ try {
3919
+ await run(
3920
+ "sl",
3921
+ ["--config", "ui.username=mu <mu@local>", "rebase", "-d", target],
3922
+ workspacePath2
3923
+ );
3924
+ } catch (err) {
3925
+ const conflicts = await listSlUnresolved(workspacePath2);
3696
3926
  await run("sl", ["rebase", "--abort"], workspacePath2).catch(() => {
3697
3927
  });
3698
- throw new WorkspaceConflictError(workspacePath2, target, conflicts);
3928
+ if (conflicts.length > 0) {
3929
+ throw new WorkspaceConflictError(workspacePath2, target, conflicts);
3930
+ }
3931
+ throw err;
3699
3932
  }
3700
- const replayedRaw = await run(
3933
+ const targetNode = await run(
3701
3934
  "sl",
3702
- ["log", "-r", `${target}::. - ${target}`, "--template", "{desc|firstline}\\n"],
3935
+ ["log", "-r", `last(${target})`, "--template", "{node}"],
3703
3936
  workspacePath2
3704
3937
  ).catch(() => "");
3938
+ const keepPrevious = previousParentRef !== void 0 && targetNode.length > 0 && await run(
3939
+ "sl",
3940
+ ["log", "-r", `${previousParentRef} & ${targetNode}:: & ::.`, "--template", "{node}"],
3941
+ workspacePath2
3942
+ ).catch(() => "") !== "";
3943
+ const parentRef = keepPrevious ? previousParentRef : targetNode;
3944
+ const moved = await slCommitId(workspacePath2) !== headBefore;
3945
+ const replayedRaw = moved ? await run(
3946
+ "sl",
3947
+ ["log", "-r", `${target}::. - ${target}`, "--template", "{desc|firstline}\\n"],
3948
+ workspacePath2
3949
+ ).catch(() => "") : "";
3705
3950
  const replayed = replayedRaw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
3706
- return { fromRef: target, replayed, conflicts: [] };
3951
+ return parentRef.length > 0 ? { fromRef: target, parentRef, replayed, conflicts: [] } : { fromRef: target, replayed, conflicts: [] };
3707
3952
  },
3708
3953
  // List sl commits in (baseRef..., minus baseRef itself), oldest-
3709
3954
  // first. Same NUL-field / \x1e-record format as the jj impl;
@@ -3879,12 +4124,13 @@ async function freeWorkspace(db, agent, opts) {
3879
4124
  db,
3880
4125
  row.workstreamName,
3881
4126
  "workspace.free",
3882
- `workspace free ${agent} (backend=${row.backend}, path=${row.path}${result.committedRef ? `, committed=${result.committedRef.slice(0, 12)}` : ""})`
4127
+ `workspace free ${agent} (backend=${row.backend}, path=${row.path}${result.committedRef ? `, committed=${result.committedRef.slice(0, 12)}` : ""}${result.branch ? `, branch=${result.branch}` : ""})`
3883
4128
  );
3884
4129
  return {
3885
4130
  removed: result.removed,
3886
4131
  rowDeleted: del.changes > 0,
3887
- ...result.committedRef !== void 0 ? { committedRef: result.committedRef } : {}
4132
+ ...result.committedRef !== void 0 ? { committedRef: result.committedRef } : {},
4133
+ ...result.branch !== void 0 ? { branch: result.branch } : {}
3888
4134
  };
3889
4135
  }
3890
4136
  async function isWorkspaceClean(row) {
@@ -4073,10 +4319,11 @@ function noteFromDb(row) {
4073
4319
  createdAt: row.created_at
4074
4320
  };
4075
4321
  }
4076
- function lookupTaskAnyWorkstream(db, localId) {
4322
+ function lookupTaskAnyWorkstream(db, localId, preferWorkstream) {
4077
4323
  const row = db.prepare(
4078
- `SELECT ${SELECT_TASK_COLS} ${TASK_FROM_JOIN} WHERE t.local_id = ? ORDER BY ws.name LIMIT 1`
4079
- ).get(localId);
4324
+ `SELECT ${SELECT_TASK_COLS} ${TASK_FROM_JOIN} WHERE t.local_id = ?
4325
+ ORDER BY (ws.name = ?) DESC, ws.name LIMIT 1`
4326
+ ).get(localId, preferWorkstream ?? null);
4080
4327
  return row ? rowFromDb3(row) : void 0;
4081
4328
  }
4082
4329
  function taskIdFor(db, localId, workstream) {
@@ -4101,6 +4348,16 @@ function getTask(db, localId, workstream) {
4101
4348
  ).get(wsId, localId);
4102
4349
  return row ? rowFromDb3(row) : void 0;
4103
4350
  }
4351
+ function getTaskOwner(db, localId, workstream) {
4352
+ return db.prepare(
4353
+ `SELECT a.name AS name, aws.name AS workstreamName
4354
+ FROM tasks t
4355
+ JOIN workstreams tws ON tws.id = t.workstream_id
4356
+ JOIN agents a ON a.id = t.owner_id
4357
+ JOIN workstreams aws ON aws.id = a.workstream_id
4358
+ WHERE t.local_id = ? AND tws.name = ?`
4359
+ ).get(localId, workstream);
4360
+ }
4104
4361
  function asList(v) {
4105
4362
  if (v === void 0) return void 0;
4106
4363
  return typeof v === "string" ? [v] : v;
@@ -4202,6 +4459,10 @@ function listNotes(db, taskLocalId, workstream, opts = {}) {
4202
4459
  const taskId = taskIdFor(db, taskLocalId, workstream);
4203
4460
  if (taskId === null) return [];
4204
4461
  let cutoff = opts.since;
4462
+ if (cutoff !== void 0) {
4463
+ const ms = Date.parse(cutoff);
4464
+ if (!Number.isNaN(ms)) cutoff = new Date(ms).toISOString();
4465
+ }
4205
4466
  if (cutoff === void 0 && opts.sinceClaim === true) {
4206
4467
  const at = lastClaimEventAt(db, workstream, taskLocalId);
4207
4468
  if (at !== null) cutoff = at;
@@ -4230,6 +4491,24 @@ function listTasksByOwner(db, workstream, owner, opts = {}) {
4230
4491
  }
4231
4492
 
4232
4493
  // src/tasks/id.ts
4494
+ var TaskTitleSlugEmptyError = class extends Error {
4495
+ constructor(title) {
4496
+ super(
4497
+ `title yields empty slug: ${JSON.stringify(title)} (no ASCII letter or digit to derive an id from)`
4498
+ );
4499
+ this.title = title;
4500
+ }
4501
+ title;
4502
+ name = "TaskTitleSlugEmptyError";
4503
+ errorNextSteps() {
4504
+ return [
4505
+ {
4506
+ intent: "Pass the id explicitly as the positional",
4507
+ command: 'mu task add <id> --title "..." --impact <n> --effort-days <n>'
4508
+ }
4509
+ ];
4510
+ }
4511
+ };
4233
4512
  var TASK_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/;
4234
4513
  function isValidTaskId(id) {
4235
4514
  return TASK_ID_RE.test(id);
@@ -4242,15 +4521,14 @@ function slugifyTitle(title) {
4242
4521
  function slugifyTitleVerbose(title) {
4243
4522
  const stripped = title.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
4244
4523
  if (stripped.length === 0) {
4245
- throw new Error(`title yields empty slug: ${JSON.stringify(title)}`);
4524
+ throw new TaskTitleSlugEmptyError(title);
4246
4525
  }
4247
4526
  let trimmed;
4248
4527
  if (stripped.length <= SLUG_SOFT_CAP) {
4249
4528
  trimmed = stripped;
4250
4529
  } else {
4251
- const window = stripped.slice(0, SLUG_SOFT_CAP);
4252
- const lastSep = window.lastIndexOf("_");
4253
- trimmed = lastSep > 0 ? window.slice(0, lastSep) : window;
4530
+ const lastSep = stripped.lastIndexOf("_", SLUG_SOFT_CAP);
4531
+ trimmed = lastSep > 0 ? stripped.slice(0, lastSep) : stripped.slice(0, SLUG_SOFT_CAP);
4254
4532
  }
4255
4533
  const applyPrefix = (s) => /^[a-z]/.test(s) ? s.slice(0, SLUG_HARD_CAP) : `t_${s}`.slice(0, SLUG_HARD_CAP);
4256
4534
  const slug = applyPrefix(trimmed);
@@ -4293,8 +4571,7 @@ var TaskNotFoundError = class extends Error {
4293
4571
  const recipe = `mu sql "SELECT ws.name AS workstream, t.local_id, t.status, t.title FROM tasks t JOIN workstreams ws ON ws.id = t.workstream_id WHERE LOWER(t.local_id) LIKE '%${idLit}%' OR LOWER(t.title) LIKE '%${idLit}%'"`;
4294
4572
  return [
4295
4573
  { intent: "List tasks in workstream", command: "mu task list -w <workstream>" },
4296
- { intent: "Search by substring (id + title)", command: recipe },
4297
- { intent: "Find which workstream owns it", command: recipe }
4574
+ { intent: "Search every workstream by substring (id + title)", command: recipe }
4298
4575
  ];
4299
4576
  }
4300
4577
  };
@@ -4309,7 +4586,7 @@ var TaskIdInvalidError = class extends Error {
4309
4586
  const sanitised = sanitiseTaskId(this.attempted);
4310
4587
  return [
4311
4588
  {
4312
- intent: "Use the auto-derived id (drop --id and pass --title)",
4589
+ intent: "Use the auto-derived id (drop the <id> positional; --title derives it)",
4313
4590
  command: 'mu task add --title "..." --impact <n> --effort-days <n>'
4314
4591
  },
4315
4592
  {
@@ -4419,6 +4696,24 @@ var ClaimerNotRegisteredError = class extends Error {
4419
4696
  return steps;
4420
4697
  }
4421
4698
  };
4699
+ var TaskClaimStaleWorkspaceError = class extends Error {
4700
+ constructor(staleness) {
4701
+ super(
4702
+ `${staleness.agentName} workspace is ${staleness.commitsBehindMain} commits behind main (\u2265${WORKSPACE_STALE_THRESHOLD} = stale); refresh before dispatch or rerun without --strict-staleness`
4703
+ );
4704
+ this.staleness = staleness;
4705
+ }
4706
+ staleness;
4707
+ name = "TaskClaimStaleWorkspaceError";
4708
+ errorNextSteps() {
4709
+ return [
4710
+ {
4711
+ intent: "Refresh first",
4712
+ command: `mu workspace refresh ${this.staleness.agentName} -w ${this.staleness.workstreamName}`
4713
+ }
4714
+ ];
4715
+ }
4716
+ };
4422
4717
  var CycleError = class extends Error {
4423
4718
  constructor(from, to) {
4424
4719
  super(`adding edge ${from} -> ${to} would create a cycle`);
@@ -4445,6 +4740,38 @@ var CycleError = class extends Error {
4445
4740
  ];
4446
4741
  }
4447
4742
  };
4743
+ var ReaperDetectedDuringWaitError = class extends Error {
4744
+ constructor(taskId, previousOwner, workstream) {
4745
+ const ownerBit = previousOwner !== null ? `owner=${previousOwner}` : "owner=<unknown>";
4746
+ super(
4747
+ `task ${taskId} was IN_PROGRESS ${ownerBit} until just now; reaper detected dead pane and flipped to OPEN. wait abandoned. Re-dispatch a worker and retry.`
4748
+ );
4749
+ this.taskId = taskId;
4750
+ this.previousOwner = previousOwner;
4751
+ this.workstream = workstream;
4752
+ }
4753
+ taskId;
4754
+ previousOwner;
4755
+ workstream;
4756
+ name = "ReaperDetectedDuringWaitError";
4757
+ errorNextSteps() {
4758
+ const ws = this.workstream;
4759
+ return [
4760
+ {
4761
+ intent: "Inspect the task's current state",
4762
+ command: `mu task show ${this.taskId} -w ${ws}`
4763
+ },
4764
+ {
4765
+ intent: "List live agents in the workstream (post-reap)",
4766
+ command: `mu agent list -w ${ws}`
4767
+ },
4768
+ {
4769
+ intent: "Re-dispatch a fresh worker, then re-run the wait",
4770
+ command: `mu agent spawn <name> -w ${ws} && mu task claim ${this.taskId} --for <name> -w ${ws}`
4771
+ }
4772
+ ];
4773
+ }
4774
+ };
4448
4775
  function stallWaitHint(owner, ws, runs) {
4449
4776
  if (runs === void 0 || runs < 1) return [];
4450
4777
  return [
@@ -4455,7 +4782,7 @@ function stallWaitHint(owner, ws, runs) {
4455
4782
  ];
4456
4783
  }
4457
4784
  var StallDetectedDuringWaitError = class extends Error {
4458
- constructor(taskName, owner, workstream, ageSecs, ownerExpectsCtl = false, ownerRuns) {
4785
+ constructor(taskName, owner, workstream, ageSecs, ownerExpectsCtl = false, ownerRuns, ownerWorkstream = workstream) {
4459
4786
  const ownerBit = owner !== null ? owner : "<unknown>";
4460
4787
  super(
4461
4788
  `task ${taskName} owned by ${ownerBit} has been needs_input for ${ageSecs}s; exiting per --on-stall exit. Read the pane to see which it is \u2014 finished without closing, waiting on an answer, or sitting at a prompt.`
@@ -4466,6 +4793,7 @@ var StallDetectedDuringWaitError = class extends Error {
4466
4793
  this.ageSecs = ageSecs;
4467
4794
  this.ownerExpectsCtl = ownerExpectsCtl;
4468
4795
  this.ownerRuns = ownerRuns;
4796
+ this.ownerWorkstream = ownerWorkstream;
4469
4797
  }
4470
4798
  taskName;
4471
4799
  owner;
@@ -4473,6 +4801,7 @@ var StallDetectedDuringWaitError = class extends Error {
4473
4801
  ageSecs;
4474
4802
  ownerExpectsCtl;
4475
4803
  ownerRuns;
4804
+ ownerWorkstream;
4476
4805
  name = "StallDetectedDuringWaitError";
4477
4806
  /**
4478
4807
  * Diagnose before acting. `mu agent read` comes FIRST because the
@@ -4485,21 +4814,22 @@ var StallDetectedDuringWaitError = class extends Error {
4485
4814
  */
4486
4815
  errorNextSteps() {
4487
4816
  const ws = this.workstream;
4817
+ const ownerWs = this.ownerWorkstream;
4488
4818
  const ownerBit = this.owner !== null ? this.owner : "<owner>";
4489
4819
  return [
4490
- ...stallWaitHint(ownerBit, ws, this.ownerRuns),
4820
+ ...stallWaitHint(ownerBit, ownerWs, this.ownerRuns),
4491
4821
  {
4492
4822
  intent: "Read the worker's pane to see what it is waiting on",
4493
- command: `mu agent read ${ownerBit} -w ${ws} --lines 60`
4823
+ command: `mu agent read ${ownerBit} -w ${ownerWs} --lines 60`
4494
4824
  },
4495
4825
  {
4496
4826
  intent: "Answer a question, or poke a worker that stalled mid-step",
4497
- command: `mu agent send ${ownerBit} '<answer-or-retry-instruction>' -w ${ws}`
4827
+ command: `mu agent send ${ownerBit} '<answer-or-retry-instruction>' -w ${ownerWs}`
4498
4828
  },
4499
4829
  ...this.ownerExpectsCtl ? [
4500
4830
  {
4501
4831
  intent: "Stuck mid-turn? Stop it through the control socket before kick or close",
4502
- command: `mu agent abort ${ownerBit} -w ${ws}`
4832
+ command: `mu agent abort ${ownerBit} -w ${ownerWs}`
4503
4833
  }
4504
4834
  ] : [],
4505
4835
  {
@@ -4531,12 +4861,12 @@ var CrossWorkstreamEdgeError = class extends Error {
4531
4861
  errorNextSteps() {
4532
4862
  return [
4533
4863
  {
4534
- intent: "Move the blocker into the dependent's workstream",
4535
- command: `mu sql "UPDATE tasks SET workstream_id=(SELECT id FROM workstreams WHERE name='${this.dependentWorkstream}') WHERE local_id='${this.blocker}' AND workstream_id=(SELECT id FROM workstreams WHERE name='${this.blockerWorkstream}')"`
4864
+ intent: "Duplicate the blocker into the dependent's workstream",
4865
+ command: `mu task add <new-id> -w ${this.dependentWorkstream} --title "<copy of ${this.blocker}>" --impact <n> --effort-days <n>`
4536
4866
  },
4537
4867
  {
4538
- intent: "Or duplicate the blocker (typed verb deferred)",
4539
- command: `mu task add <new-id> -w ${this.dependentWorkstream} --title "<copy of ${this.blocker}>" --impact <n> --effort-days <n>`
4868
+ intent: "Or wait on the blocker across workstreams instead of an edge",
4869
+ command: `mu task wait ${this.blockerWorkstream}/${this.blocker}`
4540
4870
  }
4541
4871
  ];
4542
4872
  }
@@ -4729,7 +5059,7 @@ function addBlockEdgeImpl(db, workstream, blocked, blocker) {
4729
5059
  }
4730
5060
  const blockedRow = getTask(db, blocked, workstream);
4731
5061
  if (!blockedRow) throw new TaskNotFoundError(blocked);
4732
- const blockerRow = lookupTaskAnyWorkstream(db, blocker);
5062
+ const blockerRow = lookupTaskAnyWorkstream(db, blocker, blockedRow.workstreamName);
4733
5063
  if (!blockerRow) throw new TaskNotFoundError(blocker);
4734
5064
  if (blockedRow.workstreamName !== blockerRow.workstreamName) {
4735
5065
  throw new CrossWorkstreamEdgeError(
@@ -4755,7 +5085,7 @@ function addBlockEdgeImpl(db, workstream, blocked, blocker) {
4755
5085
  return true;
4756
5086
  }
4757
5087
  return false;
4758
- })();
5088
+ }).immediate();
4759
5089
  return { added };
4760
5090
  }
4761
5091
  function removeBlockEdge(db, workstream, blocked, blocker) {
@@ -4780,7 +5110,7 @@ function removeBlockEdgeImpl(db, workstream, blocked, blocker) {
4780
5110
  return true;
4781
5111
  }
4782
5112
  return false;
4783
- })();
5113
+ }).immediate();
4784
5114
  if (removed) {
4785
5115
  }
4786
5116
  return { removed };
@@ -4802,7 +5132,7 @@ function reparentTaskImpl(db, taskLocalId, blockers, scope) {
4802
5132
  if (blockerLocalId === taskLocalId) {
4803
5133
  throw new CycleError(blockerLocalId, taskLocalId);
4804
5134
  }
4805
- const blocker = lookupTaskAnyWorkstream(db, blockerLocalId);
5135
+ const blocker = lookupTaskAnyWorkstream(db, blockerLocalId, task.workstreamName);
4806
5136
  if (!blocker) throw new TaskNotFoundError(blockerLocalId);
4807
5137
  if (blocker.workstreamName !== task.workstreamName) {
4808
5138
  throw new CrossWorkstreamEdgeError(
@@ -4836,7 +5166,7 @@ function reparentTaskImpl(db, taskLocalId, blockers, scope) {
4836
5166
  }
4837
5167
  touchTask(db, taskSurrogateId, now);
4838
5168
  return { removedEdges: removed.changes, addedEdges: blockerIds.length };
4839
- })();
5169
+ }).immediate();
4840
5170
  }
4841
5171
 
4842
5172
  // src/workstream.ts
@@ -4862,6 +5192,27 @@ async function sessionAlive(session) {
4862
5192
  function isScratchWorkstream(name) {
4863
5193
  return RESERVED_WORKSTREAM_NAMES.has(name);
4864
5194
  }
5195
+ var MU_PLACEHOLDER_WINDOW = "_mu";
5196
+ async function ensureWorkstreamSession(session) {
5197
+ const mux = await activeMux();
5198
+ if (!await mux.sessionExists(session)) {
5199
+ await mux.newSession(session, { detached: true, windowName: MU_PLACEHOLDER_WINDOW });
5200
+ return { created: true, muWindowRepaired: false };
5201
+ }
5202
+ const windows = await mux.listWindows(session).catch(() => []);
5203
+ if (windows.some((w) => w.name === MU_PLACEHOLDER_WINDOW)) {
5204
+ return { created: false, muWindowRepaired: false };
5205
+ }
5206
+ await mux.newWindow({
5207
+ session,
5208
+ name: MU_PLACEHOLDER_WINDOW,
5209
+ // A backend with no create-and-run form rejects a command and
5210
+ // starts a shell anyway (see createOrReusePane in agents/spawn.ts).
5211
+ command: mux.startAgentInPane === void 0 ? process.env.SHELL ?? "/bin/sh" : "",
5212
+ detached: true
5213
+ });
5214
+ return { created: false, muWindowRepaired: true };
5215
+ }
4865
5216
  function isValidWorkstreamName(name) {
4866
5217
  if (!WORKSTREAM_NAME_RE.test(name)) return false;
4867
5218
  if (name.startsWith(RESERVED_WORKSTREAM_PREFIX)) return false;
@@ -4893,11 +5244,12 @@ var WorkstreamNameInvalidError = class extends Error {
4893
5244
  attempted;
4894
5245
  name = "WorkstreamNameInvalidError";
4895
5246
  errorNextSteps() {
4896
- const sanitized = this.attempted.toLowerCase().replace(/^mu-/, "").replace(/[.:]/g, "_").slice(0, 32);
5247
+ const candidate = this.attempted.toLowerCase().replace(/^mu-/, "").replace(/[^a-z0-9_-]/g, "_").replace(/^[^a-z]+/, "").slice(0, 32);
5248
+ const sanitized = isValidWorkstreamName(candidate) ? candidate : "<name>";
4897
5249
  const isPrefixCase = this.attempted.toLowerCase().startsWith(RESERVED_WORKSTREAM_PREFIX);
4898
5250
  const intent = isPrefixCase ? "Retry without the 'mu-' prefix" : "Try a sanitized name (best guess)";
4899
5251
  return [
4900
- { intent, command: `mu workstream init ${sanitized || "<name>"}` },
5252
+ { intent, command: `mu workstream init ${sanitized}` },
4901
5253
  { intent: "List existing workstreams", command: "mu workstream list" }
4902
5254
  ];
4903
5255
  }
@@ -4905,6 +5257,25 @@ var WorkstreamNameInvalidError = class extends Error {
4905
5257
  function assertValidWorkstreamName(name) {
4906
5258
  if (!isValidWorkstreamName(name)) throw new WorkstreamNameInvalidError(name);
4907
5259
  }
5260
+ var WorkstreamNameReservedError = class extends Error {
5261
+ constructor(attempted) {
5262
+ super(
5263
+ `workstream name ${JSON.stringify(attempted)} is reserved: it is the off-the-cuff bucket and auto-creates on first spawn. Don't 'init' it.`
5264
+ );
5265
+ this.attempted = attempted;
5266
+ }
5267
+ attempted;
5268
+ name = "WorkstreamNameReservedError";
5269
+ errorNextSteps() {
5270
+ return [
5271
+ {
5272
+ intent: "Just spawn into it (auto-creates)",
5273
+ command: `mu agent spawn <name> -w ${this.attempted}`
5274
+ },
5275
+ { intent: "Use a durable workstream instead", command: "mu workstream init <name>" }
5276
+ ];
5277
+ }
5278
+ };
4908
5279
  function ensureWorkstream(db, name) {
4909
5280
  assertValidWorkstreamName(name);
4910
5281
  return withOpContext(
@@ -5132,7 +5503,7 @@ function addTaskImpl(db, opts) {
5132
5503
  const row = getTask(db, opts.localId, opts.workstream);
5133
5504
  if (!row) throw new Error(`addTask: row missing after insert: ${opts.localId}`);
5134
5505
  return row;
5135
- })();
5506
+ }).immediate();
5136
5507
  }
5137
5508
  function addNote(db, taskLocalId, content, opts) {
5138
5509
  return withOpContext(
@@ -5153,7 +5524,7 @@ function insertNote(db, taskLocalId, content, opts) {
5153
5524
  const r = db.prepare("INSERT INTO task_notes (task_id, author, content, created_at) VALUES (?, ?, ?, ?)").run(taskId, opts.author ?? null, content, now);
5154
5525
  touchTask(db, taskId, now);
5155
5526
  return r;
5156
- })();
5527
+ }).immediate();
5157
5528
  return {
5158
5529
  author: opts.author ?? null,
5159
5530
  content,
@@ -5171,11 +5542,25 @@ function deleteTaskImpl(db, localId, workstream, opts) {
5171
5542
  const dryRun = opts.dryRun === true;
5172
5543
  const before = getTask(db, localId, workstream);
5173
5544
  if (!before) {
5174
- return { deleted: false, deletedEdges: 0, deletedNotes: 0, dryRun, present: false };
5545
+ return {
5546
+ deleted: false,
5547
+ deletedEdges: 0,
5548
+ deletedNotes: 0,
5549
+ dryRun,
5550
+ present: false,
5551
+ group: null
5552
+ };
5175
5553
  }
5176
5554
  const taskId = taskIdFor(db, localId, before.workstreamName);
5177
5555
  if (taskId === null) {
5178
- return { deleted: false, deletedEdges: 0, deletedNotes: 0, dryRun, present: false };
5556
+ return {
5557
+ deleted: false,
5558
+ deletedEdges: 0,
5559
+ deletedNotes: 0,
5560
+ dryRun,
5561
+ present: false,
5562
+ group: null
5563
+ };
5179
5564
  }
5180
5565
  const edgesBefore = db.prepare("SELECT COUNT(*) AS n FROM task_edges WHERE from_task_id = ? OR to_task_id = ?").get(taskId, taskId).n;
5181
5566
  const notesBefore = db.prepare("SELECT COUNT(*) AS n FROM task_notes WHERE task_id = ?").get(taskId).n;
@@ -5185,7 +5570,8 @@ function deleteTaskImpl(db, localId, workstream, opts) {
5185
5570
  deletedEdges: edgesBefore,
5186
5571
  deletedNotes: notesBefore,
5187
5572
  dryRun: true,
5188
- present: true
5573
+ present: true,
5574
+ group: null
5189
5575
  };
5190
5576
  }
5191
5577
  const result = db.prepare("DELETE FROM tasks WHERE id = ?").run(taskId);
@@ -5195,7 +5581,9 @@ function deleteTaskImpl(db, localId, workstream, opts) {
5195
5581
  deletedEdges: edgesBefore,
5196
5582
  deletedNotes: notesBefore,
5197
5583
  dryRun: false,
5198
- present: true
5584
+ present: true,
5585
+ // deleteTask opened a fresh group around this call.
5586
+ group: deleted ? currentOpContext(db).groupId : null
5199
5587
  };
5200
5588
  }
5201
5589
  function updateTask(db, localId, opts, scope) {
@@ -5286,8 +5674,8 @@ function closeTask(db, localId, opts) {
5286
5674
  );
5287
5675
  }
5288
5676
  function closeTaskImpl(db, localId, opts) {
5289
- const as = opts.as ?? "done";
5290
5677
  const before = getTask(db, localId, opts.workstream);
5678
+ const as = opts.as ?? (before?.status === "CLOSED" ? before.substate : "done");
5291
5679
  if (opts.ifReady && before) {
5292
5680
  const edges = getTaskEdgesWithStatus(db, localId, before.workstreamName);
5293
5681
  const blocking = edges.blockers.filter((e) => e.status !== "CLOSED").map((e) => e.name).sort();
@@ -5314,9 +5702,11 @@ function closeTaskImpl(db, localId, opts) {
5314
5702
  if (!r.changed || !before) return { ...r, unblocked: [] };
5315
5703
  recordReasonNote(db, localId, before.workstreamName, as, opts.why, opts.author);
5316
5704
  recordEvidenceNote(db, localId, before.workstreamName, "CLOSE", opts);
5317
- const unblocked = track ? readyDependents(db, localId, before.workstreamName).filter((n) => !readyBefore.includes(n)) : [];
5705
+ const unblocked = track ? readyDependents(db, localId, before.workstreamName).filter(
5706
+ (n) => !readyBefore.includes(n)
5707
+ ) : [];
5318
5708
  return { ...r, unblocked };
5319
- })();
5709
+ }).immediate();
5320
5710
  }
5321
5711
  function readyDependents(db, localId, workstream) {
5322
5712
  const id = taskIdFor(db, localId, workstream);
@@ -5382,7 +5772,7 @@ function parkTask(db, localId, opts) {
5382
5772
  recordEvidenceNote(db, localId, before.workstreamName, "PARK", opts);
5383
5773
  }
5384
5774
  return r;
5385
- })()
5775
+ }).immediate()
5386
5776
  );
5387
5777
  }
5388
5778
  function acceptTask(db, localId, opts) {
@@ -5404,7 +5794,7 @@ function acceptTask(db, localId, opts) {
5404
5794
  const r = setTaskStatus(db, localId, "OPEN", { workstream: opts.workstream });
5405
5795
  recordEvidenceNote(db, localId, before.workstreamName, "ACCEPT", opts);
5406
5796
  return r;
5407
- })()
5797
+ }).immediate()
5408
5798
  );
5409
5799
  }
5410
5800
  function unparkTask(db, localId, opts) {
@@ -5426,7 +5816,7 @@ function unparkTask(db, localId, opts) {
5426
5816
  const r = setTaskStatus(db, localId, "OPEN", { workstream: opts.workstream });
5427
5817
  recordEvidenceNote(db, localId, before.workstreamName, "UNPARK", opts);
5428
5818
  return r;
5429
- })()
5819
+ }).immediate()
5430
5820
  );
5431
5821
  }
5432
5822
 
@@ -5533,7 +5923,7 @@ async function claimTaskImpl(db, localId, opts) {
5533
5923
  previousStatus: before.status,
5534
5924
  status: after.status
5535
5925
  };
5536
- })()
5926
+ }).immediate()
5537
5927
  );
5538
5928
  }
5539
5929
  function assertNotParked(task, opts) {
@@ -5596,7 +5986,7 @@ async function claimSelf(db, localId, opts) {
5596
5986
  previousStatus: before.status,
5597
5987
  status: after.status
5598
5988
  };
5599
- })()
5989
+ }).immediate()
5600
5990
  );
5601
5991
  }
5602
5992
 
@@ -5664,11 +6054,11 @@ async function waitForTasks(db, input, opts) {
5664
6054
  const stuckLabel = /* @__PURE__ */ new Map();
5665
6055
  const ctlOwners = /* @__PURE__ */ new Set();
5666
6056
  const ownerRuns = /* @__PURE__ */ new Map();
5667
- const stuckAgeMs = async (status, owner, workstreamName) => {
5668
- if (stuckAfterMs <= 0 || status !== "IN_PROGRESS" || owner === null || opts.readOwnerState === void 0)
6057
+ const stuckAgeMs = async (status, owner) => {
6058
+ if (stuckAfterMs <= 0 || status !== "IN_PROGRESS" || owner === void 0 || opts.readOwnerState === void 0)
5669
6059
  return null;
5670
- const reading = await opts.readOwnerState({ name: owner, workstreamName });
5671
- const ownerKey = `${workstreamName}/${owner}`;
6060
+ const reading = await opts.readOwnerState(owner);
6061
+ const ownerKey = `${owner.workstreamName}/${owner.name}`;
5672
6062
  if (reading?.ctl !== void 0) ctlOwners.add(ownerKey);
5673
6063
  if (reading?.runs !== void 0) ownerRuns.set(ownerKey, reading.runs);
5674
6064
  else ownerRuns.delete(ownerKey);
@@ -5693,21 +6083,23 @@ async function waitForTasks(db, input, opts) {
5693
6083
  const row = getTask(db, ref.name, ref.workstreamName);
5694
6084
  const status = row?.status ?? "OPEN";
5695
6085
  const substate = row?.substate ?? DEFAULT_SUBSTATE[status];
5696
- const owner = row?.ownerName ?? null;
5697
- const ageMs = await stuckAgeMs(status, owner, ref.workstreamName);
6086
+ const ownerRef = row === void 0 ? void 0 : getTaskOwner(db, ref.name, ref.workstreamName);
6087
+ const owner = ownerRef?.name ?? row?.ownerName ?? null;
6088
+ const ownerWs = ownerRef?.workstreamName ?? ref.workstreamName;
6089
+ const ageMs = await stuckAgeMs(status, ownerRef);
5698
6090
  const stuck = ageMs !== null;
5699
6091
  const key = refKey(ref);
5700
6092
  if (ageMs !== null && !stuckWarned.has(key)) {
5701
6093
  stuckWarned.add(key);
5702
6094
  const ageSecs = Math.round(ageMs / 1e3);
5703
6095
  const ownerBit = owner ?? "<none>";
5704
- const runs = ownerRuns.get(`${ref.workstreamName}/${ownerBit}`);
5705
- const stateBit = stuckLabel.get(`${ref.workstreamName}/${ownerBit}`) ?? "needs_input";
5706
- const waitLine = stallWaitHint(ownerBit, ref.workstreamName, runs).map((s) => ` ${s.command}
6096
+ const runs = ownerRuns.get(`${ownerWs}/${ownerBit}`);
6097
+ const stateBit = stuckLabel.get(`${ownerWs}/${ownerBit}`) ?? "needs_input";
6098
+ const waitLine = stallWaitHint(ownerBit, ownerWs, runs).map((s) => ` ${s.command}
5707
6099
  `).join("");
5708
6100
  currentStuckWarn(
5709
6101
  `\x1B[33mmu task wait: ${key} needs attention \u2014 owner=${ownerBit} has been in ${stateBit} for ${formatStallAge(ageMs)}. It may have finished without closing, be waiting on an answer, or be sitting at a prompt.\x1B[0m
5710
- ` + waitLine + ` mu agent read ${ownerBit} -w ${ref.workstreamName} --lines 60
6102
+ ` + waitLine + ` mu agent read ${ownerBit} -w ${ownerWs} --lines 60
5711
6103
  `
5712
6104
  );
5713
6105
  emitEvent(
@@ -5723,8 +6115,9 @@ async function waitForTasks(db, input, opts) {
5723
6115
  owner,
5724
6116
  ref.workstreamName,
5725
6117
  ageSecs,
5726
- ctlOwners.has(`${ref.workstreamName}/${owner ?? ""}`),
5727
- runs
6118
+ ctlOwners.has(`${ownerWs}/${owner ?? ""}`),
6119
+ runs,
6120
+ ownerWs
5728
6121
  );
5729
6122
  }
5730
6123
  }
@@ -5734,7 +6127,7 @@ async function waitForTasks(db, input, opts) {
5734
6127
  status,
5735
6128
  substate,
5736
6129
  owner,
5737
- reachedTarget: status === target,
6130
+ reachedTarget: row !== void 0 && status === target,
5738
6131
  stuck
5739
6132
  });
5740
6133
  }
@@ -5810,7 +6203,7 @@ async function abortAgent(db, name, opts) {
5810
6203
  }
5811
6204
  async function interruptAgent(db, name, text, opts) {
5812
6205
  const r = await interruptAgentImpl(db, name, text, opts);
5813
- recordSend(db, { name, workstreamName: opts.workstream }, "interrupt", text.length, {
6206
+ recordSend(db, { name, workstreamName: opts.workstream }, "interrupt", Buffer.byteLength(text), {
5814
6207
  transport: "ctl",
5815
6208
  wasBusy: r.wasBusy
5816
6209
  });
@@ -5866,11 +6259,7 @@ async function adoptAgent(db, opts) {
5866
6259
  const panesInSession = await mux.listPanesInSession(expectedSession);
5867
6260
  const matchingPane = panesInSession.find((p) => p.paneId === opts.paneId);
5868
6261
  if (!matchingPane) {
5869
- throw new AgentNotInWorkstreamError(
5870
- `pane ${opts.paneId}`,
5871
- opts.workstream,
5872
- "a different tmux session"
5873
- );
6262
+ throw new PaneNotInSessionError(opts.paneId, opts.workstream, expectedSession);
5874
6263
  }
5875
6264
  const previousTitle = matchingPane.title.length > 0 ? matchingPane.title : null;
5876
6265
  const candidate = opts.name ?? (previousTitle !== null ? parseAgentNameFromTitle(previousTitle) : "");
@@ -6128,19 +6517,26 @@ function insertAgent(db, input) {
6128
6517
  ensureWorkstream(db, input.workstream);
6129
6518
  const workstreamId = resolveWorkstreamId(db, input.workstream);
6130
6519
  const now = (/* @__PURE__ */ new Date()).toISOString();
6131
- db.prepare(
6132
- `INSERT INTO agents (name, workstream_id, cli, pane_id, status, role, tab, created_at, updated_at)
6133
- VALUES (@name, @workstreamId, COALESCE(@cli, 'pi'), @paneId, 'spawning',
6134
- COALESCE(@role, 'full-access'), @tab, @now, @now)`
6135
- ).run({
6136
- name: input.name,
6137
- workstreamId,
6138
- cli: input.cli ?? null,
6139
- paneId: input.paneId,
6140
- role: input.role ?? null,
6141
- tab: input.tab ?? null,
6142
- now
6143
- });
6520
+ try {
6521
+ db.prepare(
6522
+ `INSERT INTO agents (name, workstream_id, cli, pane_id, status, role, tab, created_at, updated_at)
6523
+ VALUES (@name, @workstreamId, COALESCE(@cli, 'pi'), @paneId, 'spawning',
6524
+ COALESCE(@role, 'full-access'), @tab, @now, @now)`
6525
+ ).run({
6526
+ name: input.name,
6527
+ workstreamId,
6528
+ cli: input.cli ?? null,
6529
+ paneId: input.paneId,
6530
+ role: input.role ?? null,
6531
+ tab: input.tab ?? null,
6532
+ now
6533
+ });
6534
+ } catch (err) {
6535
+ if (err.code === "SQLITE_CONSTRAINT_UNIQUE") {
6536
+ throw new AgentExistsError(input.name);
6537
+ }
6538
+ throw err;
6539
+ }
6144
6540
  const row = getAgent(db, input.name, input.workstream);
6145
6541
  if (!row) throw new Error(`agents.insertAgent: row not found after insert: ${input.name}`);
6146
6542
  return row;
@@ -6230,7 +6626,7 @@ function deleteAgentRow(db, name, workstream) {
6230
6626
  );
6231
6627
  }
6232
6628
  return true;
6233
- })()
6629
+ }).immediate()
6234
6630
  );
6235
6631
  }
6236
6632
  var AGENT_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
@@ -6248,7 +6644,7 @@ async function sendToAgent(db, name, text, opts) {
6248
6644
  db,
6249
6645
  agent,
6250
6646
  opts.fresh ? "fresh" : opts.mode === "steer" ? "steer" : "plain",
6251
- text.length,
6647
+ Buffer.byteLength(text),
6252
6648
  {
6253
6649
  transport: sent.transport,
6254
6650
  command: sent.command,
@@ -6612,7 +7008,6 @@ async function spawnAgent(db, opts) {
6612
7008
  const workspacePathStr = opts.workspace ? await prestageWorkspace(db, opts, cli) : void 0;
6613
7009
  const ctlSocket = ctlSocketPath(opts.workstream, opts.name, dirname6(db.name));
6614
7010
  mkdirSync2(dirname6(ctlSocket), { recursive: true, mode: 448 });
6615
- rmSync4(ctlSocket, { force: true });
6616
7011
  const paneEnv = {
6617
7012
  MU_MANAGED_AGENT: "1",
6618
7013
  MU_AGENT_NAME: opts.name,
@@ -6622,12 +7017,18 @@ async function spawnAgent(db, opts) {
6622
7017
  const hasWorkspace = workspacePathStr !== void 0;
6623
7018
  let paneId;
6624
7019
  let agent;
7020
+ let ownsRow = hasWorkspace;
6625
7021
  const wantsCtl = opts.ctl !== false && speaksMuCtl(cli, command) && defaultSpawnCtlMs() !== 0;
6626
7022
  let ctl;
6627
7023
  try {
6628
7024
  const created = await withSpawnLock(session, async () => {
7025
+ if (!hasWorkspace && getAgent(db, opts.name, opts.workstream) !== void 0) {
7026
+ throw new AgentExistsError(opts.name);
7027
+ }
7028
+ rmSync4(ctlSocket, { force: true });
6629
7029
  const pid = await createOrReusePane({
6630
7030
  session,
7031
+ keepSessionAlive: isScratchWorkstream(opts.workstream),
6631
7032
  windowName,
6632
7033
  command,
6633
7034
  cwd: workspacePathStr ?? opts.cwd,
@@ -6638,6 +7039,7 @@ async function spawnAgent(db, opts) {
6638
7039
  await mux.setPaneTitle(pid, opts.name);
6639
7040
  await mux.enableMuPaneBordersForPane(pid);
6640
7041
  const row = finalizeAgentRow(db, { opts, cli, paneId: pid, hasWorkspace });
7042
+ ownsRow = true;
6641
7043
  return { pid, row };
6642
7044
  });
6643
7045
  paneId = created.pid;
@@ -6657,7 +7059,7 @@ async function spawnAgent(db, opts) {
6657
7059
  throw new AgentDiedOnSpawnError(opts.name, pid, lastScrollback, command, when);
6658
7060
  };
6659
7061
  ctl = await awaitCtlHandshake(ctlSocket, check ? assertPaneAlive : void 0);
6660
- if (check) await checkSpawnHealth(pid, opts.name, command, when);
7062
+ if (check) await checkSpawnHealth(pid, opts.name, command, when, ctl === "ok");
6661
7063
  } else {
6662
7064
  await awaitSpawnLiveness(paneId, opts.name, command);
6663
7065
  }
@@ -6665,7 +7067,7 @@ async function spawnAgent(db, opts) {
6665
7067
  await startAgent({ paneId, name: opts.name, cli, command, commandSource });
6666
7068
  }
6667
7069
  } catch (err) {
6668
- await rollbackSpawn(db, opts.name, paneId, hasWorkspace, opts.workstream);
7070
+ await rollbackSpawn(db, opts.name, paneId, hasWorkspace, ownsRow, opts.workstream);
6669
7071
  if (hasWorkspace) attachOrphanCleanupHint(err, opts.name, opts.workstream);
6670
7072
  throw err;
6671
7073
  }
@@ -6724,7 +7126,7 @@ function finalizeAgentRow(db, args) {
6724
7126
  if (!row) throw new Error(`spawnAgent: agent vanished after workspace stage: ${opts.name}`);
6725
7127
  return row;
6726
7128
  }
6727
- async function rollbackSpawn(db, name, paneId, hasWorkspace, workstream) {
7129
+ async function rollbackSpawn(db, name, paneId, hasWorkspace, ownsRow, workstream) {
6728
7130
  if (paneId !== void 0) {
6729
7131
  await activeMux().then((mux) => mux.killPane(paneId)).catch(() => {
6730
7132
  });
@@ -6733,7 +7135,7 @@ async function rollbackSpawn(db, name, paneId, hasWorkspace, workstream) {
6733
7135
  await freeWorkspace(db, name, { workstream }).catch(() => {
6734
7136
  });
6735
7137
  }
6736
- deleteAgent(db, name, workstream);
7138
+ if (ownsRow) deleteAgent(db, name, workstream);
6737
7139
  }
6738
7140
  function attachOrphanCleanupHint(err, agent, workstream) {
6739
7141
  if (typeof err !== "object" || err === null) return;
@@ -6758,42 +7160,24 @@ function defaultSpawnLivenessMs() {
6758
7160
  if (Number.isNaN(parsed) || parsed < 0) return 1500;
6759
7161
  return parsed;
6760
7162
  }
6761
- var STARTUP_ERROR_PATTERNS = [
7163
+ var AUTH_ERROR_PATTERNS = [
6762
7164
  /No API key found for [\w-]+/i,
6763
7165
  /Error: invalid API key/i,
6764
7166
  /Authentication failed/i,
6765
7167
  /401 Unauthorized/i,
6766
- /Could not authenticate/i,
6767
- // fb_agent_spawn_no_validation part B: post-spawn detection of the
6768
- // "binary not found at exec time" failure mode. The pre-flight check
6769
- // above catches the common typo BEFORE any side effect, but a few
6770
- // edge cases still slip through and only surface in the pane:
6771
- // - `--command "..."` skips the pre-flight (operator opt-out).
6772
- // - PATH inside the spawned shell differs from PATH in mu's
6773
- // process (login shell rc files, /etc/paths.d, etc.).
6774
- // - Race: binary on PATH at spawn time, gone 1.5s later.
6775
- //
6776
- // Anchored to lines that LOOK like a shell error rather than prose
6777
- // mentioning the marker (review_substrate_startup_err_patterns_too_broad):
6778
- // require the marker at end-of-line, optionally followed by a single
6779
- // `: <name>` token (the zsh form: `zsh: command not found: pi-meta`).
6780
- // This still trips the canonical cases —
6781
- // `bash: pi-meta: command not found`
6782
- // `zsh: command not found: pi-meta`
6783
- // `sh: pi-meta: No such file or directory`
6784
- // — but rejects banner prose like
6785
- // `I noticed earlier you saw 'command not found'`
6786
- // `type \`mu\` — command not found? install with …`
6787
- // that previously triggered a full spawn rollback. The pre-flight
6788
- // PATH check remains the deterministic safety net for typo'd `--cli`.
6789
- /^(?:.*: )?(?:command not found|No such file or directory)(?::\s*\S+)?$/i
7168
+ /Could not authenticate/i
6790
7169
  ];
7170
+ var EXEC_FAILURE_PATTERN = /^(?:.*: )?(?:command not found|No such file or directory)(?::\s*\S+)?$/i;
7171
+ function isExecFailureLine(line) {
7172
+ return EXEC_FAILURE_PATTERN.test(line);
7173
+ }
6791
7174
  var STARTUP_ERROR_TAIL_LINES = 30;
6792
- function detectSpawnStartupError(scrollback) {
7175
+ function detectSpawnStartupError(scrollback, cliStarted = false) {
7176
+ const patterns = cliStarted ? AUTH_ERROR_PATTERNS : [...AUTH_ERROR_PATTERNS, EXEC_FAILURE_PATTERN];
6793
7177
  const lines = scrollback.split(/\r?\n/);
6794
7178
  const tail = lines.slice(Math.max(0, lines.length - STARTUP_ERROR_TAIL_LINES));
6795
7179
  for (const line of tail) {
6796
- for (const pattern of STARTUP_ERROR_PATTERNS) {
7180
+ for (const pattern of patterns) {
6797
7181
  if (pattern.test(line)) return line;
6798
7182
  }
6799
7183
  }
@@ -6805,14 +7189,14 @@ async function awaitSpawnLiveness(paneId, agentName, command) {
6805
7189
  await sleep(ms);
6806
7190
  await checkSpawnHealth(paneId, agentName, command);
6807
7191
  }
6808
- async function checkSpawnHealth(paneId, agentName, command, when) {
7192
+ async function checkSpawnHealth(paneId, agentName, command, when, cliStarted = false) {
6809
7193
  const mux = await activeMux();
6810
7194
  const scrollback = await mux.capturePane(paneId, { lines: 50 }).catch(() => void 0);
6811
7195
  if (!await mux.paneExists(paneId)) {
6812
7196
  throw new AgentDiedOnSpawnError(agentName, paneId, scrollback, command, when);
6813
7197
  }
6814
7198
  if (scrollback !== void 0) {
6815
- const matchedLine = detectSpawnStartupError(scrollback);
7199
+ const matchedLine = detectSpawnStartupError(scrollback, cliStarted);
6816
7200
  if (matchedLine !== void 0) {
6817
7201
  throw new AgentSpawnStartupError(agentName, paneId, matchedLine, scrollback, when);
6818
7202
  }
@@ -6821,7 +7205,8 @@ async function checkSpawnHealth(paneId, agentName, command, when) {
6821
7205
  async function createOrReusePane(opts) {
6822
7206
  const mux = await activeMux();
6823
7207
  const command = mux.startAgentInPane === void 0 ? opts.command : "";
6824
- if (!await mux.sessionExists(opts.session)) {
7208
+ if (opts.keepSessionAlive) await ensureWorkstreamSession(opts.session);
7209
+ else if (!await mux.sessionExists(opts.session)) {
6825
7210
  return mux.newSessionWithPane(opts.session, {
6826
7211
  windowName: opts.windowName,
6827
7212
  command,
@@ -6833,12 +7218,17 @@ async function createOrReusePane(opts) {
6833
7218
  const matching = windows.find((w) => w.name === opts.windowName);
6834
7219
  if (matching) {
6835
7220
  return mux.splitWindow({
6836
- // tmux splits a WINDOW and accepts the `session:window` form;
7221
+ // tmux splits a WINDOW: target it as `=<session>:<window_id>`.
7222
+ // A bare `<session>:<name>` falls back to tmux's session prefix
7223
+ // match, so if `mu-auth` vanishes between listWindows and here,
7224
+ // `mu-auth:Backend` splits `mu-auth-refactor`'s Backend window.
7225
+ // `=` makes the session exact and the stable window id (from
7226
+ // the listing above) must live in that session, so a vanished
7227
+ // session or window fails instead of landing elsewhere.
6837
7228
  // herdr splits a PANE and has no window-target form at all. Ask
6838
7229
  // the backend for a concrete pane in that window when it needs
6839
- // one — cheaper than a `target` union type for two backends, and
6840
- // the tmux string stays exactly as it was.
6841
- target: mux.startAgentInPane === void 0 ? `${opts.session}:${opts.windowName}` : await firstPaneInWindow(mux, opts.session, matching.id),
7230
+ // one — cheaper than a `target` union type for two backends.
7231
+ target: mux.startAgentInPane === void 0 ? `=${opts.session}:${matching.id}` : await firstPaneInWindow(mux, opts.session, matching.id),
6842
7232
  command,
6843
7233
  cwd: opts.cwd,
6844
7234
  env: opts.env
@@ -6968,6 +7358,32 @@ var AgentNotInWorkstreamError = class extends Error {
6968
7358
  ];
6969
7359
  }
6970
7360
  };
7361
+ var PaneNotInSessionError = class extends Error {
7362
+ constructor(paneId, workstream, expectedSession) {
7363
+ super(
7364
+ `pane ${paneId} is not in session ${expectedSession}; adopt only takes panes from workstream ${workstream}'s own session`
7365
+ );
7366
+ this.paneId = paneId;
7367
+ this.workstream = workstream;
7368
+ this.expectedSession = expectedSession;
7369
+ }
7370
+ paneId;
7371
+ workstream;
7372
+ expectedSession;
7373
+ name = "PaneNotInSessionError";
7374
+ errorNextSteps() {
7375
+ return [
7376
+ {
7377
+ intent: "List the workstream's agents and orphan panes to pick one",
7378
+ command: `mu agent list -w ${this.workstream}`
7379
+ },
7380
+ {
7381
+ intent: "Or adopt it into the workstream whose session holds the pane",
7382
+ command: `mu agent adopt ${this.paneId} -w <workstream>`
7383
+ }
7384
+ ];
7385
+ }
7386
+ };
6971
7387
  var AgentDiedOnSpawnError = class extends Error {
6972
7388
  constructor(agentName, paneId, scrollback, command, when = `within ${defaultSpawnLivenessMs()}ms of spawn`) {
6973
7389
  const tail = scrollback?.trim();
@@ -7058,11 +7474,26 @@ ${scrollback.trim()}
7058
7474
  scrollback;
7059
7475
  name = "AgentSpawnStartupError";
7060
7476
  errorNextSteps() {
7477
+ const inspect = {
7478
+ intent: "Inspect the parked pane's scrollback for the full error",
7479
+ command: `mu agent read ${this.agentName} -n 100`
7480
+ };
7481
+ const disable = {
7482
+ intent: "Disable the startup-error scan if you actually wanted that prompt (CI / scripted recovery)",
7483
+ command: "export MU_SPAWN_LIVENESS_MS=0"
7484
+ };
7485
+ if (isExecFailureLine(this.matchedLine.trim())) {
7486
+ return [
7487
+ inspect,
7488
+ {
7489
+ intent: "Check the spawned command resolves in the pane's shell (PATH, typo)",
7490
+ command: `mu agent spawn ${this.agentName} --command "<full path to the CLI>"`
7491
+ },
7492
+ disable
7493
+ ];
7494
+ }
7061
7495
  return [
7062
- {
7063
- intent: "Inspect the parked pane's scrollback for the full error",
7064
- command: `mu agent read ${this.agentName} -n 100`
7065
- },
7496
+ inspect,
7066
7497
  {
7067
7498
  // Most common today: the operator picked a model whose
7068
7499
  // provider has no credentials in this env. Default Anthropic
@@ -7074,10 +7505,7 @@ ${scrollback.trim()}
7074
7505
  intent: "Or set the missing API key env var for the provider you wanted, then re-spawn",
7075
7506
  command: "export ANTHROPIC_API_KEY=... # or AWS_BEARER_TOKEN_BEDROCK, OPENAI_API_KEY, ..."
7076
7507
  },
7077
- {
7078
- intent: "Disable the startup-error scan if you actually wanted that prompt (CI / scripted recovery)",
7079
- command: "export MU_SPAWN_LIVENESS_MS=0"
7080
- }
7508
+ disable
7081
7509
  ];
7082
7510
  }
7083
7511
  };
@@ -7984,13 +8412,17 @@ function applyDel(db, op) {
7984
8412
  const { taskKey: taskKey2 } = parseNoteKey(op.key);
7985
8413
  const taskId = taskRowId(db, taskKey2);
7986
8414
  if (taskId === null) return { changed: false, appliedFields: [], skipped: "absent" };
7987
- const src = db.prepare(
7988
- `SELECT payload FROM ops
7989
- WHERE entity = 'note' AND key = @key AND op = 'put'
7990
- ORDER BY hlc DESC LIMIT 1`
7991
- ).get({ key: op.key });
7992
- if (!src) return { changed: false, appliedFields: [], skipped: "absent" };
7993
- const entries = decodePayload(src.payload);
8415
+ let entries = decodePayload(op.payload);
8416
+ if (!entries.some(([f, v]) => f === "content" && v !== null)) {
8417
+ const src = db.prepare(
8418
+ `SELECT payload FROM ops
8419
+ WHERE entity = 'note' AND key = @key AND op = 'put'
8420
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
8421
+ ORDER BY hlc DESC LIMIT 1`
8422
+ ).get({ key: op.key });
8423
+ if (!src) return { changed: false, appliedFields: [], skipped: "absent" };
8424
+ entries = decodePayload(src.payload);
8425
+ }
7994
8426
  const content = entries.find(([f]) => f === "content")?.[1] ?? "";
7995
8427
  const author = entries.find(([f]) => f === "author")?.[1] ?? null;
7996
8428
  const createdAt = entries.find(([f]) => f === "created_at")?.[1] ?? null;
@@ -8038,7 +8470,7 @@ function applyOp(db, op) {
8038
8470
  case "workstream":
8039
8471
  return applyWorkstreamPut(db, op);
8040
8472
  case "task":
8041
- return db.transaction(() => applyTaskPut(db, op))();
8473
+ return db.transaction(() => applyTaskPut(db, op)).immediate();
8042
8474
  case "note":
8043
8475
  return applyNotePut(db, op);
8044
8476
  case "edge":
@@ -8145,7 +8577,7 @@ function installOpKeyFunctions(db) {
8145
8577
  function applyOps(db, ops) {
8146
8578
  const ordered = [...ops].sort((a, b) => compareHlc(a.hlc, b.hlc));
8147
8579
  const run2 = db.transaction(() => ordered.map((op) => applyOp(db, op)));
8148
- return run2();
8580
+ return run2.immediate();
8149
8581
  }
8150
8582
 
8151
8583
  // src/dag.ts
@@ -8247,12 +8679,12 @@ import { delimiter as delimiter2, dirname as dirname10, join as join11, parse }
8247
8679
  import { fileURLToPath as fileURLToPath2 } from "url";
8248
8680
 
8249
8681
  // src/drift.ts
8250
- import { mkdtempSync, rmSync as rmSync5 } from "fs";
8682
+ import { mkdtempSync, rmSync as rmSync6 } from "fs";
8251
8683
  import { tmpdir } from "os";
8252
8684
  import { join as join9 } from "path";
8253
8685
 
8254
8686
  // src/rebuild.ts
8255
- import { existsSync as existsSync10, mkdirSync as mkdirSync3, statSync } from "fs";
8687
+ import { existsSync as existsSync10, mkdirSync as mkdirSync3, rmSync as rmSync5, statSync } from "fs";
8256
8688
  import { dirname as dirname8 } from "path";
8257
8689
  var RebuildTargetExistsError = class extends Error {
8258
8690
  constructor(path) {
@@ -8320,7 +8752,7 @@ function rebuildInto(source, opts) {
8320
8752
  const identity = source.prepare(
8321
8753
  "SELECT machine_id, hostname, created_at, last_wall, last_counter FROM machine_identity WHERE id = 1"
8322
8754
  ).get();
8323
- if (!identity) throw new Error("source DB has no machine_identity row; not a v9 mu DB");
8755
+ if (!identity) throw new Error("source DB has no machine_identity row; not a mu DB");
8324
8756
  const opRows = source.prepare(
8325
8757
  `SELECT hlc, machine_id, group_id, actor, intent, entity, key, op, payload, created_at
8326
8758
  FROM ops`
@@ -8332,6 +8764,9 @@ function rebuildInto(source, opts) {
8332
8764
  const row = source.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
8333
8765
  if (row.n > 0) machineLocalLost.push({ table, rows: row.n });
8334
8766
  }
8767
+ if (opts.force === true) {
8768
+ for (const suffix of ["", "-wal", "-shm"]) rmSync5(`${targetPath}${suffix}`, { force: true });
8769
+ }
8335
8770
  const target = openDb({ path: targetPath });
8336
8771
  try {
8337
8772
  target.prepare(
@@ -8389,7 +8824,7 @@ function rebuildInto(source, opts) {
8389
8824
  }
8390
8825
  });
8391
8826
  });
8392
- replay();
8827
+ replay.immediate();
8393
8828
  const rebuiltRows = {};
8394
8829
  for (const table of ["workstreams", "tasks", "task_edges", "task_notes", "ops"]) {
8395
8830
  const row = target.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
@@ -8602,7 +9037,7 @@ function checkDrift(db) {
8602
9037
  rebuilt.close();
8603
9038
  }
8604
9039
  } finally {
8605
- rmSync5(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 });
9040
+ rmSync6(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 });
8606
9041
  }
8607
9042
  }
8608
9043
  function formatDriftRecord(record) {
@@ -8659,6 +9094,16 @@ var DriftDetectedError = class extends Error {
8659
9094
  // src/fleet-hazards.ts
8660
9095
  import { existsSync as existsSync11, statfsSync } from "fs";
8661
9096
  import { resolve as resolve4, sep } from "path";
9097
+ var HazardFailError = class extends Error {
9098
+ constructor(failedRows) {
9099
+ super(
9100
+ `doctor: ${failedRows.length} row${failedRows.length === 1 ? "" : "s"} failed (${failedRows.join(", ")}); see the remediation above`
9101
+ );
9102
+ this.failedRows = failedRows;
9103
+ }
9104
+ failedRows;
9105
+ name = "HazardFailError";
9106
+ };
8662
9107
  function isPathInside(child, parent) {
8663
9108
  const c = resolve4(child);
8664
9109
  const p = resolve4(parent);
@@ -8809,10 +9254,10 @@ function checkCaseCollisions(db) {
8809
9254
  "",
8810
9255
  "Fix: rename one side to a distinct name before adding a Mac to the fleet.",
8811
9256
  "There is no in-place rename verb; back the DB up, re-init under the new",
8812
- "name, and destroy the old one:",
9257
+ "name, and tear down the old one:",
8813
9258
  " mu db backup <file>",
8814
9259
  " mu workstream init <new-name>",
8815
- " mu workstream teardown <old-name>"
9260
+ " mu workstream teardown <old-name> --yes"
8816
9261
  ]
8817
9262
  };
8818
9263
  }
@@ -8833,7 +9278,7 @@ import {
8833
9278
  readFileSync,
8834
9279
  readlinkSync,
8835
9280
  realpathSync,
8836
- rmSync as rmSync6,
9281
+ rmSync as rmSync7,
8837
9282
  symlinkSync,
8838
9283
  writeFileSync
8839
9284
  } from "fs";
@@ -8899,6 +9344,13 @@ function linkPi(opts) {
8899
9344
  if (!existsSync12(entry)) {
8900
9345
  throw new Error(`mu pi extension not built: ${entry} is missing (run npm run build)`);
8901
9346
  }
9347
+ if (lstatOrUndefined(path)?.isSymbolicLink() === true) {
9348
+ const current = resolve5(dirname9(path), readlinkSync(path));
9349
+ if (existsSync12(current) && opts?.force !== true) {
9350
+ throw new LinkConflictError(path, "foreign-symlink", current);
9351
+ }
9352
+ rmSync7(path);
9353
+ }
8902
9354
  let replacedCopy = false;
8903
9355
  try {
8904
9356
  replacedCopy = !readFileSync(path, "utf8").includes(MU_SHIM_MARKER);
@@ -8921,7 +9373,7 @@ function linkSkill(opts) {
8921
9373
  throw new LinkConflictError(path, "foreign-symlink", current);
8922
9374
  }
8923
9375
  previous = current;
8924
- rmSync6(path);
9376
+ rmSync7(path);
8925
9377
  }
8926
9378
  mkdirSync4(dirname9(path), { recursive: true });
8927
9379
  symlinkSync(target, path, "dir");
@@ -9122,7 +9574,7 @@ function loadDoctorSummary(db, snapshot2, agentStateSource = "murmur") {
9122
9574
  detail: `${cheapDrift.totalUnexplained} row(s) with no ops; run \`mu doctor --deep\``
9123
9575
  }
9124
9576
  );
9125
- return { checks, problemCount: countProblems(checks) };
9577
+ return { checks, problemCount: countProblems(checks), agentStateSource };
9126
9578
  }
9127
9579
  function resolveExecutable(name) {
9128
9580
  for (const dir of (process.env.PATH ?? "").split(delimiter2).filter(Boolean)) {
@@ -9243,8 +9695,8 @@ function countProblems(checks) {
9243
9695
  for (const c of checks) if (c.status !== "ok") n++;
9244
9696
  return n;
9245
9697
  }
9246
- function loadDoctorChecks(db, snapshot2) {
9247
- return loadDoctorSummary(db, snapshot2).checks;
9698
+ function loadDoctorChecks(db, snapshot2, agentStateSource = "murmur") {
9699
+ return loadDoctorSummary(db, snapshot2, agentStateSource).checks;
9248
9700
  }
9249
9701
  function yankCommandForCheck(check) {
9250
9702
  switch (check.name) {
@@ -9319,10 +9771,13 @@ function remediationParagraph(check) {
9319
9771
  ];
9320
9772
  case "workspaces":
9321
9773
  return [
9322
- "An 'orphan workspace dir' is a per-agent VCS workspace under",
9323
- "the workstream that has no matching mu agent row. Run",
9324
- "`mu workspace orphans -w <ws>` to list them, then",
9325
- "`mu workspace free <agent>` to release each one."
9774
+ "An 'orphan workspace dir' is a per-agent VCS workspace dir under",
9775
+ "the workstream with no vcs_workspaces row, so `mu workspace free`",
9776
+ "cannot remove it. mu never deletes it for you: it may hold the only",
9777
+ "copy of uncommitted work. Run `mu workspace orphans -w <ws>` to list",
9778
+ "them with a cleanup command, inspect each, then remove it by hand",
9779
+ "(`git worktree remove --force <path>` from the project root for a",
9780
+ "git checkout, else `rm -rf <path>`)."
9326
9781
  ];
9327
9782
  case "drift":
9328
9783
  return [
@@ -9363,18 +9818,20 @@ function remediationParagraph(check) {
9363
9818
  ];
9364
9819
  case "schema":
9365
9820
  return [
9366
- "Missing tables typically mean an older mu binary opened the",
9367
- "DB without running migrations. Rebuild mu (npm run build)",
9368
- "and re-open; openDb runs the migration block on every",
9369
- "process start."
9821
+ "Missing tables mean something dropped them after mu created",
9822
+ "them: every mu process runs CREATE TABLE IF NOT EXISTS for the",
9823
+ "whole schema at open, so re-running any mu command restores an",
9824
+ "empty table. If a table that held data is gone, back up the DB",
9825
+ "and rebuild from the ops log: `mu rebuild <new-file>`."
9370
9826
  ];
9371
9827
  case "schema_version":
9372
9828
  return [
9373
- "Schema version mismatch means the DB was opened by a",
9374
- "different mu binary than the one running now. If `<` the",
9375
- "expected version, openDb should have migrated \u2014 check the",
9376
- "build. If `>` the expected, you may have a downgrade in",
9377
- "progress; restore from a snapshot rather than continuing."
9829
+ "mu opens only a DB at exactly its own schema version: older",
9830
+ "DBs are refused with SchemaTooOldError and newer ones with",
9831
+ "SchemaTooNewError (exit 4), and there is no in-place migration.",
9832
+ "A mismatch seen here means the row changed after open (an",
9833
+ "external SQLite client). If `<` the expected version, migrate a",
9834
+ "copy with scripts/migrate.ts; if `>`, use the newer mu binary."
9378
9835
  ];
9379
9836
  case "journal_mode":
9380
9837
  return [
@@ -9445,6 +9902,7 @@ var VERBS = {
9445
9902
  "task.open": "task open",
9446
9903
  "task.park": "task park",
9447
9904
  "task.unpark": "task unpark",
9905
+ "task.accept": "task accept",
9448
9906
  "task.reject": "task reject",
9449
9907
  "task.defer": "task defer",
9450
9908
  "task.claim": "task claim",
@@ -9456,6 +9914,7 @@ var VERBS = {
9456
9914
  "workstream.init": "workstream init",
9457
9915
  "workstream.teardown": "workstream teardown",
9458
9916
  "workstream.destroy": "workstream teardown",
9917
+ "sql.write": "sql write",
9459
9918
  "agent.spawn": "agent spawn",
9460
9919
  "agent.close": "agent close",
9461
9920
  "agent.adopt": "agent adopt",
@@ -9539,7 +9998,8 @@ function renderKnown(row, intent) {
9539
9998
  };
9540
9999
  }
9541
10000
  case "task.park":
9542
- case "task.unpark": {
10001
+ case "task.unpark":
10002
+ case "task.accept": {
9543
10003
  const detail = str(bag, "substate") === void 0 ? "" : `\u2192 ${pairText(bag, "OPEN")}`;
9544
10004
  return { verb: VERBS[intent], subject, detail };
9545
10005
  }
@@ -9575,6 +10035,19 @@ function renderKnown(row, intent) {
9575
10035
  subject,
9576
10036
  detail: key.local === void 0 ? "" : "(cascaded)"
9577
10037
  };
10038
+ case "sql.write": {
10039
+ if (deleted) return { verb: VERBS[intent], subject, detail: `${row.kind} deleted` };
10040
+ const edge = key.to === void 0 ? "" : ` -> ${key.to}`;
10041
+ const changed = changedFields(bag).map((f) => {
10042
+ const v = bag[f];
10043
+ return typeof v === "object" && v !== null ? f : `${f}=${oneLine(String(v), 40)}`;
10044
+ }).join(" ");
10045
+ return {
10046
+ verb: VERBS[intent],
10047
+ subject,
10048
+ detail: `${row.kind}${edge} ${changed === "" ? "(touched)" : changed}`
10049
+ };
10050
+ }
9578
10051
  // Local intents: no trigger can see these, so `emitEvent` wrote the
9579
10052
  // payload as prose. It is already human-readable — show it as the
9580
10053
  // detail rather than inventing a second phrasing. The leading verb
@@ -9663,7 +10136,7 @@ function refuseUserSyncDirDuringTests(dir) {
9663
10136
  }
9664
10137
  function localMachineId(db) {
9665
10138
  const row = db.prepare("SELECT machine_id FROM machine_identity WHERE id = 1").get();
9666
- if (row === void 0) throw new Error("machine_identity row missing; not a v9 mu DB");
10139
+ if (row === void 0) throw new Error("machine_identity row missing; not a mu DB");
9667
10140
  return row.machine_id;
9668
10141
  }
9669
10142
  function segmentPath(dir, machineId2) {
@@ -9875,14 +10348,17 @@ function segmentLineCount(path) {
9875
10348
  }
9876
10349
  function readSegmentTail(path) {
9877
10350
  if (!existsSync14(path)) return { count: 0, lastHlc: null, goodLines: [], defect: null };
9878
- const raw = readFileSync3(path, "utf8");
9879
- const lines = raw.split("\n").filter((l) => l.trim() !== "");
10351
+ const lines = segmentLines(readFileSync3(path, "utf8"));
9880
10352
  let lastHlc = null;
9881
10353
  let count = 0;
9882
10354
  let defect = null;
9883
10355
  for (let index = 0; index < lines.length; index++) {
9884
10356
  const line = lines[index];
9885
10357
  if (line === void 0) break;
10358
+ if (line.trim() === "") {
10359
+ defect = { kind: "malformed-shape", line: index + 1, detail: "blank line" };
10360
+ break;
10361
+ }
9886
10362
  const decoded = decodeLine(line);
9887
10363
  if (!decoded.ok) {
9888
10364
  defect = { kind: decoded.kind, line: index + 1, detail: decoded.detail };
@@ -9893,6 +10369,11 @@ function readSegmentTail(path) {
9893
10369
  }
9894
10370
  return { count, lastHlc, goodLines: lines.slice(0, count), defect };
9895
10371
  }
10372
+ function segmentLines(raw) {
10373
+ const lines = raw.split("\n");
10374
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
10375
+ return lines;
10376
+ }
9896
10377
  function writeManifest(path, machine, count, lastHlc) {
9897
10378
  const bytes = existsSync14(path) ? readFileSync3(path) : Buffer.alloc(0);
9898
10379
  const manifest = {
@@ -9946,28 +10427,32 @@ function discoverPeers(dir, selfMachineId) {
9946
10427
  } catch {
9947
10428
  continue;
9948
10429
  }
9949
- peers.push({ machineId: machineId2, path, conflictCopy });
10430
+ peers.push({ machineId: machineId2, watermarkKey: conflictCopy ? stem : machineId2, path, conflictCopy });
9950
10431
  }
9951
10432
  return peers.sort((a, b) => a.path.localeCompare(b.path));
9952
10433
  }
9953
- function getWatermark(db, machineId2) {
9954
- const row = db.prepare("SELECT last_applied_seq AS n FROM sync_peers WHERE machine_id = ?").get(machineId2);
10434
+ function peerWatermarkKey(peer) {
10435
+ return peer.watermarkKey ?? peer.machineId;
10436
+ }
10437
+ function getWatermark(db, watermarkKey) {
10438
+ const row = db.prepare("SELECT last_applied_seq AS n FROM sync_peers WHERE machine_id = ?").get(watermarkKey);
9955
10439
  return row?.n ?? 0;
9956
10440
  }
9957
- function setWatermark(db, machineId2, value) {
10441
+ function setWatermark(db, watermarkKey, value) {
9958
10442
  db.prepare(
9959
10443
  `INSERT INTO sync_peers (machine_id, last_applied_seq, last_seen_at)
9960
10444
  VALUES (@machineId, @value, @seenAt)
9961
10445
  ON CONFLICT (machine_id) DO UPDATE
9962
10446
  SET last_applied_seq = @value, last_seen_at = @seenAt`
9963
- ).run({ machineId: machineId2, value, seenAt: (/* @__PURE__ */ new Date()).toISOString() });
10447
+ ).run({ machineId: watermarkKey, value, seenAt: (/* @__PURE__ */ new Date()).toISOString() });
9964
10448
  }
9965
- function resetWatermark(db, machineId2) {
9966
- setWatermark(db, machineId2, 0);
10449
+ function resetWatermark(db, watermarkKey) {
10450
+ setWatermark(db, watermarkKey, 0);
9967
10451
  }
9968
10452
  function ingestSegment(db, peer) {
9969
10453
  const defects = [];
9970
- const start = getWatermark(db, peer.machineId);
10454
+ const watermarkKey = peerWatermarkKey(peer);
10455
+ const start = getWatermark(db, watermarkKey);
9971
10456
  if (!existsSync14(peer.path)) {
9972
10457
  return {
9973
10458
  machineId: peer.machineId,
@@ -9984,9 +10469,7 @@ function ingestSegment(db, peer) {
9984
10469
  if (!verified.ok) {
9985
10470
  defects.push({ kind: "manifest-mismatch", line: 0, detail: verified.reason });
9986
10471
  }
9987
- const raw = readFileSync3(peer.path, "utf8");
9988
- const lines = raw.split("\n");
9989
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
10472
+ const lines = segmentLines(readFileSync3(peer.path, "utf8"));
9990
10473
  let applied = 0;
9991
10474
  let changed = 0;
9992
10475
  let watermark = start;
@@ -10001,14 +10484,14 @@ function ingestSegment(db, peer) {
10001
10484
  }
10002
10485
  const run2 = db.transaction(() => {
10003
10486
  for (let index = start; index < lines.length; index++) {
10004
- const raw2 = lines[index];
10487
+ const raw = lines[index];
10005
10488
  const lineNo = index + 1;
10006
- if (raw2 === void 0 || raw2.trim() === "") {
10489
+ if (raw === void 0 || raw.trim() === "") {
10007
10490
  defects.push({ kind: "malformed-shape", line: lineNo, detail: "blank line" });
10008
10491
  truncatedAt = lineNo;
10009
10492
  break;
10010
10493
  }
10011
- const decoded = decodeLine(raw2);
10494
+ const decoded = decodeLine(raw);
10012
10495
  if (!decoded.ok) {
10013
10496
  defects.push({ kind: decoded.kind, line: lineNo, detail: decoded.detail });
10014
10497
  truncatedAt = lineNo;
@@ -10043,6 +10526,11 @@ function ingestSegment(db, peer) {
10043
10526
  truncatedAt = lineNo;
10044
10527
  break;
10045
10528
  }
10529
+ if (isLegacyLogOnlyIntent(decoded.line.intent)) {
10530
+ previousHlc = op.hlc;
10531
+ watermark = lineNo;
10532
+ continue;
10533
+ }
10046
10534
  try {
10047
10535
  const result = applyIncomingOp(db, op);
10048
10536
  if (result.changed) changed += 1;
@@ -10063,13 +10551,13 @@ function ingestSegment(db, peer) {
10063
10551
  previousHlc = op.hlc;
10064
10552
  watermark = lineNo;
10065
10553
  }
10066
- setWatermark(db, peer.machineId, watermark);
10554
+ setWatermark(db, watermarkKey, watermark);
10067
10555
  });
10068
- run2();
10556
+ run2.immediate();
10069
10557
  return {
10070
10558
  machineId: peer.machineId,
10071
10559
  path: peer.path,
10072
- read: applied,
10560
+ read: watermark - start,
10073
10561
  applied,
10074
10562
  changed,
10075
10563
  watermark,
@@ -10282,7 +10770,7 @@ async function loadWorkstreamSnapshotFast(db, workstream, opts = {}) {
10282
10770
  }
10283
10771
  async function loadWorkstreamSnapshotSlow(db, workstream, opts = {}, baseSnapshot) {
10284
10772
  const view = await listLiveAgents(db, { workstream });
10285
- let workspaces = listWorkspaces2(db, workstream);
10773
+ let workspaces = await decorateWithStaleness(listWorkspaces2(db, workstream));
10286
10774
  if (opts.withDirty === true) workspaces = await decorateWithDirty(workspaces);
10287
10775
  const commits = await loadRecentCommits(opts.withRecentCommits);
10288
10776
  const slow = {
@@ -10315,12 +10803,8 @@ function mergeSnapshotFastSlow(fast, slow) {
10315
10803
  }
10316
10804
  async function loadWorkstreamSnapshot(db, workstream, opts = {}) {
10317
10805
  const fast = await loadWorkstreamSnapshotFast(db, workstream, opts);
10318
- const fastWithStaleness = {
10319
- ...fast,
10320
- workspaces: await decorateWithStaleness(fast.workspaces)
10321
- };
10322
- const slow = await loadWorkstreamSnapshotSlow(db, workstream, opts, fastWithStaleness);
10323
- return mergeSnapshotFastSlow(fastWithStaleness, slow);
10806
+ const slow = await loadWorkstreamSnapshotSlow(db, workstream, opts, fast);
10807
+ return mergeSnapshotFastSlow(fast, slow);
10324
10808
  }
10325
10809
  function emptyLiveAgentsView() {
10326
10810
  return {
@@ -10449,13 +10933,15 @@ function peerStatuses(db, dir) {
10449
10933
  lastSeenMs = null;
10450
10934
  }
10451
10935
  const total = segmentLineCount(peer.path);
10452
- const watermark = getWatermark(db, peer.machineId);
10936
+ const watermarkKey = peerWatermarkKey(peer);
10937
+ const watermark = getWatermark(db, watermarkKey);
10453
10938
  const ageMs = lastSeenMs === null ? null : Math.max(0, now - lastSeenMs);
10454
10939
  return {
10455
10940
  machineId: peer.machineId,
10456
10941
  short: peer.machineId.slice(0, PEER_SHORT_LEN),
10457
10942
  path: peer.path,
10458
10943
  conflictCopy: peer.conflictCopy,
10944
+ watermarkKey,
10459
10945
  watermark,
10460
10946
  total,
10461
10947
  behind: Math.max(0, total - watermark),
@@ -10467,27 +10953,31 @@ function peerStatuses(db, dir) {
10467
10953
  return rows.sort((a, b) => (b.lastSeenMs ?? 0) - (a.lastSeenMs ?? 0));
10468
10954
  }
10469
10955
  function resolvePeerRef(peers, ref) {
10470
- const exact = peers.find((p) => p.machineId === ref);
10471
- if (exact !== void 0) return exact;
10472
10956
  const matches = peers.filter((p) => p.machineId.startsWith(ref));
10473
- const first = matches[0];
10957
+ const exact = matches.filter((p) => p.machineId === ref);
10958
+ const candidates = exact.length > 0 ? exact : matches;
10959
+ const machines = [...new Set(candidates.map((p) => p.machineId))];
10960
+ const first = candidates.find((p) => !p.conflictCopy) ?? candidates[0];
10474
10961
  if (first === void 0) {
10475
10962
  throw new SyncPeerNotFoundError(
10476
10963
  ref,
10477
10964
  peers.map((p) => p.short)
10478
10965
  );
10479
10966
  }
10480
- if (matches.length > 1) {
10967
+ if (machines.length > 1) {
10481
10968
  throw new SyncPeerRefAmbiguousError(
10482
10969
  ref,
10483
- matches.map((p) => p.short)
10970
+ machines.map((m) => m.slice(0, PEER_SHORT_LEN))
10484
10971
  );
10485
10972
  }
10486
10973
  return first;
10487
10974
  }
10488
10975
  function repairPeer(db, ref, dir) {
10489
- const peer = resolvePeerRef(peerStatuses(db, dir), ref);
10490
- resetWatermark(db, peer.machineId);
10976
+ const peers = peerStatuses(db, dir);
10977
+ const peer = resolvePeerRef(peers, ref);
10978
+ for (const p of peers) {
10979
+ if (p.machineId === peer.machineId) resetWatermark(db, peerWatermarkKey(p));
10980
+ }
10491
10981
  return peer;
10492
10982
  }
10493
10983
  function syncEnabled() {
@@ -10503,7 +10993,15 @@ function describeDefects(peerShort, defects) {
10503
10993
  const detail = first === void 0 ? "unknown defect" : `${first.kind} at line ${first.line}`;
10504
10994
  const more = defects.length > 1 ? ` (+${defects.length - 1} more)` : "";
10505
10995
  const skipped = /* @__PURE__ */ new Set(["entity-not-synced", "duplicate-op"]);
10506
- const hint = defects.every((d) => skipped.has(d.kind)) ? " \u2014 skipped; the rest of the segment applied" : ` \u2014 re-read with \`mu sync --repair ${peerShort}\``;
10996
+ const fileWide = /* @__PURE__ */ new Set(["manifest-mismatch"]);
10997
+ let hint;
10998
+ if (defects.every((d) => skipped.has(d.kind))) {
10999
+ hint = " \u2014 skipped; the rest of the segment applied";
11000
+ } else if (defects.every((d) => skipped.has(d.kind) || fileWide.has(d.kind))) {
11001
+ hint = " \u2014 the file is shorter than its manifest; copy it from the peer again";
11002
+ } else {
11003
+ hint = ` \u2014 re-read with \`mu sync --repair ${peerShort}\``;
11004
+ }
10507
11005
  return `peer ${peerShort}: ${detail}${more}${hint}`;
10508
11006
  }
10509
11007
  async function ambientIngest(db, opts) {
@@ -10597,7 +11095,7 @@ function ingestFromDb(db, path) {
10597
11095
  if (result.changed) changed += 1;
10598
11096
  }
10599
11097
  });
10600
- run2();
11098
+ run2.immediate();
10601
11099
  changed += reprojectDeferredOps(db);
10602
11100
  return { path, read, changed, skippedLocal };
10603
11101
  } finally {
@@ -10680,7 +11178,7 @@ var ENTITY_RESTORE_ORDER = {
10680
11178
  function restoreRank(entity) {
10681
11179
  return ENTITY_RESTORE_ORDER[entity] ?? 3;
10682
11180
  }
10683
- function priorFieldValue(db, entity, key, hlc, field) {
11181
+ function priorFieldValue(db, entity, key, hlc, field, machineId2) {
10684
11182
  const row = db.prepare(
10685
11183
  `SELECT json_extract(payload, '$.' || @field) AS value
10686
11184
  FROM ops
@@ -10688,15 +11186,16 @@ function priorFieldValue(db, entity, key, hlc, field) {
10688
11186
  AND key = @key
10689
11187
  AND op = 'put'
10690
11188
  AND hlc < @hlc
11189
+ AND (@machineId IS NULL OR machine_id = @machineId)
10691
11190
  AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
10692
11191
  AND json_type(payload, '$.' || @field) IS NOT NULL
10693
11192
  ORDER BY hlc DESC
10694
11193
  LIMIT 1`
10695
- ).get({ entity, key, hlc, field });
11194
+ ).get({ entity, key, hlc, field, machineId: machineId2 ?? null });
10696
11195
  if (row === void 0) return { found: false };
10697
11196
  return { found: true, value: row.value };
10698
11197
  }
10699
- function laterWriters(db, entity, key, hlc, field, excludeGroup) {
11198
+ function laterWriters(db, entity, key, hlc, field, excludeGroup, machineId2) {
10700
11199
  return db.prepare(
10701
11200
  `SELECT DISTINCT group_id AS groupId, intent
10702
11201
  FROM ops
@@ -10705,9 +11204,10 @@ function laterWriters(db, entity, key, hlc, field, excludeGroup) {
10705
11204
  AND op = 'put'
10706
11205
  AND hlc > @hlc
10707
11206
  AND group_id <> @excludeGroup
11207
+ AND (@machineId IS NULL OR machine_id = @machineId)
10708
11208
  AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
10709
11209
  AND json_type(payload, '$.' || @field) IS NOT NULL`
10710
- ).all({ entity, key, hlc, field, excludeGroup });
11210
+ ).all({ entity, key, hlc, field, excludeGroup, machineId: machineId2 ?? null });
10711
11211
  }
10712
11212
  function laterRowWriters(db, entity, key, hlc, excludeGroup) {
10713
11213
  return db.prepare(
@@ -10721,6 +11221,15 @@ function laterRowWriters(db, entity, key, hlc, excludeGroup) {
10721
11221
  AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}`
10722
11222
  ).all({ entity, key, hlc, excludeGroup });
10723
11223
  }
11224
+ function isDeletedByLaterGroup(db, entity, key, groupId) {
11225
+ const row = db.prepare(
11226
+ `SELECT op, group_id AS groupId FROM ops
11227
+ WHERE entity = @entity AND key = @key
11228
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
11229
+ ORDER BY hlc DESC LIMIT 1`
11230
+ ).get({ entity, key });
11231
+ return row?.op === "del" && row.groupId !== groupId;
11232
+ }
10724
11233
  function laterDeleters(db, entity, key, hlc, excludeGroup) {
10725
11234
  return db.prepare(
10726
11235
  `SELECT DISTINCT group_id AS groupId, intent
@@ -10741,6 +11250,7 @@ function listRecentGroups(db, limit = 10) {
10741
11250
  MAX(actor) AS actor
10742
11251
  FROM ops
10743
11252
  WHERE entity IN ('workstream','task','note','edge')
11253
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
10744
11254
  GROUP BY group_id
10745
11255
  ORDER BY MAX(hlc) DESC
10746
11256
  LIMIT @limit`
@@ -10775,24 +11285,36 @@ var CAPTURED_TABLE_FOR = {
10775
11285
  note: "task_notes",
10776
11286
  edge: "task_edges"
10777
11287
  };
10778
- var NEVER_RESTORE = /* @__PURE__ */ new Set(["id", "local_id", "name", "workstream_id", "task_id", "owner_id"]);
11288
+ var NEVER_RESTORE = /* @__PURE__ */ new Set(["id", "local_id", "name", "workstream_id", "task_id"]);
11289
+ var OWNER_FIELD = "owner_id";
11290
+ var NEVER_RECONSTRUCT = /* @__PURE__ */ new Set([...NEVER_RESTORE, OWNER_FIELD]);
11291
+ var TOUCH_FIELD = "updated_at";
10779
11292
  function planUndo(db, groupId) {
10780
11293
  const rows = db.prepare(
10781
- `SELECT o.seq, o.hlc, o.group_id, o.intent, o.actor, o.entity, o.key, o.op, o.payload,
11294
+ `SELECT o.seq, o.hlc, o.group_id, o.machine_id, o.intent, o.actor, o.entity, o.key, o.op, o.payload,
10782
11295
  (SELECT COUNT(*) FROM ops p
10783
- WHERE p.entity = o.entity AND p.key = o.key AND p.hlc < o.hlc) AS prior,
11296
+ WHERE p.entity = o.entity AND p.key = o.key AND p.hlc < o.hlc
11297
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}) AS prior,
10784
11298
  (SELECT p.op FROM ops p
10785
11299
  WHERE p.entity = o.entity AND p.key = o.key AND p.hlc < o.hlc
11300
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
10786
11301
  ORDER BY p.hlc DESC LIMIT 1) AS priorKind
10787
11302
  FROM ops o
10788
11303
  WHERE o.group_id = @groupId
10789
11304
  AND o.entity IN ('workstream','task','note','edge')
11305
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
10790
11306
  ORDER BY o.hlc`
10791
11307
  ).all({ groupId });
10792
11308
  if (rows.length === 0) throw new UndoGroupNotFoundError(groupId);
10793
11309
  const inverses = [];
10794
11310
  let skipped = 0;
10795
11311
  const seenLegacyNoteTasks = /* @__PURE__ */ new Set();
11312
+ const restoredFields = /* @__PURE__ */ new Map();
11313
+ let machine;
11314
+ const localMachine = () => {
11315
+ machine ??= localMachineId(db);
11316
+ return machine;
11317
+ };
10796
11318
  for (const row of rows) {
10797
11319
  if (row.op === "del") {
10798
11320
  let fields3 = reconstructRow(db, row.entity, row.key, row.hlc);
@@ -10834,6 +11356,10 @@ function planUndo(db, groupId) {
10834
11356
  continue;
10835
11357
  }
10836
11358
  if (row.prior === 0 || row.priorKind === "del") {
11359
+ if (isDeletedByLaterGroup(db, row.entity, row.key, groupId)) {
11360
+ skipped += 1;
11361
+ continue;
11362
+ }
10837
11363
  const later = [...laterRowWriters(db, row.entity, row.key, row.hlc, groupId)];
10838
11364
  inverses.push({
10839
11365
  entity: row.entity,
@@ -10848,14 +11374,26 @@ function planUndo(db, groupId) {
10848
11374
  const payload = JSON.parse(row.payload);
10849
11375
  const fields2 = {};
10850
11376
  const supersededBy = [];
11377
+ const rowKey = `${row.entity}\0${row.key}`;
11378
+ const restored = restoredFields.get(rowKey) ?? /* @__PURE__ */ new Set();
11379
+ restoredFields.set(rowKey, restored);
10851
11380
  for (const field of Object.keys(payload)) {
10852
- if (NEVER_RESTORE.has(field)) continue;
10853
- const prior = priorFieldValue(db, row.entity, row.key, row.hlc, field);
11381
+ if (NEVER_RESTORE.has(field) || restored.has(field)) continue;
11382
+ restored.add(field);
11383
+ let machineId2;
11384
+ if (field === OWNER_FIELD) {
11385
+ if (row.machine_id !== localMachine()) continue;
11386
+ machineId2 = localMachine();
11387
+ }
11388
+ const prior = field === OWNER_FIELD ? priorOwner(db, row.key, row.hlc, localMachine()) : priorFieldValue(db, row.entity, row.key, row.hlc, field, machineId2);
10854
11389
  if (!prior.found) {
10855
11390
  continue;
10856
11391
  }
11392
+ if (field === OWNER_FIELD && prior.value !== null && !agentExists(db, prior.value)) continue;
11393
+ const writers = laterWriters(db, row.entity, row.key, row.hlc, field, groupId, machineId2);
11394
+ if (field === TOUCH_FIELD && writers.length > 0) continue;
10857
11395
  fields2[field] = prior.value;
10858
- for (const writer of laterWriters(db, row.entity, row.key, row.hlc, field, groupId)) {
11396
+ for (const writer of writers) {
10859
11397
  supersededBy.push({ field, groupId: writer.groupId, intent: writer.intent });
10860
11398
  }
10861
11399
  }
@@ -10890,6 +11428,27 @@ function planUndo(db, groupId) {
10890
11428
  skipped
10891
11429
  };
10892
11430
  }
11431
+ function priorOwner(db, key, hlc, machineId2) {
11432
+ const local = priorFieldValue(db, "task", key, hlc, OWNER_FIELD, machineId2);
11433
+ if (!local.found) return { found: true, value: null };
11434
+ const tomb = db.prepare(
11435
+ `SELECT 1 FROM ops d
11436
+ WHERE d.entity = 'task' AND d.key = @key AND d.op = 'del' AND d.hlc < @hlc
11437
+ AND d.hlc > (SELECT MAX(hlc) FROM ops
11438
+ WHERE entity = 'task' AND key = @key AND op = 'put'
11439
+ AND hlc < @hlc AND machine_id = @machineId
11440
+ AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}
11441
+ AND json_type(payload, '$.owner_id') IS NOT NULL)
11442
+ AND NOT EXISTS (SELECT 1 FROM ops p
11443
+ WHERE p.entity = 'task' AND p.key = @key AND p.op = 'put'
11444
+ AND p.hlc > d.hlc AND p.seq < d.seq)
11445
+ LIMIT 1`
11446
+ ).get({ key, hlc, machineId: machineId2 });
11447
+ return tomb === void 0 ? local : { found: true, value: null };
11448
+ }
11449
+ function agentExists(db, id) {
11450
+ return db.prepare("SELECT 1 FROM agents WHERE id = ?").get(id) !== void 0;
11451
+ }
10893
11452
  function groupWhen(db, groupId) {
10894
11453
  const row = db.prepare("SELECT MIN(created_at) AS when_ FROM ops WHERE group_id = ?").get(groupId);
10895
11454
  return row?.when_ ?? "";
@@ -10924,7 +11483,7 @@ function payloadFields(payload) {
10924
11483
  const fields2 = {};
10925
11484
  const parsed = JSON.parse(payload);
10926
11485
  for (const [field, value] of Object.entries(parsed)) {
10927
- if (NEVER_RESTORE.has(field)) continue;
11486
+ if (NEVER_RECONSTRUCT.has(field)) continue;
10928
11487
  if (value === null || typeof value === "string" || typeof value === "number") {
10929
11488
  fields2[field] = value;
10930
11489
  }
@@ -10942,7 +11501,7 @@ function reconstructRow(db, entity, key, beforeHlc) {
10942
11501
  for (const put of puts) {
10943
11502
  const parsed = JSON.parse(put.payload);
10944
11503
  for (const [field, value] of Object.entries(parsed)) {
10945
- if (NEVER_RESTORE.has(field)) continue;
11504
+ if (NEVER_RECONSTRUCT.has(field)) continue;
10946
11505
  if (value === null || typeof value === "string" || typeof value === "number") {
10947
11506
  fields2[field] = value;
10948
11507
  }
@@ -10973,8 +11532,8 @@ function undoGroup(db, groupId, opts = {}) {
10973
11532
  }
10974
11533
  );
10975
11534
  });
10976
- run2();
10977
- return { plan, undoGroupId, applied };
11535
+ run2.immediate();
11536
+ return { plan, undoGroupId: applied > 0 && undoGroupId !== "" ? undoGroupId : null, applied };
10978
11537
  }
10979
11538
  function applyInverse(db, inverse) {
10980
11539
  const table = CAPTURED_TABLE_FOR[inverse.entity];
@@ -11183,6 +11742,7 @@ export {
11183
11742
  AgentCtlUnreachableError,
11184
11743
  AgentDiedOnSpawnError,
11185
11744
  AgentExistsError,
11745
+ AgentExtensionOutdatedError,
11186
11746
  AgentFreshNeedsCtlError,
11187
11747
  AgentNotFoundError,
11188
11748
  AgentNotInWorkstreamError,
@@ -11199,6 +11759,8 @@ export {
11199
11759
  DriftDetectedError,
11200
11760
  EXPECTED_TABLES,
11201
11761
  GLYPH,
11762
+ GroupIdAmbiguousError,
11763
+ HazardFailError,
11202
11764
  HlcOverflowError,
11203
11765
  HlcParseError,
11204
11766
  HomeDirAsProjectRootError,
@@ -11206,6 +11768,7 @@ export {
11206
11768
  KNOWN_INTENTS,
11207
11769
  KNOWN_VERBS,
11208
11770
  LinkConflictError,
11771
+ LogKindReservedError,
11209
11772
  MACHINE_LOCAL_ENTITIES,
11210
11773
  MACHINE_LOCAL_TABLES,
11211
11774
  MU_SHIM_MARKER,
@@ -11222,11 +11785,15 @@ export {
11222
11785
  PEER_STALE_MS,
11223
11786
  PORTABLE_TABLES,
11224
11787
  PaneNotFoundError,
11788
+ PaneNotInSessionError,
11789
+ ReaperDetectedDuringWaitError,
11225
11790
  RebuildTargetExistsError,
11226
11791
  RebuildTargetIsSourceError,
11227
11792
  SEGMENT_FORMAT_VERSION,
11228
11793
  SYNCED_ENTITIES,
11794
+ SchemaTooNewError,
11229
11795
  SchemaTooOldError,
11796
+ StallDetectedDuringWaitError,
11230
11797
  SubstateReasonRequiredError,
11231
11798
  SyncPeerNotFoundError,
11232
11799
  SyncPeerRefAmbiguousError,
@@ -11236,23 +11803,31 @@ export {
11236
11803
  TASK_SUBSTATES,
11237
11804
  TASK_SUBSTATE_ROWS,
11238
11805
  TaskAlreadyOwnedError,
11806
+ TaskClaimStaleWorkspaceError,
11239
11807
  TaskExistsError,
11808
+ TaskIdInvalidError,
11240
11809
  TaskInTriageError,
11241
11810
  TaskNotFoundError,
11242
11811
  TaskNotInWorkstreamError,
11243
11812
  TaskParkStateError,
11244
11813
  TaskParkedError,
11814
+ TaskTitleSlugEmptyError,
11245
11815
  TmuxError,
11246
11816
  UNKNOWN_REASON,
11247
11817
  UndoGroupNotFoundError,
11248
11818
  UndoSupersededError,
11249
11819
  WORKSPACE_STALE_THRESHOLD,
11820
+ WorkspaceConflictError,
11821
+ WorkspaceDirtyError,
11250
11822
  WorkspaceExistsError,
11251
11823
  WorkspaceNotFoundError,
11252
11824
  WorkspacePathNotEmptyError,
11253
11825
  WorkspacePreservedError,
11826
+ WorkspaceVcsRequiredError,
11254
11827
  WorkstreamExistsError,
11255
11828
  WorkstreamNameInvalidError,
11829
+ WorkstreamNameReservedError,
11830
+ WorkstreamNotFoundError,
11256
11831
  abortAgent,
11257
11832
  acceptTask,
11258
11833
  activeMux,