@mu-crew/mu 3.6.1 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Database } from 'better-sqlite3';
2
2
 
3
- type CtlState = "busy" | "idle" | "needs_input";
3
+ type CtlState = "busy" | "idle";
4
4
  /** Session commands the `command` op runs: `/new`, `/reload`, `/compact`. */
5
5
  declare const CTL_COMMANDS: readonly ["new", "reload", "compact"];
6
6
  type CtlCommandName = (typeof CTL_COMMANDS)[number];
@@ -17,6 +17,11 @@ interface StateReading {
17
17
  reason?: string;
18
18
  /** Set for agents that expect a control socket. */
19
19
  ctl?: Exclude<CtlLink, "n/a">;
20
+ /** ctl only: pi holds queued messages, so an idle reading is about to
21
+ * turn busy. */
22
+ pending?: boolean;
23
+ /** ctl only: pi's settled-run count (`mu agent wait --after-runs`). */
24
+ runs?: number;
20
25
  }
21
26
  interface StateAgentRef {
22
27
  name: string;
@@ -1460,7 +1465,10 @@ type SpawnedAgent = AgentRow & {
1460
1465
  * 4. setPaneTitle + enableMuPaneBordersForPane.
1461
1466
  * 5. finalizeAgentRow() — patch placeholder pane_id to real (workspace
1462
1467
  * path), or insert a fresh agent row (no-workspace path).
1463
- * 6. awaitSpawnLiveness().
1468
+ * 6. Liveness: awaitSpawnLiveness() (fixed sleep, then pane + scan
1469
+ * check), or for a tmux pi agent the ctl handshake with a pane
1470
+ * check per tick and the scan at the end. herdr: startAgentInPane.
1471
+ * 7. ctl handshake for herdr pi agents (outside the rollback).
1464
1472
  *
1465
1473
  * Failure between any of (3)–(6) calls rollbackSpawn() to undo the
1466
1474
  * pane + row + workspace. The caller-visible error is preserved.
@@ -1645,7 +1653,9 @@ declare class AgentDiedOnSpawnError extends Error implements HasNextSteps {
1645
1653
  readonly remote: boolean;
1646
1654
  constructor(agentName: string, paneId: string, scrollback: string | undefined,
1647
1655
  /** The command the pane ran; an `ssh ...` command gets remote hints. */
1648
- command?: string | undefined);
1656
+ command?: string | undefined,
1657
+ /** When it died; default `within <MU_SPAWN_LIVENESS_MS>ms of spawn`. */
1658
+ when?: string);
1649
1659
  errorNextSteps(): NextStep[];
1650
1660
  }
1651
1661
  /**
@@ -1691,7 +1701,9 @@ declare class AgentSpawnStartupError extends Error implements HasNextSteps {
1691
1701
  matchedLine: string,
1692
1702
  /** Full captured scrollback (tail-trimmed already by
1693
1703
  * awaitSpawnLiveness). Attached to the message for context. */
1694
- scrollback: string);
1704
+ scrollback: string,
1705
+ /** When it was seen; default `within <MU_SPAWN_LIVENESS_MS>ms of spawn`. */
1706
+ when?: string);
1695
1707
  errorNextSteps(): NextStep[];
1696
1708
  }
1697
1709
  /**
@@ -1918,6 +1930,13 @@ type Transport = "ctl" | "mux";
1918
1930
  type SendResult = {
1919
1931
  transport: Transport;
1920
1932
  state?: CtlState;
1933
+ /**
1934
+ * ctl only: pi's completed-run count. For a plain send it is measured
1935
+ * before dispatch (the `mu agent wait --after-runs` baseline); for
1936
+ * `--fresh` it is the new session's count once its run started.
1937
+ * Absent from an extension that predates it.
1938
+ */
1939
+ runs?: number;
1921
1940
  /** Set when the text ran as a pi session command over ctl. */
1922
1941
  command?: CtlCommandName;
1923
1942
  };
@@ -3437,6 +3456,10 @@ interface TaskWaitOptions {
3437
3456
  * observation-only (wait keeps polling); `onStall: 'exit'` makes it
3438
3457
  * terminal. */
3439
3458
  stuckAfterMs?: number;
3459
+ /** `stuckAfterMs` for an owner whose reading came from a working
3460
+ * control socket (source ctl, ctl ok). Default
3461
+ * `DEFAULT_CTL_STUCK_AFTER_MS`. `stuckAfterMs: 0` disables both. */
3462
+ ctlStuckAfterMs?: number;
3440
3463
  /** What to do when the `--stuck-after` predicate fires on a watched
3441
3464
  * task. `'warn'` (default) = today's behaviour: yellow STUCK line
3442
3465
  * to stderr (deduped per task per wait call) + corroborating
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { execa as execa3 } from "execa";
12
12
 
13
13
  // src/agents/transport.ts
14
14
  import { existsSync as existsSync8 } from "fs";
15
- import { dirname as dirname6 } from "path";
15
+ import { dirname as dirname7 } from "path";
16
16
 
17
17
  // src/ctl/client.ts
18
18
  import { createConnection } from "net";
@@ -144,6 +144,7 @@ async function ctlProbe(sock, timeoutMs = CTL_DEFAULT_TIMEOUT_MS) {
144
144
 
145
145
  // src/ctl/path.ts
146
146
  import { createHash } from "crypto";
147
+ import { rmSync } from "fs";
147
148
  import { dirname as dirname2, join as join2 } from "path";
148
149
 
149
150
  // src/db.ts
@@ -854,6 +855,13 @@ function ctlSocketPath(workstream, agent, stateDir) {
854
855
  const base = stateDir ?? (dbPath ? dirname2(dbPath) : defaultStateDir());
855
856
  return sockPathUnder(join2(base, "sock"), workstream, agent);
856
857
  }
858
+ function unlinkCtlSocket(db, name, workstream) {
859
+ if (db.memory) return;
860
+ try {
861
+ rmSync(ctlSocketPath(workstream, name, dirname2(db.name)), { force: true });
862
+ } catch {
863
+ }
864
+ }
857
865
 
858
866
  // src/mux.ts
859
867
  var mux_exports = {};
@@ -2254,13 +2262,12 @@ function resetMux() {
2254
2262
  // src/agents/spawn.ts
2255
2263
  import { execFile as execFile2 } from "child_process";
2256
2264
  import { mkdirSync as mkdirSync2, rmSync as rmSync4 } from "fs";
2257
- import { basename, dirname as dirname5 } from "path";
2265
+ import { basename, dirname as dirname6 } from "path";
2258
2266
  import { setTimeout as delay } from "timers/promises";
2259
2267
  import { promisify as promisify2 } from "util";
2260
2268
 
2261
2269
  // src/agents.ts
2262
- import { rmSync as rmSync3 } from "fs";
2263
- import { dirname as dirname4 } from "path";
2270
+ import { dirname as dirname5 } from "path";
2264
2271
 
2265
2272
  // src/glyphs.ts
2266
2273
  var AGENT_STATE_GLYPH = {
@@ -2856,7 +2863,7 @@ function workspacesRoot(workstream) {
2856
2863
  }
2857
2864
 
2858
2865
  // src/workspace/crud.ts
2859
- import { existsSync as existsSync6, rmSync as rmSync2 } from "fs";
2866
+ import { existsSync as existsSync6, rmSync as rmSync3 } from "fs";
2860
2867
  import { homedir as homedir2 } from "os";
2861
2868
  import { resolve as resolve3 } from "path";
2862
2869
 
@@ -2866,7 +2873,7 @@ import { resolve as resolve2 } from "path";
2866
2873
 
2867
2874
  // src/vcs/helpers.ts
2868
2875
  import { execFile } from "child_process";
2869
- import { existsSync, rmSync } from "fs";
2876
+ import { existsSync, rmSync as rmSync2 } from "fs";
2870
2877
  import { mkdir } from "fs/promises";
2871
2878
  import { dirname as dirname3 } from "path";
2872
2879
  import { promisify } from "util";
@@ -2972,7 +2979,7 @@ async function ensureParent(path) {
2972
2979
  }
2973
2980
  function rmDirSync(path) {
2974
2981
  if (!existsSync(path)) return false;
2975
- rmSync(path, { recursive: true, force: true });
2982
+ rmSync2(path, { recursive: true, force: true });
2976
2983
  return true;
2977
2984
  }
2978
2985
  async function run(bin, args, cwd) {
@@ -3797,7 +3804,7 @@ async function createWorkspace(db, opts) {
3797
3804
  created = await backend.createWorkspace(createOpts);
3798
3805
  } catch (err) {
3799
3806
  try {
3800
- rmSync2(path, { recursive: true, force: true });
3807
+ rmSync3(path, { recursive: true, force: true });
3801
3808
  } catch {
3802
3809
  }
3803
3810
  throw err;
@@ -4438,8 +4445,17 @@ var CycleError = class extends Error {
4438
4445
  ];
4439
4446
  }
4440
4447
  };
4448
+ function stallWaitHint(owner, ws, runs) {
4449
+ if (runs === void 0 || runs < 1) return [];
4450
+ return [
4451
+ {
4452
+ intent: "Read the worker's last answer (its final text, at once)",
4453
+ command: `mu agent wait ${owner} --after-runs ${runs - 1} --json -w ${ws}`
4454
+ }
4455
+ ];
4456
+ }
4441
4457
  var StallDetectedDuringWaitError = class extends Error {
4442
- constructor(taskName, owner, workstream, ageSecs, ownerExpectsCtl = false) {
4458
+ constructor(taskName, owner, workstream, ageSecs, ownerExpectsCtl = false, ownerRuns) {
4443
4459
  const ownerBit = owner !== null ? owner : "<unknown>";
4444
4460
  super(
4445
4461
  `task ${taskName} owned by ${ownerBit} has been needs_input for ${ageSecs}s; exiting per --on-stall exit. Read the pane to see which it is \u2014 finished without closing, waiting on an answer, or sitting at a prompt.`
@@ -4449,12 +4465,14 @@ var StallDetectedDuringWaitError = class extends Error {
4449
4465
  this.workstream = workstream;
4450
4466
  this.ageSecs = ageSecs;
4451
4467
  this.ownerExpectsCtl = ownerExpectsCtl;
4468
+ this.ownerRuns = ownerRuns;
4452
4469
  }
4453
4470
  taskName;
4454
4471
  owner;
4455
4472
  workstream;
4456
4473
  ageSecs;
4457
4474
  ownerExpectsCtl;
4475
+ ownerRuns;
4458
4476
  name = "StallDetectedDuringWaitError";
4459
4477
  /**
4460
4478
  * Diagnose before acting. `mu agent read` comes FIRST because the
@@ -4469,6 +4487,7 @@ var StallDetectedDuringWaitError = class extends Error {
4469
4487
  const ws = this.workstream;
4470
4488
  const ownerBit = this.owner !== null ? this.owner : "<owner>";
4471
4489
  return [
4490
+ ...stallWaitHint(ownerBit, ws, this.ownerRuns),
4472
4491
  {
4473
4492
  intent: "Read the worker's pane to see what it is waiting on",
4474
4493
  command: `mu agent read ${ownerBit} -w ${ws} --lines 60`
@@ -4822,7 +4841,7 @@ function reparentTaskImpl(db, taskLocalId, blockers, scope) {
4822
4841
 
4823
4842
  // src/workstream.ts
4824
4843
  import { existsSync as existsSync7, readdirSync as readdirSync2, rmdirSync } from "fs";
4825
- import { join as join5 } from "path";
4844
+ import { dirname as dirname4, join as join5 } from "path";
4826
4845
  var WORKSTREAM_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
4827
4846
  var RESERVED_WORKSTREAM_PREFIX = "mu-";
4828
4847
  var RESERVED_WORKSTREAM_NAMES = /* @__PURE__ */ new Set(["scratch"]);
@@ -4936,6 +4955,7 @@ async function teardownWorkstream(db, opts) {
4936
4955
  const notesBefore = countNotes(db, opts.workstream);
4937
4956
  const edgesBefore = countEdges(db, opts.workstream);
4938
4957
  const workspacesBefore = listWorkspaces2(db, opts.workstream);
4958
+ const agentNames = listAgentNames(db, opts.workstream);
4939
4959
  const mux = await activeMux();
4940
4960
  const muxAliveBefore = await mux.sessionExists(muxSession);
4941
4961
  if (muxAliveBefore) {
@@ -4973,6 +4993,14 @@ async function teardownWorkstream(db, opts) {
4973
4993
  } catch {
4974
4994
  }
4975
4995
  }
4996
+ for (const name of agentNames) unlinkCtlSocket(db, name, opts.workstream);
4997
+ if (!db.memory) {
4998
+ const sockDir = join5(dirname4(db.name), "sock", opts.workstream);
4999
+ try {
5000
+ if (readdirSync2(sockDir).length === 0) rmdirSync(sockDir);
5001
+ } catch {
5002
+ }
5003
+ }
4976
5004
  withOpContext(
4977
5005
  db,
4978
5006
  { intent: "workstream.teardown", group: "new" },
@@ -4989,6 +5017,14 @@ async function teardownWorkstream(db, opts) {
4989
5017
  failedWorkspaces
4990
5018
  };
4991
5019
  }
5020
+ function listAgentNames(db, workstream) {
5021
+ const rows = db.prepare(
5022
+ `SELECT a.name FROM agents a
5023
+ JOIN workstreams ws ON ws.id = a.workstream_id
5024
+ WHERE ws.name = ?`
5025
+ ).all(workstream);
5026
+ return rows.map((r) => r.name);
5027
+ }
4992
5028
  function countAgents(db, workstream) {
4993
5029
  const row = db.prepare(
4994
5030
  `SELECT COUNT(*) AS n FROM agents a
@@ -5542,6 +5578,7 @@ async function claimSelf(db, localId, opts) {
5542
5578
  var currentWaitSleep = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms));
5543
5579
  var pollCount = 0;
5544
5580
  var DEFAULT_STUCK_AFTER_MS = 12e4;
5581
+ var DEFAULT_CTL_STUCK_AFTER_MS = 5e3;
5545
5582
  var defaultStuckWarn = (msg) => {
5546
5583
  process.stderr.write(msg);
5547
5584
  };
@@ -5588,6 +5625,7 @@ async function waitForTasks(db, input, opts) {
5588
5625
  const timeoutMs = opts.timeoutMs ?? 6e5;
5589
5626
  const pollMs = opts.pollMs ?? 1e3;
5590
5627
  const stuckAfterMs = opts.stuckAfterMs ?? DEFAULT_STUCK_AFTER_MS;
5628
+ const ctlStuckAfterMs = opts.ctlStuckAfterMs ?? DEFAULT_CTL_STUCK_AFTER_MS;
5591
5629
  const onStall = opts.onStall ?? "warn";
5592
5630
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Number.POSITIVE_INFINITY;
5593
5631
  for (const ref of refs) {
@@ -5599,12 +5637,15 @@ async function waitForTasks(db, input, opts) {
5599
5637
  const ctlBrokenSince = /* @__PURE__ */ new Map();
5600
5638
  const stuckLabel = /* @__PURE__ */ new Map();
5601
5639
  const ctlOwners = /* @__PURE__ */ new Set();
5640
+ const ownerRuns = /* @__PURE__ */ new Map();
5602
5641
  const stuckAgeMs = async (status, owner, workstreamName) => {
5603
5642
  if (stuckAfterMs <= 0 || status !== "IN_PROGRESS" || owner === null || opts.readOwnerState === void 0)
5604
5643
  return null;
5605
5644
  const reading = await opts.readOwnerState({ name: owner, workstreamName });
5606
5645
  const ownerKey = `${workstreamName}/${owner}`;
5607
5646
  if (reading?.ctl !== void 0) ctlOwners.add(ownerKey);
5647
+ if (reading?.runs !== void 0) ownerRuns.set(ownerKey, reading.runs);
5648
+ else ownerRuns.delete(ownerKey);
5608
5649
  if (reading?.alive === true && (reading.ctl === "missing" || reading.ctl === "refused")) {
5609
5650
  const firstSeen = ctlBrokenSince.get(ownerKey) ?? Date.now();
5610
5651
  ctlBrokenSince.set(ownerKey, firstSeen);
@@ -5615,8 +5656,10 @@ async function waitForTasks(db, input, opts) {
5615
5656
  ctlBrokenSince.delete(ownerKey);
5616
5657
  stuckLabel.delete(ownerKey);
5617
5658
  if (reading?.state !== "needs_input" || reading.since === null) return null;
5659
+ const viaCtl = reading.source === "ctl" && reading.ctl === "ok";
5660
+ if (viaCtl && reading.pending === true) return null;
5618
5661
  const ageMs = Date.now() - reading.since;
5619
- return ageMs >= stuckAfterMs ? ageMs : null;
5662
+ return ageMs >= (viaCtl ? ctlStuckAfterMs : stuckAfterMs) ? ageMs : null;
5620
5663
  };
5621
5664
  const snapshot2 = async () => {
5622
5665
  const refStates = [];
@@ -5632,10 +5675,13 @@ async function waitForTasks(db, input, opts) {
5632
5675
  stuckWarned.add(key);
5633
5676
  const ageSecs = Math.round(ageMs / 1e3);
5634
5677
  const ownerBit = owner ?? "<none>";
5678
+ const runs = ownerRuns.get(`${ref.workstreamName}/${ownerBit}`);
5635
5679
  const stateBit = stuckLabel.get(`${ref.workstreamName}/${ownerBit}`) ?? "needs_input";
5680
+ const waitLine = stallWaitHint(ownerBit, ref.workstreamName, runs).map((s) => ` ${s.command}
5681
+ `).join("");
5636
5682
  currentStuckWarn(
5637
5683
  `\x1B[33mmu task wait: ${key} needs attention \u2014 owner=${ownerBit} has been in ${stateBit} for ${formatStallAge(ageMs)}. It may have finished without closing, be waiting on an answer, or be sitting at a prompt.\x1B[0m
5638
- mu agent read ${ownerBit} -w ${ref.workstreamName} --lines 60
5684
+ ` + waitLine + ` mu agent read ${ownerBit} -w ${ref.workstreamName} --lines 60
5639
5685
  `
5640
5686
  );
5641
5687
  emitEvent(
@@ -5651,7 +5697,8 @@ async function waitForTasks(db, input, opts) {
5651
5697
  owner,
5652
5698
  ref.workstreamName,
5653
5699
  ageSecs,
5654
- ctlOwners.has(`${ref.workstreamName}/${owner ?? ""}`)
5700
+ ctlOwners.has(`${ref.workstreamName}/${owner ?? ""}`),
5701
+ runs
5655
5702
  );
5656
5703
  }
5657
5704
  }
@@ -6085,13 +6132,6 @@ function deleteAgent(db, name, workstream) {
6085
6132
  if (deleted) unlinkCtlSocket(db, name, workstream);
6086
6133
  return deleted;
6087
6134
  }
6088
- function unlinkCtlSocket(db, name, workstream) {
6089
- if (db.memory) return;
6090
- try {
6091
- rmSync3(ctlSocketPath(workstream, name, dirname4(db.name)), { force: true });
6092
- } catch {
6093
- }
6094
- }
6095
6135
  function deleteAgentRow(db, name, workstream) {
6096
6136
  return withOpContext(
6097
6137
  db,
@@ -6187,7 +6227,7 @@ async function listLiveAgents(db, opts) {
6187
6227
  ...opts.mode !== void 0 ? { mode: opts.mode } : {}
6188
6228
  });
6189
6229
  const baseAgents = listAgents(db, { workstream: opts.workstream });
6190
- const readings = await readAgentStates(baseAgents, { stateDir: dirname4(db.name) });
6230
+ const readings = await readAgentStates(baseAgents, { stateDir: dirname5(db.name) });
6191
6231
  const now = Date.now();
6192
6232
  const agents = baseAgents.map((agent) => {
6193
6233
  const reading = readings.get(agentKey(agent)) ?? {
@@ -6452,7 +6492,7 @@ function defaultSpawnCtlMs() {
6452
6492
  return parsed;
6453
6493
  }
6454
6494
  var CTL_POLL_INTERVAL_MS = 250;
6455
- async function awaitCtlHandshake(sock) {
6495
+ async function awaitCtlHandshake(sock, onMiss) {
6456
6496
  const budgetMs = defaultSpawnCtlMs();
6457
6497
  const deadline = Date.now() + budgetMs;
6458
6498
  for (; ; ) {
@@ -6460,6 +6500,7 @@ async function awaitCtlHandshake(sock) {
6460
6500
  const probe = await ctlProbe(sock, Math.max(1, Math.min(remaining, CTL_POLL_INTERVAL_MS * 4)));
6461
6501
  if (probe.kind === "ok") return "ok";
6462
6502
  const last = probeToSpawnCtl(probe.kind);
6503
+ if (onMiss) await onMiss();
6463
6504
  const left = deadline - Date.now();
6464
6505
  if (left <= 0) return last;
6465
6506
  await delay(Math.min(CTL_POLL_INTERVAL_MS, left));
@@ -6487,8 +6528,8 @@ async function spawnAgent(db, opts) {
6487
6528
  }
6488
6529
  }
6489
6530
  const workspacePathStr = opts.workspace ? await prestageWorkspace(db, opts, cli) : void 0;
6490
- const ctlSocket = ctlSocketPath(opts.workstream, opts.name, dirname5(db.name));
6491
- mkdirSync2(dirname5(ctlSocket), { recursive: true, mode: 448 });
6531
+ const ctlSocket = ctlSocketPath(opts.workstream, opts.name, dirname6(db.name));
6532
+ mkdirSync2(dirname6(ctlSocket), { recursive: true, mode: 448 });
6492
6533
  rmSync4(ctlSocket, { force: true });
6493
6534
  const paneEnv = {
6494
6535
  MU_MANAGED_AGENT: "1",
@@ -6499,6 +6540,8 @@ async function spawnAgent(db, opts) {
6499
6540
  const hasWorkspace = workspacePathStr !== void 0;
6500
6541
  let paneId;
6501
6542
  let agent;
6543
+ const wantsCtl = opts.ctl !== false && speaksMuCtl(cli, command) && defaultSpawnCtlMs() !== 0;
6544
+ let ctl;
6502
6545
  try {
6503
6546
  const created = await withSpawnLock(session, async () => {
6504
6547
  const pid = await createOrReusePane({
@@ -6519,7 +6562,23 @@ async function spawnAgent(db, opts) {
6519
6562
  agent = created.row;
6520
6563
  const startAgent = (await activeMux()).startAgentInPane;
6521
6564
  if (startAgent === void 0) {
6522
- await awaitSpawnLiveness(paneId, opts.name, command);
6565
+ if (wantsCtl) {
6566
+ const pid = paneId;
6567
+ const check = defaultSpawnLivenessMs() !== 0;
6568
+ const when = `during the ctl handshake (up to ${defaultSpawnCtlMs()}ms after spawn)`;
6569
+ let lastScrollback;
6570
+ const assertPaneAlive = async () => {
6571
+ const mux = await activeMux();
6572
+ const sb = await mux.capturePane(pid, { lines: 50 }).catch(() => void 0);
6573
+ lastScrollback = sb ?? lastScrollback;
6574
+ if (await mux.paneExists(pid)) return;
6575
+ throw new AgentDiedOnSpawnError(opts.name, pid, lastScrollback, command, when);
6576
+ };
6577
+ ctl = await awaitCtlHandshake(ctlSocket, check ? assertPaneAlive : void 0);
6578
+ if (check) await checkSpawnHealth(pid, opts.name, command, when);
6579
+ } else {
6580
+ await awaitSpawnLiveness(paneId, opts.name, command);
6581
+ }
6523
6582
  } else {
6524
6583
  await startAgent({ paneId, name: opts.name, cli, command, commandSource });
6525
6584
  }
@@ -6528,14 +6587,13 @@ async function spawnAgent(db, opts) {
6528
6587
  if (hasWorkspace) attachOrphanCleanupHint(err, opts.name, opts.workstream);
6529
6588
  throw err;
6530
6589
  }
6531
- const ctl = opts.ctl === false || !speaksMuCtl(cli, command) || defaultSpawnCtlMs() === 0 ? "skipped" : await awaitCtlHandshake(ctlSocket);
6590
+ ctl ??= wantsCtl ? await awaitCtlHandshake(ctlSocket) : "skipped";
6532
6591
  emitEvent(
6533
6592
  db,
6534
6593
  opts.workstream,
6535
6594
  "agent.spawn",
6536
6595
  `agent spawn ${opts.name} (cli=${cli}, role=${opts.role ?? "full-access"}, pane=${paneId})`
6537
6596
  );
6538
- await refreshAgentTitle(db, opts.name, opts.workstream);
6539
6597
  maybeWarnNonConventionalAgentName(opts.name);
6540
6598
  return { ...agent, ctl, ctlSocket };
6541
6599
  }
@@ -6663,15 +6721,18 @@ async function awaitSpawnLiveness(paneId, agentName, command) {
6663
6721
  const ms = defaultSpawnLivenessMs();
6664
6722
  if (ms === 0) return;
6665
6723
  await sleep(ms);
6724
+ await checkSpawnHealth(paneId, agentName, command);
6725
+ }
6726
+ async function checkSpawnHealth(paneId, agentName, command, when) {
6666
6727
  const mux = await activeMux();
6667
6728
  const scrollback = await mux.capturePane(paneId, { lines: 50 }).catch(() => void 0);
6668
6729
  if (!await mux.paneExists(paneId)) {
6669
- throw new AgentDiedOnSpawnError(agentName, paneId, scrollback, command);
6730
+ throw new AgentDiedOnSpawnError(agentName, paneId, scrollback, command, when);
6670
6731
  }
6671
6732
  if (scrollback !== void 0) {
6672
6733
  const matchedLine = detectSpawnStartupError(scrollback);
6673
6734
  if (matchedLine !== void 0) {
6674
- throw new AgentSpawnStartupError(agentName, paneId, matchedLine, scrollback);
6735
+ throw new AgentSpawnStartupError(agentName, paneId, matchedLine, scrollback, when);
6675
6736
  }
6676
6737
  }
6677
6738
  }
@@ -6826,7 +6887,7 @@ var AgentNotInWorkstreamError = class extends Error {
6826
6887
  }
6827
6888
  };
6828
6889
  var AgentDiedOnSpawnError = class extends Error {
6829
- constructor(agentName, paneId, scrollback, command) {
6890
+ constructor(agentName, paneId, scrollback, command, when = `within ${defaultSpawnLivenessMs()}ms of spawn`) {
6830
6891
  const tail = scrollback?.trim();
6831
6892
  const detail = tail ? `
6832
6893
 
@@ -6835,9 +6896,7 @@ ${tail}
6835
6896
  --- end scrollback ---` : "";
6836
6897
  const remote = /^\s*ssh\s/.test(command ?? "");
6837
6898
  const cause = remote ? 'The command is an ssh hop, so the likely causes are remote: the remote binary is missing, or its env (PATH, provider keys) is absent because ssh runs a non-interactive shell that skips ~/.zshrc / ~/.bashrc (wrap it: $SHELL -ilc "pi --approve"); or sshd refused the socket forward (ExitOnForwardFailure=yes kills the connection; check AllowStreamLocalForwarding).' : "Most common cause: the spawned CLI exited immediately (e.g. a wrapper CLI blocking on its instance lock; set MU_<UPPER_CLI>_COMMAND to a non-blocking variant to bypass).";
6838
- super(
6839
- `agent ${agentName} died within ${defaultSpawnLivenessMs()}ms of spawn (pane ${paneId}). ${cause}${detail}`
6840
- );
6899
+ super(`agent ${agentName} died ${when} (pane ${paneId}). ${cause}${detail}`);
6841
6900
  this.agentName = agentName;
6842
6901
  this.paneId = paneId;
6843
6902
  this.scrollback = scrollback;
@@ -6896,9 +6955,9 @@ ${tail}
6896
6955
  }
6897
6956
  };
6898
6957
  var AgentSpawnStartupError = class extends Error {
6899
- constructor(agentName, paneId, matchedLine, scrollback) {
6958
+ constructor(agentName, paneId, matchedLine, scrollback, when = `within ${defaultSpawnLivenessMs()}ms of spawn`) {
6900
6959
  super(
6901
- `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.
6960
+ `agent ${agentName} reported a startup error ${when} (pane ${paneId}). The pane is alive but the spawned CLI parked at an error prompt instead of becoming a working agent.
6902
6961
 
6903
6962
  Matched line: ${matchedLine.trim()}
6904
6963
 
@@ -6984,7 +7043,14 @@ var AgentCtlUnreachableError = class extends Error {
6984
7043
  kind;
6985
7044
  name = "AgentCtlUnreachableError";
6986
7045
  errorNextSteps() {
7046
+ const trust = this.kind === "missing" ? [
7047
+ {
7048
+ intent: "Just spawned? pi may sit at its project trust prompt: answer /trust in the pane, or respawn with --command 'pi --approve'",
7049
+ command: `mu agent read ${this.agentName} -w ${this.workstream}`
7050
+ }
7051
+ ] : [];
6987
7052
  return [
7053
+ ...trust,
6988
7054
  { intent: "Check whether the mu pi extension is linked", command: "mu doctor" },
6989
7055
  {
6990
7056
  intent: "Stop a runaway tool without the socket",
@@ -7067,7 +7133,7 @@ var AgentFreshNeedsCtlError = class extends Error {
7067
7133
  }
7068
7134
  };
7069
7135
  var AgentExtensionOutdatedError = class extends Error {
7070
- constructor(agentName, workstream, op, extVersion) {
7136
+ constructor(agentName, workstream, op, extVersion, ops) {
7071
7137
  super(
7072
7138
  `agent ${agentName}'s mu pi extension (${extVersion ?? "version unknown"}) predates op ${op}: its pi loaded an older mu build; reload or respawn it`
7073
7139
  );
@@ -7075,18 +7141,21 @@ var AgentExtensionOutdatedError = class extends Error {
7075
7141
  this.workstream = workstream;
7076
7142
  this.op = op;
7077
7143
  this.extVersion = extVersion;
7144
+ this.ops = ops;
7078
7145
  }
7079
7146
  agentName;
7080
7147
  workstream;
7081
7148
  op;
7082
7149
  extVersion;
7150
+ ops;
7083
7151
  name = "AgentExtensionOutdatedError";
7084
7152
  errorNextSteps() {
7085
7153
  const w = `-w ${this.workstream}`;
7154
+ const via = this.ops?.includes("command") ? "" : " --via mux";
7086
7155
  return [
7087
7156
  {
7088
- intent: "Reload pi's extensions in the pane (keeps the session and context)",
7089
- command: `mu agent send ${this.agentName} '/reload' --via mux ${w}`
7157
+ intent: "Reload pi's extensions (keeps the session and context)",
7158
+ command: `mu agent send ${this.agentName} '/reload'${via} ${w}`
7090
7159
  },
7091
7160
  {
7092
7161
  intent: "Or respawn the agent (loses its context)",
@@ -7144,7 +7213,7 @@ var AgentSlashCommandUnsupportedError = class extends Error {
7144
7213
 
7145
7214
  // src/agents/transport.ts
7146
7215
  function agentCtlSocket(db, agent) {
7147
- return ctlSocketPath(agent.workstreamName, agent.name, dirname6(db.name));
7216
+ return ctlSocketPath(agent.workstreamName, agent.name, dirname7(db.name));
7148
7217
  }
7149
7218
  function expectsCtl(agent, sock = ctlSocketPath(agent.workstreamName, agent.name)) {
7150
7219
  return speaksMuCtl(agent.cli, resolveCliCommand(agent.cli)) || existsSync8(sock);
@@ -7172,7 +7241,7 @@ async function extensionOutdated(agent, sock, e) {
7172
7241
  if (hello.ok) extVersion = hello.extVersion;
7173
7242
  } catch {
7174
7243
  }
7175
- return new AgentExtensionOutdatedError(agent.name, agent.workstreamName, e.op, extVersion);
7244
+ return new AgentExtensionOutdatedError(agent.name, agent.workstreamName, e.op, extVersion, e.ops);
7176
7245
  }
7177
7246
  async function ctlRequestFor(agent, sock, req) {
7178
7247
  try {
@@ -7221,7 +7290,11 @@ async function sendViaTransport(agent, text, opts) {
7221
7290
  }
7222
7291
  throw new Error(`control socket refused the send: ${reply.error}`);
7223
7292
  }
7224
- return reply.state === void 0 ? { transport } : { transport, state: reply.state };
7293
+ return {
7294
+ transport,
7295
+ ...reply.state !== void 0 ? { state: reply.state } : {},
7296
+ ...reply.runs !== void 0 ? { runs: reply.runs } : {}
7297
+ };
7225
7298
  }
7226
7299
  async function runSessionCommand(agent, sock, cmd, force) {
7227
7300
  const reply = await ctlRequestFor(agent, sock, {
@@ -7422,7 +7495,9 @@ async function readAgentStates(agents, opts = {}) {
7422
7495
  source: "ctl",
7423
7496
  since: probe.status.since,
7424
7497
  alive: true,
7425
- ctl: "ok"
7498
+ ctl: "ok",
7499
+ pending: probe.status.pending,
7500
+ runs: probe.status.runs
7426
7501
  });
7427
7502
  } else {
7428
7503
  failed.set(agentKey(agent), probe?.kind === "missing" ? "missing" : "refused");
@@ -8073,7 +8148,7 @@ function formatTreeNodeLabel(t, statusFn, opts = {}) {
8073
8148
 
8074
8149
  // src/doctor-summary.ts
8075
8150
  import { accessSync as accessSync2, constants as constants2, existsSync as existsSync13, readFileSync as readFileSync2, realpathSync as realpathSync2 } from "fs";
8076
- import { delimiter as delimiter2, dirname as dirname9, join as join11, parse } from "path";
8151
+ import { delimiter as delimiter2, dirname as dirname10, join as join11, parse } from "path";
8077
8152
  import { fileURLToPath as fileURLToPath2 } from "url";
8078
8153
 
8079
8154
  // src/drift.ts
@@ -8083,7 +8158,7 @@ import { join as join9 } from "path";
8083
8158
 
8084
8159
  // src/rebuild.ts
8085
8160
  import { existsSync as existsSync10, mkdirSync as mkdirSync3, statSync } from "fs";
8086
- import { dirname as dirname7 } from "path";
8161
+ import { dirname as dirname8 } from "path";
8087
8162
  var RebuildTargetExistsError = class extends Error {
8088
8163
  constructor(path) {
8089
8164
  super(`rebuild target already exists: ${path}`);
@@ -8146,7 +8221,7 @@ function rebuildInto(source, opts) {
8146
8221
  if (existsSync10(targetPath) && opts.force !== true) {
8147
8222
  throw new RebuildTargetExistsError(targetPath);
8148
8223
  }
8149
- mkdirSync3(dirname7(targetPath), { recursive: true });
8224
+ mkdirSync3(dirname8(targetPath), { recursive: true });
8150
8225
  const identity = source.prepare(
8151
8226
  "SELECT machine_id, hostname, created_at, last_wall, last_counter FROM machine_identity WHERE id = 1"
8152
8227
  ).get();
@@ -8668,7 +8743,7 @@ import {
8668
8743
  writeFileSync
8669
8744
  } from "fs";
8670
8745
  import { homedir as homedir4 } from "os";
8671
- import { dirname as dirname8, join as join10, resolve as resolve5 } from "path";
8746
+ import { dirname as dirname9, join as join10, resolve as resolve5 } from "path";
8672
8747
  import { fileURLToPath, pathToFileURL } from "url";
8673
8748
  var MU_SHIM_MARKER = "// mu:shim";
8674
8749
  var LinkConflictError = class extends Error {
@@ -8698,7 +8773,7 @@ function resolveHome(opts) {
8698
8773
  return opts?.home ?? process.env.MU_PI_HOME ?? homedir4();
8699
8774
  }
8700
8775
  function packageRoot() {
8701
- return resolve5(dirname8(fileURLToPath(import.meta.url)), "..");
8776
+ return resolve5(dirname9(fileURLToPath(import.meta.url)), "..");
8702
8777
  }
8703
8778
  function extensionEntry() {
8704
8779
  return process.env.MU_EXTENSION_ENTRY ?? join10(packageRoot(), "dist", "extension", "mu-pi.js");
@@ -8734,7 +8809,7 @@ function linkPi(opts) {
8734
8809
  replacedCopy = !readFileSync(path, "utf8").includes(MU_SHIM_MARKER);
8735
8810
  } catch {
8736
8811
  }
8737
- mkdirSync4(dirname8(path), { recursive: true });
8812
+ mkdirSync4(dirname9(path), { recursive: true });
8738
8813
  writeFileSync(path, opts?.copy === true ? readFileSync(entry, "utf8") : shim(entry));
8739
8814
  return { path, replacedCopy };
8740
8815
  }
@@ -8745,7 +8820,7 @@ function linkSkill(opts) {
8745
8820
  const st = lstatOrUndefined(path);
8746
8821
  if (st) {
8747
8822
  if (!st.isSymbolicLink()) throw new LinkConflictError(path, "not-a-symlink");
8748
- const current = resolve5(dirname8(path), readlinkSync(path));
8823
+ const current = resolve5(dirname9(path), readlinkSync(path));
8749
8824
  if (current === target) return { path, target };
8750
8825
  if (existsSync12(current) && opts?.force !== true) {
8751
8826
  throw new LinkConflictError(path, "foreign-symlink", current);
@@ -8753,7 +8828,7 @@ function linkSkill(opts) {
8753
8828
  previous = current;
8754
8829
  rmSync6(path);
8755
8830
  }
8756
- mkdirSync4(dirname8(path), { recursive: true });
8831
+ mkdirSync4(dirname9(path), { recursive: true });
8757
8832
  symlinkSync(target, path, "dir");
8758
8833
  return previous === void 0 ? { path, target } : { path, target, previous };
8759
8834
  }
@@ -8787,7 +8862,7 @@ function inspectSkill(path) {
8787
8862
  try {
8788
8863
  target = realpathSync(path);
8789
8864
  } catch {
8790
- return { path, state: "dangling", target: resolve5(dirname8(path), readlinkSync(path)) };
8865
+ return { path, state: "dangling", target: resolve5(dirname9(path), readlinkSync(path)) };
8791
8866
  }
8792
8867
  let skillMd;
8793
8868
  try {
@@ -8966,7 +9041,7 @@ function resolveExecutable(name) {
8966
9041
  return null;
8967
9042
  }
8968
9043
  function murmurVersion(executable) {
8969
- let dir = dirname9(executable);
9044
+ let dir = dirname10(executable);
8970
9045
  const root = parse(dir).root;
8971
9046
  while (dir !== root) {
8972
9047
  const packagePath = join11(dir, "package.json");
@@ -8979,7 +9054,7 @@ function murmurVersion(executable) {
8979
9054
  } catch {
8980
9055
  }
8981
9056
  }
8982
- dir = dirname9(dir);
9057
+ dir = dirname10(dir);
8983
9058
  }
8984
9059
  return null;
8985
9060
  }
@@ -9015,7 +9090,7 @@ function murmurDoctorCheck(agentStateSource, opts = {}) {
9015
9090
  return {
9016
9091
  name: "murmur",
9017
9092
  status: "warn",
9018
- detail: `murmur ${version} is older than 1.0.0; @murmur_pane_since missing, idle/stall timing unknown`
9093
+ detail: opts.muExtOk === true ? `murmur ${version} is older than 1.0.0; idle/stall timing unknown for non-pi CLIs (pi agents use ctl)` : `murmur ${version} is older than 1.0.0; @murmur_pane_since missing, idle/stall timing unknown`
9019
9094
  };
9020
9095
  }
9021
9096
  return {
@@ -9124,11 +9199,14 @@ function remediationParagraph(check) {
9124
9199
  return [
9125
9200
  "mu talks to pi agents over a per-agent control socket served by",
9126
9201
  "the mu pi extension. `missing`: no socket (extension not loaded,",
9127
- "or the agent predates `mu link pi`). `refused`: socket present but",
9128
- "not answering. `version`: protocol mismatch; upgrade mu or pi.",
9202
+ "the agent predates `mu link pi`, or a new pi still waits at its",
9203
+ "project trust prompt: answer /trust in the pane, or spawn with",
9204
+ "--command 'pi --approve'). `refused`: socket present but not",
9205
+ "answering. `version`: protocol mismatch; upgrade mu or pi.",
9129
9206
  "Run `mu link pi`, then respawn the agent. `extension X older than",
9130
- "installed Y`: the agent's pi loaded an older mu build; type /reload",
9131
- "in its pane (`mu agent send <a> '/reload' --via mux`) or respawn it."
9207
+ "installed Y` or `lacks ops`: the agent's pi loaded an older mu",
9208
+ "build; reload it with `mu agent send <a> '/reload'` (add",
9209
+ "`--via mux` when the extension lacks op command) or respawn it."
9132
9210
  ];
9133
9211
  case "agents":
9134
9212
  return [