@zhuxixi/pi-agent-board 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,8 @@ import { launchAutoState as launchAutoStateProcess, launchHost as launchHostProc
25
25
  import { gitRepoRoot } from "../core/repo.mjs";
26
26
  import { isAlive, killProcess } from "../core/pid.mjs";
27
27
  import { acquireOwnedViewLock, tryAcquireOwnedViewLock } from "../core/locks.mjs";
28
- import { canReplaceHost } from "../core/host-coordination.mjs";
28
+ import { canFinalizeLegacyHost, canReplaceHost } from "../core/host-coordination.mjs";
29
+ import { modelRefAvailable } from "../core/launch-options.mjs";
29
30
  import { HOST_PROBE_RETRY_MS, probeHost } from "../core/host-probe.mjs";
30
31
  import * as P from "../core/paths.mjs";
31
32
  import {
@@ -34,11 +35,13 @@ import {
34
35
  listRows,
35
36
  loadRow,
36
37
  readHost,
38
+ readHostPid,
37
39
  readLaunchPrefs,
38
40
  readPid,
39
41
  readState,
40
42
  readStatus,
41
43
  updateOwnedHost,
44
+ writeHost,
42
45
  writeLaunchPrefs,
43
46
  writeMeta,
44
47
  writeState,
@@ -85,6 +88,7 @@ const ATTACH_RESOLVE_TIMEOUT_MS = 120_000;
85
88
  * signalOwnedProcess?: (identity: {pid: number, startToken: string|null}, signal: string) => void,
86
89
  * probeHostFn?: typeof probeHost,
87
90
  * sleepFn?: (ms: number) => Promise<void>,
91
+ * availableModels?: () => Array<{ provider: string, id: string }> | undefined,
88
92
  * }} opts
89
93
  */
90
94
  export function createService(opts) {
@@ -109,6 +113,10 @@ export function createService(opts) {
109
113
  // through sleepFn (never a blocking acquire / Atomics.wait).
110
114
  const probeHostImpl = opts.probeHostFn ?? probeHost;
111
115
  const sleepFnImpl = opts.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
116
+ // Live model list for the stale-defaultModel launch guard (issue #90). Called
117
+ // at validation time (never cached at startup) and wrapped defensively by the
118
+ // caller; undefined/absent skips validation entirely.
119
+ const availableModelsImpl = opts.availableModels ?? null;
112
120
  // In-flight attach resolutions, keyed by viewId: concurrent resolver calls for
113
121
  // the same view share one promise (issue #70 Task 12).
114
122
  const inflightAttachResolvers = new Map();
@@ -193,6 +201,28 @@ export function createService(opts) {
193
201
  return { ok: true, status: "pending", pid: null, socketPath: host?.socketPath ?? null, instanceId: host?.instanceId ?? null };
194
202
  }
195
203
 
204
+ /**
205
+ * Actionable error when the view's defaultModel is provably unavailable
206
+ * (issue #90): launching a child with a dead model id makes it exit 1 on
207
+ * every start, so attach would loop cold starts forever. Only fires when a
208
+ * model is configured AND a live model list is injectable; anything
209
+ * uncertain (no list, list call failure) conservatively allows the launch.
210
+ * @param {import("../core/types.mjs").ViewMeta} meta
211
+ * @returns {string|null}
212
+ */
213
+ function validateViewModelMeta(meta) {
214
+ const model = meta.defaultModel ?? null;
215
+ if (!model || !availableModelsImpl) return null;
216
+ let available;
217
+ try {
218
+ available = availableModelsImpl();
219
+ } catch {
220
+ return null;
221
+ }
222
+ if (modelRefAvailable(model, available)) return null;
223
+ return `Model "${model}" configured for this session is no longer available — update the view's model (or clear defaultModel) and retry attach.`;
224
+ }
225
+
196
226
  /**
197
227
  * Claim → spawn → merge flow. The CALLER must already hold the `host-start`
198
228
  * lease; this function never acquires or releases it.
@@ -214,6 +244,11 @@ export function createService(opts) {
214
244
  return pendingLaunchResult(existing);
215
245
  }
216
246
 
247
+ // Fail fast BEFORE claiming (issue #90): no claim record, no config file,
248
+ // no spawn — the resolver surfaces the error via its pending reason.
249
+ const modelError = validateViewModelMeta(meta);
250
+ if (modelError) return { ok: false, error: modelError };
251
+
217
252
  const instanceId = randomIdImpl();
218
253
  const configPath = P.hostConfigPathFor(root, meta.id, instanceId);
219
254
  const socketPath = P.hostEndpointPathFor(process.platform, root, meta.id, instanceId);
@@ -789,6 +824,14 @@ export function createService(opts) {
789
824
  // The record moved on (adopted elsewhere, revoked, or replaced) — surface it.
790
825
  return { ok: true, pending: true, socketPath: current?.socketPath ?? host.socketPath ?? null, instanceId: current?.instanceId ?? instanceId };
791
826
  }
827
+ // Stale-model guard (issue #90): the claim is an abandoned record, so mark
828
+ // it failed fenced (same shape as the spawn-failure path below — the next
829
+ // ensure can claim anew) instead of spawning a child doomed to exit 1.
830
+ const modelError = validateViewModelMeta(meta);
831
+ if (modelError) {
832
+ updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: modelError, claimPid: null, claimIdentity: null }));
833
+ return { ok: true, pending: true, socketPath: null, instanceId };
834
+ }
792
835
  const configPath = current.configPath ?? P.hostConfigPathFor(root, viewId, instanceId);
793
836
  const socketPath = current.socketPath ?? P.hostEndpointPathFor(process.platform, root, viewId, instanceId);
794
837
  /** @type {import("../core/types.mjs").HostConfig} */
@@ -821,8 +864,9 @@ export function createService(opts) {
821
864
  }
822
865
  if (pid == null) {
823
866
  const message = spawnError ?? "PTY host runner failed to spawn (adopted claim)";
824
- // Clear the dead claimer's fields so canReplaceHost sees the claim as
825
- // ended — a retry (fresh ensure) must not pend on a gone claimer pid.
867
+ // Clear the dead claimer's fields (failed fenced — keeps the record
868
+ // replaceable without depending on claimer liveness; canReplaceHost no
869
+ // longer consults claim fields, see issue #99).
826
870
  updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: message, claimPid: null, claimIdentity: null }));
827
871
  removeFile(configPath);
828
872
  return { ok: true, pending: true, socketPath: null, instanceId };
@@ -951,6 +995,13 @@ export function createService(opts) {
951
995
  continue;
952
996
  }
953
997
  if (withinGrace || legacy) {
998
+ // A legacy starting host whose runner is provably dead would wait out
999
+ // the grace window forever (withinGrace is always true for legacy) —
1000
+ // finalize it now instead (issue #87).
1001
+ if (legacy && finalizeDeadLegacyHost(root, viewId, host, probe.classification, nowImpl(), tryAcquireLockImpl)) {
1002
+ await sleepFnImpl(HOST_PROBE_RETRY_MS);
1003
+ continue;
1004
+ }
954
1005
  // Normal cold start (or a legacy starting host — legacy is never recovered,
955
1006
  // spec §10.1): wait out the grace window.
956
1007
  await sleepFnImpl(HOST_PROBE_RETRY_MS);
@@ -961,6 +1012,13 @@ export function createService(opts) {
961
1012
  await sleepFnImpl(HOST_PROBE_RETRY_MS);
962
1013
  continue;
963
1014
  } else if (legacy) {
1015
+ // Legacy host unreachable. spec §10.1 says never recover — unless the
1016
+ // runner pid is provably dead, in which case finalize and self-heal
1017
+ // (issue #87).
1018
+ if (finalizeDeadLegacyHost(root, viewId, host, probe.classification, nowImpl(), tryAcquireLockImpl)) {
1019
+ await sleepFnImpl(HOST_PROBE_RETRY_MS);
1020
+ continue;
1021
+ }
964
1022
  return pending(sessionFile, "legacy host unreachable — manual restart needed");
965
1023
  }
966
1024
 
@@ -1867,6 +1925,54 @@ function serviceIdentity() {
1867
1925
  return { pid: process.pid, startToken: readProcStartToken(process.pid) };
1868
1926
  }
1869
1927
 
1928
+ /**
1929
+ * Finalize a provably-dead legacy host as `exited` so the resolver loop's next
1930
+ * iteration sees hostActive=false and claims a fresh new-protocol host
1931
+ * (issue #87). The gate is `canFinalizeLegacyHost` — anything unverifiable
1932
+ * stays pending (spec §10.1 conservatism). The write itself is serialized
1933
+ * under the host-start lease (same gate as recoverHost/claim) with a
1934
+ * live-record re-check: a concurrent resolver may have already finalized and
1935
+ * claimed a new-protocol host, and a stale `alive` snapshot must never clobber
1936
+ * that replacement's record (issue #87 CR r1).
1937
+ * @param {string} root
1938
+ * @param {string} viewId
1939
+ * @param {import("../core/types.mjs").HostStatus} host
1940
+ * @param {string} probeClassification
1941
+ * @param {number} now
1942
+ * @param {(r: string, v: string, name: string, o?: object) => ({ acquired: true, lease: { release: () => void } } | { acquired: false, reason: string })} acquireHostStartLock
1943
+ * @returns {boolean} true when finalized (caller should `continue` the loop).
1944
+ */
1945
+ function finalizeDeadLegacyHost(root, viewId, host, probeClassification, now, acquireHostStartLock) {
1946
+ // Same pid fallback as loadRow: legacy records carry no runnerPid property —
1947
+ // the pid lives only in the host-pid.json mirror.
1948
+ const hostPid = Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId);
1949
+ if (!canFinalizeLegacyHost({ host, hostPid, hostPidAlive: isAlive(hostPid), probeClassification })) return false;
1950
+ // Serialize with claims: only write while the live record still matches our
1951
+ // (possibly stale) snapshot.
1952
+ const gate = acquireHostStartLock(root, viewId, "host-start", { identity: serviceIdentity() });
1953
+ if (!gate.acquired) return false; // busy — record left untouched; caller pends/waits this attempt, a later one finalizes
1954
+ try {
1955
+ const cur = readHost(root, viewId);
1956
+ if (!cur || cur.instanceId != null) return false; // already claimed by a new-protocol host
1957
+ if (cur.state !== "starting" && cur.state !== "alive") return false; // already finalized
1958
+ if (cur.socketPath !== host.socketPath || cur.state !== host.state) return false; // snapshot drifted
1959
+ const finalizedAt = now;
1960
+ writeHost(root, { ...cur, state: "exited", endedAt: finalizedAt, lastSeenAt: finalizedAt, error: "legacy host finalized: runner pid dead" });
1961
+ } finally {
1962
+ try { gate.lease.release(); } catch { /* best effort */ }
1963
+ }
1964
+ try {
1965
+ appendDiagnostic(root, viewId, {
1966
+ source: "service",
1967
+ level: "info",
1968
+ code: "legacy_host_finalized",
1969
+ message: `Finalized stale legacy host (pid ${hostPid} dead, probe ${probeClassification}) — next attach claims a fresh host`,
1970
+ details: { hostPid, probeClassification, previousState: host.state },
1971
+ });
1972
+ } catch { /* best effort */ }
1973
+ return true;
1974
+ }
1975
+
1870
1976
  /** Conservative pre-identity observation (issue #70 Task 10): a recorded pid that
1871
1977
  * is still alive is always `unknown` — never safe to release; not recorded or
1872
1978
  * provably gone is `dead`. Task 11 replaces this with identity-aware observation. */
@@ -1903,7 +2009,6 @@ function observeHostForReplace(host) {
1903
2009
  host,
1904
2010
  runnerObservation: conservativeObservation(host?.runnerPid ?? null),
1905
2011
  childObservation: conservativeObservation(host?.childPid ?? null),
1906
- claimObservation: conservativeObservation(host?.claimPid ?? null),
1907
2012
  launchLeaseActive: false,
1908
2013
  };
1909
2014
  }
@@ -18,6 +18,7 @@ import {
18
18
  clampThinkingLevel,
19
19
  listDirectorySuggestions,
20
20
  existingCwdCandidates,
21
+ modelRefAvailable,
21
22
  nextCwdPickerState,
22
23
  resolveDirectoryValue,
23
24
  resolveLaunchContext,
@@ -143,6 +144,15 @@ export class DashboardComponent implements Component {
143
144
  private inputNotice: InputNotice | null = null;
144
145
  private launch: LaunchState | null = null;
145
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;
146
156
  private readonly editor: CustomEditor;
147
157
 
148
158
  constructor(
@@ -1155,6 +1165,28 @@ export class DashboardComponent implements Component {
1155
1165
  }
1156
1166
 
1157
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[] {
1158
1190
  const allRows = this.deps.service.rows();
1159
1191
  const needs = allRows.filter((r) => r.state?.semanticState === "needs_input").length;
1160
1192
  const working = allRows.filter((r) => r.state?.semanticState === "working").length;
@@ -1184,6 +1216,12 @@ export class DashboardComponent implements Component {
1184
1216
  const body = this.renderRows(width);
1185
1217
  const windowed = this.windowBody(body, capacity);
1186
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;
1187
1225
  // Keep the compose box visually docked to the bottom instead of glued to the
1188
1226
  // final session row. This matches the Claude-style layout: list at top,
1189
1227
  // large calm workspace, input/footer at bottom.
@@ -1738,7 +1776,9 @@ function filterLaunchChoices(choices: LaunchChoice[], query: string): LaunchChoi
1738
1776
  function findLaunchModelByRef(models: LaunchModel[], ref: string): LaunchModel | null {
1739
1777
  const target = String(ref || "").trim().toLowerCase();
1740
1778
  if (!target) return null;
1741
- 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;
1742
1782
  }
1743
1783
 
1744
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;