@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/index.js CHANGED
@@ -510,6 +510,18 @@ function formatClaimEvent(opts) {
510
510
  const self = opts.anonymous ? "1" : "0";
511
511
  return `${CLAIM_EVENT_PREFIX} ${opts.localId} actor=${opts.actor} self=${self} ${opts.prose}`;
512
512
  }
513
+ function lastClaimEventAt(db, workstream, localId) {
514
+ const escaped = localId.replace(/[\\%_]/g, (c) => `\\${c}`);
515
+ const pattern = `${CLAIM_EVENT_PREFIX} ${escaped} %`;
516
+ const wsId = tryResolveWorkstreamId(db, workstream);
517
+ if (wsId === null) return null;
518
+ const row = db.prepare(
519
+ `SELECT created_at FROM agent_logs
520
+ WHERE workstream_id = ? AND kind = 'event' AND payload LIKE ? ESCAPE '\\'
521
+ ORDER BY seq DESC LIMIT 1`
522
+ ).get(wsId, pattern);
523
+ return row ? row.created_at : null;
524
+ }
513
525
 
514
526
  // src/detect.ts
515
527
  var TAIL_WINDOW_LINES = 100;
@@ -569,6 +581,7 @@ __export(tmux_exports, {
569
581
  newSessionWithPane: () => newSessionWithPane,
570
582
  newWindow: () => newWindow,
571
583
  paneExists: () => paneExists,
584
+ paneTTY: () => paneTTY,
572
585
  parseAgentNameFromTitle: () => parseAgentNameFromTitle,
573
586
  resetSleep: () => resetSleep,
574
587
  resetTmuxExecutor: () => resetTmuxExecutor,
@@ -913,6 +926,24 @@ async function enableMuPaneBorders(target) {
913
926
  await tmux(["set-option", "-w", "-t", target, "pane-active-border-style", "fg=cyan,bold"]);
914
927
  await tmux(["set-option", "-w", "-t", target, "pane-border-style", "fg=brightblack"]);
915
928
  }
929
+ async function paneTTY(paneId) {
930
+ assertValidPaneId(paneId);
931
+ const result = await currentExecutor(["display-message", "-t", paneId, "-p", "#{pane_tty}"]);
932
+ if (result.exitCode !== 0) {
933
+ if (/can't find pane|pane not found/i.test(result.stderr)) {
934
+ throw new PaneNotFoundError(paneId);
935
+ }
936
+ throw new TmuxError(
937
+ ["display-message", "-t", paneId, "-p", "#{pane_tty}"],
938
+ result.stderr,
939
+ result.stdout,
940
+ result.exitCode
941
+ );
942
+ }
943
+ const tty = result.stdout.trim();
944
+ if (tty === "") throw new PaneNotFoundError(paneId);
945
+ return tty;
946
+ }
916
947
  async function getPaneTitle(paneId) {
917
948
  if (!isValidPaneId(paneId)) return void 0;
918
949
  const result = await currentExecutor(["display-message", "-t", paneId, "-p", "#{pane_title}"]);
@@ -1583,7 +1614,7 @@ var ClaimerNotRegisteredError = class extends Error {
1583
1614
  * Three actionable resolutions in expected-frequency order:
1584
1615
  * 1. --self : orchestrator pattern (working directly)
1585
1616
  * 2. --for : dispatcher pattern (assigning to a worker)
1586
- * 3. mu adopt: registration pattern (promote pane to worker)
1617
+ * 3. mu agent adopt: registration pattern (promote pane to worker)
1587
1618
  */
1588
1619
  errorNextSteps() {
1589
1620
  const steps = [
@@ -1591,9 +1622,9 @@ var ClaimerNotRegisteredError = class extends Error {
1591
1622
  { intent: "Dispatch to a worker", command: "mu task claim <id> --for <worker>" }
1592
1623
  ];
1593
1624
  steps.push(
1594
- this.paneId !== null ? { intent: "Register this pane", command: `mu adopt ${this.paneId}` } : {
1625
+ this.paneId !== null ? { intent: "Register this pane", command: `mu agent adopt ${this.paneId}` } : {
1595
1626
  intent: "Register a pane",
1596
- command: "mu adopt <pane-id> (must be in mu-<workstream> tmux session)"
1627
+ command: "mu agent adopt <pane-id> (must be in mu-<workstream> tmux session)"
1597
1628
  }
1598
1629
  );
1599
1630
  return steps;
@@ -2162,16 +2193,6 @@ function listArchivedTasks(db, label, opts = {}) {
2162
2193
  }
2163
2194
 
2164
2195
  // src/exporting.ts
2165
- var LegacyExportLayoutError = class extends Error {
2166
- constructor(outDir) {
2167
- super(
2168
- `${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.`
2169
- );
2170
- this.outDir = outDir;
2171
- }
2172
- outDir;
2173
- name = "LegacyExportLayoutError";
2174
- };
2175
2196
  function fenceForBody(body) {
2176
2197
  const longestRun = (body.match(/`+/g) ?? []).reduce((m, s) => Math.max(m, s.length), 0);
2177
2198
  return "`".repeat(Math.max(3, longestRun + 1));
@@ -2342,9 +2363,6 @@ function readManifest(path) {
2342
2363
  if (obj.bucketVersion === 2 && typeof obj.sources === "object" && obj.sources !== null) {
2343
2364
  return { kind: "v2", manifest: obj };
2344
2365
  }
2345
- if (typeof obj.workstream === "string" && Array.isArray(obj.tasks)) {
2346
- return { kind: "legacy" };
2347
- }
2348
2366
  return { kind: "corrupt" };
2349
2367
  }
2350
2368
  function sha256Hex(content) {
@@ -2372,9 +2390,6 @@ function renderToBucket(input) {
2372
2390
  }
2373
2391
  const manifestPath = join3(outDir, "manifest.json");
2374
2392
  const probe = readManifest(manifestPath);
2375
- if (probe.kind === "legacy") {
2376
- throw new LegacyExportLayoutError(outDir);
2377
- }
2378
2393
  const now = (/* @__PURE__ */ new Date()).toISOString();
2379
2394
  const muVersion = readMuVersion();
2380
2395
  const previous = probe.kind === "v2" ? probe.manifest : void 0;
@@ -2548,7 +2563,6 @@ function exportSourcesForArchive(db, label) {
2548
2563
  for (const [sourceName, taskList] of bySource) {
2549
2564
  const tasks = taskList.map((t) => ({
2550
2565
  name: t.originalLocalId,
2551
- localId: t.originalLocalId,
2552
2566
  workstreamName: t.sourceWorkstream,
2553
2567
  title: t.title,
2554
2568
  // Status as snapshotted; cast through the TaskStatus union by
@@ -2626,24 +2640,30 @@ var WorkspaceVcsRequiredError = class extends Error {
2626
2640
  }
2627
2641
  };
2628
2642
  var WorkspaceDirtyError = class extends Error {
2629
- constructor(workspacePath2, files) {
2643
+ constructor(workspacePath2, files, verb = "rebase") {
2630
2644
  super(
2631
- `workspace dirty (${files.length} uncommitted file(s)): ${workspacePath2}; refusing to rebase`
2645
+ `workspace dirty (${files.length} uncommitted file(s)): ${workspacePath2}; refusing to ${verb}`
2632
2646
  );
2633
2647
  this.workspacePath = workspacePath2;
2634
2648
  this.files = files;
2649
+ this.verb = verb;
2635
2650
  }
2636
2651
  workspacePath;
2637
2652
  files;
2638
2653
  name = "WorkspaceDirtyError";
2654
+ /** The verb that refused ("rebase", "recreate", ...). Used to make
2655
+ * the error message + nextSteps point the operator at the right
2656
+ * escape hatch (e.g. recreate's `--force`). Default "rebase" for
2657
+ * backward compatibility with the original rebaseTo call sites. */
2658
+ verb;
2639
2659
  errorNextSteps() {
2640
- return [
2660
+ const steps = [
2641
2661
  {
2642
2662
  intent: "Inspect the dirty files",
2643
2663
  command: `(cd ${this.workspacePath} && git status -s) # or jj st / sl st`
2644
2664
  },
2645
2665
  {
2646
- intent: "Commit them first, then retry refresh",
2666
+ intent: `Commit them first, then retry ${this.verb}`,
2647
2667
  command: `(cd ${this.workspacePath} && git add -A && git commit -m WIP)`
2648
2668
  },
2649
2669
  {
@@ -2651,6 +2671,13 @@ var WorkspaceDirtyError = class extends Error {
2651
2671
  command: `(cd ${this.workspacePath} && git stash)`
2652
2672
  }
2653
2673
  ];
2674
+ if (this.verb === "recreate") {
2675
+ steps.push({
2676
+ intent: "Or DISCARD all uncommitted changes (the lossy escape)",
2677
+ command: "mu workspace recreate <agent> --force"
2678
+ });
2679
+ }
2680
+ return steps;
2654
2681
  }
2655
2682
  };
2656
2683
  var WorkspaceConflictError = class extends Error {
@@ -2719,6 +2746,13 @@ var noneBackend = {
2719
2746
  async commitsBehind(_workspacePath, _ref) {
2720
2747
  return null;
2721
2748
  },
2749
+ // none has no notion of clean — a cp -a snapshot doesn't track
2750
+ // committed vs uncommitted state. Returning true makes the
2751
+ // close-auto-free path silently free a none-workspace (consistent
2752
+ // with the fact that there are no commits to lose).
2753
+ async isClean(_workspacePath) {
2754
+ return true;
2755
+ },
2722
2756
  // none has no upstream to rebase onto. Throw a typed error so the
2723
2757
  // CLI's handle() maps it to exit 4 with a clean Next: hint.
2724
2758
  async rebaseTo(workspacePath2, _fromRef) {
@@ -2728,6 +2762,11 @@ var noneBackend = {
2728
2762
  // doesn't track history. Same typed error as rebaseTo.
2729
2763
  async commitsSinceBase(workspacePath2, _baseRef) {
2730
2764
  throw new WorkspaceVcsRequiredError("commits", workspacePath2);
2765
+ },
2766
+ // No VCS → nothing to compare against; "dirty" is unanswerable.
2767
+ // Caller (`recreateWorkspace`) treats [] as "clean" and proceeds.
2768
+ async listDirtyFiles(_workspacePath) {
2769
+ return [];
2731
2770
  }
2732
2771
  };
2733
2772
  var gitBackend = {
@@ -2748,6 +2787,20 @@ var gitBackend = {
2748
2787
  const sha = await run("git", ["rev-parse", "HEAD"], opts.workspacePath);
2749
2788
  return { parentRef: sha };
2750
2789
  },
2790
+ // Working-copy clean check: empty `git status --porcelain` output
2791
+ // means no working-tree, staged, or untracked-not-ignored changes.
2792
+ // Returns false on any failure (workspace path missing, git
2793
+ // explodes) — be conservative; auto-free should never "silently
2794
+ // succeed" because we couldn't check.
2795
+ async isClean(workspacePath2) {
2796
+ if (!existsSync3(workspacePath2)) return false;
2797
+ try {
2798
+ const files = await listGitDirtyFiles(workspacePath2);
2799
+ return files.length === 0;
2800
+ } catch {
2801
+ return false;
2802
+ }
2803
+ },
2751
2804
  // Compute commits-behind as: count of commits reachable from main
2752
2805
  // but not from `ref`. Resolves "main" via origin/HEAD (the symbolic
2753
2806
  // ref the remote advertises), falling back to origin/main and then
@@ -2890,6 +2943,10 @@ var gitBackend = {
2890
2943
  const result = { removed: true };
2891
2944
  if (committedRef !== void 0) result.committedRef = committedRef;
2892
2945
  return result;
2946
+ },
2947
+ async listDirtyFiles(workspacePath2) {
2948
+ if (!existsSync3(workspacePath2)) return [];
2949
+ return listGitDirtyFiles(workspacePath2);
2893
2950
  }
2894
2951
  };
2895
2952
  async function resolveGitMainRef(workspacePath2) {
@@ -2998,6 +3055,23 @@ var jjBackend = {
2998
3055
  if (committedRef !== void 0) result.committedRef = committedRef;
2999
3056
  return result;
3000
3057
  },
3058
+ // jj working-copy clean: @ has no diff from its parent.
3059
+ // `jj diff -r @ --summary` prints one line per changed file; empty
3060
+ // stdout = clean. jj's auto-snapshotting means there's no separate
3061
+ // "untracked" bucket — every working-tree change is already in @.
3062
+ async isClean(workspacePath2) {
3063
+ if (!existsSync3(workspacePath2)) return false;
3064
+ try {
3065
+ const out = await run(
3066
+ "jj",
3067
+ ["diff", "-r", "@", "--summary", "--no-pager", "--color", "never"],
3068
+ workspacePath2
3069
+ );
3070
+ return out.length === 0;
3071
+ } catch {
3072
+ return false;
3073
+ }
3074
+ },
3001
3075
  // Compute commits-behind via jj's `trunk()` revset, which resolves
3002
3076
  // to the project's configured trunk (default-branch heuristic).
3003
3077
  // Returns null when trunk() is unresolvable (e.g. fresh repo with
@@ -3118,6 +3192,13 @@ var jjBackend = {
3118
3192
  workspacePath2
3119
3193
  );
3120
3194
  return parseNulRecords(out);
3195
+ },
3196
+ // jj is always-snapshotted: there is no "uncommitted" state. The
3197
+ // working copy is itself a commit; the next snapshot folds any
3198
+ // edits in. Surface that by returning [] so `recreateWorkspace`
3199
+ // never refuses a jj workspace as "dirty".
3200
+ async listDirtyFiles(_workspacePath) {
3201
+ return [];
3121
3202
  }
3122
3203
  };
3123
3204
  function parseNulRecords(raw) {
@@ -3189,6 +3270,18 @@ var slBackend = {
3189
3270
  if (committedRef !== void 0) result.committedRef = committedRef;
3190
3271
  return result;
3191
3272
  },
3273
+ // sl working-copy clean: empty `sl status` output. Same shape as
3274
+ // listSlDirtyFiles below but inlined to keep the failure-mode
3275
+ // boundary tight (any throw → not clean).
3276
+ async isClean(workspacePath2) {
3277
+ if (!existsSync3(workspacePath2)) return false;
3278
+ try {
3279
+ const out = await run("sl", ["status"], workspacePath2);
3280
+ return out.length === 0;
3281
+ } catch {
3282
+ return false;
3283
+ }
3284
+ },
3192
3285
  // Same shape as the jj impl: count commits in trunk() not reachable
3193
3286
  // from ref. Sapling's revset language is close enough to jj's that
3194
3287
  // the same idiom works. Returns null when trunk() is unresolvable
@@ -3265,6 +3358,10 @@ var slBackend = {
3265
3358
  workspacePath2
3266
3359
  );
3267
3360
  return parseNulRecords(out).reverse();
3361
+ },
3362
+ async listDirtyFiles(workspacePath2) {
3363
+ if (!existsSync3(workspacePath2)) return [];
3364
+ return listSlDirtyFiles(workspacePath2);
3268
3365
  }
3269
3366
  };
3270
3367
  async function listSlDirtyFiles(workspacePath2) {
@@ -3307,6 +3404,10 @@ import { existsSync as existsSync4, readdirSync, rmSync as rmSync2 } from "fs";
3307
3404
  import { homedir as homedir2 } from "os";
3308
3405
  import { join as join5, resolve as resolve2 } from "path";
3309
3406
 
3407
+ // src/agents/spawn.ts
3408
+ import { execFile as execFile2 } from "child_process";
3409
+ import { promisify as promisify2 } from "util";
3410
+
3310
3411
  // src/output.ts
3311
3412
  import Table from "cli-table3";
3312
3413
  import picocolors from "picocolors";
@@ -3333,10 +3434,55 @@ function maybeWarnNonConventionalAgentName(name) {
3333
3434
  );
3334
3435
  }
3335
3436
  function resolveCliCommand(cli) {
3336
- const envName = `MU_${cli.toUpperCase()}_COMMAND`;
3437
+ const envName = envVarNameForCli(cli);
3337
3438
  const override = process.env[envName];
3338
3439
  return override && override.trim() !== "" ? override : cli;
3339
3440
  }
3441
+ function envVarNameForCli(cli) {
3442
+ return `MU_${cli.toUpperCase().replace(/-/g, "_")}_COMMAND`;
3443
+ }
3444
+ function resolveCliCommandWithSource(cli) {
3445
+ const envVar = envVarNameForCli(cli);
3446
+ const override = process.env[envVar];
3447
+ if (override !== void 0 && override.trim() !== "") {
3448
+ return { command: override, envVar, resolvedFromEnv: true };
3449
+ }
3450
+ return { command: cli, envVar, resolvedFromEnv: false };
3451
+ }
3452
+ var execFileP = promisify2(execFile2);
3453
+ async function defaultCommandResolver(command) {
3454
+ const binary = parseFirstToken(command);
3455
+ if (binary === "") return { ok: false, binary };
3456
+ try {
3457
+ const { stdout } = await execFileP("/bin/sh", ["-c", `command -v -- ${shellQuote(binary)}`], {
3458
+ env: process.env
3459
+ });
3460
+ const resolvedPath = stdout.trim();
3461
+ if (resolvedPath === "") return { ok: false, binary };
3462
+ return { ok: true, binary, resolvedPath };
3463
+ } catch {
3464
+ return { ok: false, binary };
3465
+ }
3466
+ }
3467
+ function shellQuote(s) {
3468
+ return `'${s.replace(/'/g, "'\\''")}'`;
3469
+ }
3470
+ function parseFirstToken(command) {
3471
+ const trimmed = command.trim();
3472
+ if (trimmed === "") return "";
3473
+ const match = trimmed.match(/^\S+/);
3474
+ return match ? match[0] : "";
3475
+ }
3476
+ var activeCommandResolver = defaultCommandResolver;
3477
+ function setCommandResolverForTests(resolver) {
3478
+ activeCommandResolver = resolver;
3479
+ }
3480
+ function resetCommandResolverForTests() {
3481
+ activeCommandResolver = defaultCommandResolver;
3482
+ }
3483
+ async function checkCommandResolvable(command) {
3484
+ return activeCommandResolver(command);
3485
+ }
3340
3486
  async function spawnAgent(db, opts) {
3341
3487
  if (!isValidAgentName(opts.name)) {
3342
3488
  throw new TypeError(
@@ -3350,33 +3496,36 @@ async function spawnAgent(db, opts) {
3350
3496
  const windowName = opts.tab ?? opts.name;
3351
3497
  const cli = opts.cli ?? "pi";
3352
3498
  const command = opts.command ?? resolveCliCommand(cli);
3499
+ if (opts.command === void 0) {
3500
+ const check = await checkCommandResolvable(command);
3501
+ if (!check.ok) {
3502
+ throw new AgentSpawnCliNotFoundError(cli, check.binary, envVarNameForCli(cli));
3503
+ }
3504
+ }
3353
3505
  const workspacePathStr = opts.workspace ? await prestageWorkspace(db, opts, cli) : void 0;
3354
3506
  const paneEnv = {
3355
3507
  MU_MANAGED_AGENT: "1",
3356
3508
  MU_AGENT_NAME: opts.name,
3357
3509
  MU_WORKSTREAM: opts.workstream
3358
3510
  };
3359
- const paneId = await createOrReusePane({
3360
- session,
3361
- windowName,
3362
- command,
3363
- cwd: workspacePathStr ?? opts.cwd,
3364
- env: paneEnv
3365
- });
3366
3511
  const hasWorkspace = workspacePathStr !== void 0;
3512
+ let paneId;
3367
3513
  let agent;
3368
3514
  try {
3515
+ paneId = await createOrReusePane({
3516
+ session,
3517
+ windowName,
3518
+ command,
3519
+ cwd: workspacePathStr ?? opts.cwd,
3520
+ env: paneEnv
3521
+ });
3369
3522
  await setPaneTitle(paneId, opts.name);
3370
3523
  await enableMuPaneBordersForPane(paneId);
3371
3524
  agent = finalizeAgentRow(db, { opts, cli, paneId, hasWorkspace });
3372
- } catch (err) {
3373
- await rollbackSpawn(db, opts.name, paneId, hasWorkspace, opts.workstream);
3374
- throw err;
3375
- }
3376
- try {
3377
3525
  await awaitSpawnLiveness(paneId, opts.name);
3378
3526
  } catch (err) {
3379
3527
  await rollbackSpawn(db, opts.name, paneId, hasWorkspace, opts.workstream);
3528
+ if (hasWorkspace) attachOrphanCleanupHint(err, opts.name, opts.workstream);
3380
3529
  throw err;
3381
3530
  }
3382
3531
  emitEvent(
@@ -3436,7 +3585,7 @@ function finalizeAgentRow(db, args) {
3436
3585
  return row;
3437
3586
  }
3438
3587
  async function rollbackSpawn(db, name, paneId, hasWorkspace, workstream) {
3439
- await killPane(paneId).catch(() => {
3588
+ if (paneId !== void 0) await killPane(paneId).catch(() => {
3440
3589
  });
3441
3590
  if (hasWorkspace) {
3442
3591
  await freeWorkspace(db, name, { workstream }).catch(() => {
@@ -3444,6 +3593,22 @@ async function rollbackSpawn(db, name, paneId, hasWorkspace, workstream) {
3444
3593
  }
3445
3594
  deleteAgent(db, name, workstream);
3446
3595
  }
3596
+ function attachOrphanCleanupHint(err, agent, workstream) {
3597
+ if (typeof err !== "object" || err === null) return;
3598
+ const target = err;
3599
+ const existing = typeof target.errorNextSteps === "function" ? target.errorNextSteps.bind(target) : null;
3600
+ const orphanHints = [
3601
+ {
3602
+ intent: "Check for an orphan workspace dir (rollback is best-effort; may have failed)",
3603
+ command: `mu workspace orphans -w ${workstream}`
3604
+ },
3605
+ {
3606
+ intent: "Free the workspace if it survived the rollback (idempotent on missing)",
3607
+ command: `mu workspace free ${agent} -w ${workstream}`
3608
+ }
3609
+ ];
3610
+ target.errorNextSteps = () => [...existing ? existing() : [], ...orphanHints];
3611
+ }
3447
3612
  function defaultSpawnLivenessMs() {
3448
3613
  const raw = process.env.MU_SPAWN_LIVENESS_MS;
3449
3614
  if (raw === void 0) return 1500;
@@ -3451,13 +3616,51 @@ function defaultSpawnLivenessMs() {
3451
3616
  if (Number.isNaN(parsed) || parsed < 0) return 1500;
3452
3617
  return parsed;
3453
3618
  }
3619
+ var STARTUP_ERROR_PATTERNS = [
3620
+ /No API key found for [\w-]+/i,
3621
+ /Error: invalid API key/i,
3622
+ /Authentication failed/i,
3623
+ /401 Unauthorized/i,
3624
+ /Could not authenticate/i,
3625
+ // fb_agent_spawn_no_validation part B: post-spawn detection of the
3626
+ // "binary not found at exec time" failure mode. The pre-flight check
3627
+ // above catches the common typo BEFORE any side effect, but a few
3628
+ // edge cases still slip through and only surface in the pane:
3629
+ // - `--command "..."` skips the pre-flight (operator opt-out).
3630
+ // - PATH inside the spawned shell differs from PATH in mu's
3631
+ // process (login shell rc files, /etc/paths.d, etc.).
3632
+ // - Race: binary on PATH at spawn time, gone 1.5s later.
3633
+ // Scoped to the FIRST 30 lines of scrollback (see
3634
+ // STARTUP_ERROR_TAIL_LINES) so a user's later `cat /no/such/file`
3635
+ // can't false-positive long after spawn.
3636
+ /command not found/i,
3637
+ /No such file or directory/i
3638
+ ];
3639
+ var STARTUP_ERROR_TAIL_LINES = 30;
3640
+ function detectSpawnStartupError(scrollback) {
3641
+ const lines = scrollback.split(/\r?\n/);
3642
+ const tail = lines.slice(Math.max(0, lines.length - STARTUP_ERROR_TAIL_LINES));
3643
+ for (const line of tail) {
3644
+ for (const pattern of STARTUP_ERROR_PATTERNS) {
3645
+ if (pattern.test(line)) return line;
3646
+ }
3647
+ }
3648
+ return void 0;
3649
+ }
3454
3650
  async function awaitSpawnLiveness(paneId, agentName) {
3455
3651
  const ms = defaultSpawnLivenessMs();
3456
3652
  if (ms === 0) return;
3457
3653
  await sleep(ms);
3458
3654
  const scrollback = await capturePane(paneId, { lines: 50 }).catch(() => void 0);
3459
- if (await paneExists(paneId)) return;
3460
- throw new AgentDiedOnSpawnError(agentName, paneId, scrollback);
3655
+ if (!await paneExists(paneId)) {
3656
+ throw new AgentDiedOnSpawnError(agentName, paneId, scrollback);
3657
+ }
3658
+ if (scrollback !== void 0) {
3659
+ const matchedLine = detectSpawnStartupError(scrollback);
3660
+ if (matchedLine !== void 0) {
3661
+ throw new AgentSpawnStartupError(agentName, paneId, matchedLine, scrollback);
3662
+ }
3663
+ }
3461
3664
  }
3462
3665
  async function createOrReusePane(opts) {
3463
3666
  if (!await sessionExists(opts.session)) {
@@ -3488,6 +3691,36 @@ async function createOrReusePane(opts) {
3488
3691
  }
3489
3692
 
3490
3693
  // src/agents/errors.ts
3694
+ var AgentSpawnCliNotFoundError = class extends Error {
3695
+ constructor(cli, binary, envVarChecked) {
3696
+ super(
3697
+ `--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".`
3698
+ );
3699
+ this.cli = cli;
3700
+ this.binary = binary;
3701
+ this.envVarChecked = envVarChecked;
3702
+ }
3703
+ cli;
3704
+ binary;
3705
+ envVarChecked;
3706
+ name = "AgentSpawnCliNotFoundError";
3707
+ errorNextSteps() {
3708
+ return [
3709
+ {
3710
+ intent: "Try the default CLI (the one mu's substrate ships against)",
3711
+ command: "mu agent spawn <name> --cli pi"
3712
+ },
3713
+ {
3714
+ intent: "If you meant a custom alias, set the env var to its real path",
3715
+ command: `export ${this.envVarChecked}="<absolute-path-to-binary> [args...]"`
3716
+ },
3717
+ {
3718
+ intent: "List installed CLIs typically supported by mu",
3719
+ command: "which pi pi-meta claude codex"
3720
+ }
3721
+ ];
3722
+ }
3723
+ };
3491
3724
  var AgentExistsError = class extends Error {
3492
3725
  constructor(agentName) {
3493
3726
  super(`agent already exists in this workstream: ${agentName}`);
@@ -3608,6 +3841,51 @@ ${tail}
3608
3841
  ];
3609
3842
  }
3610
3843
  };
3844
+ var AgentSpawnStartupError = class extends Error {
3845
+ constructor(agentName, paneId, matchedLine, scrollback) {
3846
+ super(
3847
+ `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.
3848
+
3849
+ Matched line: ${matchedLine.trim()}
3850
+
3851
+ --- pane scrollback ---
3852
+ ${scrollback.trim()}
3853
+ --- end scrollback ---`
3854
+ );
3855
+ this.agentName = agentName;
3856
+ this.paneId = paneId;
3857
+ this.matchedLine = matchedLine;
3858
+ this.scrollback = scrollback;
3859
+ }
3860
+ agentName;
3861
+ paneId;
3862
+ matchedLine;
3863
+ scrollback;
3864
+ name = "AgentSpawnStartupError";
3865
+ errorNextSteps() {
3866
+ return [
3867
+ {
3868
+ intent: "Inspect the parked pane's scrollback for the full error",
3869
+ command: `mu agent read ${this.agentName} -n 100`
3870
+ },
3871
+ {
3872
+ // Most common today: the operator picked a model whose
3873
+ // provider has no credentials in this env. Default Anthropic
3874
+ // is the safe fallback for pi-meta.
3875
+ intent: "Re-spawn with a CLI command whose provider credentials are present",
3876
+ command: `mu agent spawn ${this.agentName} --command "pi-meta --no-solo" # default Anthropic`
3877
+ },
3878
+ {
3879
+ intent: "Or set the missing API key env var for the provider you wanted, then re-spawn",
3880
+ command: "export ANTHROPIC_API_KEY=... # or AWS_BEARER_TOKEN_BEDROCK, OPENAI_API_KEY, ..."
3881
+ },
3882
+ {
3883
+ intent: "Disable the startup-error scan if you actually wanted that prompt (CI / scripted recovery)",
3884
+ command: "export MU_SPAWN_LIVENESS_MS=0"
3885
+ }
3886
+ ];
3887
+ }
3888
+ };
3611
3889
  var WorkspacePreservedError = class extends Error {
3612
3890
  constructor(agentName, workspacePath2) {
3613
3891
  super(
@@ -3864,11 +4142,13 @@ async function createWorkspace(db, opts) {
3864
4142
  });
3865
4143
  throw err;
3866
4144
  }
3867
- emitEvent(
3868
- db,
3869
- opts.workstream,
3870
- `workspace create ${opts.agent} (backend=${backend.name}, path=${path}${created.parentRef ? `, parent=${created.parentRef.slice(0, 12)}` : ""})`
3871
- );
4145
+ if (opts._suppressEvent !== true) {
4146
+ emitEvent(
4147
+ db,
4148
+ opts.workstream,
4149
+ `workspace create ${opts.agent} (backend=${backend.name}, path=${path}${created.parentRef ? `, parent=${created.parentRef.slice(0, 12)}` : ""})`
4150
+ );
4151
+ }
3872
4152
  return {
3873
4153
  agentName: opts.agent,
3874
4154
  workstreamName: opts.workstream,
@@ -3934,10 +4214,30 @@ async function mapWithConcurrency(items, limit, fn) {
3934
4214
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
3935
4215
  return results;
3936
4216
  }
4217
+ async function isWorkspaceClean(row) {
4218
+ const backend = backendByName(row.backend);
4219
+ let clean;
4220
+ try {
4221
+ clean = await backend.isClean(row.path);
4222
+ } catch {
4223
+ return false;
4224
+ }
4225
+ if (!clean) return false;
4226
+ if (row.backend === "none") return true;
4227
+ if (row.parentRef === null || row.parentRef.length === 0) return false;
4228
+ try {
4229
+ const commits = await backend.commitsSinceBase(row.path, row.parentRef);
4230
+ return commits.length === 0;
4231
+ } catch {
4232
+ return false;
4233
+ }
4234
+ }
3937
4235
  async function freeWorkspace(db, agent, opts) {
3938
4236
  const row = getWorkspaceForAgent(db, agent, opts.workstream);
3939
4237
  if (!row) return { removed: false, rowDeleted: false };
3940
- captureSnapshot(db, `workspace free ${agent}`, row.workstreamName);
4238
+ if (opts._suppressEvent !== true) {
4239
+ captureSnapshot(db, `workspace free ${agent}`, row.workstreamName);
4240
+ }
3941
4241
  const backend = backendByName(row.backend);
3942
4242
  const result = await backend.freeWorkspace({
3943
4243
  workspacePath: row.path,
@@ -3949,17 +4249,55 @@ async function freeWorkspace(db, agent, opts) {
3949
4249
  WHERE agent_id = (SELECT id FROM agents WHERE name = ? AND workstream_id = ?)
3950
4250
  AND workstream_id = ?`
3951
4251
  ).run(agent, wsIdForDel, wsIdForDel);
3952
- emitEvent(
3953
- db,
3954
- row.workstreamName,
3955
- `workspace free ${agent} (backend=${row.backend}, path=${row.path}${result.committedRef ? `, committed=${result.committedRef.slice(0, 12)}` : ""})`
3956
- );
4252
+ if (opts._suppressEvent !== true) {
4253
+ emitEvent(
4254
+ db,
4255
+ row.workstreamName,
4256
+ `workspace free ${agent} (backend=${row.backend}, path=${row.path}${result.committedRef ? `, committed=${result.committedRef.slice(0, 12)}` : ""})`
4257
+ );
4258
+ }
3957
4259
  return {
3958
4260
  removed: result.removed,
3959
4261
  rowDeleted: del.changes > 0,
3960
4262
  ...result.committedRef !== void 0 ? { committedRef: result.committedRef } : {}
3961
4263
  };
3962
4264
  }
4265
+ async function recreateWorkspace(db, agent, opts) {
4266
+ const row = getWorkspaceForAgent(db, agent, opts.workstream);
4267
+ if (!row) throw new WorkspaceNotFoundError(agent);
4268
+ if (opts.force !== true) {
4269
+ const oldBackend = backendByName(row.backend);
4270
+ const dirty = await oldBackend.listDirtyFiles(row.path);
4271
+ if (dirty.length > 0) {
4272
+ throw new WorkspaceDirtyError(row.path, dirty, "recreate");
4273
+ }
4274
+ }
4275
+ captureSnapshot(db, `workspace recreate ${agent}`, row.workstreamName);
4276
+ await freeWorkspace(db, agent, {
4277
+ workstream: opts.workstream,
4278
+ commit: false,
4279
+ _suppressEvent: true
4280
+ });
4281
+ const createOpts = {
4282
+ agent,
4283
+ workstream: opts.workstream,
4284
+ _suppressEvent: true
4285
+ };
4286
+ if (opts.projectRoot !== void 0) createOpts.projectRoot = opts.projectRoot;
4287
+ if (opts.backend !== void 0) {
4288
+ createOpts.backend = opts.backend;
4289
+ } else {
4290
+ createOpts.backend = row.backend;
4291
+ }
4292
+ if (opts.parentRef !== void 0) createOpts.parentRef = opts.parentRef;
4293
+ const fresh = await createWorkspace(db, createOpts);
4294
+ emitEvent(
4295
+ db,
4296
+ opts.workstream,
4297
+ `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"})`
4298
+ );
4299
+ return { workspace: fresh, previousParentRef: row.parentRef };
4300
+ }
3963
4301
 
3964
4302
  // src/workstream.ts
3965
4303
  var WORKSTREAM_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
@@ -4218,7 +4556,6 @@ async function waitForTasks(db, input, opts) {
4218
4556
  const stuckAfterMs = opts.stuckAfterMs ?? 3e5;
4219
4557
  const onStall = opts.onStall ?? "warn";
4220
4558
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Number.POSITIVE_INFINITY;
4221
- const startedAt = Date.now();
4222
4559
  for (const ref of refs) {
4223
4560
  if (getTask(db, ref.name, ref.workstreamName) === void 0)
4224
4561
  throw new TaskNotFoundError(`${ref.workstreamName}/${ref.name}`);
@@ -4239,7 +4576,7 @@ async function waitForTasks(db, input, opts) {
4239
4576
  return ageMs >= stuckAfterMs;
4240
4577
  };
4241
4578
  const snapshot = () => {
4242
- const tasks = refs.map((ref) => {
4579
+ const refStates = refs.map((ref) => {
4243
4580
  const row = getTask(db, ref.name, ref.workstreamName);
4244
4581
  const status = row?.status ?? "OPEN";
4245
4582
  const owner = row?.ownerName ?? null;
@@ -4271,16 +4608,15 @@ async function waitForTasks(db, input, opts) {
4271
4608
  stuck
4272
4609
  };
4273
4610
  });
4274
- const reachedCount = tasks.filter((t) => t.reachedTarget).length;
4275
4611
  return {
4276
- tasks,
4277
- allReached: reachedCount === tasks.length,
4278
- anyReached: reachedCount > 0,
4279
- elapsedMs: Date.now() - startedAt,
4612
+ refs: refStates,
4280
4613
  timedOut: false
4281
4614
  };
4282
4615
  };
4283
- const isDone = (snap2) => wantAny ? snap2.anyReached : snap2.allReached;
4616
+ const isDone = (snap2) => {
4617
+ const reached = snap2.refs.filter((r) => r.reachedTarget).length;
4618
+ return wantAny ? reached > 0 : reached === snap2.refs.length;
4619
+ };
4284
4620
  if (opts.beforePoll) await opts.beforePoll();
4285
4621
  let snap = snapshot();
4286
4622
  if (isDone(snap)) return snap;
@@ -4341,7 +4677,15 @@ function closeTask(db, localId, opts) {
4341
4677
  if (before && before.status !== "CLOSED") {
4342
4678
  captureSnapshot(db, `task close ${localId}`, before.workstreamName);
4343
4679
  }
4344
- return setTaskStatus(db, localId, "CLOSED", opts);
4680
+ const r = setTaskStatus(db, localId, "CLOSED", opts);
4681
+ if (r.changed && before && opts.evidence !== void 0 && opts.evidence !== "") {
4682
+ const noteOpts = {
4683
+ workstream: before.workstreamName
4684
+ };
4685
+ if (opts.author !== void 0 && opts.author !== "") noteOpts.author = opts.author;
4686
+ addNote(db, localId, `CLOSE: ${opts.evidence}`, noteOpts);
4687
+ }
4688
+ return r;
4345
4689
  }
4346
4690
  function openTask(db, localId, opts) {
4347
4691
  return setTaskStatus(db, localId, "OPEN", opts);
@@ -4594,7 +4938,6 @@ var SELECT_NOTE_COLS = `
4594
4938
  function rowFromDb4(row) {
4595
4939
  return {
4596
4940
  name: row.local_id,
4597
- localId: row.local_id,
4598
4941
  workstreamName: row.workstream,
4599
4942
  title: row.title,
4600
4943
  status: row.status,
@@ -4652,10 +4995,13 @@ function slugifyTitleVerbose(title) {
4652
4995
  const lastSep = window.lastIndexOf("_");
4653
4996
  trimmed = lastSep > 0 ? window.slice(0, lastSep) : window;
4654
4997
  }
4655
- const slug = /^[a-z]/.test(trimmed) ? trimmed.slice(0, SLUG_HARD_CAP) : `t_${trimmed}`.slice(0, SLUG_HARD_CAP);
4998
+ const applyPrefix = (s) => /^[a-z]/.test(s) ? s.slice(0, SLUG_HARD_CAP) : `t_${s}`.slice(0, SLUG_HARD_CAP);
4999
+ const slug = applyPrefix(trimmed);
5000
+ const originalSlug = applyPrefix(stripped);
4656
5001
  return {
4657
5002
  slug,
4658
5003
  strippedLength: stripped.length,
5004
+ originalSlug,
4659
5005
  truncated: trimmed.length < stripped.length
4660
5006
  };
4661
5007
  }
@@ -4663,11 +5009,12 @@ function idFromTitle(db, workstream, title) {
4663
5009
  return idFromTitleVerbose(db, workstream, title).id;
4664
5010
  }
4665
5011
  function idFromTitleVerbose(db, workstream, title) {
4666
- const { slug: base, truncated } = slugifyTitleVerbose(title);
4667
- if (getTask(db, base, workstream) === void 0) return { id: base, truncated };
5012
+ const { slug: base, truncated, originalSlug } = slugifyTitleVerbose(title);
5013
+ if (getTask(db, base, workstream) === void 0) return { id: base, truncated, originalSlug };
4668
5014
  for (let i = 2; i < 1e3; i++) {
4669
5015
  const candidate = `${base}_${i}`.slice(0, SLUG_HARD_CAP);
4670
- if (getTask(db, candidate, workstream) === void 0) return { id: candidate, truncated };
5016
+ if (getTask(db, candidate, workstream) === void 0)
5017
+ return { id: candidate, truncated, originalSlug };
4671
5018
  }
4672
5019
  throw new Error(`could not derive a unique id from title in workstream ${workstream}: ${title}`);
4673
5020
  }
@@ -4765,14 +5112,26 @@ function listRecentClosed(db, workstream, limit = 5) {
4765
5112
  ).all(wsId, limit);
4766
5113
  return rows.map(rowFromDb4);
4767
5114
  }
4768
- function listNotes(db, taskLocalId, workstream) {
5115
+ function listNotes(db, taskLocalId, workstream, opts = {}) {
4769
5116
  const taskId = taskIdFor(db, taskLocalId, workstream);
4770
5117
  if (taskId === null) return [];
4771
- const rows = db.prepare(
5118
+ let cutoff = opts.since;
5119
+ if (cutoff === void 0 && opts.sinceClaim === true) {
5120
+ const at = lastClaimEventAt(db, workstream, taskLocalId);
5121
+ if (at !== null) cutoff = at;
5122
+ }
5123
+ const rows = cutoff !== void 0 ? db.prepare(
4772
5124
  `SELECT ${SELECT_NOTE_COLS} FROM task_notes n JOIN tasks t ON t.id = n.task_id
4773
- WHERE n.task_id = ? ORDER BY n.id`
5125
+ WHERE n.task_id = ? AND n.created_at > ? ORDER BY n.id`
5126
+ ).all(taskId, cutoff) : db.prepare(
5127
+ `SELECT ${SELECT_NOTE_COLS} FROM task_notes n JOIN tasks t ON t.id = n.task_id
5128
+ WHERE n.task_id = ? ORDER BY n.id`
4774
5129
  ).all(taskId);
4775
- return rows.map(noteFromDb);
5130
+ const mapped = rows.map(noteFromDb);
5131
+ if (opts.tail !== void 0 && opts.tail >= 0) {
5132
+ return opts.tail === 0 ? [] : mapped.slice(-opts.tail);
5133
+ }
5134
+ return mapped;
4776
5135
  }
4777
5136
  function listTasksByOwner(db, workstream, owner, opts = {}) {
4778
5137
  const filter = opts.includeClosed ? "" : "AND t.status NOT IN ('CLOSED', 'REJECTED', 'DEFERRED')";
@@ -5194,6 +5553,148 @@ async function adoptAgent(db, opts) {
5194
5553
  };
5195
5554
  }
5196
5555
 
5556
+ // src/agents/kick.ts
5557
+ var ALLOWED_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL"];
5558
+ function isKickSignal(s) {
5559
+ return ALLOWED_SIGNALS.includes(s);
5560
+ }
5561
+ var NoForegroundProcessError = class extends Error {
5562
+ constructor(agentName, tty, reason) {
5563
+ 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)`;
5564
+ super(`agent ${agentName}: ${detail}`);
5565
+ this.agentName = agentName;
5566
+ this.tty = tty;
5567
+ this.reason = reason;
5568
+ }
5569
+ agentName;
5570
+ tty;
5571
+ reason;
5572
+ name = "NoForegroundProcessError";
5573
+ errorNextSteps() {
5574
+ return [
5575
+ {
5576
+ intent: "Inspect what's running in the pane",
5577
+ command: `mu agent show ${this.agentName} -n 50`
5578
+ },
5579
+ {
5580
+ intent: "Close the agent (kills the wrapping CLI + pane)",
5581
+ command: `mu agent close ${this.agentName}`
5582
+ }
5583
+ ];
5584
+ }
5585
+ };
5586
+ var realExecutor2 = async (cmd, args) => {
5587
+ const { execa: execa2 } = await import("execa");
5588
+ const result = await execa2(cmd, [...args], { reject: false });
5589
+ return {
5590
+ stdout: result.stdout ?? "",
5591
+ stderr: result.stderr ?? "",
5592
+ exitCode: result.exitCode ?? null
5593
+ };
5594
+ };
5595
+ var currentExecutor2 = realExecutor2;
5596
+ function setKickProcessExecutor(executor) {
5597
+ const previous = currentExecutor2;
5598
+ currentExecutor2 = executor;
5599
+ return previous;
5600
+ }
5601
+ function resetKickProcessExecutor() {
5602
+ currentExecutor2 = realExecutor2;
5603
+ }
5604
+ function parsePsTtyOutput(output) {
5605
+ const rows = [];
5606
+ for (const raw of output.split("\n")) {
5607
+ const line = raw.trim();
5608
+ if (line === "") continue;
5609
+ const parts = line.split(/\s+/);
5610
+ if (parts.length < 4) continue;
5611
+ const [pidStr, pgidStr, stat2, ...commParts] = parts;
5612
+ if (pidStr === void 0 || pgidStr === void 0 || stat2 === void 0) continue;
5613
+ const pid = Number.parseInt(pidStr, 10);
5614
+ const pgid = Number.parseInt(pgidStr, 10);
5615
+ if (!Number.isFinite(pid) || !Number.isFinite(pgid)) continue;
5616
+ rows.push({ pid, pgid, stat: stat2, comm: commParts.join(" ") });
5617
+ }
5618
+ return rows;
5619
+ }
5620
+ var WRAPPER_COMM_PREFIXES = [
5621
+ "pi",
5622
+ "claude",
5623
+ "codex",
5624
+ "bash",
5625
+ "zsh",
5626
+ "sh",
5627
+ "fish",
5628
+ "dash"
5629
+ ];
5630
+ function isWrapperComm(comm) {
5631
+ const cleaned = comm.replace(/^-/, "").trim();
5632
+ if (cleaned === "") return false;
5633
+ for (const prefix of WRAPPER_COMM_PREFIXES) {
5634
+ if (cleaned === prefix) return true;
5635
+ if (cleaned.startsWith(`${prefix}-`)) return true;
5636
+ }
5637
+ return false;
5638
+ }
5639
+ async function foregroundPgid(tty) {
5640
+ const ttyShort = tty.startsWith("/dev/") ? tty.slice("/dev/".length) : tty;
5641
+ const result = await currentExecutor2("ps", ["-t", ttyShort, "-o", "pid=,pgid=,stat=,comm="]);
5642
+ if (result.exitCode !== 0 && result.stdout.trim() === "") {
5643
+ return { kind: "no-foreground", rows: [] };
5644
+ }
5645
+ const rows = parsePsTtyOutput(result.stdout);
5646
+ if (rows.length === 0) return { kind: "no-foreground", rows };
5647
+ const fg = rows.find((r) => r.stat.includes("+"));
5648
+ if (!fg) {
5649
+ return { kind: "no-foreground", rows };
5650
+ }
5651
+ if (isWrapperComm(fg.comm)) {
5652
+ return { kind: "shell-only", pgid: fg.pgid, fgRow: fg, rows };
5653
+ }
5654
+ return { kind: "ok", pgid: fg.pgid, fgRow: fg, rows };
5655
+ }
5656
+ async function killPgrp(pgid, signal) {
5657
+ const result = await currentExecutor2("kill", [`-${signal}`, `-${pgid}`]);
5658
+ if (result.exitCode !== 0) {
5659
+ if (/no such process/i.test(result.stderr)) return;
5660
+ throw new Error(
5661
+ `kill -${signal} -${pgid} failed (exit ${result.exitCode}): ${result.stderr.trim() || "no stderr"}`
5662
+ );
5663
+ }
5664
+ }
5665
+ async function kickAgent(db, name, opts) {
5666
+ const signal = opts.signal ?? "SIGINT";
5667
+ const agent = getAgent(db, name, opts.workstream);
5668
+ if (!agent) throw new AgentNotFoundError(name, opts.workstream);
5669
+ const tty = await paneTTY(agent.paneId);
5670
+ const lookup = await foregroundPgid(tty);
5671
+ if (lookup.kind === "no-foreground") {
5672
+ throw new NoForegroundProcessError(name, tty, "no-foreground");
5673
+ }
5674
+ if (lookup.kind === "shell-only") {
5675
+ throw new NoForegroundProcessError(name, tty, "shell-only");
5676
+ }
5677
+ const pgid = lookup.pgid;
5678
+ const fgRow = lookup.fgRow;
5679
+ if (pgid === void 0 || fgRow === void 0) {
5680
+ throw new NoForegroundProcessError(name, tty, "no-foreground");
5681
+ }
5682
+ await killPgrp(pgid, signal);
5683
+ emitEvent(
5684
+ db,
5685
+ agent.workstreamName,
5686
+ `agent kick ${name} (signal=${signal}, pgid=${pgid}, comm=${fgRow.comm})`
5687
+ );
5688
+ return {
5689
+ agentName: name,
5690
+ paneId: agent.paneId,
5691
+ tty,
5692
+ signaledPgid: pgid,
5693
+ signal,
5694
+ foregroundComm: fgRow.comm
5695
+ };
5696
+ }
5697
+
5197
5698
  // src/agents.ts
5198
5699
  var DEFAULT_IDLE_THRESHOLD_MS = 3e5;
5199
5700
  function idleThresholdMs() {
@@ -5418,15 +5919,24 @@ function freeAgent(db, name, workstream) {
5418
5919
  async function closeAgent(db, name, opts) {
5419
5920
  const agent = getAgent(db, name, opts.workstream);
5420
5921
  if (!agent) {
5421
- return { killedPane: false, deletedRow: false, workspaceFreed: false };
5922
+ return {
5923
+ killedPane: false,
5924
+ deletedRow: false,
5925
+ workspaceFreed: false,
5926
+ workspaceAutoFreedClean: false
5927
+ };
5422
5928
  }
5423
5929
  const ws = getWorkspaceForAgent(db, name, agent.workstreamName);
5930
+ let autoFreeClean = false;
5424
5931
  if (ws !== void 0 && opts.discardWorkspace !== true) {
5425
- throw new WorkspacePreservedError(name, ws.path);
5932
+ autoFreeClean = await isWorkspaceClean(ws);
5933
+ if (!autoFreeClean) {
5934
+ throw new WorkspacePreservedError(name, ws.path);
5935
+ }
5426
5936
  }
5427
5937
  captureSnapshot(db, `agent close ${name}`, agent.workstreamName);
5428
5938
  let workspaceFreed = false;
5429
- if (ws !== void 0 && opts.discardWorkspace === true) {
5939
+ if (ws !== void 0 && (opts.discardWorkspace === true || autoFreeClean)) {
5430
5940
  await freeWorkspace(db, name, { commit: false, workstream: agent.workstreamName });
5431
5941
  workspaceFreed = true;
5432
5942
  }
@@ -5436,12 +5946,13 @@ async function closeAgent(db, name, opts) {
5436
5946
  emitEvent(
5437
5947
  db,
5438
5948
  agent.workstreamName,
5439
- `agent close ${name} (pane=${agent.paneId}${workspaceFreed ? ", workspace discarded" : ""})`
5949
+ `agent close ${name} (pane=${agent.paneId}${workspaceFreed ? autoFreeClean ? ", workspace auto-freed (clean)" : ", workspace discarded" : ""})`
5440
5950
  );
5441
5951
  return {
5442
5952
  killedPane: true,
5443
5953
  deletedRow,
5444
- workspaceFreed
5954
+ workspaceFreed,
5955
+ workspaceAutoFreedClean: workspaceFreed && autoFreeClean
5445
5956
  };
5446
5957
  }
5447
5958
  async function listLiveAgents(db, opts) {
@@ -5605,24 +6116,6 @@ var ImportSourceNotInBucketError = class extends Error {
5605
6116
  ];
5606
6117
  }
5607
6118
  };
5608
- var ImportLegacyLayoutError = class extends Error {
5609
- constructor(bucketDir) {
5610
- super(
5611
- `${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.`
5612
- );
5613
- this.bucketDir = bucketDir;
5614
- }
5615
- bucketDir;
5616
- name = "ImportLegacyLayoutError";
5617
- errorNextSteps() {
5618
- return [
5619
- {
5620
- intent: "Re-export the source workstream into a new bucket",
5621
- command: "mu workstream export -w <ws> --out <new-bucket-dir>"
5622
- }
5623
- ];
5624
- }
5625
- };
5626
6119
  var WorkstreamAlreadyExistsError = class extends Error {
5627
6120
  constructor(workstream) {
5628
6121
  super(
@@ -5906,9 +6399,6 @@ function walkBucket(bucketDir) {
5906
6399
  if (probe.kind === "corrupt") {
5907
6400
  throw new ImportBucketInvalidError(bucketDir, "manifest.json is unreadable / malformed");
5908
6401
  }
5909
- if (probe.kind === "legacy") {
5910
- throw new ImportLegacyLayoutError(bucketDir);
5911
- }
5912
6402
  const tasksDir = join7(bucketDir, "tasks");
5913
6403
  const looksLikeSourceWs = existsSync6(join7(bucketDir, "README.md")) && existsSync6(join7(bucketDir, "INDEX.md")) && existsSync6(tasksDir) && statSync3(tasksDir).isDirectory();
5914
6404
  if (!looksLikeSourceWs) {
@@ -6127,6 +6617,8 @@ export {
6127
6617
  AgentExistsError,
6128
6618
  AgentNotFoundError,
6129
6619
  AgentNotInWorkstreamError,
6620
+ AgentSpawnCliNotFoundError,
6621
+ AgentSpawnStartupError,
6130
6622
  ArchiveAlreadyExistsError,
6131
6623
  ArchiveLabelInvalidError,
6132
6624
  ArchiveNotFoundError,
@@ -6139,8 +6631,7 @@ export {
6139
6631
  ImportBucketInvalidError,
6140
6632
  ImportEdgeRefMissingError,
6141
6633
  ImportFrontmatterParseError,
6142
- ImportLegacyLayoutError,
6143
- LegacyExportLayoutError,
6634
+ NoForegroundProcessError,
6144
6635
  PANE_ID_RE,
6145
6636
  PaneNotFoundError,
6146
6637
  PruneOptionsInvalidError,
@@ -6174,6 +6665,7 @@ export {
6174
6665
  backendByName,
6175
6666
  capturePane,
6176
6667
  captureSnapshot,
6668
+ checkCommandResolvable,
6177
6669
  claimTask,
6178
6670
  closeAgent,
6179
6671
  closeTask,
@@ -6198,11 +6690,13 @@ export {
6198
6690
  emitEvent,
6199
6691
  ensureWorkstream,
6200
6692
  ensureWorkstreamStateDir,
6693
+ envVarNameForCli,
6201
6694
  exportArchive,
6202
6695
  exportSourceForWorkstream,
6203
6696
  exportSourcesForArchive,
6204
6697
  exportWorkstream,
6205
6698
  extractTail,
6699
+ foregroundPgid,
6206
6700
  freeAgent,
6207
6701
  freeWorkspace,
6208
6702
  gcMaxAgeDays,
@@ -6223,6 +6717,7 @@ export {
6223
6717
  idFromTitleVerbose,
6224
6718
  importBucket,
6225
6719
  insertAgent,
6720
+ isKickSignal,
6226
6721
  isStaleVersion,
6227
6722
  isTaskStatus,
6228
6723
  isValidAgentName,
@@ -6231,6 +6726,7 @@ export {
6231
6726
  isValidTaskId,
6232
6727
  isValidWorkstreamName,
6233
6728
  jjBackend,
6729
+ kickAgent,
6234
6730
  killPane,
6235
6731
  killSession,
6236
6732
  latestSeq,
@@ -6263,10 +6759,13 @@ export {
6263
6759
  openDb,
6264
6760
  openTask,
6265
6761
  paneExists,
6762
+ paneTTY,
6266
6763
  parseAgentNameFromTitle,
6764
+ parsePsTtyOutput,
6267
6765
  pruneSnapshots,
6268
6766
  readAgent,
6269
6767
  reconcile,
6768
+ recreateWorkspace,
6270
6769
  refreshAgentTitle,
6271
6770
  rejectTask,
6272
6771
  releaseTask,
@@ -6274,11 +6773,14 @@ export {
6274
6773
  removeFromArchive,
6275
6774
  renderToBucket,
6276
6775
  reparentTask,
6776
+ resetCommandResolverForTests,
6777
+ resetKickProcessExecutor,
6277
6778
  resetSleep,
6278
6779
  resetTmuxExecutor,
6279
6780
  resetWaitPollCount,
6280
6781
  resolveActorIdentity,
6281
6782
  resolveCliCommand,
6783
+ resolveCliCommandWithSource,
6282
6784
  restoreSnapshot,
6283
6785
  searchArchives,
6284
6786
  searchTasks,
@@ -6286,6 +6788,8 @@ export {
6286
6788
  sendToAgent,
6287
6789
  sendToPane,
6288
6790
  sessionExists,
6791
+ setCommandResolverForTests,
6792
+ setKickProcessExecutor,
6289
6793
  setPaneTitle,
6290
6794
  setSleepForTests,
6291
6795
  setTaskStatus,