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.
- package/CHANGELOG.md +60 -1
- package/README.md +205 -118
- package/package.json +3 -7
- package/{child.ts → src/child.ts} +25 -1
- package/{index.ts → src/index.ts} +38 -230
- package/{results.ts → src/results.ts} +42 -10
- package/{tmux.ts → src/tmux.ts} +15 -5
- package/src/tracking.ts +310 -0
- /package/{agents.ts → src/agents.ts} +0 -0
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
recordClose(id: string):
|
|
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):
|
|
256
|
+
function recordClose(id: string): void {
|
|
223
257
|
const record = byId.get(id);
|
|
224
|
-
if (record === undefined) return
|
|
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
|
-
|
|
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
|
}
|
package/{tmux.ts → src/tmux.ts}
RENAMED
|
@@ -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
|
-
/**
|
|
19
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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) {
|