squadrant 0.16.3 → 0.16.5

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.
@@ -490,6 +490,9 @@ function stampAttempt(rec, patch, now) {
490
490
  attempts.push(last);
491
491
  return { ...rec, attempts };
492
492
  }
493
+ function isStickyAttention(state) {
494
+ return state === "blocked" || state === "review";
495
+ }
493
496
  function nextPendingTool(current, ev, now) {
494
497
  if (ev.note === "agent.hook.PreToolUse")
495
498
  return { name: ev.tool ?? "tool", since: now };
@@ -518,7 +521,7 @@ function reduce(rec, ev, now) {
518
521
  };
519
522
  case "task.progress": {
520
523
  const pendingTool = nextPendingTool(rec.pendingTool, ev, now);
521
- if (rec.state === "blocked")
524
+ if (isStickyAttention(rec.state))
522
525
  return { ...rec, lastHeartbeat: now, lastEvent: ev.type, pendingTool };
523
526
  const b = { ...base, pendingTool };
524
527
  if (rec.state === "awaiting-input" || rec.state === "stalled")
@@ -526,7 +529,7 @@ function reduce(rec, ev, now) {
526
529
  return stampAttempt(b, {}, now);
527
530
  }
528
531
  case "heartbeat":
529
- if (rec.state === "blocked")
532
+ if (isStickyAttention(rec.state))
530
533
  return { ...rec, lastHeartbeat: now, lastEvent: ev.type };
531
534
  if (rec.state === "awaiting-input")
532
535
  return { ...base, state: "working" };
@@ -535,7 +538,12 @@ function reduce(rec, ev, now) {
535
538
  if (rec.state === "blocked")
536
539
  return { ...rec, lastHeartbeat: now, lastEvent: ev.type };
537
540
  return { ...base, state: "blocked", question: ev.question, pendingTool: void 0 };
541
+ case "task.review":
542
+ return { ...base, state: "review", reviewNote: ev.message, pendingTool: void 0 };
538
543
  case "task.done":
544
+ if (rec.state === "review" && ev.source !== "approve") {
545
+ return { ...rec, lastHeartbeat: now, lastEvent: ev.type };
546
+ }
539
547
  return { ...base, state: "done", resultRef: ev.resultRef, parseWarning: ev.parseWarning };
540
548
  case "task.failed":
541
549
  return { ...base, state: "failed", error: ev.error, exitCode: ev.exitCode };
@@ -548,7 +556,7 @@ function reduce(rec, ev, now) {
548
556
  case "task.turn.started":
549
557
  return { ...stampAttempt(base, {}, now), state: "working", pendingTool: void 0 };
550
558
  case "task.turn.completed":
551
- if (rec.state === "blocked")
559
+ if (isStickyAttention(rec.state))
552
560
  return { ...rec, lastHeartbeat: now, lastEvent: ev.type };
553
561
  if (rec.pendingTool)
554
562
  return stampAttempt(base, {}, now);
@@ -606,8 +614,8 @@ var DEFAULT_FIRST_TURN_RESEND_COOLDOWN_MS = 6e4;
606
614
  var DEFAULT_TASK_TIMEOUT_MS = 8 * 60 * 60 * 1e3;
607
615
  var TERMINAL_RECORD_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
608
616
  var TERMINAL_RECORD_KEEP_PER_PROJECT = 20;
609
- var ATTENTION_STATES = /* @__PURE__ */ new Set(["done", "blocked", "failed", "stalled", "awaiting-input"]);
610
- var REAPABLE_SURFACE_STATES = /* @__PURE__ */ new Set(["working", "stalled", "awaiting-input", "blocked"]);
617
+ var ATTENTION_STATES = /* @__PURE__ */ new Set(["done", "blocked", "review", "failed", "stalled", "awaiting-input"]);
618
+ var REAPABLE_SURFACE_STATES = /* @__PURE__ */ new Set(["working", "stalled", "awaiting-input", "blocked", "review"]);
611
619
  var IDLE_DEBOUNCE_MS = 12e3;
612
620
  function shortId(id) {
613
621
  return id.slice(0, 8);
@@ -629,6 +637,10 @@ function formatMessage(rec, event) {
629
637
  }
630
638
  case "blocked":
631
639
  return `CREW BLOCKED ${tag}: ${(rec.question ?? "(no question)").trim()}`;
640
+ case "review": {
641
+ const note = (rec.reviewNote ?? "").trim();
642
+ return `CREW REVIEW ${tag}: ${note || "ready for review"} \u2014 run 'squadrant diff ${rec.project} ${rec.name ?? rec.id}' then 'squadrant crew approve' or send feedback.`;
643
+ }
632
644
  case "failed":
633
645
  return `CREW FAILED ${tag}: ${(rec.error ?? "(no error)").trim()}`;
634
646
  case "stalled": {
@@ -699,6 +711,7 @@ var KNOWN_EVENT_TYPES = /* @__PURE__ */ new Set([
699
711
  "task.progress",
700
712
  "heartbeat",
701
713
  "task.blocked",
714
+ "task.review",
702
715
  "task.done",
703
716
  "task.failed",
704
717
  "task.session",
@@ -1457,13 +1470,14 @@ function projectHealth(input) {
1457
1470
  const { project, now, captainName, captainStopped, commandPresent, crews } = input;
1458
1471
  const out = [];
1459
1472
  const captainState = input.captainState ?? (captainStopped === true ? "stopped" : captainStopped === false ? "alive" : "unknown");
1473
+ const deferral = input.captainDeferral;
1460
1474
  out.push({
1461
1475
  kind: "captain",
1462
1476
  project,
1463
1477
  ref: captainName,
1464
1478
  state: captainState,
1465
1479
  lastSeenMs: null,
1466
- detail: captainState === "stopped" ? "captain workspace closed \u2014 crews reaped; delivery paused" : captainState === "gone" ? "captain process died (crash) \u2014 crews reaped" : void 0
1480
+ detail: captainState === "stopped" ? "captain workspace closed \u2014 crews reaped; delivery paused" : captainState === "gone" ? "captain process died (crash) \u2014 crews reaped" : deferral?.stuck ? `\u26A0\uFE0F delivery stuck (${deferral.maxDeferCount}+ retries) \u2014 draft/ghost text blocking captain pane; input never touched, delivers automatically once cleared` : void 0
1467
1481
  });
1468
1482
  if (commandPresent !== null) {
1469
1483
  out.push({
@@ -1797,6 +1811,8 @@ function buildContext(opts) {
1797
1811
  opencodeBridge: null,
1798
1812
  cmuxEventsBridge: null,
1799
1813
  telegramBridge: void 0,
1814
+ notifyFault: opts.notifyFault ?? (() => {
1815
+ }),
1800
1816
  lifecycleSources: opts.lifecycleSources ?? [],
1801
1817
  broadcast: () => {
1802
1818
  },
@@ -2107,7 +2123,7 @@ var CaptainDelivery = class {
2107
2123
  const seq = entry.seq;
2108
2124
  const deferCount = this.deferCounts.get(seq) ?? 0;
2109
2125
  const stable = (this.stableCounts.get(seq) ?? 0) >= this.opts.stableProbePolls;
2110
- const probe = stable || deferCount >= this.opts.maxDefers;
2126
+ const probe = stable;
2111
2127
  try {
2112
2128
  await send(msg, probe ? { probe: true } : void 0);
2113
2129
  this.deferCounts.delete(seq);
@@ -2231,7 +2247,9 @@ async function runLivenessTick(deps) {
2231
2247
  }
2232
2248
  }
2233
2249
  function createDelivery(ctx, daemonCmux) {
2234
- const { stateRoot, store, log, livenessRegistry, isPidAlive, opts } = ctx;
2250
+ const { stateRoot, store, log, livenessRegistry, isPidAlive, opts, telegramBridge } = ctx;
2251
+ const notifyFault = ctx.notifyFault ?? (() => {
2252
+ });
2235
2253
  const defaultNotify = async (args) => {
2236
2254
  try {
2237
2255
  await appendToMailbox({
@@ -2254,6 +2272,7 @@ function createDelivery(ctx, daemonCmux) {
2254
2272
  const cfg = loadConfig();
2255
2273
  const deliveries = /* @__PURE__ */ new Map();
2256
2274
  const deliveryStats = (project) => deliveries.get(project)?.stats();
2275
+ const stuckNotified = /* @__PURE__ */ new Set();
2257
2276
  const sessionStartMs = Date.now();
2258
2277
  let delivering = false;
2259
2278
  const deliveryCore = async () => {
@@ -2325,6 +2344,18 @@ function createDelivery(ctx, daemonCmux) {
2325
2344
  break;
2326
2345
  }
2327
2346
  }
2347
+ const stuck = d.stats().stuck;
2348
+ if (stuck && !stuckNotified.has(project)) {
2349
+ stuckNotified.add(project);
2350
+ const { maxDeferCount } = d.stats();
2351
+ log(`delivery stuck project=${project} deferCount=${maxDeferCount}`);
2352
+ const text = `\u26A0\uFE0F DELIVERY STUCK: an in-progress draft (or ghost text) in your input box has blocked pending notification(s) for ${maxDeferCount}+ retries. Your input is never touched \u2014 this keeps retrying safely and will deliver automatically once you submit or clear it.`;
2353
+ appendCaptainMessage({ stateRoot, project, text, source: "daemon" }).catch((e) => log(`delivery stuck alert failed project=${project}: ${e.message}`));
2354
+ Promise.resolve(notifyFault(project, text)).catch((e) => log(`delivery stuck fault-notify failed project=${project}: ${e.message}`));
2355
+ telegramBridge?.pushRaw(project, text);
2356
+ } else if (!stuck && stuckNotified.has(project)) {
2357
+ stuckNotified.delete(project);
2358
+ }
2328
2359
  }
2329
2360
  };
2330
2361
  const deliveryTick = async () => {
@@ -2575,7 +2606,12 @@ function startDaemon(ctx, opts, pkgVersion) {
2575
2606
  captainStopped: null,
2576
2607
  captainState: deriveCaptainState(capEntry),
2577
2608
  commandPresent: null,
2578
- crews: store.list(project)
2609
+ crews: store.list(project),
2610
+ // #579/#484 Gap 3: surface the same deferral stats already exposed to
2611
+ // the snapshot (line ~135 below) on the health row too, so `squadrant
2612
+ // doctor` / `squadrant status --detailed` show a stuck delivery with
2613
+ // zero configuration.
2614
+ captainDeferral: deliveryStats(project)
2579
2615
  }));
2580
2616
  }
2581
2617
  return out;
@@ -2818,8 +2854,12 @@ var REGISTRY = {
2818
2854
  build: (a) => a[0] ? ok("crews", ["crew", "list", a[0]]) : usage("crews", "usage: /crews <project>")
2819
2855
  },
2820
2856
  launch: {
2857
+ // --headless (#586, same reason as #520 on the boot-if-down path): runCommand
2858
+ // execs this argv from the daemon, which has no CMUX_WORKSPACE_ID and no
2859
+ // terminal — a plain `launch` would open the cmux GUI app and exit 0 before
2860
+ // the workspace is ever launched.
2821
2861
  usage: "/launch <project>",
2822
- build: (a) => a[0] ? ok("launch", ["launch", a[0]]) : usage("launch", "usage: /launch <project>")
2862
+ build: (a) => a[0] ? ok("launch", ["launch", a[0], "--headless"]) : usage("launch", "usage: /launch <project>")
2823
2863
  },
2824
2864
  effort: {
2825
2865
  usage: "/effort [max|balance|low]",
@@ -3004,6 +3044,9 @@ ${ev.message}` : "");
3004
3044
  case "task.blocked":
3005
3045
  return `\u{1F6A7} [${project}] CREW BLOCKED \xB7 ${ev.id}
3006
3046
  ${ev.question}`;
3047
+ case "task.review":
3048
+ return `\u{1F440} [${project}] CREW REVIEW \xB7 ${ev.id}` + (ev.message ? `
3049
+ ${ev.message}` : "");
3007
3050
  case "task.idle":
3008
3051
  return `\u{1F4A4} [${project}] CREW IDLE \xB7 ${ev.id}`;
3009
3052
  case "task.failed":
@@ -3200,6 +3243,7 @@ var DONE_ONLY = /* @__PURE__ */ new Set(["task.done", "task.failed"]);
3200
3243
  var ALERTS = /* @__PURE__ */ new Set([
3201
3244
  ...DONE_ONLY,
3202
3245
  "task.blocked",
3246
+ "task.review",
3203
3247
  "task.approval.requested",
3204
3248
  "task.input.requested",
3205
3249
  "task.timeout"
@@ -3259,6 +3303,14 @@ function createTelegramBridge(opts) {
3259
3303
  s.offset = next;
3260
3304
  saveState(stateRoot, s);
3261
3305
  }
3306
+ async function sendToTopic(project, text) {
3307
+ let threadId = loadState(stateRoot).topics[topicKey(project)];
3308
+ if (threadId === void 0) {
3309
+ threadId = await client.createForumTopic(cfg.supergroupId, topicName(project));
3310
+ setTopic(stateRoot, project, threadId);
3311
+ }
3312
+ await client.sendMessage(cfg.supergroupId, threadId, text);
3313
+ }
3262
3314
  async function deliverOutbound(project, ev) {
3263
3315
  const resolved = resolveNotify(cfg.notify, loadProjectOverride(project, configRoot));
3264
3316
  const live = loadState(stateRoot).notify[project];
@@ -3267,12 +3319,10 @@ function createTelegramBridge(opts) {
3267
3319
  return;
3268
3320
  if (!tierIncludes(resolved.crew, ev.type))
3269
3321
  return;
3270
- let threadId = loadState(stateRoot).topics[topicKey(project)];
3271
- if (threadId === void 0) {
3272
- threadId = await client.createForumTopic(cfg.supergroupId, topicName(project));
3273
- setTopic(stateRoot, project, threadId);
3274
- }
3275
- await client.sendMessage(cfg.supergroupId, threadId, formatLifecycle(project, ev));
3322
+ await sendToTopic(project, formatLifecycle(project, ev));
3323
+ }
3324
+ async function deliverRawOutbound(project, text) {
3325
+ await sendToTopic(project, text);
3276
3326
  }
3277
3327
  function resolveLiveNotify(project) {
3278
3328
  const resolved = resolveNotify(cfg.notify, loadProjectOverride(project, configRoot));
@@ -3580,6 +3630,11 @@ function createTelegramBridge(opts) {
3580
3630
  log(`telegram outbound failed project=${project}: ${e.message}`);
3581
3631
  });
3582
3632
  },
3633
+ pushRaw(project, text) {
3634
+ void deliverRawOutbound(project, text).catch((e) => {
3635
+ log(`telegram raw push failed project=${project}: ${e.message}`);
3636
+ });
3637
+ },
3583
3638
  health() {
3584
3639
  return { polling: running, lastSuccessfulPollAt, lastError, lastErrorAt };
3585
3640
  }
@@ -4624,6 +4679,18 @@ function cmux(args) {
4624
4679
  );
4625
4680
  });
4626
4681
  }
4682
+ function cmuxStdin(args, input) {
4683
+ return new Promise((resolve2, reject) => {
4684
+ const child = execFile2(resolveCmuxBin(), args, { encoding: "utf-8", timeout: CMUX_TIMEOUT, env: { ...process.env, CMUX_QUIET: "1" } }, (err, stdout) => {
4685
+ if (err) {
4686
+ reject(err.code === "ETIMEDOUT" ? new CmuxTimeoutError(args.join(" ")) : err);
4687
+ return;
4688
+ }
4689
+ resolve2(stdout.trim());
4690
+ });
4691
+ child.stdin.end(input);
4692
+ });
4693
+ }
4627
4694
  function parseList(output) {
4628
4695
  let parsed;
4629
4696
  try {
@@ -5025,6 +5092,37 @@ function createCmuxDriver() {
5025
5092
  }
5026
5093
  throw new DeferDelivery(draft);
5027
5094
  },
5095
+ async showDiff(opts) {
5096
+ const source = opts.source ?? "branch";
5097
+ const args = ["diff"];
5098
+ if (source === "staged") {
5099
+ args.push("--staged");
5100
+ } else if (source === "unstaged") {
5101
+ args.push("--unstaged");
5102
+ } else {
5103
+ args.push("--branch", "--base", opts.base);
5104
+ if (opts.lastTurn)
5105
+ args.push("--last-turn");
5106
+ }
5107
+ args.push("--cwd", opts.cwd, "--workspace", opts.workspaceId, "--layout", opts.layout ?? "split");
5108
+ if (opts.title)
5109
+ args.push("--title", opts.title);
5110
+ if (opts.focus === false)
5111
+ args.push("--no-focus");
5112
+ else
5113
+ args.push("--focus", "true");
5114
+ await cmux(args);
5115
+ },
5116
+ async showPatch(opts) {
5117
+ const args = ["diff", "-", "--workspace", opts.workspaceId, "--layout", opts.layout ?? "split"];
5118
+ if (opts.title)
5119
+ args.push("--title", opts.title);
5120
+ if (opts.focus === false)
5121
+ args.push("--no-focus");
5122
+ else
5123
+ args.push("--focus", "true");
5124
+ await cmuxStdin(args, opts.patch);
5125
+ },
5028
5126
  async listSurfaces(workspaceId) {
5029
5127
  let output;
5030
5128
  try {
@@ -5090,7 +5188,60 @@ var RuntimeRegistry = class {
5090
5188
  };
5091
5189
 
5092
5190
  // packages/workspaces/dist/notifiers/cmux.js
5093
- import { execFileSync as execFileSync6, execSync as execSync7 } from "child_process";
5191
+ import { execFile as execFileCb, execSync as execSync7 } from "child_process";
5192
+ import { promisify as promisify2 } from "util";
5193
+ var execFile3 = promisify2(execFileCb);
5194
+ function createCmuxNotifier(_scope) {
5195
+ return {
5196
+ name: "cmux",
5197
+ async probe() {
5198
+ try {
5199
+ execSync7("squadrant runtime status --command", { encoding: "utf-8", stdio: "pipe" });
5200
+ return { installed: true, reachable: true };
5201
+ } catch (err) {
5202
+ const code = err.code;
5203
+ if (code === "ENOENT") {
5204
+ return { installed: false, reachable: false };
5205
+ }
5206
+ return { installed: true, reachable: false };
5207
+ }
5208
+ },
5209
+ async notify(message) {
5210
+ await execFile3("squadrant", ["runtime", "send", "--command", message], { encoding: "utf-8", timeout: CMUX_TIMEOUT });
5211
+ }
5212
+ };
5213
+ }
5214
+
5215
+ // packages/workspaces/dist/notifiers/registry.js
5216
+ var DEFAULT_NOTIFIER = "cmux";
5217
+ var NotifierRegistry = class {
5218
+ factories;
5219
+ constructor(factories) {
5220
+ this.factories = factories;
5221
+ }
5222
+ get(config) {
5223
+ const name = config.notifier ?? DEFAULT_NOTIFIER;
5224
+ return this.getFactory(name)({});
5225
+ }
5226
+ getFactory(name) {
5227
+ const factory = this.factories[name];
5228
+ if (!factory) {
5229
+ throw new Error(`Unknown notifier provider '${name}' \u2014 no factory registered`);
5230
+ }
5231
+ return factory;
5232
+ }
5233
+ async probeAll() {
5234
+ const results = {};
5235
+ for (const [name, factory] of Object.entries(this.factories)) {
5236
+ try {
5237
+ results[name] = await factory({}).probe();
5238
+ } catch {
5239
+ results[name] = { installed: false, reachable: false };
5240
+ }
5241
+ }
5242
+ return results;
5243
+ }
5244
+ };
5094
5245
 
5095
5246
  // packages/workspaces/dist/workspaces/obsidian.js
5096
5247
  import fs16 from "fs/promises";
@@ -5868,6 +6019,16 @@ function buildTelegramBridge(cfg, stateRoot, log) {
5868
6019
  sendReply
5869
6020
  });
5870
6021
  }
6022
+ function buildNotifyFault(log) {
6023
+ const registry = new NotifierRegistry({ cmux: createCmuxNotifier });
6024
+ return async (project, text) => {
6025
+ try {
6026
+ await registry.get(loadConfig()).notify(`[${project}] ${text}`);
6027
+ } catch (e) {
6028
+ log(`fault notify failed project=${project}: ${e.message}`);
6029
+ }
6030
+ };
6031
+ }
5871
6032
  function startSquadrantd(opts = {}) {
5872
6033
  const ctx = buildContext(opts);
5873
6034
  const { stateRoot, store, log, spawn: spawn2, writeResult, inFlightHeadlessIds, activeHeadlessKills } = ctx;
@@ -5931,6 +6092,8 @@ function startSquadrantd(opts = {}) {
5931
6092
  ctx.lifecycleSources = [cmuxStoreSource, nativeHookSource, codexAppServerSource];
5932
6093
  const tgCfg = loadConfig().telegram;
5933
6094
  ctx.telegramBridge = opts.telegramBridge ?? (tgCfg && !process.env.VITEST ? buildTelegramBridge(tgCfg, stateRoot, log) : void 0);
6095
+ if (opts.notifyFault) ctx.notifyFault = opts.notifyFault;
6096
+ else if (!process.env.VITEST) ctx.notifyFault = buildNotifyFault(log);
5934
6097
  ctx.daemonCmux = opts.daemonCmux ?? (opts.makeDaemonCmux ?? (() => new DaemonCmux(createCmuxDriver())))();
5935
6098
  const resendRuntime = createCmuxDriver();
5936
6099
  ctx.resendFirstTurn = opts.resendFirstTurn ?? (async (rec) => {