@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.
- package/CHANGELOG.md +44 -0
- package/README.md +43 -5
- package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
- package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
- package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
- package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
- package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
- package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
- package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
- package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
- package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
- package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
- package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
- package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
- package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
- package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
- package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
- package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
- package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
- package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
- package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
- package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
- package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
- package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
- package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
- package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
- package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
- package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
- package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
- package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
- package/package.json +83 -81
- package/runner/job-runner.mjs +2 -2
- package/runner/pty-runner.mjs +626 -3
- package/runner/state-runner.mjs +3 -0
- package/runner/title-runner.mjs +1 -1
- package/scripts/release_helper.mjs +277 -0
- package/src/commands/agent-board.ts +47 -35
- package/src/commands/attach-decision.mjs +66 -0
- package/src/commands/attach-flow.ts +45 -39
- package/src/commands/bg.ts +9 -0
- package/src/core/code-refs.mjs +85 -33
- package/src/core/evidence.mjs +2 -2
- package/src/core/heuristics.mjs +75 -2
- package/src/core/host-coordination.mjs +182 -0
- package/src/core/host-crash.mjs +43 -3
- package/src/core/host-probe.mjs +196 -0
- package/src/core/launch-options.mjs +17 -0
- package/src/core/launch.mjs +35 -35
- package/src/core/locks.mjs +196 -1
- package/src/core/paths.mjs +24 -0
- package/src/core/store.mjs +164 -5
- package/src/core/types.mjs +17 -1
- package/src/core/warm-host-sweeper.mjs +150 -0
- package/src/index.ts +40 -3
- package/src/runtime/service.mjs +1071 -108
- package/src/ui/dashboard-decisions.mjs +55 -0
- package/src/ui/dashboard.ts +67 -13
- 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
|
+
}
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -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 ·
|
|
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", "
|
|
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" })
|
|
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" })
|
|
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" })
|
|
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;
|