pi-squire 0.13.1 → 0.15.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 +141 -1
- package/README.md +192 -87
- package/package.json +3 -3
- package/src/agents.ts +361 -111
- package/src/child.ts +72 -21
- package/src/index.ts +243 -67
- package/src/navigator.ts +28 -52
- package/src/new-delegate.ts +17 -8
- package/src/poll.ts +10 -33
- package/src/result-sessions.ts +39 -0
- package/src/results.ts +2 -0
- package/src/resume-checks.ts +45 -12
- package/src/resume.ts +7 -2
- package/src/tmux.ts +4 -88
- package/src/tracking.ts +1 -5
- package/src/popups.ts +0 -49
package/src/navigator.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// The delegate navigator: the `/delegates` panel and `/delegate-open`. It lists
|
|
2
|
-
// the delegations whose window is open and reaches one of them: a
|
|
3
|
-
// the user's tmux client
|
|
4
|
-
// close. Everything here is a command or a key; the model gets no tool that
|
|
2
|
+
// the delegations whose window is open and reaches one of them: a switch of
|
|
3
|
+
// the user's tmux client to its window, a peek at the pane, or a close. Everything here is a command or a key; the model gets no tool that
|
|
5
4
|
// moves the user's terminal.
|
|
6
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
6
|
import { type Component, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
@@ -10,11 +9,11 @@ import { type DelegationState, deriveState, isStarted, type Results, recordedNam
|
|
|
10
9
|
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
11
10
|
import { listWindows, ownWindow, type Tracking } from "./tracking.ts";
|
|
12
11
|
|
|
13
|
-
/** What `/delegate-open` does
|
|
14
|
-
export type OpenMode = "
|
|
12
|
+
/** What `/delegate-open` does; switch is what enter does in the panel. */
|
|
13
|
+
export type OpenMode = "switch" | "peek";
|
|
15
14
|
|
|
16
|
-
export const OPEN_USAGE = "/delegate-open <id> [--
|
|
17
|
-
const OPEN_FLAGS = ["--
|
|
15
|
+
export const OPEN_USAGE = "/delegate-open <id> [--switch|--peek]";
|
|
16
|
+
const OPEN_FLAGS = ["--switch", "--peek"] as const;
|
|
18
17
|
|
|
19
18
|
/** One delegation whose window is open. */
|
|
20
19
|
type OpenDelegate = {
|
|
@@ -26,14 +25,14 @@ type OpenDelegate = {
|
|
|
26
25
|
};
|
|
27
26
|
|
|
28
27
|
/** What the panel hands back: the action the user chose on one delegation, or none. */
|
|
29
|
-
type PanelResult = { action: "
|
|
28
|
+
type PanelResult = { action: "switch" | "close" | "peek"; id: string } | { action: "new" } | undefined;
|
|
30
29
|
|
|
31
|
-
/** What the peek hands back:
|
|
32
|
-
type PeekResult = { action: "
|
|
30
|
+
/** What the peek hands back: switch to the delegate, or nothing. */
|
|
31
|
+
type PeekResult = { action: "switch" } | undefined;
|
|
33
32
|
|
|
34
|
-
const HINT = "↑↓ select · enter
|
|
33
|
+
const HINT = "↑↓ select · enter switch · p peek · x close · n new · esc leave";
|
|
35
34
|
const EMPTY = "No open delegates · n new · esc close";
|
|
36
|
-
const PEEK_HINT = "r refresh · enter
|
|
35
|
+
const PEEK_HINT = "r refresh · enter switch · esc back";
|
|
37
36
|
|
|
38
37
|
/** Where a peek shows: over the panel or the editor, not in its place. */
|
|
39
38
|
const PEEK_OPTIONS = { overlay: true, overlayOptions: { width: "90%", maxHeight: "90%", anchor: "center" } } as const;
|
|
@@ -82,9 +81,8 @@ class Panel implements Component {
|
|
|
82
81
|
else if (item === undefined) return;
|
|
83
82
|
else if (matchesKey(data, "up")) this.selected = (this.selected + count - 1) % count;
|
|
84
83
|
else if (matchesKey(data, "down")) this.selected = (this.selected + 1) % count;
|
|
85
|
-
else if (matchesKey(data, "enter")) this.done({ action: "
|
|
84
|
+
else if (matchesKey(data, "enter")) this.done({ action: "switch", id: item.id });
|
|
86
85
|
else if (matchesKey(data, "p")) this.done({ action: "peek", id: item.id });
|
|
87
|
-
else if (matchesKey(data, "s")) this.done({ action: "switch", id: item.id });
|
|
88
86
|
else if (matchesKey(data, "x")) this.done({ action: "close", id: item.id });
|
|
89
87
|
this.redraw();
|
|
90
88
|
}
|
|
@@ -101,7 +99,7 @@ class Panel implements Component {
|
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
101
|
|
|
104
|
-
/** A snapshot of one delegate's pane, shown as an overlay: `r` refreshes, enter
|
|
102
|
+
/** A snapshot of one delegate's pane, shown as an overlay: `r` refreshes, enter switches, esc returns. */
|
|
105
103
|
class PeekView implements Component {
|
|
106
104
|
private capture: Capture = { text: "", error: undefined, loading: true };
|
|
107
105
|
private readonly item: OpenDelegate;
|
|
@@ -147,7 +145,7 @@ class PeekView implements Component {
|
|
|
147
145
|
handleInput(data: string): void {
|
|
148
146
|
if (matchesKey(data, "escape")) this.done(undefined);
|
|
149
147
|
else if (matchesKey(data, "r")) this.refresh();
|
|
150
|
-
else if (matchesKey(data, "enter")) this.done({ action: "
|
|
148
|
+
else if (matchesKey(data, "enter")) this.done({ action: "switch" });
|
|
151
149
|
this.redraw();
|
|
152
150
|
}
|
|
153
151
|
|
|
@@ -176,10 +174,10 @@ export function parseOpen(text: string): { id: string; mode: OpenMode } | { erro
|
|
|
176
174
|
const ids = words.filter((word) => !word.startsWith("-"));
|
|
177
175
|
const unknown = flags.find((flag) => !(OPEN_FLAGS as readonly string[]).includes(flag));
|
|
178
176
|
if (unknown !== undefined) return { error: `unknown flag ${unknown}; ${OPEN_USAGE}` };
|
|
179
|
-
if (flags.length > 1) return { error: `give at most one of --
|
|
177
|
+
if (flags.length > 1) return { error: `give at most one of --switch and --peek; ${OPEN_USAGE}` };
|
|
180
178
|
if (ids.length !== 1) return { error: `give one delegation id; ${OPEN_USAGE}` };
|
|
181
179
|
const flag = flags[0];
|
|
182
|
-
return { id: ids[0], mode: flag === undefined ? "
|
|
180
|
+
return { id: ids[0], mode: flag === undefined ? "switch" : (flag.slice(2) as OpenMode) };
|
|
183
181
|
}
|
|
184
182
|
|
|
185
183
|
/** Completions for `/delegate-open`: ids first, then the mode flags not yet given. */
|
|
@@ -194,7 +192,7 @@ export function openCompletions(prefix: string, ids: readonly string[]): { value
|
|
|
194
192
|
return hits.length > 0 ? hits.map((value) => ({ value: head + value, label: value })) : null;
|
|
195
193
|
}
|
|
196
194
|
|
|
197
|
-
type Env = { results: Results; tmux: TmuxClient; tracking: Pick<Tracking, "
|
|
195
|
+
type Env = { results: Results; tmux: TmuxClient; tracking: Pick<Tracking, "close"> };
|
|
198
196
|
|
|
199
197
|
/** The open delegations, newest first, from the records and one listing; undefined when tmux cannot be asked. */
|
|
200
198
|
async function openDelegates({ results, tmux }: Env): Promise<OpenDelegate[] | undefined> {
|
|
@@ -207,7 +205,7 @@ async function openDelegates({ results, tmux }: Env): Promise<OpenDelegate[] | u
|
|
|
207
205
|
if (window === undefined || window.exited) continue;
|
|
208
206
|
const state = deriveState(record, "open");
|
|
209
207
|
if (state !== "running" && state !== "done") continue;
|
|
210
|
-
found.push({ id: record.id, name: recordedName(record), agent: record.agent, state, window });
|
|
208
|
+
found.push({ id: record.id, name: recordedName(record), agent: record.displayName ?? record.agent, state, window });
|
|
211
209
|
}
|
|
212
210
|
return found.reverse();
|
|
213
211
|
}
|
|
@@ -233,24 +231,6 @@ async function clientOf(tmux: TmuxClient): Promise<{ client: string } | { refusa
|
|
|
233
231
|
}
|
|
234
232
|
}
|
|
235
233
|
|
|
236
|
-
async function popup(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
|
|
237
|
-
const found = await clientOf(env.tmux);
|
|
238
|
-
if ("refusal" in found) return ctx.ui.notify(`delegate: cannot open a popup: ${found.refusal}`, "warning");
|
|
239
|
-
// Tracked before it launches, so a popup that closes at once is forgotten too.
|
|
240
|
-
const forget = env.tracking.trackPopup({
|
|
241
|
-
windowId: item.window.id,
|
|
242
|
-
windowName: item.window.name,
|
|
243
|
-
client: found.client,
|
|
244
|
-
});
|
|
245
|
-
try {
|
|
246
|
-
// Resolves once the popup is up, not when it closes; `forget` runs when it closes.
|
|
247
|
-
await env.tmux.popup(found.client, { id: item.window.id, name: item.window.name }, forget);
|
|
248
|
-
} catch (err) {
|
|
249
|
-
forget();
|
|
250
|
-
return ctx.ui.notify(`delegate: could not open a popup: ${(err as Error).message}`, "error");
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
234
|
async function switchTo(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
|
|
255
235
|
const found = await clientOf(env.tmux);
|
|
256
236
|
if ("refusal" in found) return ctx.ui.notify(`delegate: cannot switch: ${found.refusal}`, "warning");
|
|
@@ -259,13 +239,10 @@ async function switchTo(env: Env, ctx: ExtensionContext, item: OpenDelegate): Pr
|
|
|
259
239
|
} catch (err) {
|
|
260
240
|
return ctx.ui.notify(`delegate: could not switch: ${(err as Error).message}`, "error");
|
|
261
241
|
}
|
|
262
|
-
ctx.ui.notify(
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
async function open(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
|
|
267
|
-
const own = await ownSessionName(env.tmux);
|
|
268
|
-
return own !== undefined && own === item.window.session ? switchTo(env, ctx, item) : popup(env, ctx, item);
|
|
242
|
+
ctx.ui.notify(
|
|
243
|
+
`delegate: switched to ${item.name} (${item.window.id}) · your tmux last-session key returns you`,
|
|
244
|
+
"info",
|
|
245
|
+
);
|
|
269
246
|
}
|
|
270
247
|
|
|
271
248
|
/** Close through the `delegation_close` path, asking first only while the delegate runs. */
|
|
@@ -294,7 +271,7 @@ function show(ctx: ExtensionContext, items: readonly OpenDelegate[]): Promise<Pa
|
|
|
294
271
|
);
|
|
295
272
|
}
|
|
296
273
|
|
|
297
|
-
/** Peek at a delegate's pane in an overlay; true when the user chose to
|
|
274
|
+
/** Peek at a delegate's pane in an overlay; true when the user chose to switch to it. */
|
|
298
275
|
async function peek(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<boolean> {
|
|
299
276
|
const result = await ctx.ui.custom<PeekResult>(
|
|
300
277
|
(tui, theme, _keybindings, done): PeekView =>
|
|
@@ -308,7 +285,7 @@ async function peek(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promis
|
|
|
308
285
|
}),
|
|
309
286
|
PEEK_OPTIONS,
|
|
310
287
|
);
|
|
311
|
-
return result?.action === "
|
|
288
|
+
return result?.action === "switch";
|
|
312
289
|
}
|
|
313
290
|
|
|
314
291
|
const noUi = (ctx: ExtensionContext): void => ctx.ui.notify("delegate: this needs an interactive session", "error");
|
|
@@ -335,10 +312,10 @@ async function act(
|
|
|
335
312
|
}
|
|
336
313
|
if (result.action === "peek") {
|
|
337
314
|
if (!(await peek(env, ctx, item))) return true; // back to the panel
|
|
338
|
-
await
|
|
315
|
+
await switchTo(env, ctx, item);
|
|
339
316
|
return false;
|
|
340
317
|
}
|
|
341
|
-
await
|
|
318
|
+
await switchTo(env, ctx, item);
|
|
342
319
|
return false;
|
|
343
320
|
}
|
|
344
321
|
|
|
@@ -352,16 +329,15 @@ async function openOne(env: Env, ctx: ExtensionContext, id: string, mode: OpenMo
|
|
|
352
329
|
return ctx.ui.notify(`delegate: no open delegate ${JSON.stringify(id)}; open: ${known}`, "error");
|
|
353
330
|
}
|
|
354
331
|
if (mode === "switch") return switchTo(env, ctx, item);
|
|
355
|
-
if (mode !== "peek") return open(env, ctx, item); // --popup follows enter's rule too
|
|
356
332
|
if (!ctx.hasUI) return noUi(ctx);
|
|
357
|
-
if (await peek(env, ctx, item)) await
|
|
333
|
+
if (await peek(env, ctx, item)) await switchTo(env, ctx, item);
|
|
358
334
|
}
|
|
359
335
|
|
|
360
336
|
/** The navigator over the records, tmux and tracking. `startNew` is the new-delegate flow, wired by the entry. */
|
|
361
337
|
export function createNavigator(
|
|
362
338
|
results: Results,
|
|
363
339
|
tmux: TmuxClient,
|
|
364
|
-
tracking: Pick<Tracking, "
|
|
340
|
+
tracking: Pick<Tracking, "close">,
|
|
365
341
|
startNew: (ctx: ExtensionContext) => Promise<void> | void,
|
|
366
342
|
): Navigator {
|
|
367
343
|
const env: Env = { results, tmux, tracking };
|
package/src/new-delegate.ts
CHANGED
|
@@ -47,6 +47,8 @@ export type NewDelegateDeps = {
|
|
|
47
47
|
roster(ctx: ExtensionContext): Roster;
|
|
48
48
|
/** The refusal text for an empty roster. */
|
|
49
49
|
noAgents(): string;
|
|
50
|
+
/** The model and thinking `agent` runs on when the request names none: its own, else the parent's. */
|
|
51
|
+
defaults(agent: Agent, ctx: ExtensionContext): { model?: string; thinking: string };
|
|
50
52
|
/** Why `value` is not a usable label, or undefined; the check `--label` gets. */
|
|
51
53
|
labelProblem(value: string): string | undefined;
|
|
52
54
|
/** Start the delegate as `/delegate` would, notifying the start or the refusal. */
|
|
@@ -207,12 +209,12 @@ export class SettingsForm implements Component {
|
|
|
207
209
|
this.options = options;
|
|
208
210
|
const { agent, models, style } = options;
|
|
209
211
|
for (const model of models) this.byId.set(`${model.provider}/${model.id}`, model);
|
|
210
|
-
this.model = agent.model;
|
|
211
|
-
this.thinking = agent.thinking;
|
|
212
|
+
this.model = agent.model ?? "";
|
|
213
|
+
this.thinking = agent.thinking ?? "";
|
|
212
214
|
this.autoExit = agent.autoExit;
|
|
213
215
|
this.cwd = options.cwd;
|
|
214
216
|
this.clampInitialThinking();
|
|
215
|
-
const modelItems: SelectItem[] = [...new Set([
|
|
217
|
+
const modelItems: SelectItem[] = [...new Set([this.model, ...this.byId.keys()])].map((value) => ({
|
|
216
218
|
value,
|
|
217
219
|
label: value,
|
|
218
220
|
}));
|
|
@@ -360,8 +362,8 @@ export class SettingsForm implements Component {
|
|
|
360
362
|
return;
|
|
361
363
|
}
|
|
362
364
|
this.options.done({
|
|
363
|
-
...(this.model === this.options.agent.model ? {} : { model: this.model }),
|
|
364
|
-
...(this.thinking === this.options.agent.thinking ? {} : { thinking: this.thinking }),
|
|
365
|
+
...(this.model === (this.options.agent.model ?? "") ? {} : { model: this.model }),
|
|
366
|
+
...(this.thinking === (this.options.agent.thinking ?? "") ? {} : { thinking: this.thinking }),
|
|
365
367
|
...(this.autoExit === this.options.agent.autoExit ? {} : { autoExit: this.autoExit }),
|
|
366
368
|
...(this.cwd === this.options.cwd ? {} : { cwd: this.cwd }),
|
|
367
369
|
...(this.worktree === "" ? {} : { worktree: this.worktree }),
|
|
@@ -410,14 +412,20 @@ export async function startNewDelegate(ctx: ExtensionContext, deps: NewDelegateD
|
|
|
410
412
|
if (unavailable !== undefined) return fail(unavailable);
|
|
411
413
|
const roster = deps.roster(ctx);
|
|
412
414
|
if (roster.agents.length === 0) return fail(deps.noAgents());
|
|
413
|
-
const options = roster.agents.map(
|
|
415
|
+
const options = roster.agents.map(
|
|
416
|
+
(agent) =>
|
|
417
|
+
`${agent.name}${agent.displayName === undefined ? "" : ` (${agent.displayName})`} — ${agent.description}`,
|
|
418
|
+
);
|
|
414
419
|
const picked = await ctx.ui.select("New delegate: agent", options);
|
|
415
420
|
const agent = roster.agents[options.indexOf(picked ?? "")];
|
|
416
421
|
if (agent === undefined) return;
|
|
417
422
|
const task = await ctx.ui.editor(`Task for ${agent.name}`);
|
|
418
423
|
if (task === undefined) return;
|
|
419
424
|
if (task.trim() === "") return fail("a task is required");
|
|
420
|
-
const typed = await ctx.ui.input(
|
|
425
|
+
const typed = await ctx.ui.input(
|
|
426
|
+
`Label (optional; empty for ${agent.displayName ?? agent.name}-<id>)`,
|
|
427
|
+
"letters, digits, _ or -",
|
|
428
|
+
);
|
|
421
429
|
if (typed === undefined) return;
|
|
422
430
|
const label = typed.trim() === "" ? undefined : typed.trim();
|
|
423
431
|
const badLabel = label === undefined ? undefined : deps.labelProblem(label);
|
|
@@ -425,7 +433,8 @@ export async function startNewDelegate(ctx: ExtensionContext, deps: NewDelegateD
|
|
|
425
433
|
const chosen = await ctx.ui.custom<FormResult | undefined>(
|
|
426
434
|
(_tui, theme, _keys, done): Component =>
|
|
427
435
|
new SettingsForm({
|
|
428
|
-
|
|
436
|
+
// The rows start at what a request naming nothing runs on.
|
|
437
|
+
agent: { ...agent, ...deps.defaults(agent, ctx) } as Agent,
|
|
429
438
|
models: ctx.modelRegistry.getAvailable() as Model<never>[],
|
|
430
439
|
cwd: ctx.cwd,
|
|
431
440
|
style: theme,
|
package/src/poll.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// The poll: every few seconds the parent checks its running delegations,
|
|
2
|
-
// declares gone those whose window is gone
|
|
3
|
-
//
|
|
2
|
+
// declares gone those whose window is gone. It runs only while a delegation is
|
|
3
|
+
// running.
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
|
-
import { createPopups, type Popup, type Popups } from "./popups.ts";
|
|
7
6
|
import type { Results } from "./results.ts";
|
|
8
7
|
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
9
8
|
import { listWindows, windowState } from "./windows.ts";
|
|
@@ -24,27 +23,16 @@ function declareGone(pi: ExtensionAPI, results: Results, windows: TmuxWindow[] |
|
|
|
24
23
|
}
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
/** One sweep: claim waiting replies, declare gone every delegation whose window is missing or exited with still no result
|
|
28
|
-
async function sweep(
|
|
29
|
-
pi: ExtensionAPI,
|
|
30
|
-
results: Results,
|
|
31
|
-
tmux: TmuxClient,
|
|
32
|
-
popups: Popups,
|
|
33
|
-
live: () => boolean,
|
|
34
|
-
): Promise<void> {
|
|
35
|
-
let windows: TmuxWindow[] | undefined;
|
|
36
|
-
let asked = false;
|
|
26
|
+
/** One sweep: claim waiting replies, declare gone every delegation whose window is missing or exited with still no result. */
|
|
27
|
+
async function sweep(pi: ExtensionAPI, results: Results, tmux: TmuxClient, live: () => boolean): Promise<void> {
|
|
37
28
|
if (results.running().length > 0) {
|
|
38
29
|
const scan: { scanned?: boolean } = {};
|
|
39
30
|
pi.events.emit(SCAN, scan);
|
|
40
31
|
// No message:* provider: a waiting reply cannot be ruled out, so nothing is declared gone.
|
|
41
32
|
if (scan.scanned === true) {
|
|
42
|
-
|
|
43
|
-
asked = true;
|
|
44
|
-
declareGone(pi, results, windows, live);
|
|
33
|
+
declareGone(pi, results, await listWindows(tmux), live); // one ask per tick
|
|
45
34
|
}
|
|
46
35
|
}
|
|
47
|
-
if (popups.count() > 0) await popups.closeGone(asked ? windows : await listWindows(tmux));
|
|
48
36
|
}
|
|
49
37
|
|
|
50
38
|
/** The poll over the records and tmux. `arm` readies it for a new session; `stop` ends it for good. */
|
|
@@ -52,13 +40,12 @@ export function createPoll(
|
|
|
52
40
|
pi: ExtensionAPI,
|
|
53
41
|
results: Results,
|
|
54
42
|
tmux: TmuxClient,
|
|
55
|
-
): { start(): void; stop(): void; arm(): void
|
|
56
|
-
/** The poll timer; set only while
|
|
43
|
+
): { start(): void; stop(): void; arm(): void } {
|
|
44
|
+
/** The poll timer; set only while a delegation is running. */
|
|
57
45
|
let poll: ReturnType<typeof setInterval> | undefined;
|
|
58
46
|
let ticking = false;
|
|
59
47
|
/** False once the session shuts down, so a tick still awaiting tmux declares nothing. */
|
|
60
48
|
let live = true;
|
|
61
|
-
const popups = createPopups(tmux);
|
|
62
49
|
|
|
63
50
|
function stopPolling(): void {
|
|
64
51
|
if (poll !== undefined) clearInterval(poll);
|
|
@@ -66,21 +53,21 @@ export function createPoll(
|
|
|
66
53
|
}
|
|
67
54
|
|
|
68
55
|
/** Whether the poll has anything left to watch. */
|
|
69
|
-
const busy = (): boolean => results.running().length > 0
|
|
56
|
+
const busy = (): boolean => results.running().length > 0;
|
|
70
57
|
|
|
71
58
|
async function tick(): Promise<void> {
|
|
72
59
|
if (ticking) return;
|
|
73
60
|
ticking = true;
|
|
74
61
|
try {
|
|
75
62
|
if (!busy()) return stopPolling();
|
|
76
|
-
await sweep(pi, results, tmux,
|
|
63
|
+
await sweep(pi, results, tmux, () => live);
|
|
77
64
|
if (!busy()) stopPolling();
|
|
78
65
|
} finally {
|
|
79
66
|
ticking = false;
|
|
80
67
|
}
|
|
81
68
|
}
|
|
82
69
|
|
|
83
|
-
/** Start the timer
|
|
70
|
+
/** Start the timer while a delegation is running; it never keeps the process alive. */
|
|
84
71
|
function startPolling(): void {
|
|
85
72
|
if (poll !== undefined || !busy()) return;
|
|
86
73
|
poll = setInterval(() => void tick(), POLL_MS);
|
|
@@ -89,22 +76,12 @@ export function createPoll(
|
|
|
89
76
|
|
|
90
77
|
return {
|
|
91
78
|
start: startPolling,
|
|
92
|
-
track(popup: Popup): () => void {
|
|
93
|
-
const forget = popups.track(popup);
|
|
94
|
-
startPolling();
|
|
95
|
-
return (): void => {
|
|
96
|
-
forget();
|
|
97
|
-
if (!busy()) stopPolling();
|
|
98
|
-
};
|
|
99
|
-
},
|
|
100
79
|
stop(): void {
|
|
101
80
|
live = false;
|
|
102
81
|
stopPolling();
|
|
103
|
-
popups.clear();
|
|
104
82
|
},
|
|
105
83
|
arm(): void {
|
|
106
84
|
stopPolling();
|
|
107
|
-
popups.clear();
|
|
108
85
|
live = true;
|
|
109
86
|
},
|
|
110
87
|
};
|
package/src/result-sessions.ts
CHANGED
|
@@ -77,3 +77,42 @@ export function finishedModel(sessionPath: string | undefined): string | undefin
|
|
|
77
77
|
}
|
|
78
78
|
return undefined;
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
/** One session-file line, as much of it as the branch walk reads. */
|
|
82
|
+
type BranchEntry = { type?: unknown; id?: unknown; parentId?: unknown; thinkingLevel?: unknown };
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The thinking level a delegate's session last changed to on its active
|
|
86
|
+
* branch: Pi records one `thinking_level_change` when a session starts and
|
|
87
|
+
* one per later change, the clamp to a new model's levels included. The
|
|
88
|
+
* branch is the leaf, Pi's last entry, back through each `parentId`.
|
|
89
|
+
* Undefined when the file cannot be read or holds no change on that branch.
|
|
90
|
+
*/
|
|
91
|
+
export function sessionThinking(sessionPath: string): string | undefined {
|
|
92
|
+
let lines: string[];
|
|
93
|
+
try {
|
|
94
|
+
lines = readFileSync(sessionPath, "utf8").split("\n");
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const byId = new Map<string, BranchEntry>();
|
|
99
|
+
let leaf: BranchEntry | undefined;
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
let entry: BranchEntry;
|
|
102
|
+
try {
|
|
103
|
+
entry = JSON.parse(line);
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (entry?.type === "session" || typeof entry?.id !== "string") continue;
|
|
108
|
+
byId.set(entry.id, entry);
|
|
109
|
+
leaf = entry;
|
|
110
|
+
}
|
|
111
|
+
const seen = new Set<BranchEntry>();
|
|
112
|
+
for (let entry = leaf; entry !== undefined && !seen.has(entry); ) {
|
|
113
|
+
seen.add(entry);
|
|
114
|
+
if (entry.type === "thinking_level_change" && typeof entry.thinkingLevel === "string") return entry.thinkingLevel;
|
|
115
|
+
entry = typeof entry.parentId === "string" ? byId.get(entry.parentId) : undefined;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
package/src/results.ts
CHANGED
|
@@ -35,6 +35,8 @@ const INBOUND = "message:inbound";
|
|
|
35
35
|
export type Delegation = {
|
|
36
36
|
id: string;
|
|
37
37
|
agent: string;
|
|
38
|
+
/** The agent's `display_name` at launch, shown instead of `agent`; absent when it had none. */
|
|
39
|
+
displayName?: string;
|
|
38
40
|
model: string;
|
|
39
41
|
thinking: string;
|
|
40
42
|
/** The delegate's session and window name; absent in records from before labels, where it was `<agent>-<8 id characters>`. */
|
package/src/resume-checks.ts
CHANGED
|
@@ -8,8 +8,8 @@ import { isAbsolute } from "node:path";
|
|
|
8
8
|
|
|
9
9
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
|
|
11
|
-
import type
|
|
12
|
-
import { sessionHeaderCwd, sessionIdOf } from "./result-sessions.ts";
|
|
11
|
+
import { type Agent, disabledProblem, isThinking, withoutLocked } from "./agents.ts";
|
|
12
|
+
import { sessionHeaderCwd, sessionIdOf, sessionThinking } from "./result-sessions.ts";
|
|
13
13
|
import { deriveState, finishedModel, locateSession, type StartedRecorded } from "./results.ts";
|
|
14
14
|
import type { ResumeDeps, ResumeRequest } from "./resume.ts";
|
|
15
15
|
import { requireTmuxVersion, type TmuxWindow } from "./tmux.ts";
|
|
@@ -79,10 +79,35 @@ export function sessionFor(record: StartedRecorded, ctx: ExtensionContext): Sess
|
|
|
79
79
|
return { path, cwd, dir };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
/**
|
|
83
|
+
* What a resume runs with when the call passes nothing. A field the agent
|
|
84
|
+
* locks takes the agent file's current value, or the parent's current one when
|
|
85
|
+
* the file sets none, even when the delegate last ran on another (a fallback
|
|
86
|
+
* after a usage limit, or a value from before the file changed). A field it
|
|
87
|
+
* does not lock keeps what the delegate last ran on: the model of its last
|
|
88
|
+
* answer, and the thinking level its session last changed to, or the
|
|
89
|
+
* recorded ones when the session does not say. The models are in order, the
|
|
90
|
+
* agent's fallbacks after the first.
|
|
91
|
+
*/
|
|
92
|
+
export function resumeDefaults(
|
|
93
|
+
record: StartedRecorded,
|
|
94
|
+
agent: Agent,
|
|
95
|
+
sessionPath: string,
|
|
96
|
+
parent: { model?: string; thinking: string },
|
|
97
|
+
): { models: string[]; thinking: string } {
|
|
98
|
+
const first = agent.locked.includes("model")
|
|
99
|
+
? (agent.model ?? parent.model)
|
|
100
|
+
: (finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model);
|
|
101
|
+
const thinking = agent.locked.includes("thinking")
|
|
102
|
+
? (agent.thinking ?? parent.thinking)
|
|
103
|
+
: lastThinking(record, sessionPath);
|
|
104
|
+
return { models: [...new Set([...(first === undefined ? [] : [first]), ...agent.fallback])], thinking };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The thinking level the delegate last ran on: its session's last change, else the level recorded at its start. */
|
|
108
|
+
function lastThinking(record: StartedRecorded, sessionPath: string): string {
|
|
109
|
+
const level = sessionThinking(sessionPath);
|
|
110
|
+
return level !== undefined && isThinking(level) ? level : record.thinking;
|
|
86
111
|
}
|
|
87
112
|
|
|
88
113
|
/** Steps 5 and 6: the session file and the agent, and no resume of the delegation already under way. */
|
|
@@ -92,7 +117,10 @@ export function recordCheck(
|
|
|
92
117
|
ctx: ExtensionContext,
|
|
93
118
|
): { session: SessionFile; agent: Agent } {
|
|
94
119
|
const session = sessionFor(record, ctx);
|
|
95
|
-
const
|
|
120
|
+
const roster = deps.roster(ctx);
|
|
121
|
+
const disabled = disabledProblem(roster, record.agent);
|
|
122
|
+
if (disabled !== undefined) throw new Error(`${disabled}, so delegation ${record.id} is not resumed`);
|
|
123
|
+
const agent = roster.agents.find((item) => item.name === record.agent);
|
|
96
124
|
if (agent === undefined) {
|
|
97
125
|
throw new Error(
|
|
98
126
|
`agent ${JSON.stringify(record.agent)} is no longer in the roster, so delegation ${record.id} is not resumed`,
|
|
@@ -113,12 +141,17 @@ export async function settleModel(
|
|
|
113
141
|
},
|
|
114
142
|
ctx: ExtensionContext,
|
|
115
143
|
): Promise<{ thinking: string; chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>> }> {
|
|
116
|
-
const { record, agent, session
|
|
117
|
-
const
|
|
144
|
+
const { record, agent, session } = input;
|
|
145
|
+
const defaults = resumeDefaults(record, agent, session.path, deps.parentDefaults(ctx));
|
|
146
|
+
const { request, notice } = withoutLocked(agent, input.request, {
|
|
147
|
+
model: defaults.models[0],
|
|
148
|
+
thinking: defaults.thinking,
|
|
149
|
+
});
|
|
150
|
+
const thinking = request.thinking ?? defaults.thinking;
|
|
118
151
|
const badThinking = deps.thinkingProblem(thinking);
|
|
119
152
|
if (badThinking !== undefined) throw new Error(badThinking);
|
|
120
|
-
const
|
|
121
|
-
return { thinking, chosen:
|
|
153
|
+
const chosen = await deps.chooseModel(request.model, agent, ctx.modelRegistry, defaults.models);
|
|
154
|
+
return { thinking, chosen: notice === undefined ? chosen : { ...chosen, notices: [notice, ...chosen.notices] } };
|
|
122
155
|
}
|
|
123
156
|
|
|
124
157
|
/** The call-wide prerequisites of a resume: inside tmux, a new enough tmux, a valid placement setting (read once). */
|
|
@@ -143,7 +176,7 @@ export async function passes(
|
|
|
143
176
|
windowCheck(record, windows);
|
|
144
177
|
const { session, agent } = recordCheck(deps, record, ctx);
|
|
145
178
|
// Model choice depends on the agent and where the delegate ended, so records alike share one answer.
|
|
146
|
-
const key = JSON.stringify([
|
|
179
|
+
const key = JSON.stringify([agent.name, resumeDefaults(record, agent, session.path, deps.parentDefaults(ctx))]);
|
|
147
180
|
let ok = models.get(key);
|
|
148
181
|
if (ok === undefined) {
|
|
149
182
|
ok = await settleModel(deps, { record, agent, session, request: {} }, ctx).then(
|
package/src/resume.ts
CHANGED
|
@@ -52,6 +52,8 @@ export type ResumeDeps = {
|
|
|
52
52
|
/** The window placement for this call; throws on an invalid setting. */
|
|
53
53
|
placement(): WindowPlacement;
|
|
54
54
|
roster(ctx: ExtensionContext): Roster;
|
|
55
|
+
/** The parent's current model and thinking level, which a field the agent locks but does not set keeps. */
|
|
56
|
+
parentDefaults(ctx: ExtensionContext): { model?: string; thinking: string };
|
|
55
57
|
chooseModel(
|
|
56
58
|
explicit: string | undefined,
|
|
57
59
|
agent: Agent,
|
|
@@ -243,6 +245,7 @@ async function startResume(
|
|
|
243
245
|
{
|
|
244
246
|
id,
|
|
245
247
|
agent: start.agent.name,
|
|
248
|
+
...(start.agent.displayName === undefined ? {} : { displayName: start.agent.displayName }),
|
|
246
249
|
model: start.model,
|
|
247
250
|
thinking: start.thinking,
|
|
248
251
|
name,
|
|
@@ -348,11 +351,13 @@ function resumeTool(deps: ResumeDeps): ToolDef {
|
|
|
348
351
|
model: Type.Optional(
|
|
349
352
|
Type.String({
|
|
350
353
|
description:
|
|
351
|
-
"provider/id to resume on; used as given. Omit to continue on the model the delegate finished on, or its agent's next fallback when that one is unavailable.",
|
|
354
|
+
"provider/id to resume on; used as given. Omit to continue on the model the delegate finished on, or its agent's next fallback when that one is unavailable. Not used when the agent locks model: the agent's current model applies.",
|
|
352
355
|
}),
|
|
353
356
|
),
|
|
354
357
|
thinking: Type.Optional(
|
|
355
|
-
Type.String({
|
|
358
|
+
Type.String({
|
|
359
|
+
description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the level the delegate last ran on, from its session. Not used when the agent locks thinking: the agent's current level applies.`,
|
|
360
|
+
}),
|
|
356
361
|
),
|
|
357
362
|
auto_exit: Type.Optional(
|
|
358
363
|
Type.Boolean({ description: "Close the window once it finishes; defaults to the delegation's setting." }),
|