@ian-pascoe/pi-termctrl 0.0.0
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/LICENSE +21 -0
- package/README.md +191 -0
- package/package.json +59 -0
- package/skills/pi-termctrl/SKILL.md +16 -0
- package/src/bash-replacement.ts +369 -0
- package/src/exit-notification.ts +113 -0
- package/src/index.ts +1 -0
- package/src/pi-termctrl-extension.ts +186 -0
- package/src/pi-termctrl-settings.ts +128 -0
- package/src/ps-panel.ts +343 -0
- package/src/termctrl-binary.ts +25 -0
- package/src/termctrl-driver.ts +127 -0
- package/src/termctrl-registry.ts +710 -0
- package/src/terminal-driver.ts +51 -0
- package/src/terminal-tools.ts +726 -0
package/src/ps-panel.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
matchesKey,
|
|
4
|
+
truncateToWidth,
|
|
5
|
+
visibleWidth,
|
|
6
|
+
type Component,
|
|
7
|
+
type OverlayHandle,
|
|
8
|
+
type TUI,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { formatDuration } from "./exit-notification.js";
|
|
11
|
+
import type { TermctrlEntry, TermctrlRegistry } from "./termctrl-registry.js";
|
|
12
|
+
|
|
13
|
+
const REFRESH_MS = 1_000;
|
|
14
|
+
const MARGIN = 1;
|
|
15
|
+
/** Narrower than this, the owner and age columns are dropped. */
|
|
16
|
+
const WIDE_COLUMNS = 60;
|
|
17
|
+
const STATUS_KEY = "termctrl";
|
|
18
|
+
|
|
19
|
+
/** Starts the periodic refresh and returns its cancel function. Tests substitute fake timers. */
|
|
20
|
+
export type StartRefresh = (refresh: () => void) => () => void;
|
|
21
|
+
|
|
22
|
+
function startRefreshInterval(refresh: () => void): () => void {
|
|
23
|
+
const interval = setInterval(refresh, REFRESH_MS);
|
|
24
|
+
interval.unref?.();
|
|
25
|
+
return () => clearInterval(interval);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stateLabel(entry: TermctrlEntry): string {
|
|
29
|
+
if (entry.state === "running") return "running";
|
|
30
|
+
if (entry.exit?.signal !== null && entry.exit?.signal !== undefined) return entry.exit.signal;
|
|
31
|
+
const code = entry.exit?.code;
|
|
32
|
+
return code === null || code === undefined ? "exited" : `exit ${code}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ageOf(entry: TermctrlEntry, now: number): string {
|
|
36
|
+
return formatDuration((entry.exitedAt ?? now) - entry.startedAt);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function singleLine(text: string): string {
|
|
40
|
+
return text.replaceAll(/\s+/gu, " ").trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The live `/ps` overlay: every entry in the process, a preview of the selected one, and stop keys. */
|
|
44
|
+
export class TermctrlPsPanel implements Component {
|
|
45
|
+
private entries: readonly TermctrlEntry[] = [];
|
|
46
|
+
private selectedId: string | undefined;
|
|
47
|
+
private preview = "";
|
|
48
|
+
private previewPending = false;
|
|
49
|
+
private notice = "";
|
|
50
|
+
private disposed = false;
|
|
51
|
+
private readonly stopRefresh: () => void;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly registry: TermctrlRegistry,
|
|
55
|
+
private readonly viewerOwner: string,
|
|
56
|
+
private readonly tui: TUI,
|
|
57
|
+
private readonly theme: Theme,
|
|
58
|
+
private readonly keybindings: KeybindingsManager,
|
|
59
|
+
private readonly onClose: () => void,
|
|
60
|
+
startRefresh: StartRefresh = startRefreshInterval,
|
|
61
|
+
) {
|
|
62
|
+
this.refresh();
|
|
63
|
+
this.stopRefresh = startRefresh(() => {
|
|
64
|
+
this.refresh();
|
|
65
|
+
this.tui.requestRender();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
handleInput(data: string): void {
|
|
70
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
71
|
+
this.close();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (this.keybindings.matches(data, "tui.select.up")) this.move(-1);
|
|
75
|
+
else if (this.keybindings.matches(data, "tui.select.down")) this.move(1);
|
|
76
|
+
else if (matchesKey(data, "k")) this.stopSelected();
|
|
77
|
+
else if (matchesKey(data, "x")) this.removeSelected();
|
|
78
|
+
else return;
|
|
79
|
+
this.tui.requestRender();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
render(width: number): string[] {
|
|
83
|
+
if (width <= 0) return [];
|
|
84
|
+
const height = Math.max(
|
|
85
|
+
1,
|
|
86
|
+
Math.min(Math.floor(this.tui.terminal.rows * 0.9), this.tui.terminal.rows - 2 * MARGIN),
|
|
87
|
+
);
|
|
88
|
+
if (width < 8 || height < 6) {
|
|
89
|
+
return [truncateToWidth("Esc close · Enlarge terminal", width, "…")];
|
|
90
|
+
}
|
|
91
|
+
const inner = width - 4;
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const running = this.entries.filter((entry) => entry.state === "running").length;
|
|
94
|
+
const header = [
|
|
95
|
+
this.theme.bold(`Terminals and Background jobs · ${running} running`),
|
|
96
|
+
this.theme.fg("warning", this.notice),
|
|
97
|
+
];
|
|
98
|
+
const help = [this.theme.fg("dim", "↑↓ select · k stop · x remove exited · esc close")];
|
|
99
|
+
const listHeight = Math.max(
|
|
100
|
+
1,
|
|
101
|
+
Math.min(this.entries.length || 1, Math.floor((height - 6) / 2)),
|
|
102
|
+
);
|
|
103
|
+
const rows =
|
|
104
|
+
this.entries.length === 0
|
|
105
|
+
? [this.theme.fg("muted", "Nothing is running.")]
|
|
106
|
+
: this.visibleRows(listHeight).map((entry) => this.row(entry, inner, now));
|
|
107
|
+
const previewHeight = Math.max(0, height - 2 - header.length - rows.length - help.length - 1);
|
|
108
|
+
const previewLines = this.previewLines(previewHeight);
|
|
109
|
+
const body = [
|
|
110
|
+
...header,
|
|
111
|
+
...rows,
|
|
112
|
+
this.theme.fg("border", "─".repeat(inner)),
|
|
113
|
+
...previewLines,
|
|
114
|
+
...help,
|
|
115
|
+
];
|
|
116
|
+
const border = (text: string) => this.theme.fg("border", text);
|
|
117
|
+
const framed = body.map((line) => {
|
|
118
|
+
const content = truncateToWidth(line, inner, "…");
|
|
119
|
+
return `${border("│")} ${content}${" ".repeat(Math.max(0, inner - visibleWidth(content)))} ${border("│")}`;
|
|
120
|
+
});
|
|
121
|
+
return [border(`╭${"─".repeat(width - 2)}╮`), ...framed, border(`╰${"─".repeat(width - 2)}╯`)];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
invalidate(): void {}
|
|
125
|
+
|
|
126
|
+
/** Release the refresh timer exactly once. */
|
|
127
|
+
dispose(): void {
|
|
128
|
+
if (this.disposed) return;
|
|
129
|
+
this.disposed = true;
|
|
130
|
+
this.stopRefresh();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Close the overlay and release its timer. */
|
|
134
|
+
close(): void {
|
|
135
|
+
if (this.disposed) return;
|
|
136
|
+
this.dispose();
|
|
137
|
+
this.onClose();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private visibleRows(limit: number): readonly TermctrlEntry[] {
|
|
141
|
+
const selected = Math.max(
|
|
142
|
+
0,
|
|
143
|
+
this.entries.findIndex((entry) => entry.id === this.selectedId),
|
|
144
|
+
);
|
|
145
|
+
const start = Math.min(
|
|
146
|
+
Math.max(0, selected - limit + 1),
|
|
147
|
+
Math.max(0, this.entries.length - limit),
|
|
148
|
+
);
|
|
149
|
+
return this.entries.slice(start, start + limit);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private row(entry: TermctrlEntry, width: number, now: number): string {
|
|
153
|
+
const selected = entry.id === this.selectedId;
|
|
154
|
+
const kind = entry.kind === "terminal" ? "term" : "job ";
|
|
155
|
+
const owner = entry.owner === this.viewerOwner ? "root " : "child";
|
|
156
|
+
const columns =
|
|
157
|
+
width >= WIDE_COLUMNS
|
|
158
|
+
? [
|
|
159
|
+
kind,
|
|
160
|
+
entry.id.padEnd(4),
|
|
161
|
+
owner,
|
|
162
|
+
stateLabel(entry).padEnd(9),
|
|
163
|
+
ageOf(entry, now).padStart(6),
|
|
164
|
+
]
|
|
165
|
+
: [entry.id.padEnd(4), stateLabel(entry).padEnd(9)];
|
|
166
|
+
const line = `${selected ? ">" : " "} ${columns.join(" ")} ${singleLine(entry.command)}`;
|
|
167
|
+
const styled = selected
|
|
168
|
+
? this.theme.fg("accent", line)
|
|
169
|
+
: entry.state === "exited"
|
|
170
|
+
? this.theme.fg("muted", line)
|
|
171
|
+
: line;
|
|
172
|
+
return truncateToWidth(styled, width, "…");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private previewLines(height: number): string[] {
|
|
176
|
+
if (height <= 0) return [];
|
|
177
|
+
const selected = this.entries.find((entry) => entry.id === this.selectedId);
|
|
178
|
+
if (selected === undefined) return [];
|
|
179
|
+
const title = this.theme.fg(
|
|
180
|
+
"dim",
|
|
181
|
+
selected.kind === "terminal" ? `${selected.id} screen` : `${selected.id} log tail`,
|
|
182
|
+
);
|
|
183
|
+
const text = this.preview.replace(/\n+$/u, "");
|
|
184
|
+
const lines = text === "" ? [this.theme.fg("muted", "(no output yet)")] : text.split("\n");
|
|
185
|
+
return [title, ...lines.slice(-(height - 1))];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private refresh(): void {
|
|
189
|
+
this.entries = this.registry.entries();
|
|
190
|
+
if (!this.entries.some((entry) => entry.id === this.selectedId)) {
|
|
191
|
+
this.selectedId = this.entries[0]?.id;
|
|
192
|
+
}
|
|
193
|
+
this.refreshPreview();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private refreshPreview(): void {
|
|
197
|
+
const entry = this.entries.find((candidate) => candidate.id === this.selectedId);
|
|
198
|
+
if (entry === undefined) {
|
|
199
|
+
this.preview = "";
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (entry.kind === "job") {
|
|
203
|
+
this.preview = entry.child.tail();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (entry.state === "exited") {
|
|
207
|
+
this.preview = entry.finalScreen ?? entry.lastScreen ?? "";
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (this.previewPending) return;
|
|
211
|
+
this.previewPending = true;
|
|
212
|
+
const id = entry.id;
|
|
213
|
+
entry.handle.snapshot().then(
|
|
214
|
+
(snapshot) => {
|
|
215
|
+
this.previewPending = false;
|
|
216
|
+
if (this.disposed || this.selectedId !== id) return;
|
|
217
|
+
this.preview = snapshot.screen;
|
|
218
|
+
this.tui.requestRender();
|
|
219
|
+
},
|
|
220
|
+
() => {
|
|
221
|
+
this.previewPending = false;
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private move(delta: number): void {
|
|
227
|
+
if (this.entries.length === 0) return;
|
|
228
|
+
const current = this.entries.findIndex((entry) => entry.id === this.selectedId);
|
|
229
|
+
const next = Math.max(0, Math.min(this.entries.length - 1, current + delta));
|
|
230
|
+
this.selectedId = this.entries[next]?.id;
|
|
231
|
+
this.preview = "";
|
|
232
|
+
this.refreshPreview();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private stopSelected(): void {
|
|
236
|
+
const entry = this.entries.find((candidate) => candidate.id === this.selectedId);
|
|
237
|
+
if (entry === undefined || entry.state !== "running") return;
|
|
238
|
+
this.notice = `Stopping ${entry.id}…`;
|
|
239
|
+
void this.registry.stopByUser(entry.id).then(() => {
|
|
240
|
+
if (this.disposed) return;
|
|
241
|
+
this.notice = `Stopped ${entry.id}.`;
|
|
242
|
+
this.refresh();
|
|
243
|
+
this.tui.requestRender();
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private removeSelected(): void {
|
|
248
|
+
const entry = this.entries.find((candidate) => candidate.id === this.selectedId);
|
|
249
|
+
if (entry === undefined) return;
|
|
250
|
+
if (entry.state === "running") {
|
|
251
|
+
this.notice = `${entry.id} is running; press k to stop it first.`;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
void this.registry.remove(entry.id).then(() => {
|
|
255
|
+
if (this.disposed) return;
|
|
256
|
+
this.notice = `Removed ${entry.id}.`;
|
|
257
|
+
this.refresh();
|
|
258
|
+
this.tui.requestRender();
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Opens one `/ps` overlay per session and keeps the footer's running count. */
|
|
264
|
+
export class TermctrlPsController {
|
|
265
|
+
private panel: TermctrlPsPanel | undefined;
|
|
266
|
+
private pending: Promise<void> | undefined;
|
|
267
|
+
private handle: OverlayHandle | undefined;
|
|
268
|
+
private readonly stopFooter: () => void;
|
|
269
|
+
|
|
270
|
+
constructor(
|
|
271
|
+
private readonly registry: TermctrlRegistry,
|
|
272
|
+
private readonly context: ExtensionContext,
|
|
273
|
+
private readonly startRefresh: StartRefresh = startRefreshInterval,
|
|
274
|
+
) {
|
|
275
|
+
const update = () => {
|
|
276
|
+
const running = registry.runningCount();
|
|
277
|
+
context.ui.setStatus(STATUS_KEY, running > 0 ? `${running} running` : undefined);
|
|
278
|
+
};
|
|
279
|
+
this.stopFooter = context.hasUI ? registry.onChange(update) : () => {};
|
|
280
|
+
if (context.hasUI) update();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Open or focus the overlay. RPC receives a one-line summary; other modes stay silent. */
|
|
284
|
+
open(): Promise<void> {
|
|
285
|
+
if (this.pending !== undefined) {
|
|
286
|
+
this.handle?.focus();
|
|
287
|
+
return this.pending;
|
|
288
|
+
}
|
|
289
|
+
if (this.context.mode === "rpc") {
|
|
290
|
+
const entries = this.registry.entries();
|
|
291
|
+
const summary =
|
|
292
|
+
entries.length === 0
|
|
293
|
+
? "Nothing is running."
|
|
294
|
+
: entries
|
|
295
|
+
.map((entry) => `${entry.id} ${stateLabel(entry)} ${singleLine(entry.command)}`)
|
|
296
|
+
.join("\n");
|
|
297
|
+
this.context.ui.notify(summary, "info");
|
|
298
|
+
return Promise.resolve();
|
|
299
|
+
}
|
|
300
|
+
if (this.context.mode !== "tui") return Promise.resolve();
|
|
301
|
+
const viewer = this.context.sessionManager.getSessionId();
|
|
302
|
+
this.pending = this.context.ui
|
|
303
|
+
.custom<void>(
|
|
304
|
+
(tui, theme, keybindings, done) => {
|
|
305
|
+
this.panel = new TermctrlPsPanel(
|
|
306
|
+
this.registry,
|
|
307
|
+
viewer,
|
|
308
|
+
tui,
|
|
309
|
+
theme,
|
|
310
|
+
keybindings,
|
|
311
|
+
() => done(undefined),
|
|
312
|
+
this.startRefresh,
|
|
313
|
+
);
|
|
314
|
+
return this.panel;
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
overlay: true,
|
|
318
|
+
overlayOptions: { anchor: "center", width: "90%", maxHeight: "90%", margin: MARGIN },
|
|
319
|
+
onHandle: (handle) => {
|
|
320
|
+
this.handle = handle;
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
.catch(() => {
|
|
325
|
+
this.context.ui.notify("The /ps view failed.", "error");
|
|
326
|
+
})
|
|
327
|
+
.finally(() => {
|
|
328
|
+
this.panel?.dispose();
|
|
329
|
+
this.panel = undefined;
|
|
330
|
+
this.pending = undefined;
|
|
331
|
+
this.handle = undefined;
|
|
332
|
+
});
|
|
333
|
+
return this.pending;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Close the overlay and stop updating the footer. Called on every shutdown, including reload. */
|
|
337
|
+
dispose(clearFooter: boolean): void {
|
|
338
|
+
this.panel?.close();
|
|
339
|
+
this.panel = undefined;
|
|
340
|
+
this.stopFooter();
|
|
341
|
+
if (clearFooter && this.context.hasUI) this.context.ui.setStatus(STATUS_KEY, undefined);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { accessSync, constants } from "node:fs";
|
|
2
|
+
import { resolveTerminalControlBinary } from "@kitlangton/terminal-control";
|
|
3
|
+
|
|
4
|
+
/** Whether a usable `termctrl` executable exists for Terminal tools. */
|
|
5
|
+
export type TermctrlBinaryResolution =
|
|
6
|
+
| { readonly kind: "available"; readonly path: string }
|
|
7
|
+
| { readonly kind: "missing"; readonly reason: string };
|
|
8
|
+
|
|
9
|
+
/** Resolve the termctrl binary through the SDK, which honours `TERMCTRL_BINARY`, and verify it runs. */
|
|
10
|
+
export function resolveTermctrlBinary(
|
|
11
|
+
resolve: () => string = () => resolveTerminalControlBinary(),
|
|
12
|
+
): TermctrlBinaryResolution {
|
|
13
|
+
let path: string;
|
|
14
|
+
try {
|
|
15
|
+
path = resolve();
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return { kind: "missing", reason: error instanceof Error ? error.message : String(error) };
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
accessSync(path, constants.X_OK);
|
|
21
|
+
} catch {
|
|
22
|
+
return { kind: "missing", reason: `termctrl binary ${path} is not executable` };
|
|
23
|
+
}
|
|
24
|
+
return { kind: "available", path };
|
|
25
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { type Key, type Session, TerminalControl } from "@kitlangton/terminal-control";
|
|
5
|
+
import type {
|
|
6
|
+
TerminalDriver,
|
|
7
|
+
TerminalHandle,
|
|
8
|
+
TerminalLaunchRequest,
|
|
9
|
+
TerminalSnapshot,
|
|
10
|
+
} from "./terminal-driver.js";
|
|
11
|
+
|
|
12
|
+
/** Directory for Background job logs and Terminal pid handoff files. */
|
|
13
|
+
export function termctrlTemporaryDirectory(): string {
|
|
14
|
+
return join(tmpdir(), "pi-termctrl");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PID_WAIT_MS = 2_000;
|
|
18
|
+
const PID_POLL_MS = 10;
|
|
19
|
+
/**
|
|
20
|
+
* Records the PTY root's pid, then execs the real command so the pid stays the same.
|
|
21
|
+
* termctrl never reports the pid, and escalating a stuck stop to SIGKILL needs it.
|
|
22
|
+
*/
|
|
23
|
+
const PID_WRAPPER = 'echo $$ > "$1"; shift; exec "$@"';
|
|
24
|
+
|
|
25
|
+
async function readPid(pidFile: string): Promise<number | undefined> {
|
|
26
|
+
const deadline = Date.now() + PID_WAIT_MS;
|
|
27
|
+
while (Date.now() < deadline) {
|
|
28
|
+
try {
|
|
29
|
+
const pid = Number.parseInt(await readFile(pidFile, "utf8"), 10);
|
|
30
|
+
if (Number.isSafeInteger(pid) && pid > 0) return pid;
|
|
31
|
+
} catch {
|
|
32
|
+
// The wrapper has not written the file yet.
|
|
33
|
+
}
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, PID_POLL_MS));
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class TermctrlTerminal implements TerminalHandle {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly session: Session,
|
|
42
|
+
private readonly pid: number | undefined,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
async snapshot(): Promise<TerminalSnapshot> {
|
|
46
|
+
const status = await this.session.status();
|
|
47
|
+
const capture = await this.session.screen.capture({
|
|
48
|
+
allowIncomplete: true,
|
|
49
|
+
settleMs: 0,
|
|
50
|
+
deadlineMs: 0,
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
screen: capture.text,
|
|
54
|
+
state: status.state,
|
|
55
|
+
exit: status.exit === null ? null : { code: status.exit.code, signal: status.exit.signal },
|
|
56
|
+
idleForMs: status.idleForMs,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
logs(): Promise<string> {
|
|
61
|
+
return this.session.logs.text();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type(text: string): Promise<void> {
|
|
65
|
+
return this.session.keyboard.type(text);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
press(keys: readonly Key[]): Promise<void> {
|
|
69
|
+
return this.session.keyboard.sequence(keys);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop(): Promise<void> {
|
|
73
|
+
return this.session.stop();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
isAlive(): boolean {
|
|
77
|
+
if (this.pid === undefined) return false;
|
|
78
|
+
try {
|
|
79
|
+
process.kill(this.pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
kill(): void {
|
|
87
|
+
if (this.pid === undefined) return;
|
|
88
|
+
try {
|
|
89
|
+
process.kill(-this.pid, "SIGKILL");
|
|
90
|
+
} catch {
|
|
91
|
+
try {
|
|
92
|
+
process.kill(this.pid, "SIGKILL");
|
|
93
|
+
} catch {
|
|
94
|
+
// Already gone.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class TermctrlDriver implements TerminalDriver {
|
|
101
|
+
constructor(private readonly control: TerminalControl) {}
|
|
102
|
+
|
|
103
|
+
async launch(request: TerminalLaunchRequest): Promise<TerminalHandle> {
|
|
104
|
+
const directory = termctrlTemporaryDirectory();
|
|
105
|
+
await mkdir(directory, { recursive: true });
|
|
106
|
+
const pidFile = join(directory, `${process.pid}-${request.id}.pid`);
|
|
107
|
+
try {
|
|
108
|
+
const session = await this.control.launch({
|
|
109
|
+
command: ["/bin/sh", "-c", PID_WRAPPER, "pi-termctrl", pidFile, ...request.command],
|
|
110
|
+
cwd: request.cwd,
|
|
111
|
+
viewport: request.viewport,
|
|
112
|
+
});
|
|
113
|
+
return new TermctrlTerminal(session, await readPid(pidFile));
|
|
114
|
+
} finally {
|
|
115
|
+
await rm(pidFile, { force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
close(): Promise<void> {
|
|
120
|
+
return this.control.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Start one termctrl driver process from a resolved binary. */
|
|
125
|
+
export async function createTermctrlDriver(binaryPath: string): Promise<TerminalDriver> {
|
|
126
|
+
return new TermctrlDriver(await TerminalControl.make({ binaryPath }));
|
|
127
|
+
}
|