@zhuxixi/pi-agent-board 0.5.2 → 0.6.1

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +43 -5
  3. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
  4. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
  5. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
  6. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
  7. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
  8. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
  9. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
  10. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
  11. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
  12. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
  13. package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
  14. package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
  15. package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
  16. package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
  17. package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -0
  18. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
  19. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
  20. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
  21. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
  22. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
  23. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
  24. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
  25. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
  26. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
  27. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
  28. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
  29. package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
  30. package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
  31. package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
  32. package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
  33. package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
  34. package/package.json +83 -81
  35. package/runner/job-runner.mjs +2 -2
  36. package/runner/pty-runner.mjs +626 -3
  37. package/runner/state-runner.mjs +3 -0
  38. package/runner/title-runner.mjs +1 -1
  39. package/scripts/release_helper.mjs +277 -0
  40. package/src/commands/agent-board.ts +47 -35
  41. package/src/commands/attach-decision.mjs +66 -0
  42. package/src/commands/attach-flow.ts +45 -39
  43. package/src/commands/bg.ts +9 -0
  44. package/src/core/code-refs.mjs +85 -33
  45. package/src/core/evidence.mjs +2 -2
  46. package/src/core/heuristics.mjs +75 -2
  47. package/src/core/host-coordination.mjs +182 -0
  48. package/src/core/host-crash.mjs +43 -3
  49. package/src/core/host-probe.mjs +196 -0
  50. package/src/core/launch-options.mjs +17 -0
  51. package/src/core/launch.mjs +35 -35
  52. package/src/core/locks.mjs +196 -1
  53. package/src/core/paths.mjs +24 -0
  54. package/src/core/store.mjs +164 -5
  55. package/src/core/types.mjs +17 -1
  56. package/src/core/warm-host-sweeper.mjs +150 -0
  57. package/src/index.ts +40 -3
  58. package/src/runtime/service.mjs +1071 -108
  59. package/src/ui/dashboard-decisions.mjs +55 -0
  60. package/src/ui/dashboard.ts +67 -13
  61. package/src/ui/pty-attach.ts +23 -5
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Dashboard decision helpers for host prewarm and attach prelude (issue #70 A14).
3
+ *
4
+ * Pure functions extracted from src/ui/dashboard.ts so the branching contract is
5
+ * node-testable without pi's TS loader (same pattern as commands/attach-decision.mjs).
6
+ * `hostActive` covers starting/alive/stopping claims from store.loadRow — pending
7
+ * claims count as an already-registered launch, so prewarm never re-spawns them.
8
+ */
9
+
10
+ /**
11
+ * Prewarm decision for the selected row.
12
+ * - "mark-only": the row has an active host claim — mark it prewarmed WITHOUT
13
+ * calling prewarmHost (a pending/starting claim is already a launch; the old
14
+ * hostAlive guard re-checked it on every debounce tick).
15
+ * - "skip": busy row without a host claim — no prewarm, no mark.
16
+ * - "prewarm": no claim — call service.prewarmHost once; the caller marks only on ok.
17
+ * @param {{ hostActive?: boolean, agentBusy?: boolean }} row
18
+ * @returns {{ action: "mark-only" | "skip" | "prewarm" }}
19
+ */
20
+ export function planPrewarm({ hostActive, agentBusy }) {
21
+ if (hostActive) return { action: "mark-only" };
22
+ if (agentBusy) return { action: "skip" };
23
+ return { action: "prewarm" };
24
+ }
25
+
26
+ /**
27
+ * Attach prelude for a dashboard row. A hostActive row attaches directly — the
28
+ * async resolver owns starting/stopping wait, so no interrupt confirmation is
29
+ * offered even when the row is busy. A busy row WITHOUT a host claim keeps the
30
+ * existing stopFirst confirm; everything else attaches directly.
31
+ * @param {{ hostActive?: boolean, agentBusy?: boolean }} row
32
+ * @returns {{ plan: "attach" | "confirm-stop-first" }}
33
+ */
34
+ export function planDashboardAttach({ hostActive, agentBusy }) {
35
+ if (!hostActive && agentBusy) return { plan: "confirm-stop-first" };
36
+ return { plan: "attach" };
37
+ }
38
+
39
+ /**
40
+ * Map a service reply() result to the dashboard notice (issue #70: queued and
41
+ * sent are different outcomes — a queued reply is durable, not yet delivered).
42
+ * Result shapes from service.reply(): {ok:false,error} | {ok:true,queued:true}
43
+ * | {ok:true,sent:true} | {ok:true,hostMode,fallbackReason?}.
44
+ * @param {{ ok?: boolean, error?: string, queued?: boolean, sent?: boolean, hostMode?: string|null, fallbackReason?: string }} res
45
+ * @returns {{ level: "info" | "warn" | "error", text: string }}
46
+ */
47
+ export function replyNotice(res) {
48
+ if (!res?.ok) return { level: "error", text: res?.error ?? "Reply failed" };
49
+ if (res.queued) return { level: "info", text: "Reply queued — will deliver when the host is ready" };
50
+ if (res.sent) return { level: "info", text: "Reply sent" };
51
+ if (res.hostMode === "json-runner") {
52
+ return { level: "warn", text: `Reply sent with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}` };
53
+ }
54
+ return { level: "info", text: "Reply sent" };
55
+ }
@@ -18,12 +18,14 @@ import {
18
18
  clampThinkingLevel,
19
19
  listDirectorySuggestions,
20
20
  existingCwdCandidates,
21
+ modelRefAvailable,
21
22
  nextCwdPickerState,
22
23
  resolveDirectoryValue,
23
24
  resolveLaunchContext,
24
25
  supportedThinkingLevels,
25
26
  } from "../core/launch-options.mjs";
26
27
  import { createPrewarmScheduler } from "../core/prewarm-schedule.mjs";
28
+ import { planDashboardAttach, planPrewarm, replyNotice } from "./dashboard-decisions.mjs";
27
29
  import { ensureCwdStatsSeeded, rankedCwdCandidates, recordCwdLaunch } from "../core/cwd-stats.mjs";
28
30
  import { filterRows, groupRowsByFolder, rowState, stateGlyph } from "../core/rows.mjs";
29
31
  import { loadSessionView } from "../core/session-view.mjs";
@@ -142,6 +144,15 @@ export class DashboardComponent implements Component {
142
144
  private inputNotice: InputNotice | null = null;
143
145
  private launch: LaunchState | null = null;
144
146
  private lastLaunchPrefs: { cwd: string | null; model: string | null; thinkingLevel: ThinkingLevel | null } | null = null;
147
+ /** First frame after mount must clear the screen: pi-tui's first render
148
+ * "assumes clean screen" (fullRender(false)) and overlays never get
149
+ * clearOnShrink — crash output / dirty bottoms would persist (issue #88). */
150
+ private needsFullClear = true;
151
+ /** Content line count of the previous frame BEFORE spacer/padding fill
152
+ * (padding always fills the terminal height, so padded counts never
153
+ * shrink — shrink detection must run on pre-fill counts). */
154
+ private lastContentLineCount: number | null = null;
155
+ private frameContentLineCount = 0;
145
156
  private readonly editor: CustomEditor;
146
157
 
147
158
  constructor(
@@ -266,7 +277,16 @@ export class DashboardComponent implements Component {
266
277
  const id = this.selectedId;
267
278
  if (!id || id === this.prewarmedId) return;
268
279
  const row = this.selectedRow();
269
- if (!row || row.hostAlive || isAgentBusy(row)) return;
280
+ if (!row) return;
281
+ // hostActive claims (starting/alive/stopping) already have a launch — mark
282
+ // them prewarmed without another service call; a pending claim is not a
283
+ // failure to retry on every debounce tick (issue #70 A14).
284
+ const decision = planPrewarm({ hostActive: row.hostActive, agentBusy: isAgentBusy(row) });
285
+ if (decision.action === "skip") return;
286
+ if (decision.action === "mark-only") {
287
+ this.prewarmedId = id;
288
+ return;
289
+ }
270
290
  const res = this.deps.service.prewarmHost?.(id);
271
291
  if (res?.ok) this.prewarmedId = id;
272
292
  }
@@ -792,7 +812,8 @@ export class DashboardComponent implements Component {
792
812
  case "rename":
793
813
  return this.submitRename();
794
814
  case "reply":
795
- return this.submitReply();
815
+ void this.submitReply();
816
+ return;
796
817
  case "list":
797
818
  case "dispatch":
798
819
  return this.input.trim() ? this.openLaunchDialog() : this.attachSelected();
@@ -866,7 +887,7 @@ export class DashboardComponent implements Component {
866
887
  this.refresh();
867
888
  }
868
889
 
869
- private submitReply(): void {
890
+ private async submitReply(): Promise<void> {
870
891
  const text = this.input.trim();
871
892
  const row = this.selectedRow();
872
893
  if (!text || !row) {
@@ -874,10 +895,11 @@ export class DashboardComponent implements Component {
874
895
  this.setInput("");
875
896
  return;
876
897
  }
877
- const res = this.deps.service.reply(row.meta.id, text);
878
- if (!res.ok) this.notice(res.error ?? "Reply failed", "error");
879
- else if (res.hostMode === "json-runner") this.notice(`Reply sent with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}`, "warn");
880
- else this.notice("Reply sent", "info");
898
+ // reply() is async since issue #70 A13 (ack-gated host delivery); the
899
+ // result (sent vs queued) only shapes the notice.
900
+ const res = await this.deps.service.reply(row.meta.id, text);
901
+ const notice = replyNotice(res);
902
+ this.notice(notice.text, notice.level);
881
903
  this.setInput("");
882
904
  this.mode = "peek";
883
905
  this.inputNotice = null;
@@ -1116,17 +1138,19 @@ export class DashboardComponent implements Component {
1116
1138
  }
1117
1139
 
1118
1140
  private requestAttach(row: Row): void {
1119
- if (row.hostAlive) {
1120
- this.done({ action: "attach", viewId: row.meta.id, stopFirst: false });
1121
- } else if (isAgentBusy(row)) {
1141
+ // hostActive rows (starting/alive/stopping) attach directly — the async
1142
+ // resolver owns the starting/stopping wait, so no interrupt confirm even
1143
+ // when the row is busy (issue #70 A14).
1144
+ const plan = planDashboardAttach({ hostActive: row.hostActive, agentBusy: isAgentBusy(row) });
1145
+ if (plan.plan === "confirm-stop-first") {
1122
1146
  this.pending = {
1123
1147
  prompt: `"${row.meta.name}" is running. Interrupt and attach? (y/N)`,
1124
1148
  onYes: () => this.done({ action: "attach", viewId: row.meta.id, stopFirst: true }),
1125
1149
  };
1126
1150
  this.mode = "confirm";
1127
- } else {
1128
- this.done({ action: "attach", viewId: row.meta.id, stopFirst: false });
1151
+ return;
1129
1152
  }
1153
+ this.done({ action: "attach", viewId: row.meta.id, stopFirst: false });
1130
1154
  }
1131
1155
 
1132
1156
  // ---- rendering ----------------------------------------------------------
@@ -1141,6 +1165,28 @@ export class DashboardComponent implements Component {
1141
1165
  }
1142
1166
 
1143
1167
  render(width: number): string[] {
1168
+ const lines = this.renderLines(width);
1169
+ // Self-heal frames (issue #88): pi-tui disables clearOnShrink while an
1170
+ // overlay is active ("overlays need the padding"), so a content shrink
1171
+ // under the dashboard overlay would leave stale rows forever. Force a
1172
+ // full clear on the first frame and on any content-line shrink.
1173
+ // The force MUST be deferred past the active doRender pass: calling
1174
+ // requestRender(true) synchronously here resets pi-tui's diff state
1175
+ // mid-frame (previousLines=[], previousWidth=-1), making the CURRENT frame
1176
+ // take the first-render no-clear branch, after which the deferred re-render
1177
+ // diffs identical content — nothing is ever cleared (issue #88 CR r1).
1178
+ // Deferred to nextTick, the reset lands between frames: the next doRender
1179
+ // sees previousWidth=-1 ≠ width → widthChanged → fullRender(true), a real
1180
+ // clear wrapped in DECSET 2026 synchronized output by pi-tui itself.
1181
+ if (this.needsFullClear || (this.lastContentLineCount != null && this.frameContentLineCount < this.lastContentLineCount)) {
1182
+ this.needsFullClear = false;
1183
+ process.nextTick(() => this.tui.requestRender(true));
1184
+ }
1185
+ this.lastContentLineCount = this.frameContentLineCount;
1186
+ return lines;
1187
+ }
1188
+
1189
+ private renderLines(width: number): string[] {
1144
1190
  const allRows = this.deps.service.rows();
1145
1191
  const needs = allRows.filter((r) => r.state?.semanticState === "needs_input").length;
1146
1192
  const working = allRows.filter((r) => r.state?.semanticState === "working").length;
@@ -1170,6 +1216,12 @@ export class DashboardComponent implements Component {
1170
1216
  const body = this.renderRows(width);
1171
1217
  const windowed = this.windowBody(body, capacity);
1172
1218
  lines.push(...windowed.lines);
1219
+ // Shrink-detection count: recorded BEFORE the spacer fill — the spacer
1220
+ // pads the frame back out to the terminal height, so the padded (or
1221
+ // pre-fitToHeight-padding) total never shrinks and would mask row
1222
+ // removals, the exact ghosting trigger (issue #88). Non-list modes
1223
+ // return earlier and intentionally don't record.
1224
+ this.frameContentLineCount = lines.length;
1173
1225
  // Keep the compose box visually docked to the bottom instead of glued to the
1174
1226
  // final session row. This matches the Claude-style layout: list at top,
1175
1227
  // large calm workspace, input/footer at bottom.
@@ -1724,7 +1776,9 @@ function filterLaunchChoices(choices: LaunchChoice[], query: string): LaunchChoi
1724
1776
  function findLaunchModelByRef(models: LaunchModel[], ref: string): LaunchModel | null {
1725
1777
  const target = String(ref || "").trim().toLowerCase();
1726
1778
  if (!target) return null;
1727
- return models.find((model) => `${model.provider}/${model.id}`.toLowerCase() === target) ?? null;
1779
+ // Single source of truth with the service-side stale-model guard (issue #90):
1780
+ // same case-insensitive exact "provider/id" rule.
1781
+ return models.find((model) => modelRefAvailable(target, [model])) ?? null;
1728
1782
  }
1729
1783
 
1730
1784
  function stripBracketedPaste(data: string): string {
@@ -244,6 +244,16 @@ export class PtyAttachComponent implements Component {
244
244
  this.send({ type: "input", data });
245
245
  return;
246
246
  }
247
+ if (matchesKey(data, Key.ctrl("left"))) {
248
+ // Explicit detach chord (issue #89): single ← is gated on editor state
249
+ // (it doubles as cursor-left inside a non-empty draft), so a user with
250
+ // a draft had no way out short of Ctrl+C/D, which kills the child Pi.
251
+ // Ctrl+← is unambiguous intent — detach unconditionally, regardless of
252
+ // editor state or socket liveness (same always-exitable guarantee as
253
+ // the disconnected-← escape, issue #48).
254
+ this.detach();
255
+ return;
256
+ }
247
257
  if (matchesKey(data, Key.left)) {
248
258
  // While the socket is down the key can never reach the child, so
249
259
  // escape unconditionally — the view must always be exitable, even
@@ -279,7 +289,7 @@ export class PtyAttachComponent implements Component {
279
289
  }
280
290
  const header =
281
291
  this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
282
- this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach`);
292
+ this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ←/Ctrl+← detach`);
283
293
  return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
284
294
  }
285
295
 
@@ -295,7 +305,7 @@ export class PtyAttachComponent implements Component {
295
305
  out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
296
306
  out.push(center(this.theme.fg("muted", detail), width));
297
307
  out.push("");
298
- out.push(center(this.theme.fg("dim", "← to detach"), width));
308
+ out.push(center(this.theme.fg("dim", "←/Ctrl+← to detach"), width));
299
309
  while (out.length < height) out.push("");
300
310
  return out.slice(0, height);
301
311
  }
@@ -1150,16 +1160,24 @@ function asciiCellsForBufferLine(line: BufferLineLike, reusable: BufferCellLike)
1150
1160
  function openExternalTarget(target: string): boolean {
1151
1161
  const sanitized = sanitizeOscPayload(target).trim();
1152
1162
  if (!sanitized) return false;
1163
+ // Opening the target is best-effort: an async spawn failure (e.g. xdg-open
1164
+ // missing on a minimal server) must be swallowed, not crash the host (issue #86).
1153
1165
  try {
1154
1166
  if (process.platform === "darwin") {
1155
- spawn("open", [sanitized], { detached: true, stdio: "ignore" }).unref();
1167
+ const child = spawn("open", [sanitized], { detached: true, stdio: "ignore" });
1168
+ child.on("error", () => {});
1169
+ child.unref();
1156
1170
  return true;
1157
1171
  }
1158
1172
  if (process.platform === "win32") {
1159
- spawn("cmd", ["/c", "start", "", sanitized], { detached: true, stdio: "ignore" }).unref();
1173
+ const child = spawn("cmd", ["/c", "start", "", sanitized], { detached: true, stdio: "ignore" });
1174
+ child.on("error", () => {});
1175
+ child.unref();
1160
1176
  return true;
1161
1177
  }
1162
- spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" }).unref();
1178
+ const child = spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" });
1179
+ child.on("error", () => {});
1180
+ child.unref();
1163
1181
  return true;
1164
1182
  } catch {
1165
1183
  return false;