@zhuxixi/pi-agent-board 0.4.3 → 0.5.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/PROGRESS.md +18 -3
- package/README.md +295 -72
- package/VERIFY.md +3 -3
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -3
- package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +223 -0
- package/docs/superpowers/plans/2026-08-30-circular-navigation.md +308 -0
- package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +37 -0
- package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +311 -0
- package/docs/superpowers/plans/2026-08-30-readme-v2.md +294 -0
- package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +4 -0
- package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +4 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +128 -0
- package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +47 -0
- package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +77 -0
- package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +80 -0
- package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +105 -0
- package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +115 -0
- package/package.json +1 -1
- package/runner/job-runner.mjs +29 -3
- package/runner/pty-runner.mjs +139 -25
- package/runner/state-runner.mjs +7 -2
- package/runner/title-runner.mjs +1 -1
- package/src/core/atomic.mjs +40 -1
- package/src/core/code-refs-store.mjs +315 -0
- package/src/core/code-refs.mjs +861 -0
- package/src/core/host-crash.mjs +39 -0
- package/src/core/launch.mjs +6 -0
- package/src/core/paths.mjs +24 -1
- package/src/core/pty-attach-jiggle-controller.mjs +71 -17
- package/src/core/pty-attach-reconnect.mjs +43 -0
- package/src/core/pty-scroll.mjs +4 -3
- package/src/core/repo.mjs +56 -0
- package/src/core/rows.mjs +50 -0
- package/src/core/store.mjs +4 -1
- package/src/core/types.mjs +12 -0
- package/src/core/worktree.mjs +1 -0
- package/src/runtime/service.mjs +7 -1
- package/src/ui/dashboard.ts +20 -4
- package/src/ui/pty-attach.ts +124 -27
package/src/ui/dashboard.ts
CHANGED
|
@@ -250,7 +250,9 @@ export class DashboardComponent implements Component {
|
|
|
250
250
|
private moveSelection(delta: number): void {
|
|
251
251
|
if (this.orderedIds.length === 0) return;
|
|
252
252
|
const cur = this.selectedId ? this.orderedIds.indexOf(this.selectedId) : 0;
|
|
253
|
-
const
|
|
253
|
+
const len = this.orderedIds.length;
|
|
254
|
+
// Wrap around both ends: down past last -> first, up past first -> last.
|
|
255
|
+
const next = (((cur < 0 ? 0 : cur) + delta) % len + len) % len;
|
|
254
256
|
const nextId = this.orderedIds[next];
|
|
255
257
|
if (nextId === this.selectedId) return;
|
|
256
258
|
this.selectedId = nextId;
|
|
@@ -1095,7 +1097,9 @@ export class DashboardComponent implements Component {
|
|
|
1095
1097
|
if (!this.peekId) return;
|
|
1096
1098
|
const idx = this.orderedIds.indexOf(this.peekId);
|
|
1097
1099
|
if (idx < 0) return;
|
|
1098
|
-
const
|
|
1100
|
+
const len = this.orderedIds.length;
|
|
1101
|
+
// Wrap around both ends, same as moveSelection().
|
|
1102
|
+
const next = ((idx + delta) % len + len) % len;
|
|
1099
1103
|
this.peekId = this.orderedIds[next];
|
|
1100
1104
|
this.selectedId = this.peekId;
|
|
1101
1105
|
}
|
|
@@ -1362,8 +1366,9 @@ export class DashboardComponent implements Component {
|
|
|
1362
1366
|
const contentWidth = Math.max(20, width - indent);
|
|
1363
1367
|
const glyph = stateGlyph(rv.state, rv.alive, rv.hostAlive, rv.unread);
|
|
1364
1368
|
const marker = stageFg(rv.state, selected ? `›${glyph}` : ` ${glyph}`);
|
|
1365
|
-
const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
|
|
1366
|
-
const
|
|
1369
|
+
const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.staleHost ? "⌁ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
|
|
1370
|
+
const refsBadge = rv.refsBadge ? (rv.refsLowConfidence ? t.fg("dim", rv.refsBadge) : rv.refsBadge) : "";
|
|
1371
|
+
const badge = `${this.mode === "select" ? `${this.isSelectedForBatch(rv.id) ? "◉" : "○"} ` : ""}${rv.pinned ? "★ " : ""}${rv.worktree ? "⌥ " : ""}${statusBadges}${refsBadge ? `${refsBadge} ` : ""}`;
|
|
1367
1372
|
const ageRaw = ` ${rv.age}`;
|
|
1368
1373
|
const nameW = clamp(Math.floor(contentWidth * 0.34), 18, 30);
|
|
1369
1374
|
const folderW = opts.showFolder !== false ? (contentWidth >= 72 ? clamp(Math.floor(contentWidth * 0.16), 10, 22) : contentWidth >= 56 ? 10 : 0) : 0;
|
|
@@ -1396,6 +1401,17 @@ export class DashboardComponent implements Component {
|
|
|
1396
1401
|
out.push(t.fg("muted", "Auto-state"));
|
|
1397
1402
|
out.push(clip(` ${auto.kind} · ${auto.confidence} · ${auto.source}${auto.reason ? ` — ${auto.reason}` : ""}`, width));
|
|
1398
1403
|
}
|
|
1404
|
+
if (row.codeRefs && row.codeRefs.allRefs.length > 0) {
|
|
1405
|
+
const codeRefs = row.codeRefs;
|
|
1406
|
+
out.push("");
|
|
1407
|
+
out.push(t.fg("muted", "Refs"));
|
|
1408
|
+
if (codeRefs.provider) out.push(clip(` ${codeRefs.provider}`, width));
|
|
1409
|
+
for (const ref of codeRefs.allRefs) {
|
|
1410
|
+
const prefix = ref.kind === "pr" ? (codeRefs.prPrefix ?? "▸#") : (codeRefs.issuePrefix ?? "#");
|
|
1411
|
+
const line = ` ${ref.kind} ${prefix}${ref.number} · ${ref.confidence} · ${ref.source}${ref.url ? ` · ${ref.url}` : ""}`;
|
|
1412
|
+
out.push(clip(line, width));
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1399
1415
|
if (row.state?.question) {
|
|
1400
1416
|
out.push("");
|
|
1401
1417
|
out.push(t.fg("warning", "Question / blocker"));
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@
|
|
|
8
8
|
import { isProbablyEmptyPiInputLine } from "../core/pty-input.mjs";
|
|
9
9
|
import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
|
|
10
10
|
import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
11
|
+
import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
|
|
11
12
|
import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
|
|
12
13
|
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
|
|
13
14
|
import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
|
|
@@ -28,7 +29,7 @@ export interface PtyAttachOptions {
|
|
|
28
29
|
const require = createRequire(import.meta.url);
|
|
29
30
|
const { Terminal } = require("@xterm/headless") as { Terminal: new (opts: Record<string, unknown>) => XtermLike };
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
|
|
32
33
|
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
33
34
|
const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
|
|
34
35
|
const XTSHIFTESCAPE_SELECT = "\x1b[>0s";
|
|
@@ -39,6 +40,8 @@ const LOADING_TICK_MS = 120;
|
|
|
39
40
|
const ATTACH_SETTLE_MS = 250;
|
|
40
41
|
/** Hard cap on the attach transition so a silent session can't stall the banner. */
|
|
41
42
|
const ATTACH_HARD_TIMEOUT_MS = 2500;
|
|
43
|
+
/** Give ordered detach packets time to flush before using destroy as a fallback. */
|
|
44
|
+
const GRACEFUL_SOCKET_CLOSE_MS = 1000;
|
|
42
45
|
/** How many tail bytes of the screen log to replay on attach. Read from the file tail
|
|
43
46
|
* (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
|
|
44
47
|
* handful of screens, which is all a fresh attach needs. */
|
|
@@ -133,6 +136,13 @@ export class PtyAttachComponent implements Component {
|
|
|
133
136
|
private osc52Carry = "";
|
|
134
137
|
private passthroughCarry = "";
|
|
135
138
|
private readonly connectStartedAt = Date.now();
|
|
139
|
+
// Set once the first socket connect succeeds; drives the reconnect give-up
|
|
140
|
+
// policy (short window after a live session dies, long window during cold
|
|
141
|
+
// host start — see pty-attach-reconnect.mjs).
|
|
142
|
+
private everConnected = false;
|
|
143
|
+
// Timestamp of the first disconnect that started the current reconnect
|
|
144
|
+
// cycle; reset on every successful (re)connect.
|
|
145
|
+
private disconnectedAt: number | null = null;
|
|
136
146
|
// Lines scrolled per mouse-wheel event; configurable via $AGENT_BOARD_WHEEL_LINES.
|
|
137
147
|
private readonly mouseWheelLines = resolveWheelLines();
|
|
138
148
|
private readonly term: XtermLike;
|
|
@@ -158,6 +168,7 @@ export class PtyAttachComponent implements Component {
|
|
|
158
168
|
private attaching = true;
|
|
159
169
|
private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
160
170
|
private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
171
|
+
private gracefulSocketCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
161
172
|
// Force a single full-clear on the first paint so the prior session/dashboard can't
|
|
162
173
|
// ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
|
|
163
174
|
// differential renderer so wheel/output bursts don't each trigger a full repaint.
|
|
@@ -230,12 +241,12 @@ export class PtyAttachComponent implements Component {
|
|
|
230
241
|
this.send({ type: "input", data });
|
|
231
242
|
return;
|
|
232
243
|
}
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (this.childInputLooksEmpty()) {
|
|
244
|
+
if (matchesKey(data, Key.left)) {
|
|
245
|
+
// While the socket is down the key can never reach the child, so
|
|
246
|
+
// escape unconditionally — the view must always be exitable, even
|
|
247
|
+
// after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
|
|
248
|
+
// a detach key — it passes through to Pi (tui.editor.jumpForward).
|
|
249
|
+
if (shouldEscapeAttach(this.connected, this.childInputLooksEmpty())) {
|
|
239
250
|
this.detach();
|
|
240
251
|
return;
|
|
241
252
|
}
|
|
@@ -257,7 +268,7 @@ export class PtyAttachComponent implements Component {
|
|
|
257
268
|
const bodyHeight = Math.max(1, height - 2);
|
|
258
269
|
let body: string[];
|
|
259
270
|
if (!this.attaching && this.receivedOutput) {
|
|
260
|
-
const projected = this.project(bodyHeight
|
|
271
|
+
const projected = this.project(bodyHeight);
|
|
261
272
|
body = projected.lines;
|
|
262
273
|
while (body.length < bodyHeight) body.unshift("");
|
|
263
274
|
} else {
|
|
@@ -265,7 +276,7 @@ export class PtyAttachComponent implements Component {
|
|
|
265
276
|
}
|
|
266
277
|
const header =
|
|
267
278
|
this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
|
|
268
|
-
this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach
|
|
279
|
+
this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach`);
|
|
269
280
|
return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
|
|
270
281
|
}
|
|
271
282
|
|
|
@@ -281,14 +292,14 @@ export class PtyAttachComponent implements Component {
|
|
|
281
292
|
out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
|
|
282
293
|
out.push(center(this.theme.fg("muted", detail), width));
|
|
283
294
|
out.push("");
|
|
284
|
-
out.push(center(this.theme.fg("dim", "←
|
|
295
|
+
out.push(center(this.theme.fg("dim", "← to detach"), width));
|
|
285
296
|
while (out.length < height) out.push("");
|
|
286
297
|
return out.slice(0, height);
|
|
287
298
|
}
|
|
288
299
|
|
|
289
300
|
private loadingDetail(elapsedSeconds: number): string {
|
|
290
301
|
if (this.status === "attached") return "Attached · waiting for the session to render…";
|
|
291
|
-
if (this.status.startsWith("error") || this.status === "host exited") return this.status;
|
|
302
|
+
if (this.status.startsWith("error") || this.status === "host exited" || this.status === "host not reachable") return this.status;
|
|
292
303
|
if (this.status === "disconnected") return `Reconnecting to the session host… ${elapsedSeconds}s`;
|
|
293
304
|
return `Starting the session host… ${elapsedSeconds}s`;
|
|
294
305
|
}
|
|
@@ -300,8 +311,12 @@ export class PtyAttachComponent implements Component {
|
|
|
300
311
|
}
|
|
301
312
|
|
|
302
313
|
private detach(): void {
|
|
314
|
+
// Restore a held PTY before ending the control socket. The runner closes
|
|
315
|
+
// the socket immediately after receiving detach, so sending detach first
|
|
316
|
+
// can drop the G3 restore resize (issue #42).
|
|
317
|
+
this.jiggleRetry.restoreAndStop();
|
|
303
318
|
this.send({ type: "detach" });
|
|
304
|
-
this.close();
|
|
319
|
+
this.close(true);
|
|
305
320
|
this.done({ action: "detached" });
|
|
306
321
|
}
|
|
307
322
|
|
|
@@ -315,16 +330,28 @@ export class PtyAttachComponent implements Component {
|
|
|
315
330
|
|
|
316
331
|
private connect(): void {
|
|
317
332
|
if (this.closed || this.connected || this.socket) return;
|
|
318
|
-
|
|
333
|
+
// On Windows the control socket is a named pipe — it never exists as a
|
|
334
|
+
// filesystem entry, so existsSync can't gate the connect; a missing pipe
|
|
335
|
+
// surfaces as an error event on the socket and retries via scheduleReconnect.
|
|
336
|
+
if (process.platform !== "win32" && !existsSync(this.opts.socketPath)) {
|
|
319
337
|
this.status = `starting host… ${Math.ceil((Date.now() - this.connectStartedAt) / 1000)}s`;
|
|
320
338
|
this.scheduleReconnect();
|
|
321
339
|
return;
|
|
322
340
|
}
|
|
323
341
|
const socket = createConnection(this.opts.socketPath);
|
|
324
342
|
this.socket = socket;
|
|
343
|
+
// A partial protocol line left over from a failed socket would prefix the
|
|
344
|
+
// replacement connection's first line; start each socket with a clean buffer.
|
|
345
|
+
this.parserBuffer = "";
|
|
325
346
|
socket.on("connect", () => {
|
|
347
|
+
if (this.socket !== socket) {
|
|
348
|
+
try { socket.destroy(); } catch {}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
326
351
|
this.clearRetry();
|
|
327
352
|
this.connected = true;
|
|
353
|
+
this.everConnected = true;
|
|
354
|
+
this.disconnectedAt = null;
|
|
328
355
|
this.status = "attached";
|
|
329
356
|
this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
|
|
330
357
|
this.jiggleRetry.start(this.cols, this.rows);
|
|
@@ -332,10 +359,17 @@ export class PtyAttachComponent implements Component {
|
|
|
332
359
|
this.scheduleRender();
|
|
333
360
|
this.startAttachSettle();
|
|
334
361
|
});
|
|
335
|
-
socket.on("data", (chunk) =>
|
|
362
|
+
socket.on("data", (chunk) => {
|
|
363
|
+
if (this.socket !== socket) return;
|
|
364
|
+
this.onSocketData(chunk.toString("utf8"));
|
|
365
|
+
});
|
|
336
366
|
socket.on("close", () => {
|
|
367
|
+
// A failed socket can close after a replacement connection has already
|
|
368
|
+
// succeeded. Never let that stale event clear the replacement state.
|
|
369
|
+
if (this.socket !== socket) return;
|
|
337
370
|
this.socket = null;
|
|
338
371
|
this.connected = false;
|
|
372
|
+
this.disconnectedAt ??= Date.now();
|
|
339
373
|
if (!this.closed && this.status !== "host exited") {
|
|
340
374
|
this.status = "disconnected";
|
|
341
375
|
this.scheduleReconnect();
|
|
@@ -343,8 +377,16 @@ export class PtyAttachComponent implements Component {
|
|
|
343
377
|
if (!this.closed) this.scheduleRender();
|
|
344
378
|
});
|
|
345
379
|
socket.on("error", (err) => {
|
|
380
|
+
// The error may belong to an old socket after reconnect. Dispose that
|
|
381
|
+
// socket, but do not alter the state of the current connection.
|
|
382
|
+
if (this.socket !== socket) {
|
|
383
|
+
try { socket.destroy(); } catch {}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
346
386
|
this.socket = null;
|
|
347
387
|
this.connected = false;
|
|
388
|
+
try { socket.destroy(); } catch {}
|
|
389
|
+
this.disconnectedAt ??= Date.now();
|
|
348
390
|
if (this.closed) return;
|
|
349
391
|
this.status = `waiting for host… ${err.message}`;
|
|
350
392
|
this.scheduleReconnect();
|
|
@@ -365,6 +407,17 @@ export class PtyAttachComponent implements Component {
|
|
|
365
407
|
|
|
366
408
|
private scheduleReconnect(): void {
|
|
367
409
|
if (this.closed || this.retryTimer) return;
|
|
410
|
+
const verdict = evaluateAttachReconnect({
|
|
411
|
+
everConnected: this.everConnected,
|
|
412
|
+
disconnectedAt: this.disconnectedAt,
|
|
413
|
+
connectStartedAt: this.connectStartedAt,
|
|
414
|
+
now: Date.now(),
|
|
415
|
+
});
|
|
416
|
+
if (verdict.giveUp) {
|
|
417
|
+
this.status = verdict.status ?? "host exited";
|
|
418
|
+
this.scheduleRender();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
368
421
|
this.retryTimer = setTimeout(() => {
|
|
369
422
|
this.retryTimer = null;
|
|
370
423
|
this.connect();
|
|
@@ -447,12 +500,20 @@ export class PtyAttachComponent implements Component {
|
|
|
447
500
|
this.retryTimer = null;
|
|
448
501
|
}
|
|
449
502
|
|
|
503
|
+
private clearGracefulSocketCloseTimer(): void {
|
|
504
|
+
if (!this.gracefulSocketCloseTimer) return;
|
|
505
|
+
clearTimeout(this.gracefulSocketCloseTimer);
|
|
506
|
+
this.gracefulSocketCloseTimer = null;
|
|
507
|
+
}
|
|
508
|
+
|
|
450
509
|
private enableMouseScroll(): void {
|
|
451
510
|
if (!this.mouseScrollEnabled()) return;
|
|
452
511
|
try {
|
|
453
512
|
this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
|
|
454
513
|
this.tui.terminal.write(MOUSE_ENABLE);
|
|
455
|
-
} catch {
|
|
514
|
+
} catch {
|
|
515
|
+
/* best-effort: some terminals reject these sequences; mouse reporting is optional */
|
|
516
|
+
}
|
|
456
517
|
}
|
|
457
518
|
|
|
458
519
|
private mouseScrollEnabled(): boolean {
|
|
@@ -478,7 +539,9 @@ export class PtyAttachComponent implements Component {
|
|
|
478
539
|
private disableMouseScroll(): void {
|
|
479
540
|
try {
|
|
480
541
|
this.tui.terminal.write(MOUSE_DISABLE);
|
|
481
|
-
} catch {
|
|
542
|
+
} catch {
|
|
543
|
+
/* best-effort: terminal may already be gone at teardown */
|
|
544
|
+
}
|
|
482
545
|
}
|
|
483
546
|
|
|
484
547
|
private handleMouseInputChunk(events: Array<{ raw: string; mouse: { button: number; row: number; col: number; action: string } }>): boolean {
|
|
@@ -692,7 +755,9 @@ export class PtyAttachComponent implements Component {
|
|
|
692
755
|
if (seq) {
|
|
693
756
|
try {
|
|
694
757
|
this.tui.terminal.write(seq);
|
|
695
|
-
} catch {
|
|
758
|
+
} catch {
|
|
759
|
+
/* best-effort: OSC52 clipboard support is optional */
|
|
760
|
+
}
|
|
696
761
|
}
|
|
697
762
|
// Also mirror the selection into the X11 PRIMARY selection so the rest of the
|
|
698
763
|
// desktop can middle-click-paste it — closes the loop with pastePrimarySelection().
|
|
@@ -713,7 +778,9 @@ export class PtyAttachComponent implements Component {
|
|
|
713
778
|
const timer = setTimeout(() => {
|
|
714
779
|
try {
|
|
715
780
|
child.kill("SIGKILL");
|
|
716
|
-
} catch {
|
|
781
|
+
} catch {
|
|
782
|
+
/* the child may have already exited before the timeout fired */
|
|
783
|
+
}
|
|
717
784
|
}, 800);
|
|
718
785
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
719
786
|
out += chunk.toString("utf8");
|
|
@@ -723,7 +790,9 @@ export class PtyAttachComponent implements Component {
|
|
|
723
790
|
clearTimeout(timer);
|
|
724
791
|
if (!this.closed && out) this.send({ type: "input", data: out });
|
|
725
792
|
});
|
|
726
|
-
} catch {
|
|
793
|
+
} catch {
|
|
794
|
+
/* silent no-op when xclip is absent — documented contract of this helper */
|
|
795
|
+
}
|
|
727
796
|
}
|
|
728
797
|
|
|
729
798
|
/** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */
|
|
@@ -734,7 +803,9 @@ export class PtyAttachComponent implements Component {
|
|
|
734
803
|
child.stdin?.on("error", () => {});
|
|
735
804
|
child.on("error", () => {});
|
|
736
805
|
child.stdin?.end(text);
|
|
737
|
-
} catch {
|
|
806
|
+
} catch {
|
|
807
|
+
/* silent no-op when xclip is absent */
|
|
808
|
+
}
|
|
738
809
|
}
|
|
739
810
|
|
|
740
811
|
private selectionText(): string {
|
|
@@ -763,6 +834,9 @@ export class PtyAttachComponent implements Component {
|
|
|
763
834
|
}
|
|
764
835
|
|
|
765
836
|
private currentSize(): { cols: number; rows: number } {
|
|
837
|
+
// SAFETY: duck-typed read — Pi TUI's Terminal type does not consistently expose
|
|
838
|
+
// cols/columns/rows across versions (see resizeIfNeeded below). Runtime
|
|
839
|
+
// fallbacks (120/24) keep this safe when the fields are absent.
|
|
766
840
|
const term = this.tui.terminal as unknown as { cols?: number; columns?: number; rows?: number } | undefined;
|
|
767
841
|
return {
|
|
768
842
|
cols: Math.max(20, term?.cols ?? term?.columns ?? 120),
|
|
@@ -892,7 +966,9 @@ export class PtyAttachComponent implements Component {
|
|
|
892
966
|
for (const seq of toWrite) {
|
|
893
967
|
try {
|
|
894
968
|
this.tui.terminal.write(seq);
|
|
895
|
-
} catch {
|
|
969
|
+
} catch {
|
|
970
|
+
/* best-effort: forwarded sequences are enhancements, never critical */
|
|
971
|
+
}
|
|
896
972
|
}
|
|
897
973
|
}
|
|
898
974
|
|
|
@@ -919,7 +995,9 @@ export class PtyAttachComponent implements Component {
|
|
|
919
995
|
} finally {
|
|
920
996
|
closeSync(fd);
|
|
921
997
|
}
|
|
922
|
-
} catch {
|
|
998
|
+
} catch {
|
|
999
|
+
/* best-effort: a missing or racing screen.log must not block attach */
|
|
1000
|
+
}
|
|
923
1001
|
}
|
|
924
1002
|
|
|
925
1003
|
private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
|
|
@@ -938,7 +1016,7 @@ export class PtyAttachComponent implements Component {
|
|
|
938
1016
|
});
|
|
939
1017
|
}
|
|
940
1018
|
|
|
941
|
-
private project(height: number
|
|
1019
|
+
private project(height: number): { lines: string[]; cursor: { row: number; col: number } | null } {
|
|
942
1020
|
const out: string[] = [];
|
|
943
1021
|
const buf = this.term.buffer.active;
|
|
944
1022
|
const selection = normalizeSelection(this.selection);
|
|
@@ -957,7 +1035,13 @@ export class PtyAttachComponent implements Component {
|
|
|
957
1035
|
return { lines: out.slice(-height), cursor };
|
|
958
1036
|
}
|
|
959
1037
|
|
|
960
|
-
private close(): void {
|
|
1038
|
+
private close(gracefulSocket = false): void {
|
|
1039
|
+
if (this.closed && !this.socket) {
|
|
1040
|
+
// Already closed (e.g. dispose() after detach()); a pending graceful-close
|
|
1041
|
+
// fallback timer stays armed on purpose — it self-clears on socket close
|
|
1042
|
+
// or destroys the socket after the grace window.
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
961
1045
|
this.closed = true;
|
|
962
1046
|
this.imeCoalesceUninstall?.();
|
|
963
1047
|
this.jiggleRetry.restoreAndStop();
|
|
@@ -976,11 +1060,24 @@ export class PtyAttachComponent implements Component {
|
|
|
976
1060
|
clearTimeout(this.attachHardTimeout);
|
|
977
1061
|
this.attachHardTimeout = null;
|
|
978
1062
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
} catch {}
|
|
1063
|
+
this.clearGracefulSocketCloseTimer();
|
|
1064
|
+
const socket = this.socket;
|
|
982
1065
|
this.socket = null;
|
|
983
1066
|
this.connected = false;
|
|
1067
|
+
if (!socket) return;
|
|
1068
|
+
if (!gracefulSocket) {
|
|
1069
|
+
try { socket.destroy(); } catch {}
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
// socket.end() preserves the ordering of the already-buffered restore and
|
|
1073
|
+
// detach packets. destroy() would discard buffered writes under backpressure.
|
|
1074
|
+
socket.once("close", () => this.clearGracefulSocketCloseTimer());
|
|
1075
|
+
try { socket.end(); } catch {}
|
|
1076
|
+
this.gracefulSocketCloseTimer = setTimeout(() => {
|
|
1077
|
+
this.gracefulSocketCloseTimer = null;
|
|
1078
|
+
try { socket.destroy(); } catch {}
|
|
1079
|
+
}, GRACEFUL_SOCKET_CLOSE_MS);
|
|
1080
|
+
this.gracefulSocketCloseTimer.unref?.();
|
|
984
1081
|
}
|
|
985
1082
|
}
|
|
986
1083
|
|