pi-squire 0.1.0 → 0.2.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.
@@ -33,6 +33,8 @@
33
33
  // The `session:launch` contract this package provides, and the `message:*`
34
34
  // hooks it consumes, live in this package's README.
35
35
  import { randomUUID } from "node:crypto";
36
+ import { mkdtempSync, writeFileSync } from "node:fs";
37
+ import { tmpdir } from "node:os";
36
38
  import { basename, join } from "node:path";
37
39
 
38
40
  import {
@@ -45,17 +47,14 @@ import { Type } from "typebox";
45
47
 
46
48
  import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
47
49
  import { AUTO_EXIT_ENV, registerChild } from "./child.ts";
48
- import { createResults, findSession, type Recorded } from "./results.ts";
50
+ import { createResults, delegateName } from "./results.ts";
49
51
  import { createTmuxClient, type TmuxClient } from "./tmux.ts";
52
+ import { createTracking, toolResult } from "./tracking.ts";
50
53
 
51
54
  /** The hook a provider of `message:*` answers with the written request. */
52
55
  const SEND = "message:send";
53
56
  /** The hook emitted just before a delegate's window opens. */
54
57
  const LAUNCH = "session:launch";
55
- /** The hook a tick emits so the provider claims waiting replies before windows are checked. */
56
- const SCAN = "message:scan";
57
- /** How often the parent checks its running delegations. */
58
- const POLL_MS = 5000;
59
58
  /** Set in a delegate's environment, so this extension stays off there. */
60
59
  const PARENT_ENV = "PI_DELEGATE_PARENT";
61
60
  /** Forwarded to the child when the parent has it set. */
@@ -63,22 +62,12 @@ const SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
63
62
  /** Asked of every delegate, so the parent gets an answer it can use alone. */
64
63
  const FINAL_LINE =
65
64
  "End with one self-contained final message: the parent session sees only that message, never this conversation.";
66
- /** How much of a delegation id a delegate's session name carries. */
67
- const SHORT_ID = 8;
68
65
 
69
66
  /** The longest label a delegation can carry. */
70
67
  const LABEL_MAX = 32;
71
68
  /** A label is one safe tmux window name and session name segment. */
72
69
  const LABEL = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
73
70
 
74
- /**
75
- * A delegate's name, used for both its session and its window: its agent and
76
- * the label, or the first 8 characters of its id when there is no label.
77
- */
78
- function delegateName(agent: string, id: string, label?: string): string {
79
- return `${agent}-${label ?? id.slice(0, SHORT_ID)}`;
80
- }
81
-
82
71
  /** Why `value` is not a usable label, or undefined when it is. */
83
72
  function labelProblem(value: string): string | undefined {
84
73
  if (LABEL.test(value) && value.length <= LABEL_MAX) return undefined;
@@ -112,13 +101,30 @@ const USAGE =
112
101
  "usage: /delegate <agent> [--model <provider/id>] [--thinking <level>] [--label <name>] [--auto-exit | --no-auto-exit] <task>";
113
102
 
114
103
  type SendPayload = { to: unknown; body: unknown; envelope?: { id?: unknown }; error?: unknown };
115
- type LaunchPayload = { args: string[]; env: Record<string, string> };
104
+ type LaunchPayload = { args: string[]; env: Record<string, string>; agent: string };
116
105
  type Parsed = { agent: string; model?: string; thinking?: string; label?: string; autoExit?: boolean; task: string };
117
106
  /** A request that passed the roster, model, thinking and label checks. */
118
107
  type Start = { agent: Agent; model: string; thinking: string; label?: string; autoExit: boolean; task: string };
119
108
  /** A delegation that is open in a tmux window. */
120
109
  type Launched = { id: string; name: string; windowId: string; windowName: string; requestId: string };
121
110
 
111
+ /**
112
+ * Write the appended system prompt to a fresh owner-only directory under the
113
+ * system temporary folder and return the file's path. Pi reads an existing
114
+ * path as a file, so the prompt stays off the delegate's command line and out
115
+ * of `ps`. The directory comes from `mkdtemp`, which no other user can
116
+ * pre-create or swap.
117
+ *
118
+ * ponytail: one file per launch accumulates until the operating system clears
119
+ * its temporary folder; the upgrade path is to delete the file once the poll
120
+ * finds the window gone.
121
+ */
122
+ function writePromptFile(prompt: string): string {
123
+ const path = join(mkdtempSync(join(tmpdir(), "pi-squire-")), "prompt.md");
124
+ writeFileSync(path, prompt, { mode: 0o600 });
125
+ return path;
126
+ }
127
+
122
128
  /** The runtimes whose executable needs the script from `process.argv[1]`. */
123
129
  const RUNTIMES = new Set(["node", "nodejs", "bun", "deno"]);
124
130
 
@@ -193,7 +199,11 @@ function parse(text: string): { parsed: Parsed } | { error: string } {
193
199
  function modelProblem(value: string, registry: ModelRegistry): string | undefined {
194
200
  const slash = value.indexOf("/");
195
201
  if (slash <= 0 || slash === value.length - 1) return `--model takes provider/id, got ${JSON.stringify(value)}`;
196
- if (registry.find(value.slice(0, slash), value.slice(slash + 1)) !== undefined) return undefined;
202
+ const known = registry.find(value.slice(0, slash), value.slice(slash + 1));
203
+ if (known !== undefined) {
204
+ if (registry.hasConfiguredAuth(known)) return undefined;
205
+ return `no credentials for model ${JSON.stringify(value)}; log in to the provider ${JSON.stringify(known.provider)} (/login), then try again`;
206
+ }
197
207
  const names = registry.getAll().map((model) => `${model.provider}/${model.id}`);
198
208
  const close = closest(value, names);
199
209
  return `unknown model ${JSON.stringify(value)}${close.length > 0 ? `; close matches: ${close.join(", ")}` : ""}`;
@@ -238,14 +248,6 @@ function toolDescription(agents: readonly Agent[]): string {
238
248
  ].join("\n");
239
249
  }
240
250
 
241
- /** The one result every tool returns. */
242
- function toolResult(
243
- payload: string,
244
- details: unknown = {},
245
- ): { content: { type: "text"; text: string }[]; details: unknown } {
246
- return { content: [{ type: "text", text: payload }], details };
247
- }
248
-
249
251
  /**
250
252
  * Register `/delegate` and the three tools. The second parameter is a
251
253
  * test-only seam for tmux: Pi passes only `pi`, so it is not part of the
@@ -253,57 +255,15 @@ function toolResult(
253
255
  */
254
256
  export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmuxClient()): void {
255
257
  // A delegate registers only its auto-exit side: `delegate` is one level deep (Q27).
256
- if ((process.env[PARENT_ENV] ?? "") !== "") {
257
- registerChild(pi);
258
+ const parentId = process.env[PARENT_ENV] ?? "";
259
+ if (parentId !== "") {
260
+ registerChild(pi, parentId);
258
261
  return;
259
262
  }
260
263
 
261
264
  const results = createResults(pi);
262
265
 
263
- /** The poll timer; set only while some delegation is running. */
264
- let poll: ReturnType<typeof setInterval> | undefined;
265
- let ticking = false;
266
- /** False once the session shuts down, so a tick still awaiting tmux declares nothing. */
267
- let live = true;
268
-
269
- function stopPolling(): void {
270
- if (poll !== undefined) clearInterval(poll);
271
- poll = undefined;
272
- }
273
-
274
- /** Claim waiting replies first, then declare gone every window that is missing with still no result. */
275
- async function tick(): Promise<void> {
276
- if (ticking) return;
277
- ticking = true;
278
- try {
279
- if (results.running().length === 0) return stopPolling();
280
- const scan: { scanned?: boolean } = {};
281
- pi.events.emit(SCAN, scan);
282
- if (scan.scanned !== true) return; // no message:* provider: a waiting reply cannot be ruled out
283
- for (const record of results.running()) {
284
- let alive: boolean;
285
- try {
286
- alive = await tmux.isAlive(record.windowId);
287
- } catch {
288
- continue; // tmux could not be asked; never read that as gone
289
- }
290
- if (alive || !live) continue;
291
- // A reply may have landed while the windows before this one were checked.
292
- pi.events.emit(SCAN, {});
293
- results.recordGone(record.id);
294
- }
295
- if (results.running().length === 0) stopPolling();
296
- } finally {
297
- ticking = false;
298
- }
299
- }
300
-
301
- /** Start the timer when something is running; it never keeps the process alive. */
302
- function startPolling(): void {
303
- if (poll !== undefined || results.running().length === 0) return;
304
- poll = setInterval(() => void tick(), POLL_MS);
305
- poll.unref?.();
306
- }
266
+ const tracking = createTracking(pi, results, tmux);
307
267
 
308
268
  /** Captured because `getArgumentCompletions` is called without a context. */
309
269
  let registry: ModelRegistry | undefined;
@@ -391,6 +351,7 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
391
351
 
392
352
  const parent = ctx.sessionManager.getSessionId();
393
353
  const name = delegateName(start.agent.name, id, start.label);
354
+ const prompt = writePromptFile(`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}`);
394
355
  const argv = [
395
356
  ...parentCommand(),
396
357
  "--session-id",
@@ -403,7 +364,7 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
403
364
  start.thinking,
404
365
  ...(start.agent.excludeTools.length > 0 ? ["--exclude-tools", start.agent.excludeTools.join(",")] : []),
405
366
  "--append-system-prompt",
406
- `${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}`,
367
+ prompt,
407
368
  ];
408
369
  const env: Record<string, string> = {
409
370
  PI_CODING_AGENT_DIR: getAgentDir(),
@@ -413,7 +374,7 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
413
374
  const sessionDir = process.env[SESSION_DIR_ENV];
414
375
  if (sessionDir !== undefined && sessionDir !== "") env[SESSION_DIR_ENV] = sessionDir;
415
376
  // Listeners may only append to `args` and add to `env`; there is no veto.
416
- const payload: LaunchPayload = { args: argv, env };
377
+ const payload: LaunchPayload = { args: argv, env, agent: start.agent.name };
417
378
  pi.events.emit(LAUNCH, payload);
418
379
 
419
380
  const windowName = name;
@@ -439,7 +400,7 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
439
400
  },
440
401
  ctx,
441
402
  );
442
- startPolling();
403
+ tracking.startPolling();
443
404
  return { id, name, windowId, windowName, requestId };
444
405
  }
445
406
 
@@ -526,167 +487,15 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
526
487
  },
527
488
  });
528
489
 
529
- /** One delegation's state, from its records and the window tmux reports. */
530
- async function describe(record: Recorded, ctx: ExtensionContext): Promise<string> {
531
- let alive: boolean | undefined;
532
- try {
533
- alive = await tmux.isAlive(record.windowId);
534
- } catch {
535
- alive = undefined; // tmux could not be asked; never read that as gone
536
- }
537
- const state =
538
- record.closed === true
539
- ? "closed"
540
- : record.result !== undefined
541
- ? "done"
542
- : record.gone === true
543
- ? "gone"
544
- : alive === true
545
- ? "running"
546
- : alive === false
547
- ? "closed"
548
- : "unknown";
549
- const session = record.result?.sessionPath ?? findSession(ctx.sessionManager.getSessionDir(), record.id);
550
- const lines = [
551
- `${record.id} ${record.agent} (${record.model}, thinking ${record.thinking})`,
552
- ` name: ${record.name ?? delegateName(record.agent, record.id)}`,
553
- ` state: ${stateLine(state, record)}`,
554
- ` window: ${record.windowName} (${record.windowId})`,
555
- ` auto-exit: ${autoExitLine(record)}`,
556
- ` session: ${session ?? `${record.id} (no session file yet)`}`,
557
- ];
558
- if (record.result !== undefined) lines.push(` envelope: ${record.result.envelopePath}`);
559
- return lines.join("\n");
560
- }
561
-
562
- /** Records from before auto-exit existed kept their windows open, so they read off. */
563
- function autoExitLine(record: Recorded): string {
564
- return record.autoExit === true
565
- ? "on — the delegate closes its window after a normal completion unless the user took over there"
566
- : "off — the window stays open after the result";
567
- }
568
-
569
- function stateLine(state: string, record: Recorded): string {
570
- if (state === "running") return "running — the window is open and no result has arrived";
571
- if (state === "gone")
572
- return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
573
- if (state === "done") return `done — result status: ${record.result?.status ?? "unknown"}`;
574
- if (state === "closed") {
575
- return record.closed === true ? "closed — delegation_close recorded it" : "closed — the window is gone";
576
- }
577
- return "unknown — tmux could not be asked whether the window is open";
578
- }
579
-
580
- const statusTool = {
581
- name: "delegation_status",
582
- label: "Delegation status",
583
- description:
584
- "The delegations this session started, each reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Names the delegation id, agent, session name, model, thinking, window, delegate session path and result envelope path. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
585
- parameters: Type.Object({
586
- id: Type.Optional(Type.String({ description: "One delegation id; omit to report every delegation." })),
587
- }),
588
- async execute(
589
- _toolCallId: string,
590
- params: { id?: string },
591
- _signal: unknown,
592
- _onUpdate: unknown,
593
- ctx: ExtensionContext,
594
- ) {
595
- const all = results.list();
596
- const records = params.id === undefined ? all : all.filter((record) => record.id === params.id);
597
- if (params.id !== undefined && records.length === 0) {
598
- throw new Error(
599
- `no delegation ${JSON.stringify(params.id)}; known: ${all.map((record) => record.id).join(", ") || "none"}`,
600
- );
601
- }
602
- const details = records.map((record) => ({
603
- id: record.id,
604
- name: delegateName(record.agent, record.id),
605
- agent: record.agent,
606
- model: record.model,
607
- thinking: record.thinking,
608
- autoExit: record.autoExit === true,
609
- windowId: record.windowId,
610
- windowName: record.windowName,
611
- closed: record.closed === true,
612
- closedWithoutResult: record.gone === true && record.result === undefined && record.closed !== true,
613
- status: record.result?.status,
614
- envelopePath: record.result?.envelopePath,
615
- sessionPath: record.result?.sessionPath ?? findSession(ctx.sessionManager.getSessionDir(), record.id),
616
- }));
617
- if (records.length === 0)
618
- return toolResult("No delegations are recorded in this session.", { delegations: details });
619
- const blocks: string[] = [];
620
- for (const record of records) blocks.push(await describe(record, ctx));
621
- const heading = `${records.length} delegation${records.length === 1 ? "" : "s"}:`;
622
- return toolResult(`${heading}\n\n${blocks.join("\n\n")}`, { delegations: details });
623
- },
624
- };
625
-
626
- const closeTool = {
627
- name: "delegation_close",
628
- label: "Close delegation",
629
- description:
630
- "Close one delegation: kill its tmux window through tmux and record the close, so delegation_status reports it closed. The delegate's session file and result envelope stay on disk. An unknown or already closed id returns a message with nothing killed.",
631
- parameters: Type.Object({
632
- id: Type.String({ description: "The delegation id to close." }),
633
- }),
634
- async execute(
635
- _toolCallId: string,
636
- params: { id: string },
637
- _signal: unknown,
638
- _onUpdate: unknown,
639
- _ctx: ExtensionContext,
640
- ) {
641
- const record = results.find(params.id);
642
- if (record === undefined) {
643
- const known =
644
- results
645
- .list()
646
- .map((item) => item.id)
647
- .join(", ") || "none";
648
- throw new Error(`no delegation ${JSON.stringify(params.id)}; known: ${known}`);
649
- }
650
- if (record.closed === true)
651
- return toolResult(`Delegation ${record.id} is already closed.`, { id: record.id, closed: true });
652
- let alive: boolean;
653
- try {
654
- alive = await tmux.isAlive(record.windowId);
655
- } catch (err) {
656
- throw new Error(`could not ask tmux about window ${record.windowName}: ${(err as Error).message}`);
657
- }
658
- if (alive) {
659
- try {
660
- await tmux.kill(record.windowId);
661
- } catch (err) {
662
- throw new Error(`could not kill window ${record.windowName} (${record.windowId}): ${(err as Error).message}`);
663
- }
664
- }
665
- results.recordClose(record.id);
666
- const how = alive
667
- ? `killed window ${record.windowName} (${record.windowId})`
668
- : `its window ${record.windowName} was already gone`;
669
- return toolResult(`Closed delegation ${record.id}: ${how}.`, {
670
- id: record.id,
671
- windowId: record.windowId,
672
- killed: alive,
673
- });
674
- },
675
- };
676
-
677
490
  pi.on("session_start", (_event, ctx) => {
678
491
  registry = ctx.modelRegistry;
679
- stopPolling();
680
- live = true;
681
- results.restore(ctx);
682
- startPolling();
492
+ tracking.restore(ctx);
683
493
  // The roster is read at call time; the description can only be fixed here.
684
494
  pi.registerTool(delegateTool() as never);
685
495
  });
686
496
 
687
497
  pi.on("session_shutdown", () => {
688
- live = false;
689
- stopPolling();
498
+ tracking.stop();
690
499
  });
691
500
 
692
501
  /** Agent names, flags, model ids and thinking levels, at the cursor's token. */
@@ -712,6 +521,5 @@ export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmux
712
521
  });
713
522
 
714
523
  pi.registerTool(delegateTool() as never);
715
- pi.registerTool(statusTool as never);
716
- pi.registerTool(closeTool as never);
524
+ tracking.registerTools();
717
525
  }
@@ -87,8 +87,8 @@ export type Results = {
87
87
  restore(ctx: ExtensionContext): void;
88
88
  /** Record a delegation and show it as running. */
89
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";
90
+ /** Record a close; does nothing when there is no such delegation or it is already closed. */
91
+ recordClose(id: string): void;
92
92
  /** Delegations with no result, no recorded close and no gone-window record. */
93
93
  running(): Recorded[];
94
94
  /** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
@@ -97,10 +97,44 @@ export type Results = {
97
97
  list(): Recorded[];
98
98
  /** One delegation by id. */
99
99
  find(id: string): Recorded | undefined;
100
- /** Take over a reply that answers a recorded delegation, if any. */
101
- takeReply(payload: Inbound): void;
102
100
  };
103
101
 
102
+ /** What tmux reported for a delegation's window: listed, not listed, or not askable. */
103
+ export type WindowState = "open" | "gone" | "unknown";
104
+
105
+ /**
106
+ * A delegation's state. `closed` is a recorded `delegation_close`; `gone` is
107
+ * closed without a result (the poll found the window missing); `window-closed`
108
+ * is a window tmux no longer lists, seen when asked directly.
109
+ */
110
+ export type DelegationState = "closed" | "done" | "gone" | "running" | "window-closed" | "unknown";
111
+
112
+ /** How much of a delegation id a delegate's session name carries. */
113
+ const SHORT_ID = 8;
114
+
115
+ /**
116
+ * A delegate's name, used for both its session and its window: its agent and
117
+ * the label, or the first 8 characters of its id when there is no label.
118
+ */
119
+ export function delegateName(agent: string, id: string, label?: string): string {
120
+ return `${agent}-${label ?? id.slice(0, SHORT_ID)}`;
121
+ }
122
+
123
+ /** The recorded name, or the pre-label name for records from before labels. */
124
+ export function recordedName(record: Recorded): string {
125
+ return record.name ?? delegateName(record.agent, record.id);
126
+ }
127
+
128
+ /** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
129
+ export function deriveState(record: Recorded, window: WindowState): DelegationState {
130
+ if (record.closed === true) return "closed";
131
+ if (record.result !== undefined) return "done";
132
+ if (record.gone === true) return "gone";
133
+ if (window === "open") return "running";
134
+ if (window === "gone") return "window-closed";
135
+ return "unknown";
136
+ }
137
+
104
138
  /** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
105
139
  function cap(text: string, max: number): string | undefined {
106
140
  const bytes = Buffer.from(text, "utf8");
@@ -219,14 +253,12 @@ export function createResults(pi: ExtensionAPI): Results {
219
253
  updateFooter();
220
254
  }
221
255
 
222
- function recordClose(id: string): "closed" | "already-closed" | "unknown" {
256
+ function recordClose(id: string): void {
223
257
  const record = byId.get(id);
224
- if (record === undefined) return "unknown";
225
- if (record.closed === true) return "already-closed";
258
+ if (record === undefined || record.closed === true) return;
226
259
  pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
227
260
  record.closed = true;
228
261
  updateFooter();
229
- return "closed";
230
262
  }
231
263
 
232
264
  function recordGone(id: string): boolean {
@@ -256,7 +288,8 @@ export function createResults(pi: ExtensionAPI): Results {
256
288
  if (payload?.handled === true) return;
257
289
  const reply = payload?.envelope;
258
290
  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));
291
+ // Only the delegate's own reply counts: `from` must be the delegation's id.
292
+ const requestId = reply.in_reply_to.find((id) => byRequest.get(id)?.id === reply.from);
260
293
  if (requestId === undefined) return;
261
294
  payload.handled = true;
262
295
  if (shown.has(reply.id)) return;
@@ -288,6 +321,5 @@ export function createResults(pi: ExtensionAPI): Results {
288
321
  recordGone,
289
322
  list: () => [...byId.values()],
290
323
  find: (id) => byId.get(id),
291
- takeReply,
292
324
  };
293
325
  }
@@ -10,13 +10,16 @@ export type WindowSpec = {
10
10
  env: Readonly<Record<string, string>>;
11
11
  };
12
12
 
13
+ /** One window as tmux lists it: its id, its name, and whether the program in its pane has exited. */
14
+ export type TmuxWindow = { id: string; name: string; exited: boolean };
15
+
13
16
  export interface TmuxClient {
14
17
  /** Whether this process runs in a tmux pane. */
15
18
  insideTmux(): boolean;
16
19
  /** Open a detached window and return its id (for example `@3`). */
17
20
  openWindow(spec: WindowSpec): Promise<string>;
18
- /** Whether a window id is still listed. */
19
- isAlive(windowId: string): Promise<boolean>;
21
+ /** Every window on the server, in one tmux call. */
22
+ listWindows(): Promise<TmuxWindow[]>;
20
23
  /** Kill a window. */
21
24
  kill(windowId: string): Promise<void>;
22
25
  }
@@ -63,9 +66,16 @@ export function createTmuxClient(): TmuxClient {
63
66
  return windowId;
64
67
  },
65
68
 
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
+ async listWindows() {
70
+ // The name goes last: it is the one field that could hold a tab.
71
+ const out = await run(["list-windows", "-a", "-F", "#{window_id}\t#{pane_dead}\t#{window_name}"]);
72
+ return out
73
+ .split("\n")
74
+ .filter((line) => line.trim() !== "")
75
+ .map((line) => {
76
+ const [id, dead, ...name] = line.split("\t");
77
+ return { id, name: name.join("\t"), exited: dead === "1" };
78
+ });
69
79
  },
70
80
 
71
81
  async kill(windowId) {