pi-squire 0.12.0 → 0.13.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 +24 -1
- package/README.md +56 -1
- package/package.json +3 -1
- package/src/index.ts +82 -22
- package/src/navigator.ts +382 -0
- package/src/new-delegate.ts +438 -0
- package/src/poll.ts +111 -0
- package/src/popups.ts +49 -0
- package/src/resume-checks.ts +159 -0
- package/src/resume-picker.ts +50 -0
- package/src/resume.ts +39 -98
- package/src/tmux.ts +117 -1
- package/src/tracking.ts +57 -127
- package/src/windows.ts +24 -0
- package/src/workspace.ts +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The checks a resume makes before it launches anything, shared by `resume`
|
|
2
|
+
// and the resume picker so both accept the same delegations: the call-wide
|
|
3
|
+
// prerequisites once, then per record the window, session file, agent, thinking
|
|
4
|
+
// level and model. None of them changes anything; killing an exited window and
|
|
5
|
+
// deciding trust stay with the resume itself.
|
|
6
|
+
import { statSync } from "node:fs";
|
|
7
|
+
import { isAbsolute } from "node:path";
|
|
8
|
+
|
|
9
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
import type { Agent } from "./agents.ts";
|
|
12
|
+
import { sessionHeaderCwd, sessionIdOf } from "./result-sessions.ts";
|
|
13
|
+
import { deriveState, finishedModel, locateSession, type StartedRecorded } from "./results.ts";
|
|
14
|
+
import type { ResumeDeps, ResumeRequest } from "./resume.ts";
|
|
15
|
+
import { requireTmuxVersion, type TmuxWindow } from "./tmux.ts";
|
|
16
|
+
import { ownWindow, windowState } from "./tracking.ts";
|
|
17
|
+
import type { WindowPlacement } from "./window-placement.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Steps 3 and 4: no process of this delegate is left. A running delegate is
|
|
21
|
+
* mailed instead; tmux that cannot be asked proves nothing; a window with the
|
|
22
|
+
* record's id and name that still runs its program is refused, and one whose
|
|
23
|
+
* program exited is killed first.
|
|
24
|
+
*/
|
|
25
|
+
export function windowCheck(record: StartedRecorded, windows: TmuxWindow[] | undefined): TmuxWindow | undefined {
|
|
26
|
+
const state = deriveState(record, windowState(windows, record));
|
|
27
|
+
if (state === "running") {
|
|
28
|
+
throw new Error(`delegation ${record.id} is running; send it a message instead of resuming it`);
|
|
29
|
+
}
|
|
30
|
+
if (windows === undefined || state === "unknown") {
|
|
31
|
+
throw new Error(`could not ask tmux whether delegation ${record.id}'s window is open, so it is not resumed`);
|
|
32
|
+
}
|
|
33
|
+
const own = ownWindow(windows, record);
|
|
34
|
+
if (own !== undefined && !own.exited) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`delegation ${record.id}'s window ${record.windowName} (${record.windowId}) is still open; its Pi may still be running, so it is not resumed. Close it with delegation_close, or send it a message`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return own;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether `path` is an existing directory. */
|
|
43
|
+
export function isDirectory(path: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
return statSync(path).isDirectory();
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A delegate's session file, the directory its header says Pi runs it in, and the session directory to pass. */
|
|
52
|
+
export type SessionFile = { path: string; cwd: string; dir: string };
|
|
53
|
+
|
|
54
|
+
/** Step 5: the session file, and the directory its header says Pi will run it in. */
|
|
55
|
+
export function sessionFor(record: StartedRecorded, ctx: ExtensionContext): SessionFile {
|
|
56
|
+
const dir = record.sessionDir ?? ctx.sessionManager.getSessionDir();
|
|
57
|
+
const path = locateSession(record, ctx.sessionManager.getSessionDir());
|
|
58
|
+
if (path === undefined) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`no session file for session ${sessionIdOf(record)} was found in ${dir}, so delegation ${record.id} is not resumable`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!isAbsolute(path) || !isAbsolute(dir)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`the session file ${path} or its directory ${dir} is not an absolute path, so delegation ${record.id} is not resumable`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const cwd = sessionHeaderCwd(path);
|
|
69
|
+
if (cwd === undefined) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`could not read a working directory from the header of ${path}, so delegation ${record.id} is not resumable`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!isDirectory(cwd)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`delegation ${record.id}'s session runs in ${cwd}, which no longer exists, so it is not resumable (a removed worktree cannot be resumed)`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return { path, cwd, dir };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The model choices: an explicit model alone, else where the delegate ended, then the agent's fallbacks. */
|
|
83
|
+
export function preferredModels(record: StartedRecorded, agent: Agent, sessionPath: string): string[] {
|
|
84
|
+
const ended = finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model;
|
|
85
|
+
return [...new Set([ended, ...agent.fallback])];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Steps 5 and 6: the session file and the agent, and no resume of the delegation already under way. */
|
|
89
|
+
export function recordCheck(
|
|
90
|
+
deps: ResumeDeps,
|
|
91
|
+
record: StartedRecorded,
|
|
92
|
+
ctx: ExtensionContext,
|
|
93
|
+
): { session: SessionFile; agent: Agent } {
|
|
94
|
+
const session = sessionFor(record, ctx);
|
|
95
|
+
const agent = deps.roster(ctx).agents.find((item) => item.name === record.agent);
|
|
96
|
+
if (agent === undefined) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`agent ${JSON.stringify(record.agent)} is no longer in the roster, so delegation ${record.id} is not resumed`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (inFlight.has(record.id)) throw new Error(`delegation ${record.id} is already being resumed`);
|
|
102
|
+
return { session, agent };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The thinking level and model a resume runs with; throws when the level is invalid or no model can be used. */
|
|
106
|
+
export async function settleModel(
|
|
107
|
+
deps: ResumeDeps,
|
|
108
|
+
input: {
|
|
109
|
+
record: StartedRecorded;
|
|
110
|
+
agent: Agent;
|
|
111
|
+
session: SessionFile;
|
|
112
|
+
request: Pick<ResumeRequest, "model" | "thinking">;
|
|
113
|
+
},
|
|
114
|
+
ctx: ExtensionContext,
|
|
115
|
+
): Promise<{ thinking: string; chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>> }> {
|
|
116
|
+
const { record, agent, session, request } = input;
|
|
117
|
+
const thinking = request.thinking ?? record.thinking;
|
|
118
|
+
const badThinking = deps.thinkingProblem(thinking);
|
|
119
|
+
if (badThinking !== undefined) throw new Error(badThinking);
|
|
120
|
+
const preferred = preferredModels(record, agent, session.path);
|
|
121
|
+
return { thinking, chosen: await deps.chooseModel(request.model, agent, ctx.modelRegistry, preferred) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The call-wide prerequisites of a resume: inside tmux, a new enough tmux, a valid placement setting (read once). */
|
|
125
|
+
export async function preflight(deps: ResumeDeps): Promise<WindowPlacement> {
|
|
126
|
+
if (!deps.tmux.insideTmux()) throw new Error(deps.notInTmux);
|
|
127
|
+
await requireTmuxVersion(deps.tmux);
|
|
128
|
+
return deps.placement();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The delegation ids with a resume under way in this process. */
|
|
132
|
+
export const inFlight = new Set<string>();
|
|
133
|
+
|
|
134
|
+
/** Whether `record` passes every check a resume makes that changes nothing; the resume's own kill of an exited window and trust prompt are left to it. */
|
|
135
|
+
export async function passes(
|
|
136
|
+
deps: ResumeDeps,
|
|
137
|
+
record: StartedRecorded,
|
|
138
|
+
windows: TmuxWindow[],
|
|
139
|
+
ctx: ExtensionContext,
|
|
140
|
+
models: Map<string, boolean>,
|
|
141
|
+
): Promise<boolean> {
|
|
142
|
+
try {
|
|
143
|
+
windowCheck(record, windows);
|
|
144
|
+
const { session, agent } = recordCheck(deps, record, ctx);
|
|
145
|
+
// Model choice depends on the agent and where the delegate ended, so records alike share one answer.
|
|
146
|
+
const key = JSON.stringify([record.thinking, agent.name, preferredModels(record, agent, session.path)]);
|
|
147
|
+
let ok = models.get(key);
|
|
148
|
+
if (ok === undefined) {
|
|
149
|
+
ok = await settleModel(deps, { record, agent, session, request: {} }, ctx).then(
|
|
150
|
+
() => true,
|
|
151
|
+
() => false,
|
|
152
|
+
);
|
|
153
|
+
models.set(key, ok);
|
|
154
|
+
}
|
|
155
|
+
return ok;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `/delegate-resume` with no arguments: pick one of the delegations a resume
|
|
2
|
+
// would accept, write the follow-up in the multi-line editor, then resume it
|
|
3
|
+
// the way the command does.
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { recordedName, type StartedRecorded } from "./results.ts";
|
|
7
|
+
|
|
8
|
+
/** What the picker reads from the resume path in `index.ts`. */
|
|
9
|
+
export type ResumePickerDeps = {
|
|
10
|
+
/** Why nothing can resume now (not inside tmux), or undefined. */
|
|
11
|
+
unavailable(): string | undefined;
|
|
12
|
+
/** The delegations a resume accepts now, newest first. */
|
|
13
|
+
eligible(ctx: ExtensionContext): Promise<StartedRecorded[]>;
|
|
14
|
+
/** Resume as the command does, notifying the start or the refusal. */
|
|
15
|
+
resume(request: { id: string; task: string }, ctx: ExtensionContext): Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** One picker line; the id prefix keeps two delegations with one name apart. */
|
|
19
|
+
const optionFor = (record: StartedRecorded): string =>
|
|
20
|
+
`${recordedName(record)} · ${record.agent} · ${record.id.slice(0, 8)}`;
|
|
21
|
+
|
|
22
|
+
export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps): Promise<void> {
|
|
23
|
+
const unavailable = deps.unavailable();
|
|
24
|
+
if (unavailable !== undefined) {
|
|
25
|
+
ctx.ui.notify(`delegate: ${unavailable}`, "error");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let records: StartedRecorded[];
|
|
29
|
+
try {
|
|
30
|
+
records = await deps.eligible(ctx);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
ctx.ui.notify(`delegate: ${(err as Error).message}`, "error");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (records.length === 0) {
|
|
36
|
+
ctx.ui.notify("delegate: no delegation can be resumed now", "info");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const options = records.map(optionFor);
|
|
40
|
+
const picked = await ctx.ui.select("Resume which delegation?", options);
|
|
41
|
+
const record = records[options.indexOf(picked ?? "")];
|
|
42
|
+
if (record === undefined) return;
|
|
43
|
+
const task = await ctx.ui.editor(`Follow-up for ${recordedName(record)}`);
|
|
44
|
+
if (task === undefined) return;
|
|
45
|
+
if (task.trim() === "") {
|
|
46
|
+
ctx.ui.notify("delegate: a task is required", "error");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await deps.resume({ id: record.id, task }, ctx);
|
|
50
|
+
}
|
package/src/resume.ts
CHANGED
|
@@ -5,25 +5,24 @@
|
|
|
5
5
|
// `index.ts` supplies the pieces it shares with a launch: model choice, trust,
|
|
6
6
|
// the task mail and the window.
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { statSync } from "node:fs";
|
|
9
|
-
import { isAbsolute } from "node:path";
|
|
10
8
|
|
|
11
9
|
import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
12
10
|
import { type TObject, Type } from "typebox";
|
|
13
11
|
|
|
14
12
|
import { type Agent, type Roster, THINKING_LEVELS } from "./agents.ts";
|
|
15
|
-
import {
|
|
13
|
+
import { sessionIdOf } from "./result-sessions.ts";
|
|
14
|
+
import { isStarted, type Results, recordedName, type StartedRecorded } from "./results.ts";
|
|
16
15
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
type
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} from "./
|
|
25
|
-
import {
|
|
26
|
-
import { listWindows,
|
|
16
|
+
inFlight,
|
|
17
|
+
passes,
|
|
18
|
+
preflight,
|
|
19
|
+
recordCheck,
|
|
20
|
+
type SessionFile,
|
|
21
|
+
settleModel,
|
|
22
|
+
windowCheck,
|
|
23
|
+
} from "./resume-checks.ts";
|
|
24
|
+
import type { OpenedWindow, TmuxClient } from "./tmux.ts";
|
|
25
|
+
import { listWindows, type ToolDef, toolResult } from "./tracking.ts";
|
|
27
26
|
import type { WindowPlacement } from "./window-placement.ts";
|
|
28
27
|
import type { Workspace } from "./workspace.ts";
|
|
29
28
|
|
|
@@ -142,28 +141,10 @@ function newestStarted(results: Results, id: string): StartedRecorded {
|
|
|
142
141
|
return record;
|
|
143
142
|
}
|
|
144
143
|
|
|
145
|
-
/**
|
|
146
|
-
* Steps 3 and 4: no process of this delegate is left. A running delegate is
|
|
147
|
-
* mailed instead; tmux that cannot be asked proves nothing; a window with the
|
|
148
|
-
* record's id and name that still runs its program is refused, and one whose
|
|
149
|
-
* program exited is killed first.
|
|
150
|
-
*/
|
|
144
|
+
/** Steps 3 and 4, then the kill of an exited window; `windowCheck` is the part the picker shares. */
|
|
151
145
|
async function ensureExited(record: StartedRecorded, tmux: TmuxClient): Promise<void> {
|
|
152
|
-
const
|
|
153
|
-
const state = deriveState(record, windowState(windows, record));
|
|
154
|
-
if (state === "running") {
|
|
155
|
-
throw new Error(`delegation ${record.id} is running; send it a message instead of resuming it`);
|
|
156
|
-
}
|
|
157
|
-
if (windows === undefined || state === "unknown") {
|
|
158
|
-
throw new Error(`could not ask tmux whether delegation ${record.id}'s window is open, so it is not resumed`);
|
|
159
|
-
}
|
|
160
|
-
const own = ownWindow(windows, record);
|
|
146
|
+
const own = windowCheck(record, await listWindows(tmux));
|
|
161
147
|
if (own === undefined) return;
|
|
162
|
-
if (!own.exited) {
|
|
163
|
-
throw new Error(
|
|
164
|
-
`delegation ${record.id}'s window ${record.windowName} (${record.windowId}) is still open; its Pi may still be running, so it is not resumed. Close it with delegation_close, or send it a message`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
148
|
try {
|
|
168
149
|
await tmux.kill(own.id);
|
|
169
150
|
} catch (err) {
|
|
@@ -173,52 +154,6 @@ async function ensureExited(record: StartedRecorded, tmux: TmuxClient): Promise<
|
|
|
173
154
|
}
|
|
174
155
|
}
|
|
175
156
|
|
|
176
|
-
/** Whether `path` is an existing directory. */
|
|
177
|
-
function isDirectory(path: string): boolean {
|
|
178
|
-
try {
|
|
179
|
-
return statSync(path).isDirectory();
|
|
180
|
-
} catch {
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** A delegate's session file, the directory its header says Pi runs it in, and the session directory to pass. */
|
|
186
|
-
type SessionFile = { path: string; cwd: string; dir: string };
|
|
187
|
-
|
|
188
|
-
/** Step 5: the session file, and the directory its header says Pi will run it in. */
|
|
189
|
-
function sessionFor(record: StartedRecorded, ctx: ExtensionContext): SessionFile {
|
|
190
|
-
const dir = record.sessionDir ?? ctx.sessionManager.getSessionDir();
|
|
191
|
-
const path = locateSession(record, ctx.sessionManager.getSessionDir());
|
|
192
|
-
if (path === undefined) {
|
|
193
|
-
throw new Error(
|
|
194
|
-
`no session file for session ${sessionIdOf(record)} was found in ${dir}, so delegation ${record.id} is not resumable`,
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
if (!isAbsolute(path) || !isAbsolute(dir)) {
|
|
198
|
-
throw new Error(
|
|
199
|
-
`the session file ${path} or its directory ${dir} is not an absolute path, so delegation ${record.id} is not resumable`,
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
const cwd = sessionHeaderCwd(path);
|
|
203
|
-
if (cwd === undefined) {
|
|
204
|
-
throw new Error(
|
|
205
|
-
`could not read a working directory from the header of ${path}, so delegation ${record.id} is not resumable`,
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
if (!isDirectory(cwd)) {
|
|
209
|
-
throw new Error(
|
|
210
|
-
`delegation ${record.id}'s session runs in ${cwd}, which no longer exists, so it is not resumable (a removed worktree cannot be resumed)`,
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
return { path, cwd, dir };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** The model choices: an explicit model alone, else where the delegate ended, then the agent's fallbacks. */
|
|
217
|
-
function preferredModels(record: StartedRecorded, agent: Agent, sessionPath: string): string[] {
|
|
218
|
-
const ended = finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model;
|
|
219
|
-
return [...new Set([ended, ...agent.fallback])];
|
|
220
|
-
}
|
|
221
|
-
|
|
222
157
|
/** The tool's result: the `delegate` tool's plus the delegation it resumed. */
|
|
223
158
|
function resultSchema(delegateSchema: TObject): TObject {
|
|
224
159
|
return Type.Object({
|
|
@@ -243,11 +178,7 @@ async function planResume(
|
|
|
243
178
|
signal: AbortSignal | undefined,
|
|
244
179
|
): Promise<Plan> {
|
|
245
180
|
const { record, agent, session, request } = input;
|
|
246
|
-
const thinking =
|
|
247
|
-
const badThinking = deps.thinkingProblem(thinking);
|
|
248
|
-
if (badThinking !== undefined) throw new Error(badThinking);
|
|
249
|
-
const preferred = preferredModels(record, agent, session.path);
|
|
250
|
-
const chosen = await deps.chooseModel(request.model, agent, ctx.modelRegistry, preferred);
|
|
181
|
+
const { thinking, chosen } = await settleModel(deps, input, ctx);
|
|
251
182
|
const autoExit = request.autoExit ?? record.autoExit === true;
|
|
252
183
|
const prepared = await deps.prepareDirectory(session.cwd, ctx, signal);
|
|
253
184
|
// A managed worktree keeps its launch facts while the session still runs in it.
|
|
@@ -350,9 +281,6 @@ async function startResume(
|
|
|
350
281
|
};
|
|
351
282
|
}
|
|
352
283
|
|
|
353
|
-
/** The delegation ids with a resume under way in this process. */
|
|
354
|
-
const inFlight = new Set<string>();
|
|
355
|
-
|
|
356
284
|
/** Resume `request.id`: every eligibility check in order, then the launch. */
|
|
357
285
|
async function resume(
|
|
358
286
|
deps: ResumeDeps,
|
|
@@ -360,20 +288,11 @@ async function resume(
|
|
|
360
288
|
ctx: ExtensionContext,
|
|
361
289
|
signal: AbortSignal | undefined,
|
|
362
290
|
): Promise<Resumed> {
|
|
363
|
-
|
|
364
|
-
await requireTmuxVersion(deps.tmux);
|
|
365
|
-
const placement = deps.placement(); // read once, before the exited window is killed or anything is mailed
|
|
291
|
+
const placement = await preflight(deps); // read once, before the exited window is killed or anything is mailed
|
|
366
292
|
if (request.task.trim() === "") throw new Error("a task is required");
|
|
367
293
|
const record = newestStarted(deps.results, request.id);
|
|
368
294
|
await ensureExited(record, deps.tmux);
|
|
369
|
-
const session =
|
|
370
|
-
const agent = deps.roster(ctx).agents.find((item) => item.name === record.agent);
|
|
371
|
-
if (agent === undefined) {
|
|
372
|
-
throw new Error(
|
|
373
|
-
`agent ${JSON.stringify(record.agent)} is no longer in the roster, so delegation ${record.id} is not resumed`,
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
if (inFlight.has(record.id)) throw new Error(`delegation ${record.id} is already being resumed`);
|
|
295
|
+
const { session, agent } = recordCheck(deps, record, ctx);
|
|
377
296
|
inFlight.add(record.id);
|
|
378
297
|
try {
|
|
379
298
|
const plan = await planResume(deps, { record, agent, session, request }, ctx, signal);
|
|
@@ -387,6 +306,25 @@ async function resume(
|
|
|
387
306
|
}
|
|
388
307
|
}
|
|
389
308
|
|
|
309
|
+
/**
|
|
310
|
+
* The delegations that pass every check `resume` makes, newest first, read
|
|
311
|
+
* with one `listWindows` and changing nothing: an exited window is left for
|
|
312
|
+
* the resume itself to kill and trust is left for its prompt. The call-wide
|
|
313
|
+
* prerequisites are checked once and refuse as `resume` does.
|
|
314
|
+
*/
|
|
315
|
+
async function eligible(deps: ResumeDeps, ctx: ExtensionContext): Promise<StartedRecorded[]> {
|
|
316
|
+
await preflight(deps);
|
|
317
|
+
const windows = await listWindows(deps.tmux);
|
|
318
|
+
if (windows === undefined) return [];
|
|
319
|
+
const ids = new Set(resumableIds(deps.results));
|
|
320
|
+
const models = new Map<string, boolean>();
|
|
321
|
+
const found: StartedRecorded[] = [];
|
|
322
|
+
for (const record of startedRecords(deps.results)) {
|
|
323
|
+
if (ids.has(record.id) && (await passes(deps, record, windows, ctx, models))) found.push(record);
|
|
324
|
+
}
|
|
325
|
+
return found.reverse();
|
|
326
|
+
}
|
|
327
|
+
|
|
390
328
|
/** The session a window opened in, for the start text. */
|
|
391
329
|
const inSession = (session: string | undefined): string => (session === undefined ? "" : ` in session ${session}`);
|
|
392
330
|
|
|
@@ -463,9 +401,12 @@ function requestFrom(params: {
|
|
|
463
401
|
/** Resuming as a function for the command, and the tool for the model. */
|
|
464
402
|
export function createResume(deps: ResumeDeps): {
|
|
465
403
|
resume(request: ResumeRequest, ctx: ExtensionContext, signal?: AbortSignal): Promise<Resumed>;
|
|
404
|
+
/** The delegations a resume would accept now, newest first. */
|
|
405
|
+
eligible(ctx: ExtensionContext): Promise<StartedRecorded[]>;
|
|
466
406
|
tool(): ToolDef;
|
|
467
407
|
} {
|
|
468
408
|
return {
|
|
409
|
+
eligible: (ctx: ExtensionContext) => eligible(deps, ctx),
|
|
469
410
|
resume: (request: ResumeRequest, ctx: ExtensionContext, signal?: AbortSignal) => resume(deps, request, ctx, signal),
|
|
470
411
|
tool: () => resumeTool(deps),
|
|
471
412
|
};
|
package/src/tmux.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The tmux operations `delegate` needs, behind an interface so tests can use a
|
|
2
2
|
// fake. This client talks to tmux with `execFile` and no shell.
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
4
|
|
|
5
5
|
import type { WindowPlacement } from "./window-placement.ts";
|
|
6
6
|
|
|
@@ -62,6 +62,12 @@ export class WindowOpenError extends Error {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** A window as the popup needs it: its id and its name, which titles the popup. */
|
|
66
|
+
export type PopupWindow = { id: string; name: string };
|
|
67
|
+
|
|
68
|
+
/** The session the parent's pane is in: its id (`$N`) and name. */
|
|
69
|
+
export type OwnSession = { id: string; name: string };
|
|
70
|
+
|
|
65
71
|
export interface TmuxClient {
|
|
66
72
|
/** Whether this process runs in a tmux pane. */
|
|
67
73
|
insideTmux(): boolean;
|
|
@@ -77,6 +83,24 @@ export interface TmuxClient {
|
|
|
77
83
|
listWindows(): Promise<TmuxWindow[]>;
|
|
78
84
|
/** Kill a window. */
|
|
79
85
|
kill(windowId: string): Promise<void>;
|
|
86
|
+
/** The session the parent's pane is in; undefined when it cannot be told. */
|
|
87
|
+
ownSession(): Promise<OwnSession | undefined>;
|
|
88
|
+
/** The most recently active client attached to the session named `session`; undefined when none is. */
|
|
89
|
+
clientFor(session: string): Promise<string | undefined>;
|
|
90
|
+
/** Show the window to a client: `switch-client -c <client> -t @N`. */
|
|
91
|
+
switchTo(client: string, windowId: string): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Open a popup over a client that attaches to the window's session at the
|
|
94
|
+
* window. `display-popup` returns only when the popup closes, so this
|
|
95
|
+
* resolves once the popup was launched, not when it ends, and a launch that
|
|
96
|
+
* fails at once rejects. `onExit` runs when the popup's process ends, however
|
|
97
|
+
* the popup was closed, and not when the launch failed.
|
|
98
|
+
*/
|
|
99
|
+
popup(client: string, window: PopupWindow, onExit: () => void): Promise<void>;
|
|
100
|
+
/** Close the popup on a client (`display-popup -C`). */
|
|
101
|
+
closePopup(client: string): Promise<void>;
|
|
102
|
+
/** The window's visible pane as text (`capture-pane -p`). */
|
|
103
|
+
capture(windowId: string): Promise<string>;
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
/** Run one tmux command and return its stdout; never a shell. */
|
|
@@ -115,6 +139,48 @@ async function parentSession(run: Run): Promise<Parent | undefined> {
|
|
|
115
139
|
return id.trim() === "" ? undefined : { id: id.trim(), name: name.join("\t"), companion: marker === "1" };
|
|
116
140
|
}
|
|
117
141
|
|
|
142
|
+
/** How long a launched popup may take to fail before it counts as shown. */
|
|
143
|
+
const POPUP_SETTLE_MS = 300;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Launch `display-popup`, which blocks until the popup closes, detached and
|
|
147
|
+
* outside `runTmux`'s timeout. Resolves once it is up; rejects on a spawn
|
|
148
|
+
* error or an exit within the settle time. Its stderr is read only until then
|
|
149
|
+
* and then released, so the open popup keeps nothing of this process alive;
|
|
150
|
+
* `onExit` runs when the popup's process ends, however it was closed.
|
|
151
|
+
*/
|
|
152
|
+
function launchPopup(args: string[], onExit: () => void): Promise<void> {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
let settled = false;
|
|
155
|
+
let stderr = "";
|
|
156
|
+
const child = spawn("tmux", args, { detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
157
|
+
const onData = (chunk: Buffer): void => {
|
|
158
|
+
if (stderr.length < 4096) stderr += chunk.toString();
|
|
159
|
+
};
|
|
160
|
+
child.stderr?.on("data", onData);
|
|
161
|
+
const settle = (error?: Error): void => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
child.stderr?.off("data", onData);
|
|
166
|
+
child.stderr?.destroy();
|
|
167
|
+
if (error === undefined) resolve();
|
|
168
|
+
else reject(error);
|
|
169
|
+
};
|
|
170
|
+
const timer = setTimeout(() => settle(), POPUP_SETTLE_MS);
|
|
171
|
+
child.on("error", (err) => settle(err));
|
|
172
|
+
child.on("close", (code) => {
|
|
173
|
+
if (!settled && code !== 0) {
|
|
174
|
+
settle(new Error(stderr.trim() !== "" ? stderr.trim() : `tmux display-popup exited with ${code}`));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
settle();
|
|
178
|
+
onExit();
|
|
179
|
+
});
|
|
180
|
+
child.unref();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
118
184
|
const envArgs = (spec: WindowSpec): string[] =>
|
|
119
185
|
Object.entries(spec.env).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
120
186
|
|
|
@@ -184,6 +250,54 @@ function openCurrent(run: Run, spec: WindowSpec, parent: Parent | undefined): Pr
|
|
|
184
250
|
return create(run, args);
|
|
185
251
|
}
|
|
186
252
|
|
|
253
|
+
/** The methods that reach a delegate's window from the user's terminal: clients, switch, popup, capture. */
|
|
254
|
+
function navigation(
|
|
255
|
+
run: Run,
|
|
256
|
+
): Pick<TmuxClient, "ownSession" | "clientFor" | "switchTo" | "popup" | "closePopup" | "capture"> {
|
|
257
|
+
return {
|
|
258
|
+
async ownSession() {
|
|
259
|
+
const parent = await parentSession(run);
|
|
260
|
+
return parent === undefined || parent.name === "" ? undefined : { id: parent.id, name: parent.name };
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async clientFor(session) {
|
|
264
|
+
const out = await run(["list-clients", "-t", `=${session}:`, "-F", "#{client_activity}\t#{client_name}"]);
|
|
265
|
+
let best: { activity: number; name: string } | undefined;
|
|
266
|
+
for (const line of out.split("\n")) {
|
|
267
|
+
const [activity, ...name] = line.split("\t");
|
|
268
|
+
const at = Number(activity);
|
|
269
|
+
if (name.length === 0 || Number.isNaN(at)) continue;
|
|
270
|
+
if (best === undefined || at > best.activity) best = { activity: at, name: name.join("\t") };
|
|
271
|
+
}
|
|
272
|
+
return best?.name;
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
async switchTo(client, windowId) {
|
|
276
|
+
await run(["switch-client", "-c", client, "-t", windowId]);
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
async popup(client, window, onExit) {
|
|
280
|
+
if (!/^@[0-9]+$/.test(window.id)) throw new Error(`${window.id} is not a tmux window id`);
|
|
281
|
+
const sessionId = (await run(["display-message", "-p", "-t", window.id, "-F", "#{session_id}"])).trim();
|
|
282
|
+
if (!/^\$[0-9]+$/.test(sessionId)) throw new Error(`tmux reported an invalid session id for ${window.id}`);
|
|
283
|
+
// Ids are `@N` and `$N` only; quoted so the popup's shell does not expand `$N`.
|
|
284
|
+
const command = `tmux select-window -t '${window.id}' \\; attach-session -t '${sessionId}'`;
|
|
285
|
+
await launchPopup(
|
|
286
|
+
["display-popup", "-c", client, "-E", "-w", "90%", "-h", "90%", "-T", window.name, command],
|
|
287
|
+
onExit,
|
|
288
|
+
);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async closePopup(client) {
|
|
292
|
+
await run(["display-popup", "-C", "-c", client]);
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
async capture(windowId) {
|
|
296
|
+
return run(["capture-pane", "-p", "-t", windowId]);
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
187
301
|
/**
|
|
188
302
|
* The real client. A window is opened relative to the session of the pane in
|
|
189
303
|
* `TMUX_PANE`, not whatever session a client happens to have attached, so it
|
|
@@ -239,5 +353,7 @@ export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClie
|
|
|
239
353
|
async kill(windowId) {
|
|
240
354
|
await run(["kill-window", "-t", windowId]);
|
|
241
355
|
},
|
|
356
|
+
|
|
357
|
+
...navigation(run),
|
|
242
358
|
};
|
|
243
359
|
}
|