pi-squire 0.1.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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +351 -0
- package/agents.ts +161 -0
- package/child.ts +67 -0
- package/index.ts +717 -0
- package/package.json +61 -0
- package/results.ts +293 -0
- package/tmux.ts +75 -0
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-squire",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-extension"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Gage Vander Clay <gvanderclay@gmail.com>",
|
|
12
|
+
"homepage": "https://github.com/gvanderclay/pi-squire#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/gvanderclay/pi-squire.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/gvanderclay/pi-squire/issues"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22.19.0"
|
|
23
|
+
},
|
|
24
|
+
"packageManager": "pnpm@10.33.0",
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./index.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.ts",
|
|
32
|
+
"child.ts",
|
|
33
|
+
"agents.ts",
|
|
34
|
+
"tmux.ts",
|
|
35
|
+
"results.ts",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"CHANGELOG.md"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"lint": "biome ci .",
|
|
42
|
+
"typecheck": "tsc -p .",
|
|
43
|
+
"test": "node --test test/*.test.ts",
|
|
44
|
+
"check": "pnpm lint && pnpm typecheck && pnpm test",
|
|
45
|
+
"release": "node scripts/release.mjs"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
52
|
+
"typebox": "*"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@biomejs/biome": "2.5.14",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
57
|
+
"@types/node": "26.6.3",
|
|
58
|
+
"typebox": "1.3.27",
|
|
59
|
+
"typescript": "7.0.2"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/results.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// The delegation records and the result message. A delegation is recorded in
|
|
2
|
+
// the parent session when it starts, and again when it is closed; when the
|
|
3
|
+
// provider claims a reply that answers a recorded task, this listener takes
|
|
4
|
+
// the message over, shows the parent one delegate result and records the
|
|
5
|
+
// result. The records are the one source of truth for a delegation's state,
|
|
6
|
+
// for the footer, `delegation_status` and `delegation_close`.
|
|
7
|
+
import { readdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
/** The session entry type both delegation records and results use. */
|
|
13
|
+
export const CUSTOM_TYPE = "delegate";
|
|
14
|
+
/** The footer entry that counts delegations whose result has not arrived. */
|
|
15
|
+
export const STATUS_KEY = "delegate";
|
|
16
|
+
/** A task quoted into the result header is cut at this many UTF-8 bytes. */
|
|
17
|
+
const TASK_CAP = 2 * 1024;
|
|
18
|
+
/** A result body is cut at this many UTF-8 bytes. */
|
|
19
|
+
const BODY_CAP = 32 * 1024;
|
|
20
|
+
/** The `pi.events` channel a provider emits claimed envelopes on. */
|
|
21
|
+
const INBOUND = "message:inbound";
|
|
22
|
+
|
|
23
|
+
/** One delegation, as recorded and rebuilt on resume. It holds no task text. */
|
|
24
|
+
export type Delegation = {
|
|
25
|
+
id: string;
|
|
26
|
+
agent: string;
|
|
27
|
+
model: string;
|
|
28
|
+
thinking: string;
|
|
29
|
+
/** The delegate's session and window name; absent in records from before labels, where it was `<agent>-<8 id characters>`. */
|
|
30
|
+
name?: string;
|
|
31
|
+
windowId: string;
|
|
32
|
+
windowName: string;
|
|
33
|
+
requestId: string;
|
|
34
|
+
/** Whether the delegate closes its own window after a normal completion; absent in records from before auto-exit. */
|
|
35
|
+
autoExit?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An envelope on the `message:inbound` payload. `delegate` declares its own
|
|
40
|
+
* copy of the provider's shape; the provider's README owns the contract.
|
|
41
|
+
*/
|
|
42
|
+
export type Envelope = {
|
|
43
|
+
id: string;
|
|
44
|
+
from: string;
|
|
45
|
+
to: string;
|
|
46
|
+
in_reply_to: string[];
|
|
47
|
+
status: string;
|
|
48
|
+
ts: string;
|
|
49
|
+
body: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A reply's request copy from the parent's `sent/`. */
|
|
53
|
+
export type RequestCopy = { envelope: Envelope; path: string };
|
|
54
|
+
|
|
55
|
+
/** A result, appended to the session so it is shown once and survives a resume. */
|
|
56
|
+
export type Result = {
|
|
57
|
+
/** The envelope's status: `done`, `failed` or `stopped`. */
|
|
58
|
+
status: string;
|
|
59
|
+
/** The reply envelope's id, so the same reply is never shown twice. */
|
|
60
|
+
replyId: string;
|
|
61
|
+
/** The reply envelope's path in the parent's `cur/`. */
|
|
62
|
+
envelopePath: string;
|
|
63
|
+
/** The delegate's session file, when it could be found. */
|
|
64
|
+
sessionPath?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** One delegation as it stands: what was recorded at its start, plus what came after. */
|
|
68
|
+
export type Recorded = Delegation & {
|
|
69
|
+
/** Set when a result arrived. */
|
|
70
|
+
result?: Result;
|
|
71
|
+
/** Set when `delegation_close` recorded a close. */
|
|
72
|
+
closed?: boolean;
|
|
73
|
+
/** Set when the poll found the window gone with no result; a result outranks it. */
|
|
74
|
+
gone?: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The provider's payload on `message:inbound`. */
|
|
78
|
+
export type Inbound = {
|
|
79
|
+
envelope: Envelope;
|
|
80
|
+
path: string;
|
|
81
|
+
requests: RequestCopy[];
|
|
82
|
+
handled: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type Results = {
|
|
86
|
+
/** Rebuild the records from the session's custom entries, as on a resume. */
|
|
87
|
+
restore(ctx: ExtensionContext): void;
|
|
88
|
+
/** Record a delegation and show it as running. */
|
|
89
|
+
recordStart(delegation: Delegation, ctx: ExtensionContext): void;
|
|
90
|
+
/** Record a close. `unknown` when no such delegation, `already-closed` when one was recorded. */
|
|
91
|
+
recordClose(id: string): "closed" | "already-closed" | "unknown";
|
|
92
|
+
/** Delegations with no result, no recorded close and no gone-window record. */
|
|
93
|
+
running(): Recorded[];
|
|
94
|
+
/** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
|
|
95
|
+
recordGone(id: string): boolean;
|
|
96
|
+
/** Every delegation, in the order it was recorded. */
|
|
97
|
+
list(): Recorded[];
|
|
98
|
+
/** One delegation by id. */
|
|
99
|
+
find(id: string): Recorded | undefined;
|
|
100
|
+
/** Take over a reply that answers a recorded delegation, if any. */
|
|
101
|
+
takeReply(payload: Inbound): void;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
|
|
105
|
+
function cap(text: string, max: number): string | undefined {
|
|
106
|
+
const bytes = Buffer.from(text, "utf8");
|
|
107
|
+
if (bytes.length <= max) return undefined;
|
|
108
|
+
let end = max;
|
|
109
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; // back off a split character
|
|
110
|
+
return bytes.subarray(0, end).toString("utf8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The delegate's session file in `dir`, found by its `_<id>.jsonl` suffix. */
|
|
114
|
+
export function findSession(dir: string | undefined, id: string): string | undefined {
|
|
115
|
+
if (dir === undefined) return undefined;
|
|
116
|
+
let names: string[];
|
|
117
|
+
try {
|
|
118
|
+
names = readdirSync(dir);
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const name = names.find((candidate) => candidate.endsWith(`_${id}.jsonl`));
|
|
123
|
+
return name === undefined ? undefined : join(dir, name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The one message the parent sees for a settled delegation. */
|
|
127
|
+
function resultText(delegation: Delegation, payload: Inbound, session: string | undefined): string {
|
|
128
|
+
const reply = payload.envelope;
|
|
129
|
+
const copy = payload.requests.find((request) => request.envelope?.id === delegation.requestId);
|
|
130
|
+
let quote: string;
|
|
131
|
+
if (copy === undefined) {
|
|
132
|
+
quote = `[delegate] The task ${delegation.requestId} has no copy in sent/; only its id is known.`;
|
|
133
|
+
} else {
|
|
134
|
+
const cut = cap(copy.envelope.body, TASK_CAP);
|
|
135
|
+
const lines =
|
|
136
|
+
cut === undefined ? copy.envelope.body : `${cut}\n[delegate] Task cut at 2 KiB; the full copy is ${copy.path}`;
|
|
137
|
+
quote = `Task, quoted from ${copy.path}:\n${lines
|
|
138
|
+
.split("\n")
|
|
139
|
+
.map((line) => `> ${line}`)
|
|
140
|
+
.join("\n")}`;
|
|
141
|
+
}
|
|
142
|
+
const cut = cap(reply.body, BODY_CAP);
|
|
143
|
+
const result =
|
|
144
|
+
cut === undefined ? reply.body : `${cut}\n[delegate] Body cut at 32 KiB; the full envelope is ${payload.path}`;
|
|
145
|
+
return [
|
|
146
|
+
`[delegate] Result from ${delegation.agent} (${delegation.model}), delegation ${delegation.id}, request ${delegation.requestId}.`,
|
|
147
|
+
`Status: ${reply.status}`,
|
|
148
|
+
quote,
|
|
149
|
+
`Envelope: ${payload.path}`,
|
|
150
|
+
`Delegate session: ${session ?? delegation.id}`,
|
|
151
|
+
"",
|
|
152
|
+
result,
|
|
153
|
+
].join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createResults(pi: ExtensionAPI): Results {
|
|
157
|
+
/** Every recorded delegation by its id, in the order recorded. */
|
|
158
|
+
const byId = new Map<string, Recorded>();
|
|
159
|
+
/** The same records by their request's envelope id, for matching replies. */
|
|
160
|
+
const byRequest = new Map<string, Recorded>();
|
|
161
|
+
/** Reply envelope ids a result message has already been sent for. */
|
|
162
|
+
const shown = new Set<string>();
|
|
163
|
+
/** The latest session context, for the footer and the session directory. */
|
|
164
|
+
let ctx: ExtensionContext | undefined;
|
|
165
|
+
/** Whether the footer currently shows the entry, so a clear is sent only when needed. */
|
|
166
|
+
let footerShown = false;
|
|
167
|
+
|
|
168
|
+
/** A delegation still going: no result and no recorded close. */
|
|
169
|
+
const running = (): Recorded[] =>
|
|
170
|
+
[...byId.values()].filter(
|
|
171
|
+
(record) => record.result === undefined && record.closed !== true && record.gone !== true,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
/** The footer counts the running delegations, and hides itself at zero. */
|
|
175
|
+
function updateFooter(): void {
|
|
176
|
+
if (ctx?.hasUI !== true) return;
|
|
177
|
+
const count = running().length;
|
|
178
|
+
const visible = count > 0;
|
|
179
|
+
if (!visible && !footerShown) return;
|
|
180
|
+
footerShown = visible;
|
|
181
|
+
ctx.ui.setStatus(STATUS_KEY, visible ? `⇄ ${count} running` : undefined);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Rebuild the records from the session's entries, so a resume still recognises replies. */
|
|
185
|
+
function restore(context: ExtensionContext): void {
|
|
186
|
+
ctx = context;
|
|
187
|
+
byId.clear();
|
|
188
|
+
byRequest.clear();
|
|
189
|
+
shown.clear();
|
|
190
|
+
for (const entry of context.sessionManager.getEntries()) {
|
|
191
|
+
if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue;
|
|
192
|
+
const data = entry.data as Partial<Delegation & { result: Result; closed: boolean; gone: boolean }> | undefined;
|
|
193
|
+
if (data === undefined || typeof data.id !== "string") continue;
|
|
194
|
+
if (typeof data.requestId === "string") {
|
|
195
|
+
const record: Recorded = { ...(data as Delegation) };
|
|
196
|
+
byId.set(record.id, record);
|
|
197
|
+
byRequest.set(record.requestId, record);
|
|
198
|
+
} else if (typeof data.result?.replyId === "string") {
|
|
199
|
+
shown.add(data.result.replyId);
|
|
200
|
+
const record = byId.get(data.id);
|
|
201
|
+
if (record !== undefined) record.result = data.result;
|
|
202
|
+
} else if (data.closed === true) {
|
|
203
|
+
const record = byId.get(data.id);
|
|
204
|
+
if (record !== undefined) record.closed = true;
|
|
205
|
+
} else if (data.gone === true) {
|
|
206
|
+
const record = byId.get(data.id);
|
|
207
|
+
if (record !== undefined) record.gone = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
updateFooter();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function recordStart(delegation: Delegation, context: ExtensionContext): void {
|
|
214
|
+
ctx = context;
|
|
215
|
+
pi.appendEntry(CUSTOM_TYPE, delegation);
|
|
216
|
+
const record: Recorded = { ...delegation };
|
|
217
|
+
byId.set(record.id, record);
|
|
218
|
+
byRequest.set(record.requestId, record);
|
|
219
|
+
updateFooter();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function recordClose(id: string): "closed" | "already-closed" | "unknown" {
|
|
223
|
+
const record = byId.get(id);
|
|
224
|
+
if (record === undefined) return "unknown";
|
|
225
|
+
if (record.closed === true) return "already-closed";
|
|
226
|
+
pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
|
|
227
|
+
record.closed = true;
|
|
228
|
+
updateFooter();
|
|
229
|
+
return "closed";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function recordGone(id: string): boolean {
|
|
233
|
+
const record = byId.get(id);
|
|
234
|
+
if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
|
|
235
|
+
return false;
|
|
236
|
+
const session = findSession(ctx?.sessionManager.getSessionDir(), record.id);
|
|
237
|
+
pi.appendEntry(CUSTOM_TYPE, { id, gone: true });
|
|
238
|
+
record.gone = true;
|
|
239
|
+
pi.sendMessage(
|
|
240
|
+
{
|
|
241
|
+
customType: CUSTOM_TYPE,
|
|
242
|
+
content: [
|
|
243
|
+
`[delegate] Delegation ${record.id} (${record.agent}, ${record.model}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
|
|
244
|
+
`Delegate session: ${session ?? record.id}`,
|
|
245
|
+
"A reply that arrives later is still delivered.",
|
|
246
|
+
].join("\n"),
|
|
247
|
+
display: true,
|
|
248
|
+
},
|
|
249
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
250
|
+
);
|
|
251
|
+
updateFooter();
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function takeReply(payload: Inbound): void {
|
|
256
|
+
if (payload?.handled === true) return;
|
|
257
|
+
const reply = payload?.envelope;
|
|
258
|
+
if (reply === undefined || !Array.isArray(reply.in_reply_to) || reply.in_reply_to.length === 0) return;
|
|
259
|
+
const requestId = reply.in_reply_to.find((id) => byRequest.has(id));
|
|
260
|
+
if (requestId === undefined) return;
|
|
261
|
+
payload.handled = true;
|
|
262
|
+
if (shown.has(reply.id)) return;
|
|
263
|
+
const delegation = byRequest.get(requestId) as Recorded;
|
|
264
|
+
shown.add(reply.id);
|
|
265
|
+
const session = findSession(ctx?.sessionManager.getSessionDir(), delegation.id);
|
|
266
|
+
pi.sendMessage(
|
|
267
|
+
{ customType: CUSTOM_TYPE, content: resultText(delegation, payload, session), display: true },
|
|
268
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
269
|
+
);
|
|
270
|
+
const result: Result = {
|
|
271
|
+
status: reply.status,
|
|
272
|
+
replyId: reply.id,
|
|
273
|
+
envelopePath: payload.path,
|
|
274
|
+
...(session === undefined ? {} : { sessionPath: session }),
|
|
275
|
+
};
|
|
276
|
+
pi.appendEntry(CUSTOM_TYPE, { id: delegation.id, result });
|
|
277
|
+
delegation.result = result;
|
|
278
|
+
updateFooter();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
pi.events.on(INBOUND, (data) => takeReply(data as Inbound));
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
restore,
|
|
285
|
+
recordStart,
|
|
286
|
+
recordClose,
|
|
287
|
+
running,
|
|
288
|
+
recordGone,
|
|
289
|
+
list: () => [...byId.values()],
|
|
290
|
+
find: (id) => byId.get(id),
|
|
291
|
+
takeReply,
|
|
292
|
+
};
|
|
293
|
+
}
|
package/tmux.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The tmux operations `delegate` needs, behind an interface so tests can use a
|
|
2
|
+
// fake. This client talks to tmux with `execFile` and no shell.
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
/** One window to open: its name, working directory, command and environment. */
|
|
6
|
+
export type WindowSpec = {
|
|
7
|
+
name: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
argv: readonly string[];
|
|
10
|
+
env: Readonly<Record<string, string>>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface TmuxClient {
|
|
14
|
+
/** Whether this process runs in a tmux pane. */
|
|
15
|
+
insideTmux(): boolean;
|
|
16
|
+
/** Open a detached window and return its id (for example `@3`). */
|
|
17
|
+
openWindow(spec: WindowSpec): Promise<string>;
|
|
18
|
+
/** Whether a window id is still listed. */
|
|
19
|
+
isAlive(windowId: string): Promise<boolean>;
|
|
20
|
+
/** Kill a window. */
|
|
21
|
+
kill(windowId: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Run one tmux command and return its stdout; never a shell. */
|
|
25
|
+
function run(args: string[]): Promise<string> {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
execFile("tmux", args, { encoding: "utf8" }, (error, stdout, stderr) => {
|
|
28
|
+
if (error) {
|
|
29
|
+
const detail = typeof stderr === "string" && stderr.trim() !== "" ? stderr.trim() : error.message;
|
|
30
|
+
reject(new Error(detail));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve(stdout);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The real client. A window is opened in the session of the pane in
|
|
40
|
+
* `TMUX_PANE`, not in whatever session a client happens to have attached, so
|
|
41
|
+
* it works with several clients on one server.
|
|
42
|
+
*/
|
|
43
|
+
export function createTmuxClient(): TmuxClient {
|
|
44
|
+
/** The id of the session this pane belongs to, when known. */
|
|
45
|
+
async function targetSession(): Promise<string | undefined> {
|
|
46
|
+
const pane = process.env.TMUX_PANE;
|
|
47
|
+
if (pane === undefined || pane === "") return undefined;
|
|
48
|
+
const sessionId = (await run(["display-message", "-p", "-t", pane, "-F", "#{session_id}"])).trim();
|
|
49
|
+
return sessionId === "" ? undefined : sessionId;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
insideTmux: () => (process.env.TMUX ?? "") !== "",
|
|
54
|
+
|
|
55
|
+
async openWindow(spec) {
|
|
56
|
+
const args = ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", spec.name, "-c", spec.cwd];
|
|
57
|
+
for (const [key, value] of Object.entries(spec.env)) args.push("-e", `${key}=${value}`);
|
|
58
|
+
const session = await targetSession();
|
|
59
|
+
if (session !== undefined) args.push("-t", session);
|
|
60
|
+
args.push("--", ...spec.argv);
|
|
61
|
+
const windowId = (await run(args)).trim();
|
|
62
|
+
if (windowId === "") throw new Error("tmux reported no window id");
|
|
63
|
+
return windowId;
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async isAlive(windowId) {
|
|
67
|
+
const ids = (await run(["list-windows", "-a", "-F", "#{window_id}"])).split("\n");
|
|
68
|
+
return ids.some((id) => id.trim() === windowId);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async kill(windowId) {
|
|
72
|
+
await run(["kill-window", "-t", windowId]);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|