@martintrojer/mu 0.3.1 → 0.3.2

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/cli.js CHANGED
@@ -530,6 +530,18 @@ function lastClaimActor(db, workstream, localId) {
530
530
  if (!row) return null;
531
531
  return parseClaimEventActor(row.payload);
532
532
  }
533
+ function lastClaimEventAt(db, workstream, localId) {
534
+ const escaped = localId.replace(/[\\%_]/g, (c) => `\\${c}`);
535
+ const pattern = `${CLAIM_EVENT_PREFIX} ${escaped} %`;
536
+ const wsId = tryResolveWorkstreamId(db, workstream);
537
+ if (wsId === null) return null;
538
+ const row = db.prepare(
539
+ `SELECT created_at FROM agent_logs
540
+ WHERE workstream_id = ? AND kind = 'event' AND payload LIKE ? ESCAPE '\\'
541
+ ORDER BY seq DESC LIMIT 1`
542
+ ).get(wsId, pattern);
543
+ return row ? row.created_at : null;
544
+ }
533
545
  var EVENT_VERB_PREFIXES = [
534
546
  // src/tasks.ts + src/tasks/*.ts
535
547
  "task add",
@@ -552,6 +564,7 @@ var EVENT_VERB_PREFIXES = [
552
564
  "agent close",
553
565
  "agent free",
554
566
  "agent adopt",
567
+ "agent kick",
555
568
  // src/tasks/wait.ts — emitted when --stuck-after fires (alive +
556
569
  // assigned + no recent progress; idle_assigned_agent_detection).
557
570
  "agent stalled",
@@ -559,6 +572,7 @@ var EVENT_VERB_PREFIXES = [
559
572
  "workspace create",
560
573
  "workspace free",
561
574
  "workspace refresh",
575
+ "workspace recreate",
562
576
  // src/workstream.ts
563
577
  "workstream init",
564
578
  "workstream destroy",
@@ -892,6 +906,24 @@ async function enableMuPaneBorders(target) {
892
906
  await tmux(["set-option", "-w", "-t", target, "pane-active-border-style", "fg=cyan,bold"]);
893
907
  await tmux(["set-option", "-w", "-t", target, "pane-border-style", "fg=brightblack"]);
894
908
  }
909
+ async function paneTTY(paneId) {
910
+ assertValidPaneId(paneId);
911
+ const result = await currentExecutor(["display-message", "-t", paneId, "-p", "#{pane_tty}"]);
912
+ if (result.exitCode !== 0) {
913
+ if (/can't find pane|pane not found/i.test(result.stderr)) {
914
+ throw new PaneNotFoundError(paneId);
915
+ }
916
+ throw new TmuxError(
917
+ ["display-message", "-t", paneId, "-p", "#{pane_tty}"],
918
+ result.stderr,
919
+ result.stdout,
920
+ result.exitCode
921
+ );
922
+ }
923
+ const tty = result.stdout.trim();
924
+ if (tty === "") throw new PaneNotFoundError(paneId);
925
+ return tty;
926
+ }
895
927
  async function getPaneTitle(paneId) {
896
928
  if (!isValidPaneId(paneId)) return void 0;
897
929
  const result = await currentExecutor(["display-message", "-t", paneId, "-p", "#{pane_title}"]);
@@ -1559,7 +1591,7 @@ var ClaimerNotRegisteredError = class extends Error {
1559
1591
  * Three actionable resolutions in expected-frequency order:
1560
1592
  * 1. --self : orchestrator pattern (working directly)
1561
1593
  * 2. --for : dispatcher pattern (assigning to a worker)
1562
- * 3. mu adopt: registration pattern (promote pane to worker)
1594
+ * 3. mu agent adopt: registration pattern (promote pane to worker)
1563
1595
  */
1564
1596
  errorNextSteps() {
1565
1597
  const steps = [
@@ -1567,9 +1599,9 @@ var ClaimerNotRegisteredError = class extends Error {
1567
1599
  { intent: "Dispatch to a worker", command: "mu task claim <id> --for <worker>" }
1568
1600
  ];
1569
1601
  steps.push(
1570
- this.paneId !== null ? { intent: "Register this pane", command: `mu adopt ${this.paneId}` } : {
1602
+ this.paneId !== null ? { intent: "Register this pane", command: `mu agent adopt ${this.paneId}` } : {
1571
1603
  intent: "Register a pane",
1572
- command: "mu adopt <pane-id> (must be in mu-<workstream> tmux session)"
1604
+ command: "mu agent adopt <pane-id> (must be in mu-<workstream> tmux session)"
1573
1605
  }
1574
1606
  );
1575
1607
  return steps;
@@ -2170,16 +2202,6 @@ function listArchivedTasks(db, label, opts = {}) {
2170
2202
  }
2171
2203
 
2172
2204
  // src/exporting.ts
2173
- var LegacyExportLayoutError = class extends Error {
2174
- constructor(outDir) {
2175
- super(
2176
- `${outDir} was created with a pre-bucket export (mu < 0.3); the on-disk shape changed in 0.3 (top-level README/INDEX/manifest + per-source-ws subdirs). Re-export is not in-place; either pick a different --out or 'rm -rf ${outDir}' and re-run.`
2177
- );
2178
- this.outDir = outDir;
2179
- }
2180
- outDir;
2181
- name = "LegacyExportLayoutError";
2182
- };
2183
2205
  function fenceForBody(body) {
2184
2206
  const longestRun = (body.match(/`+/g) ?? []).reduce((m, s) => Math.max(m, s.length), 0);
2185
2207
  return "`".repeat(Math.max(3, longestRun + 1));
@@ -2350,9 +2372,6 @@ function readManifest(path) {
2350
2372
  if (obj.bucketVersion === 2 && typeof obj.sources === "object" && obj.sources !== null) {
2351
2373
  return { kind: "v2", manifest: obj };
2352
2374
  }
2353
- if (typeof obj.workstream === "string" && Array.isArray(obj.tasks)) {
2354
- return { kind: "legacy" };
2355
- }
2356
2375
  return { kind: "corrupt" };
2357
2376
  }
2358
2377
  function sha256Hex(content) {
@@ -2380,9 +2399,6 @@ function renderToBucket(input) {
2380
2399
  }
2381
2400
  const manifestPath = join3(outDir, "manifest.json");
2382
2401
  const probe = readManifest(manifestPath);
2383
- if (probe.kind === "legacy") {
2384
- throw new LegacyExportLayoutError(outDir);
2385
- }
2386
2402
  const now = (/* @__PURE__ */ new Date()).toISOString();
2387
2403
  const muVersion = readMuVersion();
2388
2404
  const previous = probe.kind === "v2" ? probe.manifest : void 0;
@@ -2556,7 +2572,6 @@ function exportSourcesForArchive(db, label) {
2556
2572
  for (const [sourceName, taskList] of bySource) {
2557
2573
  const tasks = taskList.map((t) => ({
2558
2574
  name: t.originalLocalId,
2559
- localId: t.originalLocalId,
2560
2575
  workstreamName: t.sourceWorkstream,
2561
2576
  title: t.title,
2562
2577
  // Status as snapshotted; cast through the TaskStatus union by
@@ -2634,24 +2649,30 @@ var WorkspaceVcsRequiredError = class extends Error {
2634
2649
  }
2635
2650
  };
2636
2651
  var WorkspaceDirtyError = class extends Error {
2637
- constructor(workspacePath2, files) {
2652
+ constructor(workspacePath2, files, verb = "rebase") {
2638
2653
  super(
2639
- `workspace dirty (${files.length} uncommitted file(s)): ${workspacePath2}; refusing to rebase`
2654
+ `workspace dirty (${files.length} uncommitted file(s)): ${workspacePath2}; refusing to ${verb}`
2640
2655
  );
2641
2656
  this.workspacePath = workspacePath2;
2642
2657
  this.files = files;
2658
+ this.verb = verb;
2643
2659
  }
2644
2660
  workspacePath;
2645
2661
  files;
2646
2662
  name = "WorkspaceDirtyError";
2663
+ /** The verb that refused ("rebase", "recreate", ...). Used to make
2664
+ * the error message + nextSteps point the operator at the right
2665
+ * escape hatch (e.g. recreate's `--force`). Default "rebase" for
2666
+ * backward compatibility with the original rebaseTo call sites. */
2667
+ verb;
2647
2668
  errorNextSteps() {
2648
- return [
2669
+ const steps = [
2649
2670
  {
2650
2671
  intent: "Inspect the dirty files",
2651
2672
  command: `(cd ${this.workspacePath} && git status -s) # or jj st / sl st`
2652
2673
  },
2653
2674
  {
2654
- intent: "Commit them first, then retry refresh",
2675
+ intent: `Commit them first, then retry ${this.verb}`,
2655
2676
  command: `(cd ${this.workspacePath} && git add -A && git commit -m WIP)`
2656
2677
  },
2657
2678
  {
@@ -2659,6 +2680,13 @@ var WorkspaceDirtyError = class extends Error {
2659
2680
  command: `(cd ${this.workspacePath} && git stash)`
2660
2681
  }
2661
2682
  ];
2683
+ if (this.verb === "recreate") {
2684
+ steps.push({
2685
+ intent: "Or DISCARD all uncommitted changes (the lossy escape)",
2686
+ command: "mu workspace recreate <agent> --force"
2687
+ });
2688
+ }
2689
+ return steps;
2662
2690
  }
2663
2691
  };
2664
2692
  var WorkspaceConflictError = class extends Error {
@@ -2727,6 +2755,13 @@ var noneBackend = {
2727
2755
  async commitsBehind(_workspacePath, _ref) {
2728
2756
  return null;
2729
2757
  },
2758
+ // none has no notion of clean — a cp -a snapshot doesn't track
2759
+ // committed vs uncommitted state. Returning true makes the
2760
+ // close-auto-free path silently free a none-workspace (consistent
2761
+ // with the fact that there are no commits to lose).
2762
+ async isClean(_workspacePath) {
2763
+ return true;
2764
+ },
2730
2765
  // none has no upstream to rebase onto. Throw a typed error so the
2731
2766
  // CLI's handle() maps it to exit 4 with a clean Next: hint.
2732
2767
  async rebaseTo(workspacePath2, _fromRef) {
@@ -2736,6 +2771,11 @@ var noneBackend = {
2736
2771
  // doesn't track history. Same typed error as rebaseTo.
2737
2772
  async commitsSinceBase(workspacePath2, _baseRef) {
2738
2773
  throw new WorkspaceVcsRequiredError("commits", workspacePath2);
2774
+ },
2775
+ // No VCS → nothing to compare against; "dirty" is unanswerable.
2776
+ // Caller (`recreateWorkspace`) treats [] as "clean" and proceeds.
2777
+ async listDirtyFiles(_workspacePath) {
2778
+ return [];
2739
2779
  }
2740
2780
  };
2741
2781
  var gitBackend = {
@@ -2756,6 +2796,20 @@ var gitBackend = {
2756
2796
  const sha = await run("git", ["rev-parse", "HEAD"], opts.workspacePath);
2757
2797
  return { parentRef: sha };
2758
2798
  },
2799
+ // Working-copy clean check: empty `git status --porcelain` output
2800
+ // means no working-tree, staged, or untracked-not-ignored changes.
2801
+ // Returns false on any failure (workspace path missing, git
2802
+ // explodes) — be conservative; auto-free should never "silently
2803
+ // succeed" because we couldn't check.
2804
+ async isClean(workspacePath2) {
2805
+ if (!existsSync3(workspacePath2)) return false;
2806
+ try {
2807
+ const files = await listGitDirtyFiles(workspacePath2);
2808
+ return files.length === 0;
2809
+ } catch {
2810
+ return false;
2811
+ }
2812
+ },
2759
2813
  // Compute commits-behind as: count of commits reachable from main
2760
2814
  // but not from `ref`. Resolves "main" via origin/HEAD (the symbolic
2761
2815
  // ref the remote advertises), falling back to origin/main and then
@@ -2898,6 +2952,10 @@ var gitBackend = {
2898
2952
  const result = { removed: true };
2899
2953
  if (committedRef !== void 0) result.committedRef = committedRef;
2900
2954
  return result;
2955
+ },
2956
+ async listDirtyFiles(workspacePath2) {
2957
+ if (!existsSync3(workspacePath2)) return [];
2958
+ return listGitDirtyFiles(workspacePath2);
2901
2959
  }
2902
2960
  };
2903
2961
  async function resolveGitMainRef(workspacePath2) {
@@ -3006,6 +3064,23 @@ var jjBackend = {
3006
3064
  if (committedRef !== void 0) result.committedRef = committedRef;
3007
3065
  return result;
3008
3066
  },
3067
+ // jj working-copy clean: @ has no diff from its parent.
3068
+ // `jj diff -r @ --summary` prints one line per changed file; empty
3069
+ // stdout = clean. jj's auto-snapshotting means there's no separate
3070
+ // "untracked" bucket — every working-tree change is already in @.
3071
+ async isClean(workspacePath2) {
3072
+ if (!existsSync3(workspacePath2)) return false;
3073
+ try {
3074
+ const out = await run(
3075
+ "jj",
3076
+ ["diff", "-r", "@", "--summary", "--no-pager", "--color", "never"],
3077
+ workspacePath2
3078
+ );
3079
+ return out.length === 0;
3080
+ } catch {
3081
+ return false;
3082
+ }
3083
+ },
3009
3084
  // Compute commits-behind via jj's `trunk()` revset, which resolves
3010
3085
  // to the project's configured trunk (default-branch heuristic).
3011
3086
  // Returns null when trunk() is unresolvable (e.g. fresh repo with
@@ -3126,6 +3201,13 @@ var jjBackend = {
3126
3201
  workspacePath2
3127
3202
  );
3128
3203
  return parseNulRecords(out);
3204
+ },
3205
+ // jj is always-snapshotted: there is no "uncommitted" state. The
3206
+ // working copy is itself a commit; the next snapshot folds any
3207
+ // edits in. Surface that by returning [] so `recreateWorkspace`
3208
+ // never refuses a jj workspace as "dirty".
3209
+ async listDirtyFiles(_workspacePath) {
3210
+ return [];
3129
3211
  }
3130
3212
  };
3131
3213
  function parseNulRecords(raw) {
@@ -3197,6 +3279,18 @@ var slBackend = {
3197
3279
  if (committedRef !== void 0) result.committedRef = committedRef;
3198
3280
  return result;
3199
3281
  },
3282
+ // sl working-copy clean: empty `sl status` output. Same shape as
3283
+ // listSlDirtyFiles below but inlined to keep the failure-mode
3284
+ // boundary tight (any throw → not clean).
3285
+ async isClean(workspacePath2) {
3286
+ if (!existsSync3(workspacePath2)) return false;
3287
+ try {
3288
+ const out = await run("sl", ["status"], workspacePath2);
3289
+ return out.length === 0;
3290
+ } catch {
3291
+ return false;
3292
+ }
3293
+ },
3200
3294
  // Same shape as the jj impl: count commits in trunk() not reachable
3201
3295
  // from ref. Sapling's revset language is close enough to jj's that
3202
3296
  // the same idiom works. Returns null when trunk() is unresolvable
@@ -3273,6 +3367,10 @@ var slBackend = {
3273
3367
  workspacePath2
3274
3368
  );
3275
3369
  return parseNulRecords(out).reverse();
3370
+ },
3371
+ async listDirtyFiles(workspacePath2) {
3372
+ if (!existsSync3(workspacePath2)) return [];
3373
+ return listSlDirtyFiles(workspacePath2);
3276
3374
  }
3277
3375
  };
3278
3376
  async function listSlDirtyFiles(workspacePath2) {
@@ -3315,6 +3413,10 @@ import { existsSync as existsSync4, readdirSync, rmSync as rmSync2 } from "fs";
3315
3413
  import { homedir as homedir2 } from "os";
3316
3414
  import { join as join5, resolve as resolve2 } from "path";
3317
3415
 
3416
+ // src/agents/spawn.ts
3417
+ import { execFile as execFile2 } from "child_process";
3418
+ import { promisify as promisify2 } from "util";
3419
+
3318
3420
  // src/output.ts
3319
3421
  import Table from "cli-table3";
3320
3422
  import picocolors from "picocolors";
@@ -3397,10 +3499,49 @@ function maybeWarnNonConventionalAgentName(name) {
3397
3499
  );
3398
3500
  }
3399
3501
  function resolveCliCommand(cli) {
3400
- const envName = `MU_${cli.toUpperCase()}_COMMAND`;
3502
+ const envName = envVarNameForCli(cli);
3401
3503
  const override = process.env[envName];
3402
3504
  return override && override.trim() !== "" ? override : cli;
3403
3505
  }
3506
+ function envVarNameForCli(cli) {
3507
+ return `MU_${cli.toUpperCase().replace(/-/g, "_")}_COMMAND`;
3508
+ }
3509
+ function resolveCliCommandWithSource(cli) {
3510
+ const envVar = envVarNameForCli(cli);
3511
+ const override = process.env[envVar];
3512
+ if (override !== void 0 && override.trim() !== "") {
3513
+ return { command: override, envVar, resolvedFromEnv: true };
3514
+ }
3515
+ return { command: cli, envVar, resolvedFromEnv: false };
3516
+ }
3517
+ var execFileP = promisify2(execFile2);
3518
+ async function defaultCommandResolver(command) {
3519
+ const binary = parseFirstToken(command);
3520
+ if (binary === "") return { ok: false, binary };
3521
+ try {
3522
+ const { stdout } = await execFileP("/bin/sh", ["-c", `command -v -- ${shellQuote(binary)}`], {
3523
+ env: process.env
3524
+ });
3525
+ const resolvedPath = stdout.trim();
3526
+ if (resolvedPath === "") return { ok: false, binary };
3527
+ return { ok: true, binary, resolvedPath };
3528
+ } catch {
3529
+ return { ok: false, binary };
3530
+ }
3531
+ }
3532
+ function shellQuote(s) {
3533
+ return `'${s.replace(/'/g, "'\\''")}'`;
3534
+ }
3535
+ function parseFirstToken(command) {
3536
+ const trimmed = command.trim();
3537
+ if (trimmed === "") return "";
3538
+ const match = trimmed.match(/^\S+/);
3539
+ return match ? match[0] : "";
3540
+ }
3541
+ var activeCommandResolver = defaultCommandResolver;
3542
+ async function checkCommandResolvable(command) {
3543
+ return activeCommandResolver(command);
3544
+ }
3404
3545
  async function spawnAgent(db, opts) {
3405
3546
  if (!isValidAgentName(opts.name)) {
3406
3547
  throw new TypeError(
@@ -3414,33 +3555,36 @@ async function spawnAgent(db, opts) {
3414
3555
  const windowName = opts.tab ?? opts.name;
3415
3556
  const cli = opts.cli ?? "pi";
3416
3557
  const command = opts.command ?? resolveCliCommand(cli);
3558
+ if (opts.command === void 0) {
3559
+ const check = await checkCommandResolvable(command);
3560
+ if (!check.ok) {
3561
+ throw new AgentSpawnCliNotFoundError(cli, check.binary, envVarNameForCli(cli));
3562
+ }
3563
+ }
3417
3564
  const workspacePathStr = opts.workspace ? await prestageWorkspace(db, opts, cli) : void 0;
3418
3565
  const paneEnv = {
3419
3566
  MU_MANAGED_AGENT: "1",
3420
3567
  MU_AGENT_NAME: opts.name,
3421
3568
  MU_WORKSTREAM: opts.workstream
3422
3569
  };
3423
- const paneId = await createOrReusePane({
3424
- session,
3425
- windowName,
3426
- command,
3427
- cwd: workspacePathStr ?? opts.cwd,
3428
- env: paneEnv
3429
- });
3430
3570
  const hasWorkspace = workspacePathStr !== void 0;
3571
+ let paneId;
3431
3572
  let agent;
3432
3573
  try {
3574
+ paneId = await createOrReusePane({
3575
+ session,
3576
+ windowName,
3577
+ command,
3578
+ cwd: workspacePathStr ?? opts.cwd,
3579
+ env: paneEnv
3580
+ });
3433
3581
  await setPaneTitle(paneId, opts.name);
3434
3582
  await enableMuPaneBordersForPane(paneId);
3435
3583
  agent = finalizeAgentRow(db, { opts, cli, paneId, hasWorkspace });
3436
- } catch (err) {
3437
- await rollbackSpawn(db, opts.name, paneId, hasWorkspace, opts.workstream);
3438
- throw err;
3439
- }
3440
- try {
3441
3584
  await awaitSpawnLiveness(paneId, opts.name);
3442
3585
  } catch (err) {
3443
3586
  await rollbackSpawn(db, opts.name, paneId, hasWorkspace, opts.workstream);
3587
+ if (hasWorkspace) attachOrphanCleanupHint(err, opts.name, opts.workstream);
3444
3588
  throw err;
3445
3589
  }
3446
3590
  emitEvent(
@@ -3500,7 +3644,7 @@ function finalizeAgentRow(db, args) {
3500
3644
  return row;
3501
3645
  }
3502
3646
  async function rollbackSpawn(db, name, paneId, hasWorkspace, workstream) {
3503
- await killPane(paneId).catch(() => {
3647
+ if (paneId !== void 0) await killPane(paneId).catch(() => {
3504
3648
  });
3505
3649
  if (hasWorkspace) {
3506
3650
  await freeWorkspace(db, name, { workstream }).catch(() => {
@@ -3508,6 +3652,22 @@ async function rollbackSpawn(db, name, paneId, hasWorkspace, workstream) {
3508
3652
  }
3509
3653
  deleteAgent(db, name, workstream);
3510
3654
  }
3655
+ function attachOrphanCleanupHint(err, agent, workstream) {
3656
+ if (typeof err !== "object" || err === null) return;
3657
+ const target = err;
3658
+ const existing = typeof target.errorNextSteps === "function" ? target.errorNextSteps.bind(target) : null;
3659
+ const orphanHints = [
3660
+ {
3661
+ intent: "Check for an orphan workspace dir (rollback is best-effort; may have failed)",
3662
+ command: `mu workspace orphans -w ${workstream}`
3663
+ },
3664
+ {
3665
+ intent: "Free the workspace if it survived the rollback (idempotent on missing)",
3666
+ command: `mu workspace free ${agent} -w ${workstream}`
3667
+ }
3668
+ ];
3669
+ target.errorNextSteps = () => [...existing ? existing() : [], ...orphanHints];
3670
+ }
3511
3671
  function defaultSpawnLivenessMs() {
3512
3672
  const raw = process.env.MU_SPAWN_LIVENESS_MS;
3513
3673
  if (raw === void 0) return 1500;
@@ -3515,13 +3675,51 @@ function defaultSpawnLivenessMs() {
3515
3675
  if (Number.isNaN(parsed) || parsed < 0) return 1500;
3516
3676
  return parsed;
3517
3677
  }
3678
+ var STARTUP_ERROR_PATTERNS = [
3679
+ /No API key found for [\w-]+/i,
3680
+ /Error: invalid API key/i,
3681
+ /Authentication failed/i,
3682
+ /401 Unauthorized/i,
3683
+ /Could not authenticate/i,
3684
+ // fb_agent_spawn_no_validation part B: post-spawn detection of the
3685
+ // "binary not found at exec time" failure mode. The pre-flight check
3686
+ // above catches the common typo BEFORE any side effect, but a few
3687
+ // edge cases still slip through and only surface in the pane:
3688
+ // - `--command "..."` skips the pre-flight (operator opt-out).
3689
+ // - PATH inside the spawned shell differs from PATH in mu's
3690
+ // process (login shell rc files, /etc/paths.d, etc.).
3691
+ // - Race: binary on PATH at spawn time, gone 1.5s later.
3692
+ // Scoped to the FIRST 30 lines of scrollback (see
3693
+ // STARTUP_ERROR_TAIL_LINES) so a user's later `cat /no/such/file`
3694
+ // can't false-positive long after spawn.
3695
+ /command not found/i,
3696
+ /No such file or directory/i
3697
+ ];
3698
+ var STARTUP_ERROR_TAIL_LINES = 30;
3699
+ function detectSpawnStartupError(scrollback) {
3700
+ const lines = scrollback.split(/\r?\n/);
3701
+ const tail = lines.slice(Math.max(0, lines.length - STARTUP_ERROR_TAIL_LINES));
3702
+ for (const line of tail) {
3703
+ for (const pattern of STARTUP_ERROR_PATTERNS) {
3704
+ if (pattern.test(line)) return line;
3705
+ }
3706
+ }
3707
+ return void 0;
3708
+ }
3518
3709
  async function awaitSpawnLiveness(paneId, agentName) {
3519
3710
  const ms = defaultSpawnLivenessMs();
3520
3711
  if (ms === 0) return;
3521
3712
  await sleep(ms);
3522
3713
  const scrollback = await capturePane(paneId, { lines: 50 }).catch(() => void 0);
3523
- if (await paneExists(paneId)) return;
3524
- throw new AgentDiedOnSpawnError(agentName, paneId, scrollback);
3714
+ if (!await paneExists(paneId)) {
3715
+ throw new AgentDiedOnSpawnError(agentName, paneId, scrollback);
3716
+ }
3717
+ if (scrollback !== void 0) {
3718
+ const matchedLine = detectSpawnStartupError(scrollback);
3719
+ if (matchedLine !== void 0) {
3720
+ throw new AgentSpawnStartupError(agentName, paneId, matchedLine, scrollback);
3721
+ }
3722
+ }
3525
3723
  }
3526
3724
  async function createOrReusePane(opts) {
3527
3725
  if (!await sessionExists(opts.session)) {
@@ -3552,6 +3750,36 @@ async function createOrReusePane(opts) {
3552
3750
  }
3553
3751
 
3554
3752
  // src/agents/errors.ts
3753
+ var AgentSpawnCliNotFoundError = class extends Error {
3754
+ constructor(cli, binary, envVarChecked) {
3755
+ super(
3756
+ `--cli ${cli} resolved to binary "${binary}" which is not on PATH (and not an executable absolute/relative path). Refusing to spawn \u2014 would create a pane that dies immediately on "command not found".`
3757
+ );
3758
+ this.cli = cli;
3759
+ this.binary = binary;
3760
+ this.envVarChecked = envVarChecked;
3761
+ }
3762
+ cli;
3763
+ binary;
3764
+ envVarChecked;
3765
+ name = "AgentSpawnCliNotFoundError";
3766
+ errorNextSteps() {
3767
+ return [
3768
+ {
3769
+ intent: "Try the default CLI (the one mu's substrate ships against)",
3770
+ command: "mu agent spawn <name> --cli pi"
3771
+ },
3772
+ {
3773
+ intent: "If you meant a custom alias, set the env var to its real path",
3774
+ command: `export ${this.envVarChecked}="<absolute-path-to-binary> [args...]"`
3775
+ },
3776
+ {
3777
+ intent: "List installed CLIs typically supported by mu",
3778
+ command: "which pi pi-meta claude codex"
3779
+ }
3780
+ ];
3781
+ }
3782
+ };
3555
3783
  var AgentExistsError = class extends Error {
3556
3784
  constructor(agentName) {
3557
3785
  super(`agent already exists in this workstream: ${agentName}`);
@@ -3672,6 +3900,51 @@ ${tail}
3672
3900
  ];
3673
3901
  }
3674
3902
  };
3903
+ var AgentSpawnStartupError = class extends Error {
3904
+ constructor(agentName, paneId, matchedLine, scrollback) {
3905
+ super(
3906
+ `agent ${agentName} reported a startup error within ${defaultSpawnLivenessMs()}ms of spawn (pane ${paneId}). The pane is alive but the spawned CLI parked at an error prompt instead of becoming a working agent.
3907
+
3908
+ Matched line: ${matchedLine.trim()}
3909
+
3910
+ --- pane scrollback ---
3911
+ ${scrollback.trim()}
3912
+ --- end scrollback ---`
3913
+ );
3914
+ this.agentName = agentName;
3915
+ this.paneId = paneId;
3916
+ this.matchedLine = matchedLine;
3917
+ this.scrollback = scrollback;
3918
+ }
3919
+ agentName;
3920
+ paneId;
3921
+ matchedLine;
3922
+ scrollback;
3923
+ name = "AgentSpawnStartupError";
3924
+ errorNextSteps() {
3925
+ return [
3926
+ {
3927
+ intent: "Inspect the parked pane's scrollback for the full error",
3928
+ command: `mu agent read ${this.agentName} -n 100`
3929
+ },
3930
+ {
3931
+ // Most common today: the operator picked a model whose
3932
+ // provider has no credentials in this env. Default Anthropic
3933
+ // is the safe fallback for pi-meta.
3934
+ intent: "Re-spawn with a CLI command whose provider credentials are present",
3935
+ command: `mu agent spawn ${this.agentName} --command "pi-meta --no-solo" # default Anthropic`
3936
+ },
3937
+ {
3938
+ intent: "Or set the missing API key env var for the provider you wanted, then re-spawn",
3939
+ command: "export ANTHROPIC_API_KEY=... # or AWS_BEARER_TOKEN_BEDROCK, OPENAI_API_KEY, ..."
3940
+ },
3941
+ {
3942
+ intent: "Disable the startup-error scan if you actually wanted that prompt (CI / scripted recovery)",
3943
+ command: "export MU_SPAWN_LIVENESS_MS=0"
3944
+ }
3945
+ ];
3946
+ }
3947
+ };
3675
3948
  var WorkspacePreservedError = class extends Error {
3676
3949
  constructor(agentName, workspacePath2) {
3677
3950
  super(
@@ -3928,11 +4201,13 @@ async function createWorkspace(db, opts) {
3928
4201
  });
3929
4202
  throw err;
3930
4203
  }
3931
- emitEvent(
3932
- db,
3933
- opts.workstream,
3934
- `workspace create ${opts.agent} (backend=${backend.name}, path=${path}${created.parentRef ? `, parent=${created.parentRef.slice(0, 12)}` : ""})`
3935
- );
4204
+ if (opts._suppressEvent !== true) {
4205
+ emitEvent(
4206
+ db,
4207
+ opts.workstream,
4208
+ `workspace create ${opts.agent} (backend=${backend.name}, path=${path}${created.parentRef ? `, parent=${created.parentRef.slice(0, 12)}` : ""})`
4209
+ );
4210
+ }
3936
4211
  return {
3937
4212
  agentName: opts.agent,
3938
4213
  workstreamName: opts.workstream,
@@ -4024,10 +4299,30 @@ async function listCommitsForWorkspace(db, agent, opts) {
4024
4299
  const commits = await backend.commitsSinceBase(row.path, baseRef);
4025
4300
  return { vcs: row.backend, baseRef, commits, workspacePath: row.path };
4026
4301
  }
4302
+ async function isWorkspaceClean(row) {
4303
+ const backend = backendByName(row.backend);
4304
+ let clean;
4305
+ try {
4306
+ clean = await backend.isClean(row.path);
4307
+ } catch {
4308
+ return false;
4309
+ }
4310
+ if (!clean) return false;
4311
+ if (row.backend === "none") return true;
4312
+ if (row.parentRef === null || row.parentRef.length === 0) return false;
4313
+ try {
4314
+ const commits = await backend.commitsSinceBase(row.path, row.parentRef);
4315
+ return commits.length === 0;
4316
+ } catch {
4317
+ return false;
4318
+ }
4319
+ }
4027
4320
  async function freeWorkspace(db, agent, opts) {
4028
4321
  const row = getWorkspaceForAgent(db, agent, opts.workstream);
4029
4322
  if (!row) return { removed: false, rowDeleted: false };
4030
- captureSnapshot(db, `workspace free ${agent}`, row.workstreamName);
4323
+ if (opts._suppressEvent !== true) {
4324
+ captureSnapshot(db, `workspace free ${agent}`, row.workstreamName);
4325
+ }
4031
4326
  const backend = backendByName(row.backend);
4032
4327
  const result = await backend.freeWorkspace({
4033
4328
  workspacePath: row.path,
@@ -4039,17 +4334,55 @@ async function freeWorkspace(db, agent, opts) {
4039
4334
  WHERE agent_id = (SELECT id FROM agents WHERE name = ? AND workstream_id = ?)
4040
4335
  AND workstream_id = ?`
4041
4336
  ).run(agent, wsIdForDel, wsIdForDel);
4042
- emitEvent(
4043
- db,
4044
- row.workstreamName,
4045
- `workspace free ${agent} (backend=${row.backend}, path=${row.path}${result.committedRef ? `, committed=${result.committedRef.slice(0, 12)}` : ""})`
4046
- );
4337
+ if (opts._suppressEvent !== true) {
4338
+ emitEvent(
4339
+ db,
4340
+ row.workstreamName,
4341
+ `workspace free ${agent} (backend=${row.backend}, path=${row.path}${result.committedRef ? `, committed=${result.committedRef.slice(0, 12)}` : ""})`
4342
+ );
4343
+ }
4047
4344
  return {
4048
4345
  removed: result.removed,
4049
4346
  rowDeleted: del.changes > 0,
4050
4347
  ...result.committedRef !== void 0 ? { committedRef: result.committedRef } : {}
4051
4348
  };
4052
4349
  }
4350
+ async function recreateWorkspace(db, agent, opts) {
4351
+ const row = getWorkspaceForAgent(db, agent, opts.workstream);
4352
+ if (!row) throw new WorkspaceNotFoundError(agent);
4353
+ if (opts.force !== true) {
4354
+ const oldBackend = backendByName(row.backend);
4355
+ const dirty = await oldBackend.listDirtyFiles(row.path);
4356
+ if (dirty.length > 0) {
4357
+ throw new WorkspaceDirtyError(row.path, dirty, "recreate");
4358
+ }
4359
+ }
4360
+ captureSnapshot(db, `workspace recreate ${agent}`, row.workstreamName);
4361
+ await freeWorkspace(db, agent, {
4362
+ workstream: opts.workstream,
4363
+ commit: false,
4364
+ _suppressEvent: true
4365
+ });
4366
+ const createOpts = {
4367
+ agent,
4368
+ workstream: opts.workstream,
4369
+ _suppressEvent: true
4370
+ };
4371
+ if (opts.projectRoot !== void 0) createOpts.projectRoot = opts.projectRoot;
4372
+ if (opts.backend !== void 0) {
4373
+ createOpts.backend = opts.backend;
4374
+ } else {
4375
+ createOpts.backend = row.backend;
4376
+ }
4377
+ if (opts.parentRef !== void 0) createOpts.parentRef = opts.parentRef;
4378
+ const fresh = await createWorkspace(db, createOpts);
4379
+ emitEvent(
4380
+ db,
4381
+ opts.workstream,
4382
+ `workspace recreate ${agent} (backend=${fresh.backend}, path=${fresh.path}, old_parent=${row.parentRef ? row.parentRef.slice(0, 12) : "\u2014"}, new_parent=${fresh.parentRef ? fresh.parentRef.slice(0, 12) : "\u2014"})`
4383
+ );
4384
+ return { workspace: fresh, previousParentRef: row.parentRef };
4385
+ }
4053
4386
 
4054
4387
  // src/workstream.ts
4055
4388
  var WORKSTREAM_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
@@ -4331,7 +4664,6 @@ async function waitForTasks(db, input, opts) {
4331
4664
  const stuckAfterMs = opts.stuckAfterMs ?? 3e5;
4332
4665
  const onStall = opts.onStall ?? "warn";
4333
4666
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Number.POSITIVE_INFINITY;
4334
- const startedAt = Date.now();
4335
4667
  for (const ref of refs) {
4336
4668
  if (getTask(db, ref.name, ref.workstreamName) === void 0)
4337
4669
  throw new TaskNotFoundError(`${ref.workstreamName}/${ref.name}`);
@@ -4352,7 +4684,7 @@ async function waitForTasks(db, input, opts) {
4352
4684
  return ageMs >= stuckAfterMs;
4353
4685
  };
4354
4686
  const snapshot = () => {
4355
- const tasks = refs.map((ref) => {
4687
+ const refStates = refs.map((ref) => {
4356
4688
  const row = getTask(db, ref.name, ref.workstreamName);
4357
4689
  const status = row?.status ?? "OPEN";
4358
4690
  const owner = row?.ownerName ?? null;
@@ -4384,16 +4716,15 @@ async function waitForTasks(db, input, opts) {
4384
4716
  stuck
4385
4717
  };
4386
4718
  });
4387
- const reachedCount = tasks.filter((t) => t.reachedTarget).length;
4388
4719
  return {
4389
- tasks,
4390
- allReached: reachedCount === tasks.length,
4391
- anyReached: reachedCount > 0,
4392
- elapsedMs: Date.now() - startedAt,
4720
+ refs: refStates,
4393
4721
  timedOut: false
4394
4722
  };
4395
4723
  };
4396
- const isDone = (snap2) => wantAny ? snap2.anyReached : snap2.allReached;
4724
+ const isDone = (snap2) => {
4725
+ const reached = snap2.refs.filter((r) => r.reachedTarget).length;
4726
+ return wantAny ? reached > 0 : reached === snap2.refs.length;
4727
+ };
4397
4728
  if (opts.beforePoll) await opts.beforePoll();
4398
4729
  let snap = snapshot();
4399
4730
  if (isDone(snap)) return snap;
@@ -4454,7 +4785,15 @@ function closeTask(db, localId, opts) {
4454
4785
  if (before && before.status !== "CLOSED") {
4455
4786
  captureSnapshot(db, `task close ${localId}`, before.workstreamName);
4456
4787
  }
4457
- return setTaskStatus(db, localId, "CLOSED", opts);
4788
+ const r = setTaskStatus(db, localId, "CLOSED", opts);
4789
+ if (r.changed && before && opts.evidence !== void 0 && opts.evidence !== "") {
4790
+ const noteOpts = {
4791
+ workstream: before.workstreamName
4792
+ };
4793
+ if (opts.author !== void 0 && opts.author !== "") noteOpts.author = opts.author;
4794
+ addNote(db, localId, `CLOSE: ${opts.evidence}`, noteOpts);
4795
+ }
4796
+ return r;
4458
4797
  }
4459
4798
  function openTask(db, localId, opts) {
4460
4799
  return setTaskStatus(db, localId, "OPEN", opts);
@@ -4707,7 +5046,6 @@ var SELECT_NOTE_COLS = `
4707
5046
  function rowFromDb4(row) {
4708
5047
  return {
4709
5048
  name: row.local_id,
4710
- localId: row.local_id,
4711
5049
  workstreamName: row.workstream,
4712
5050
  title: row.title,
4713
5051
  status: row.status,
@@ -4762,19 +5100,23 @@ function slugifyTitleVerbose(title) {
4762
5100
  const lastSep = window.lastIndexOf("_");
4763
5101
  trimmed = lastSep > 0 ? window.slice(0, lastSep) : window;
4764
5102
  }
4765
- const slug = /^[a-z]/.test(trimmed) ? trimmed.slice(0, SLUG_HARD_CAP) : `t_${trimmed}`.slice(0, SLUG_HARD_CAP);
5103
+ const applyPrefix = (s) => /^[a-z]/.test(s) ? s.slice(0, SLUG_HARD_CAP) : `t_${s}`.slice(0, SLUG_HARD_CAP);
5104
+ const slug = applyPrefix(trimmed);
5105
+ const originalSlug = applyPrefix(stripped);
4766
5106
  return {
4767
5107
  slug,
4768
5108
  strippedLength: stripped.length,
5109
+ originalSlug,
4769
5110
  truncated: trimmed.length < stripped.length
4770
5111
  };
4771
5112
  }
4772
5113
  function idFromTitleVerbose(db, workstream, title) {
4773
- const { slug: base, truncated } = slugifyTitleVerbose(title);
4774
- if (getTask(db, base, workstream) === void 0) return { id: base, truncated };
5114
+ const { slug: base, truncated, originalSlug } = slugifyTitleVerbose(title);
5115
+ if (getTask(db, base, workstream) === void 0) return { id: base, truncated, originalSlug };
4775
5116
  for (let i = 2; i < 1e3; i++) {
4776
5117
  const candidate = `${base}_${i}`.slice(0, SLUG_HARD_CAP);
4777
- if (getTask(db, candidate, workstream) === void 0) return { id: candidate, truncated };
5118
+ if (getTask(db, candidate, workstream) === void 0)
5119
+ return { id: candidate, truncated, originalSlug };
4778
5120
  }
4779
5121
  throw new Error(`could not derive a unique id from title in workstream ${workstream}: ${title}`);
4780
5122
  }
@@ -4872,14 +5214,26 @@ function listRecentClosed(db, workstream, limit = 5) {
4872
5214
  ).all(wsId, limit);
4873
5215
  return rows.map(rowFromDb4);
4874
5216
  }
4875
- function listNotes(db, taskLocalId, workstream) {
5217
+ function listNotes(db, taskLocalId, workstream, opts = {}) {
4876
5218
  const taskId = taskIdFor(db, taskLocalId, workstream);
4877
5219
  if (taskId === null) return [];
4878
- const rows = db.prepare(
5220
+ let cutoff = opts.since;
5221
+ if (cutoff === void 0 && opts.sinceClaim === true) {
5222
+ const at = lastClaimEventAt(db, workstream, taskLocalId);
5223
+ if (at !== null) cutoff = at;
5224
+ }
5225
+ const rows = cutoff !== void 0 ? db.prepare(
5226
+ `SELECT ${SELECT_NOTE_COLS} FROM task_notes n JOIN tasks t ON t.id = n.task_id
5227
+ WHERE n.task_id = ? AND n.created_at > ? ORDER BY n.id`
5228
+ ).all(taskId, cutoff) : db.prepare(
4879
5229
  `SELECT ${SELECT_NOTE_COLS} FROM task_notes n JOIN tasks t ON t.id = n.task_id
4880
- WHERE n.task_id = ? ORDER BY n.id`
5230
+ WHERE n.task_id = ? ORDER BY n.id`
4881
5231
  ).all(taskId);
4882
- return rows.map(noteFromDb);
5232
+ const mapped = rows.map(noteFromDb);
5233
+ if (opts.tail !== void 0 && opts.tail >= 0) {
5234
+ return opts.tail === 0 ? [] : mapped.slice(-opts.tail);
5235
+ }
5236
+ return mapped;
4883
5237
  }
4884
5238
  function listTasksByOwner(db, workstream, owner, opts = {}) {
4885
5239
  const filter = opts.includeClosed ? "" : "AND t.status NOT IN ('CLOSED', 'REJECTED', 'DEFERRED')";
@@ -5287,6 +5641,140 @@ async function adoptAgent(db, opts) {
5287
5641
  };
5288
5642
  }
5289
5643
 
5644
+ // src/agents/kick.ts
5645
+ var ALLOWED_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL"];
5646
+ function isKickSignal(s) {
5647
+ return ALLOWED_SIGNALS.includes(s);
5648
+ }
5649
+ var NoForegroundProcessError = class extends Error {
5650
+ constructor(agentName, tty, reason) {
5651
+ const detail = reason === "no-foreground" ? `no foreground process group on tty ${tty} (pane is idle)` : `the only foreground process on tty ${tty} is the agent's wrapping CLI itself; refusing to signal it (use \`mu agent close ${agentName}\` to close the agent)`;
5652
+ super(`agent ${agentName}: ${detail}`);
5653
+ this.agentName = agentName;
5654
+ this.tty = tty;
5655
+ this.reason = reason;
5656
+ }
5657
+ agentName;
5658
+ tty;
5659
+ reason;
5660
+ name = "NoForegroundProcessError";
5661
+ errorNextSteps() {
5662
+ return [
5663
+ {
5664
+ intent: "Inspect what's running in the pane",
5665
+ command: `mu agent show ${this.agentName} -n 50`
5666
+ },
5667
+ {
5668
+ intent: "Close the agent (kills the wrapping CLI + pane)",
5669
+ command: `mu agent close ${this.agentName}`
5670
+ }
5671
+ ];
5672
+ }
5673
+ };
5674
+ var realExecutor2 = async (cmd, args) => {
5675
+ const { execa: execa2 } = await import("execa");
5676
+ const result = await execa2(cmd, [...args], { reject: false });
5677
+ return {
5678
+ stdout: result.stdout ?? "",
5679
+ stderr: result.stderr ?? "",
5680
+ exitCode: result.exitCode ?? null
5681
+ };
5682
+ };
5683
+ var currentExecutor2 = realExecutor2;
5684
+ function parsePsTtyOutput(output) {
5685
+ const rows = [];
5686
+ for (const raw of output.split("\n")) {
5687
+ const line = raw.trim();
5688
+ if (line === "") continue;
5689
+ const parts = line.split(/\s+/);
5690
+ if (parts.length < 4) continue;
5691
+ const [pidStr, pgidStr, stat2, ...commParts] = parts;
5692
+ if (pidStr === void 0 || pgidStr === void 0 || stat2 === void 0) continue;
5693
+ const pid = Number.parseInt(pidStr, 10);
5694
+ const pgid = Number.parseInt(pgidStr, 10);
5695
+ if (!Number.isFinite(pid) || !Number.isFinite(pgid)) continue;
5696
+ rows.push({ pid, pgid, stat: stat2, comm: commParts.join(" ") });
5697
+ }
5698
+ return rows;
5699
+ }
5700
+ var WRAPPER_COMM_PREFIXES = [
5701
+ "pi",
5702
+ "claude",
5703
+ "codex",
5704
+ "bash",
5705
+ "zsh",
5706
+ "sh",
5707
+ "fish",
5708
+ "dash"
5709
+ ];
5710
+ function isWrapperComm(comm) {
5711
+ const cleaned = comm.replace(/^-/, "").trim();
5712
+ if (cleaned === "") return false;
5713
+ for (const prefix of WRAPPER_COMM_PREFIXES) {
5714
+ if (cleaned === prefix) return true;
5715
+ if (cleaned.startsWith(`${prefix}-`)) return true;
5716
+ }
5717
+ return false;
5718
+ }
5719
+ async function foregroundPgid(tty) {
5720
+ const ttyShort = tty.startsWith("/dev/") ? tty.slice("/dev/".length) : tty;
5721
+ const result = await currentExecutor2("ps", ["-t", ttyShort, "-o", "pid=,pgid=,stat=,comm="]);
5722
+ if (result.exitCode !== 0 && result.stdout.trim() === "") {
5723
+ return { kind: "no-foreground", rows: [] };
5724
+ }
5725
+ const rows = parsePsTtyOutput(result.stdout);
5726
+ if (rows.length === 0) return { kind: "no-foreground", rows };
5727
+ const fg = rows.find((r) => r.stat.includes("+"));
5728
+ if (!fg) {
5729
+ return { kind: "no-foreground", rows };
5730
+ }
5731
+ if (isWrapperComm(fg.comm)) {
5732
+ return { kind: "shell-only", pgid: fg.pgid, fgRow: fg, rows };
5733
+ }
5734
+ return { kind: "ok", pgid: fg.pgid, fgRow: fg, rows };
5735
+ }
5736
+ async function killPgrp(pgid, signal) {
5737
+ const result = await currentExecutor2("kill", [`-${signal}`, `-${pgid}`]);
5738
+ if (result.exitCode !== 0) {
5739
+ if (/no such process/i.test(result.stderr)) return;
5740
+ throw new Error(
5741
+ `kill -${signal} -${pgid} failed (exit ${result.exitCode}): ${result.stderr.trim() || "no stderr"}`
5742
+ );
5743
+ }
5744
+ }
5745
+ async function kickAgent(db, name, opts) {
5746
+ const signal = opts.signal ?? "SIGINT";
5747
+ const agent = getAgent(db, name, opts.workstream);
5748
+ if (!agent) throw new AgentNotFoundError(name, opts.workstream);
5749
+ const tty = await paneTTY(agent.paneId);
5750
+ const lookup = await foregroundPgid(tty);
5751
+ if (lookup.kind === "no-foreground") {
5752
+ throw new NoForegroundProcessError(name, tty, "no-foreground");
5753
+ }
5754
+ if (lookup.kind === "shell-only") {
5755
+ throw new NoForegroundProcessError(name, tty, "shell-only");
5756
+ }
5757
+ const pgid = lookup.pgid;
5758
+ const fgRow = lookup.fgRow;
5759
+ if (pgid === void 0 || fgRow === void 0) {
5760
+ throw new NoForegroundProcessError(name, tty, "no-foreground");
5761
+ }
5762
+ await killPgrp(pgid, signal);
5763
+ emitEvent(
5764
+ db,
5765
+ agent.workstreamName,
5766
+ `agent kick ${name} (signal=${signal}, pgid=${pgid}, comm=${fgRow.comm})`
5767
+ );
5768
+ return {
5769
+ agentName: name,
5770
+ paneId: agent.paneId,
5771
+ tty,
5772
+ signaledPgid: pgid,
5773
+ signal,
5774
+ foregroundComm: fgRow.comm
5775
+ };
5776
+ }
5777
+
5290
5778
  // src/agents.ts
5291
5779
  var DEFAULT_IDLE_THRESHOLD_MS = 3e5;
5292
5780
  function idleThresholdMs() {
@@ -5511,15 +5999,24 @@ function freeAgent(db, name, workstream) {
5511
5999
  async function closeAgent(db, name, opts) {
5512
6000
  const agent = getAgent(db, name, opts.workstream);
5513
6001
  if (!agent) {
5514
- return { killedPane: false, deletedRow: false, workspaceFreed: false };
6002
+ return {
6003
+ killedPane: false,
6004
+ deletedRow: false,
6005
+ workspaceFreed: false,
6006
+ workspaceAutoFreedClean: false
6007
+ };
5515
6008
  }
5516
6009
  const ws = getWorkspaceForAgent(db, name, agent.workstreamName);
6010
+ let autoFreeClean = false;
5517
6011
  if (ws !== void 0 && opts.discardWorkspace !== true) {
5518
- throw new WorkspacePreservedError(name, ws.path);
6012
+ autoFreeClean = await isWorkspaceClean(ws);
6013
+ if (!autoFreeClean) {
6014
+ throw new WorkspacePreservedError(name, ws.path);
6015
+ }
5519
6016
  }
5520
6017
  captureSnapshot(db, `agent close ${name}`, agent.workstreamName);
5521
6018
  let workspaceFreed = false;
5522
- if (ws !== void 0 && opts.discardWorkspace === true) {
6019
+ if (ws !== void 0 && (opts.discardWorkspace === true || autoFreeClean)) {
5523
6020
  await freeWorkspace(db, name, { commit: false, workstream: agent.workstreamName });
5524
6021
  workspaceFreed = true;
5525
6022
  }
@@ -5529,12 +6026,13 @@ async function closeAgent(db, name, opts) {
5529
6026
  emitEvent(
5530
6027
  db,
5531
6028
  agent.workstreamName,
5532
- `agent close ${name} (pane=${agent.paneId}${workspaceFreed ? ", workspace discarded" : ""})`
6029
+ `agent close ${name} (pane=${agent.paneId}${workspaceFreed ? autoFreeClean ? ", workspace auto-freed (clean)" : ", workspace discarded" : ""})`
5533
6030
  );
5534
6031
  return {
5535
6032
  killedPane: true,
5536
6033
  deletedRow,
5537
- workspaceFreed
6034
+ workspaceFreed,
6035
+ workspaceAutoFreedClean: workspaceFreed && autoFreeClean
5538
6036
  };
5539
6037
  }
5540
6038
  async function listLiveAgents(db, opts) {
@@ -5682,9 +6180,10 @@ function formatEdgeList(edges, dim) {
5682
6180
  }
5683
6181
  async function cmdTaskAdd(db, localId, opts) {
5684
6182
  const workstream = await resolveWorkstream(opts.workstream);
6183
+ const autoDerived = localId === void 0;
5685
6184
  let derivation;
5686
6185
  if (localId !== void 0) {
5687
- derivation = { id: localId, truncated: false };
6186
+ derivation = { id: localId, truncated: false, originalSlug: localId };
5688
6187
  } else {
5689
6188
  derivation = idFromTitleVerbose(db, workstream, opts.title);
5690
6189
  }
@@ -5718,7 +6217,13 @@ async function cmdTaskAdd(db, localId, opts) {
5718
6217
  }
5719
6218
  ];
5720
6219
  if (opts.json) {
5721
- emitJson({ task: withRoiAll([task])[0], blockers: blockedBy ?? [], nextSteps });
6220
+ const truncationFields = autoDerived && derivation.truncated ? { truncated: true, originalSlug: derivation.originalSlug } : {};
6221
+ emitJson({
6222
+ task: withRoiAll([task])[0],
6223
+ blockers: blockedBy ?? [],
6224
+ nextSteps,
6225
+ ...truncationFields
6226
+ });
5722
6227
  return;
5723
6228
  }
5724
6229
  if (derivation.truncated) {
@@ -5807,12 +6312,32 @@ async function cmdTaskShow(db, rawId, opts = {}) {
5807
6312
  }
5808
6313
  }
5809
6314
  async function cmdTaskNotes(db, rawId, opts = {}) {
6315
+ if (opts.since !== void 0 && opts.sinceClaim === true) {
6316
+ throw new UsageError(
6317
+ "--since and --since-claim are mutually exclusive (both define a cutoff); pick one"
6318
+ );
6319
+ }
6320
+ if (opts.tail !== void 0 && (!Number.isFinite(opts.tail) || opts.tail <= 0 || !Number.isInteger(opts.tail))) {
6321
+ throw new UsageError(`--tail must be a positive integer (got ${JSON.stringify(opts.tail)})`);
6322
+ }
6323
+ if (opts.since !== void 0) {
6324
+ const parsed = Date.parse(opts.since);
6325
+ if (Number.isNaN(parsed)) {
6326
+ throw new UsageError(
6327
+ `--since must be an ISO 8601 timestamp (got ${JSON.stringify(opts.since)})`
6328
+ );
6329
+ }
6330
+ }
5810
6331
  const { name: localId } = await resolveEntityRef(db, rawId, opts, "task");
5811
6332
  assertTaskInWorkstream(db, localId, opts.workstream);
5812
6333
  const ws = await resolveWorkstream(opts.workstream);
5813
6334
  const task = getTask(db, localId, ws);
5814
6335
  if (!task) throw new TaskNotFoundError(localId);
5815
- const notes = listNotes(db, localId, task.workstreamName);
6336
+ const filterOpts = {};
6337
+ if (opts.tail !== void 0) filterOpts.tail = opts.tail;
6338
+ if (opts.since !== void 0) filterOpts.since = opts.since;
6339
+ if (opts.sinceClaim === true) filterOpts.sinceClaim = true;
6340
+ const notes = listNotes(db, localId, task.workstreamName, filterOpts);
5816
6341
  if (opts.json) {
5817
6342
  emitJsonCollection(notes);
5818
6343
  return;
@@ -6015,10 +6540,12 @@ async function cmdTaskWait(db, ids, opts) {
6015
6540
  priorState.set(key, { status, owner });
6016
6541
  }
6017
6542
  };
6543
+ const startedAt = Date.now();
6018
6544
  const result = await waitForTasks(db, refs, sdkOpts);
6019
- const firingRef = wantFirstShape && !result.timedOut ? result.tasks.find((t) => t.reachedTarget) ?? null : null;
6020
- const reachedRefs = result.tasks.filter((t) => t.reachedTarget);
6021
- const unmetRefs = result.tasks.filter((t) => !t.reachedTarget);
6545
+ const elapsedMs = Date.now() - startedAt;
6546
+ const firingRef = wantFirstShape && !result.timedOut ? result.refs.find((t) => t.reachedTarget) ?? null : null;
6547
+ const reachedRefs = result.refs.filter((t) => t.reachedTarget);
6548
+ const unmetRefs = result.refs.filter((t) => !t.reachedTarget);
6022
6549
  const nextSteps = [];
6023
6550
  if (!result.timedOut && firingRef !== null) {
6024
6551
  const owner = firingRef.owner;
@@ -6030,7 +6557,7 @@ async function cmdTaskWait(db, ids, opts) {
6030
6557
  }
6031
6558
  nextSteps.push({
6032
6559
  intent: "Verify the cherry-pick",
6033
- command: "npm run typecheck && npm run lint && npm run test && npm run build"
6560
+ command: "<your project verify command \u2014 e.g. npm run test, cargo test, uv run pytest>"
6034
6561
  });
6035
6562
  if (owner !== null) {
6036
6563
  nextSteps.push({
@@ -6041,7 +6568,7 @@ async function cmdTaskWait(db, ids, opts) {
6041
6568
  } else if (!result.timedOut && wantFirstShape === false) {
6042
6569
  nextSteps.push({
6043
6570
  intent: "Verify the merged work",
6044
- command: "npm run typecheck && npm run lint && npm run test && npm run build"
6571
+ command: "<your project verify command \u2014 e.g. npm run test, cargo test, uv run pytest>"
6045
6572
  });
6046
6573
  }
6047
6574
  for (const t of unmetRefs) {
@@ -6068,7 +6595,6 @@ async function cmdTaskWait(db, ids, opts) {
6068
6595
  };
6069
6596
  const timedOutArray = result.timedOut ? unmetRefs.map((t) => ({ ...t, qualifiedId: qualifiedId(t) })) : [];
6070
6597
  emitJson({
6071
- ...result,
6072
6598
  firing: firingJson,
6073
6599
  all: reachedRefs.map((t) => ({
6074
6600
  ...t,
@@ -6085,11 +6611,11 @@ async function cmdTaskWait(db, ids, opts) {
6085
6611
  if (firingRef !== null) {
6086
6612
  console.log(qualifiedId(firingRef));
6087
6613
  }
6088
- const summary = result.timedOut ? pc.yellow(`Timed out after ${result.elapsedMs}ms`) : pc.green(
6089
- `${wantAny ? "any-of" : "all-of"} ${refs.length} reached ${targetStatus} in ${result.elapsedMs}ms`
6614
+ const summary = result.timedOut ? pc.yellow(`Timed out after ${elapsedMs}ms`) : pc.green(
6615
+ `${wantAny ? "any-of" : "all-of"} ${refs.length} reached ${targetStatus} in ${elapsedMs}ms`
6090
6616
  );
6091
6617
  console.log(summary);
6092
- for (const t of result.tasks) {
6618
+ for (const t of result.refs) {
6093
6619
  const marker = t.reachedTarget ? pc.green("\u2713") : pc.dim("\u2022");
6094
6620
  const label = workstreamSet.size > 1 ? qualifiedId(t) : t.name;
6095
6621
  console.log(` ${marker} ${pc.bold(label)} ${pc.dim(`(${t.status})`)}`);
@@ -6232,6 +6758,9 @@ async function cmdTaskClose(db, rawId, opts = {}) {
6232
6758
  const sdkOpts = { workstream: ws };
6233
6759
  if (opts.evidence !== void 0) sdkOpts.evidence = opts.evidence;
6234
6760
  if (opts.ifReady) sdkOpts.ifReady = true;
6761
+ if (opts.evidence !== void 0 && opts.evidence !== "") {
6762
+ sdkOpts.author = await resolveActorIdentity();
6763
+ }
6235
6764
  const taskRow = getTask(db, localId, ws);
6236
6765
  const r = closeTask(db, localId, sdkOpts);
6237
6766
  if ("skipped" in r) {
@@ -6537,9 +7066,21 @@ function wireTaskCommands(program) {
6537
7066
  const opts = this.opts();
6538
7067
  return handle((db) => cmdTaskTree(db, id, opts), this)();
6539
7068
  });
6540
- task.command("notes <id>").description("List the notes attached to a task (oldest first)").option(...WORKSTREAM_OPT).option(...JSON_OPT).action(function(id) {
7069
+ task.command("notes <id>").description(
7070
+ "List the notes attached to a task (oldest first). Filters: --tail N (last N), --since <iso> (after timestamp), --since-claim (since most recent claim event)."
7071
+ ).option(...WORKSTREAM_OPT).option(...JSON_OPT).option("--tail <n>", "print only the last N notes (alias --last)", parsePositiveNumber).option("--last <n>", "alias for --tail", parsePositiveNumber).option("--since <iso>", "print only notes created after this ISO 8601 timestamp").option(
7072
+ "--since-claim",
7073
+ "print only notes since the most recent 'task claim' event (auto-resolved)"
7074
+ ).action(function(id) {
6541
7075
  const opts = this.opts();
6542
- return handle((db) => cmdTaskNotes(db, id, opts), this)();
7076
+ const tail = opts.tail ?? opts.last;
7077
+ const merged = {};
7078
+ if (opts.json !== void 0) merged.json = opts.json;
7079
+ if (opts.workstream !== void 0) merged.workstream = opts.workstream;
7080
+ if (tail !== void 0) merged.tail = tail;
7081
+ if (opts.since !== void 0) merged.since = opts.since;
7082
+ if (opts.sinceClaim !== void 0) merged.sinceClaim = opts.sinceClaim;
7083
+ return handle((db) => cmdTaskNotes(db, id, merged), this)();
6543
7084
  });
6544
7085
  const EVIDENCE_OPT = [
6545
7086
  "--evidence <text>",
@@ -6683,6 +7224,7 @@ async function cmdSpawn(db, name, opts) {
6683
7224
  const workspace = opts.workspace ? getWorkspaceForAgent(db, name, workstream) : void 0;
6684
7225
  const resolvedCommand = opts.command ?? resolveCliCommand(agent.cli);
6685
7226
  const commandOverridden = resolvedCommand !== agent.cli;
7227
+ const envSourced = opts.command === void 0 ? resolveCliCommandWithSource(agent.cli) : void 0;
6686
7228
  const nextSteps = [
6687
7229
  { intent: "Send work", command: `mu agent send ${name} "..." -w ${workstream}` },
6688
7230
  { intent: "Read pane", command: `mu agent read ${name} -w ${workstream}` },
@@ -6698,12 +7240,16 @@ async function cmdSpawn(db, name, opts) {
6698
7240
  workspace: workspace ?? null,
6699
7241
  resolvedCommand,
6700
7242
  commandOverridden,
7243
+ // env-var attribution for machine consumers: present iff the
7244
+ // resolution came from $MU_<UPPER_CLI>_COMMAND. Mirrors the
7245
+ // human `(via $MU_PI_META_COMMAND)` suffix below.
7246
+ ...envSourced?.resolvedFromEnv ? { resolvedFromEnvVar: envSourced.envVar } : {},
6701
7247
  nextSteps
6702
7248
  });
6703
7249
  return;
6704
7250
  }
6705
7251
  const wsBit = opts.workspace ? pc.dim(" with auto-workspace") : "";
6706
- const cliDisplay = commandOverridden ? `${agent.cli} ${pc.dim(`(cmd: ${resolvedCommand})`)}` : agent.cli;
7252
+ const cliDisplay = opts.command !== void 0 ? `${agent.cli} ${pc.dim(`(cmd: ${resolvedCommand})`)}` : envSourced?.resolvedFromEnv ? `${agent.cli} ${pc.dim(`(via $${envSourced.envVar})`)}` : agent.cli;
6707
7253
  console.log(
6708
7254
  `Spawned ${pc.bold(agent.name)} (${cliDisplay}) in window ${pc.bold(agent.tab ?? agent.name)} of ${pc.bold(`mu-${workstream}`)}, pane ${pc.dim(agent.paneId)}${wsBit}`
6709
7255
  );
@@ -6761,7 +7307,7 @@ async function cmdList(db, opts) {
6761
7307
  console.log(pc.dim(" Panes that look like agents but aren't in the registry."));
6762
7308
  console.log(
6763
7309
  pc.dim(
6764
- " Run `mu adopt <pane-id>` to register one as a managed agent (e.g. `mu adopt %15`)."
7310
+ " Run `mu agent adopt <pane-id>` to register one as a managed agent (e.g. `mu agent adopt %15`)."
6765
7311
  )
6766
7312
  );
6767
7313
  for (const orphan of view.orphans) {
@@ -6848,7 +7394,7 @@ async function cmdClose(db, rawName, opts = {}) {
6848
7394
  const next = [];
6849
7395
  if (result.workspaceFreed) {
6850
7396
  next.push({
6851
- intent: "Workspace was freed alongside the agent (--discard-workspace)",
7397
+ intent: result.workspaceAutoFreedClean ? "Workspace was clean (no uncommitted changes, no commits since fork) so it was auto-freed alongside the agent" : "Workspace was freed alongside the agent (--discard-workspace)",
6852
7398
  command: "cd / # the workspace dir is gone"
6853
7399
  });
6854
7400
  }
@@ -6865,7 +7411,7 @@ async function cmdClose(db, rawName, opts = {}) {
6865
7411
  printNextSteps(next);
6866
7412
  return;
6867
7413
  }
6868
- const wsBit = result.workspaceFreed ? pc.dim(" (workspace discarded)") : "";
7414
+ const wsBit = result.workspaceFreed ? pc.dim(result.workspaceAutoFreedClean ? " (workspace auto-freed)" : " (workspace discarded)") : "";
6869
7415
  console.log(`Closed ${pc.bold(name)}${wsBit}`);
6870
7416
  printNextSteps(next);
6871
7417
  }
@@ -6924,6 +7470,41 @@ async function cmdAdopt(db, paneOrTitle, opts) {
6924
7470
  }
6925
7471
  printNextSteps(nextSteps);
6926
7472
  }
7473
+ async function cmdKick(db, rawName, opts = {}) {
7474
+ const { name } = await resolveEntityRef(db, rawName, opts, "agent");
7475
+ assertAgentInWorkstream(db, name, opts.workstream);
7476
+ const ws = await resolveWorkstream(opts.workstream);
7477
+ const sigRaw = opts.signal ?? "SIGINT";
7478
+ if (!isKickSignal(sigRaw)) {
7479
+ throw new UsageError(
7480
+ `--signal must be one of SIGINT, SIGTERM, SIGKILL (got ${JSON.stringify(sigRaw)})`
7481
+ );
7482
+ }
7483
+ const signal = sigRaw;
7484
+ const result = await kickAgent(db, name, { workstream: ws, signal });
7485
+ const nextSteps = [
7486
+ {
7487
+ intent: "Read the pane to confirm the tool aborted",
7488
+ command: `mu agent read ${name} -n 30 -w ${ws}`
7489
+ },
7490
+ {
7491
+ intent: "Send follow-up steering once the prompt returns",
7492
+ command: `mu agent send ${name} '...' -w ${ws}`
7493
+ },
7494
+ {
7495
+ intent: "Escalate (graceful \u2192 polite \u2192 hammer)",
7496
+ command: `mu agent kick ${name} --signal SIGTERM -w ${ws}`
7497
+ }
7498
+ ];
7499
+ if (opts.json) {
7500
+ emitJson({ ...result, nextSteps });
7501
+ return;
7502
+ }
7503
+ console.log(
7504
+ `Kicked ${pc.bold(name)} ${pc.dim(`(signal=${result.signal}, pgid=${result.signaledPgid}, comm=${result.foregroundComm}, tty=${result.tty})`)}`
7505
+ );
7506
+ printNextSteps(nextSteps);
7507
+ }
6927
7508
  async function cmdFree(db, rawName, opts = {}) {
6928
7509
  const { name } = await resolveEntityRef(db, rawName, opts, "agent");
6929
7510
  assertAgentInWorkstream(db, name, opts.workstream);
@@ -7007,7 +7588,7 @@ function wireAgentCommands(program) {
7007
7588
  return handle((db) => cmdAgentShow(db, name, opts), this)();
7008
7589
  });
7009
7590
  agent.command("close <name>").description(
7010
- "Kill an agent's pane and remove its registry row. If the agent has a workspace, refuses by default (would orphan the on-disk dir); pass --discard-workspace to free both, or run `mu workspace free <agent>` first."
7591
+ "Kill an agent's pane and remove its registry row. If the agent has a clean workspace (no uncommitted changes AND no commits since fork) it is auto-freed alongside the close; otherwise close refuses (would orphan or lose work) \u2014 pass --discard-workspace to free both anyway (lossy), or run `mu workspace free <agent>` first."
7011
7592
  ).option(
7012
7593
  "--discard-workspace",
7013
7594
  "free the agent's workspace alongside close (lossy: pending changes are gone)"
@@ -7015,6 +7596,16 @@ function wireAgentCommands(program) {
7015
7596
  const opts = this.opts();
7016
7597
  return handle((db) => cmdClose(db, name, opts), this)();
7017
7598
  });
7599
+ agent.command("kick <name>").description(
7600
+ "Signal the foreground process group of an agent's pane TTY (escape hatch for a worker wedged on an unbounded `find` / busy-wait loop). Default --signal SIGINT (graceful, matches Ctrl-C). Refuses when the foreground is the wrapping CLI itself \u2014 use `mu agent close` to close the agent."
7601
+ ).option(
7602
+ "--signal <sig>",
7603
+ "signal to send: SIGINT (default; graceful), SIGTERM (polite), SIGKILL (hammer)",
7604
+ "SIGINT"
7605
+ ).option(...WORKSTREAM_OPT).option(...JSON_OPT).action(function(name) {
7606
+ const opts = this.opts();
7607
+ return handle((db) => cmdKick(db, name, opts), this)();
7608
+ });
7018
7609
  agent.command("free <name>").description(
7019
7610
  "Mark an agent's status as 'free' (idempotent). Pane untouched; reconcile flips back to busy on real activity."
7020
7611
  ).option(...WORKSTREAM_OPT).option(...JSON_OPT).action(function(name) {
@@ -7025,7 +7616,7 @@ function wireAgentCommands(program) {
7025
7616
  const opts = this.opts();
7026
7617
  return handle((db) => cmdAttach(db, name, opts), this)();
7027
7618
  });
7028
- program.command("adopt <pane-or-title>").description(
7619
+ agent.command("adopt <pane-or-title>").description(
7029
7620
  "Register an existing tmux pane as a managed mu agent (the inverse of `mu agent list`'s 'orphan' state). Pane id form '%15' or pane title form 'worker-2'."
7030
7621
  ).option("--name <name>", "agent name (defaults to the pane's current title)").option("--cli <cli>", "agent CLI key (default: pi)").option("--role <role>", "full-access | read-only", "full-access").option(...WORKSTREAM_OPT).option(...JSON_OPT).action(function(paneOrTitle) {
7031
7622
  const opts = this.optsWithGlobals();
@@ -7737,24 +8328,6 @@ var ImportSourceNotInBucketError = class extends Error {
7737
8328
  ];
7738
8329
  }
7739
8330
  };
7740
- var ImportLegacyLayoutError = class extends Error {
7741
- constructor(bucketDir) {
7742
- super(
7743
- `${bucketDir} is a pre-0.3 (single-workstream) export; mu workstream import requires bucketVersion 2. Re-export with mu \u2265 0.3, or run mu workstream import on a freshly-rendered bucket.`
7744
- );
7745
- this.bucketDir = bucketDir;
7746
- }
7747
- bucketDir;
7748
- name = "ImportLegacyLayoutError";
7749
- errorNextSteps() {
7750
- return [
7751
- {
7752
- intent: "Re-export the source workstream into a new bucket",
7753
- command: "mu workstream export -w <ws> --out <new-bucket-dir>"
7754
- }
7755
- ];
7756
- }
7757
- };
7758
8331
  var WorkstreamAlreadyExistsError = class extends Error {
7759
8332
  constructor(workstream) {
7760
8333
  super(
@@ -8038,9 +8611,6 @@ function walkBucket(bucketDir) {
8038
8611
  if (probe.kind === "corrupt") {
8039
8612
  throw new ImportBucketInvalidError(bucketDir, "manifest.json is unreadable / malformed");
8040
8613
  }
8041
- if (probe.kind === "legacy") {
8042
- throw new ImportLegacyLayoutError(bucketDir);
8043
- }
8044
8614
  const tasksDir = join7(bucketDir, "tasks");
8045
8615
  const looksLikeSourceWs = existsSync6(join7(bucketDir, "README.md")) && existsSync6(join7(bucketDir, "INDEX.md")) && existsSync6(tasksDir) && statSync3(tasksDir).isDirectory();
8046
8616
  if (!looksLikeSourceWs) {
@@ -8295,7 +8865,7 @@ var NameAmbiguousError = class extends Error {
8295
8865
  }
8296
8866
  };
8297
8867
  function classifyError(err) {
8298
- if (err instanceof UsageError || err instanceof WorkstreamNameInvalidError || err instanceof ArchiveLabelInvalidError || err instanceof LegacyExportLayoutError || err instanceof ImportBucketInvalidError || err instanceof ImportLegacyLayoutError || err instanceof ImportFrontmatterParseError || err instanceof ImportEdgeRefMissingError || err instanceof PruneOptionsInvalidError) {
8868
+ if (err instanceof UsageError || err instanceof WorkstreamNameInvalidError || err instanceof ArchiveLabelInvalidError || err instanceof ImportBucketInvalidError || err instanceof ImportFrontmatterParseError || err instanceof ImportEdgeRefMissingError || err instanceof PruneOptionsInvalidError) {
8299
8869
  return { label: "error", exitCode: 2 };
8300
8870
  }
8301
8871
  if (err instanceof AgentNotFoundError || err instanceof TaskNotFoundError || err instanceof WorkstreamNotFoundError || err instanceof WorkspaceNotFoundError || err instanceof SnapshotNotFoundError || err instanceof ArchiveNotFoundError) {
@@ -8304,9 +8874,15 @@ function classifyError(err) {
8304
8874
  if (err instanceof NameAmbiguousError || err instanceof AgentExistsError || err instanceof TaskExistsError || err instanceof TaskAlreadyOwnedError || err instanceof TaskNotInWorkstreamError || err instanceof AgentNotInWorkstreamError || err instanceof CycleError || err instanceof TaskHasOpenDependentsError || err instanceof CrossWorkstreamEdgeError || err instanceof WorkspaceExistsError || err instanceof WorkspacePathNotEmptyError || err instanceof WorkspacePreservedError || err instanceof HomeDirAsProjectRootError || err instanceof WorkspaceVcsRequiredError || err instanceof WorkspaceDirtyError || err instanceof ClaimerNotRegisteredError || err instanceof SnapshotVersionMismatchError || err instanceof SchemaTooOldError || err instanceof TaskIdInvalidError || err instanceof ArchiveAlreadyExistsError || err instanceof ImportSourceNotInBucketError || err instanceof WorkstreamAlreadyExistsError) {
8305
8875
  return { label: "conflict", exitCode: 4 };
8306
8876
  }
8877
+ if (err instanceof AgentSpawnCliNotFoundError) {
8878
+ return { label: "spawn cli not found", exitCode: 1 };
8879
+ }
8307
8880
  if (err instanceof AgentDiedOnSpawnError) {
8308
8881
  return { label: "spawn failed", exitCode: 1 };
8309
8882
  }
8883
+ if (err instanceof AgentSpawnStartupError) {
8884
+ return { label: "spawn startup error", exitCode: 1 };
8885
+ }
8310
8886
  if (err instanceof TmuxError || err instanceof PaneNotFoundError) {
8311
8887
  return { label: "tmux", exitCode: 5 };
8312
8888
  }
@@ -8954,7 +9530,7 @@ async function cmdSnapshotShow(db, id, opts = {}) {
8954
9530
  }
8955
9531
  function wireSnapshotCommands(program) {
8956
9532
  program.command("undo").description(
8957
- "Restore the most recent snapshot (or one selected via --to). Pass --yes to actually restore; otherwise prints a dry-run summary. tmux state is NOT rolled back \u2014 the post-restore reconcile prunes ghost agents and surfaces orphan panes; re-spawn or `mu adopt` as needed."
9533
+ "Restore the most recent snapshot (or one selected via --to). Pass --yes to actually restore; otherwise prints a dry-run summary. tmux state is NOT rolled back \u2014 the post-restore reconcile prunes ghost agents and surfaces orphan panes; re-spawn or `mu agent adopt` as needed."
8958
9534
  ).option("--to <id>", "snapshot id to restore (default: most recent)", parseLines).option("-y, --yes", "actually restore (without this flag, prints a dry-run summary)").option(...JSON_OPT).action(function() {
8959
9535
  const opts = this.opts();
8960
9536
  return handle((db) => cmdUndo(db, opts), this)();
@@ -9912,6 +10488,38 @@ async function cmdWorkspaceList(db, opts) {
9912
10488
  }
9913
10489
  console.log(formatWorkspacesTable(decorated));
9914
10490
  }
10491
+ async function cmdWorkspaceRecreate(db, rawAgent, opts) {
10492
+ const { name: agent } = await resolveEntityRef(db, rawAgent, opts, "workspace");
10493
+ assertAgentInWorkstream(db, agent, opts.workstream);
10494
+ const workstream = await resolveWorkstream(opts.workstream);
10495
+ const recreateOpts = { workstream };
10496
+ if (opts.backend !== void 0) recreateOpts.backend = opts.backend;
10497
+ if (opts.from !== void 0) recreateOpts.parentRef = opts.from;
10498
+ if (opts.projectRoot !== void 0) recreateOpts.projectRoot = opts.projectRoot;
10499
+ if (opts.force === true) recreateOpts.force = true;
10500
+ const r = await recreateWorkspace(db, agent, recreateOpts);
10501
+ const nextSteps = [
10502
+ {
10503
+ intent: "Send work to the agent (workspace is ready)",
10504
+ command: `mu agent send ${agent} -w ${workstream} "<prompt>"`
10505
+ },
10506
+ {
10507
+ intent: "cd into the freshly recreated workspace",
10508
+ command: `cd $(mu workspace path ${agent} -w ${workstream})`
10509
+ },
10510
+ { intent: "List workspaces in this workstream", command: `mu workspace list -w ${workstream}` }
10511
+ ];
10512
+ if (opts.json) {
10513
+ emitJson({ workspace: r.workspace, previousParentRef: r.previousParentRef, nextSteps });
10514
+ return;
10515
+ }
10516
+ const oldRef = r.previousParentRef ? r.previousParentRef.slice(0, 12) : "\u2014";
10517
+ const newRef = r.workspace.parentRef ? r.workspace.parentRef.slice(0, 12) : "\u2014";
10518
+ console.log(
10519
+ `Recreated workspace ${pc.bold(agent)} ${pc.dim(`(backend=${r.workspace.backend}, ${oldRef} \u2192 ${newRef})`)}`
10520
+ );
10521
+ printNextSteps(nextSteps);
10522
+ }
9915
10523
  async function cmdWorkspaceFree(db, rawAgent, opts) {
9916
10524
  const { name: agent } = await resolveEntityRef(db, rawAgent, opts, "workspace");
9917
10525
  assertAgentInWorkstream(db, agent, opts.workstream);
@@ -10082,6 +10690,15 @@ function wireWorkspaceCommands(program) {
10082
10690
  const opts = this.opts();
10083
10691
  return handle((db) => cmdWorkspaceFree(db, agent, opts), this)();
10084
10692
  });
10693
+ workspace.command("recreate <agent>").description(
10694
+ "Free + create an agent's workspace in one shot \u2014 the canonical between-wave \"prep this worker for the next dispatch\" verb. Atomic guarantees match the underlying free + create pair (one pre-mutation snapshot, one `workspace recreate` event in the audit trail). Reuses the previous backend unless --backend overrides; bases on the project's current main unless --from <ref> overrides. Refuses on a dirty workspace (uncommitted changes) the same way `free` does \u2014 pass --force to discard the dirty changes (the lossy escape hatch)."
10695
+ ).option(
10696
+ "--backend <name>",
10697
+ "force a backend instead of reusing the previous one (jj | sl | git | none)"
10698
+ ).option("--from <ref>", "base the new workspace on a specific commit / branch / changeset").option("--project-root <path>", "override the project root to branch from (default: cwd)").option("--force", "discard uncommitted changes in the existing workspace (the lossy escape)").option(...WORKSTREAM_OPT).option(...JSON_OPT).action(function(agent) {
10699
+ const opts = this.opts();
10700
+ return handle((db) => cmdWorkspaceRecreate(db, agent, opts), this)();
10701
+ });
10085
10702
  workspace.command("commits <agent>").description(
10086
10703
  "Print commits the agent's workspace has on top of its recorded parent_ref (the fork point), oldest-first. Default text output is `<sha> <subject>` per line; --json emits the full array `[{sha, subject, body, authorDate}]` for piping. --since <ref> overrides the base. The `none` backend errors (no fork point to compare against)."
10087
10704
  ).option("--since <ref>", "override the base ref (default: workspace's recorded parent_ref)").option(...WORKSTREAM_OPT).option(...JSON_OPT).action(function(agent) {
@@ -11188,12 +11805,22 @@ function applyExitOverride(cmd) {
11188
11805
  applyExitOverride(sub);
11189
11806
  }
11190
11807
  }
11808
+ function injectBareNamespaceHelp(program, argv) {
11809
+ if (argv.length !== 3) return argv;
11810
+ const token = argv[2];
11811
+ if (token === void 0 || token.startsWith("-")) return argv;
11812
+ const sub = program.commands.find((c) => c.name() === token || c.aliases().includes(token));
11813
+ if (!sub) return argv;
11814
+ if (sub.commands.length === 0) return argv;
11815
+ return [...argv, "--help"];
11816
+ }
11191
11817
  if (isMainEntrypoint()) {
11192
11818
  const program = buildProgram();
11819
+ const argv = injectBareNamespaceHelp(program, process.argv);
11193
11820
  try {
11194
- await program.parseAsync(process.argv);
11821
+ await program.parseAsync(argv);
11195
11822
  } catch (err) {
11196
- const failingCmd = findCommandForArgv(program, process.argv.slice(2));
11823
+ const failingCmd = findCommandForArgv(program, argv.slice(2));
11197
11824
  const exitCode = emitParseError(err, failingCmd);
11198
11825
  process.exit(exitCode);
11199
11826
  }
@@ -11233,6 +11860,7 @@ export {
11233
11860
  formatWorkspacesTable,
11234
11861
  formatWorkstreamsTable,
11235
11862
  handle,
11863
+ injectBareNamespaceHelp,
11236
11864
  isTaskSortKey,
11237
11865
  parseCsvFlag,
11238
11866
  parseImpact,