@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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -2
- 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/plans/2026-09-09-claimpid-blocks-replace.md +267 -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/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +147 -0
- package/package.json +1 -1
- package/runner/pty-runner.mjs +54 -2
- package/src/commands/agent-board.ts +9 -0
- package/src/commands/bg.ts +9 -0
- package/src/core/heuristics.mjs +35 -0
- package/src/core/host-coordination.mjs +32 -7
- package/src/core/launch-options.mjs +17 -0
- package/src/core/launch.mjs +32 -34
- package/src/index.ts +11 -2
- package/src/runtime/service.mjs +109 -4
- package/src/ui/dashboard.ts +41 -1
- package/src/ui/pty-attach.ts +23 -5
package/src/runtime/service.mjs
CHANGED
|
@@ -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
|
|
825
|
-
//
|
|
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
|
}
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
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;
|