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/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 popup over
3
- // the user's tmux client, a switch of that client, a peek at the pane, or a
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: `auto` is what enter does in the panel. */
14
- export type OpenMode = "auto" | "popup" | "switch" | "peek";
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> [--popup|--switch|--peek]";
17
- const OPEN_FLAGS = ["--popup", "--switch", "--peek"] as const;
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: "open" | "switch" | "close" | "peek"; id: string } | { action: "new" } | undefined;
28
+ type PanelResult = { action: "switch" | "close" | "peek"; id: string } | { action: "new" } | undefined;
30
29
 
31
- /** What the peek hands back: open the delegate, or nothing. */
32
- type PeekResult = { action: "open" } | undefined;
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 open · p peek · s switch · x close · n new · esc leave";
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 open · esc back";
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: "open", id: item.id });
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 opens, esc returns. */
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: "open" });
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 --popup, --switch and --peek; ${OPEN_USAGE}` };
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 ? "auto" : (flag.slice(2) as OpenMode) };
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, "trackPopup" | "close"> };
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(`delegate: switched to ${item.name} (${item.window.id}) · prefix L returns you`, "info");
263
- }
264
-
265
- /** Enter's behaviour: a switch inside the parent's own session, else a popup. */
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 open it. */
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 === "open";
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 open(env, ctx, item);
315
+ await switchTo(env, ctx, item);
339
316
  return false;
340
317
  }
341
- await (result.action === "switch" ? switchTo(env, ctx, item) : open(env, ctx, item));
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 open(env, ctx, item);
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, "trackPopup" | "close">,
340
+ tracking: Pick<Tracking, "close">,
365
341
  startNew: (ctx: ExtensionContext) => Promise<void> | void,
366
342
  ): Navigator {
367
343
  const env: Env = { results, tmux, tracking };
@@ -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([agent.model, ...this.byId.keys()])].map((value) => ({
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((agent) => `${agent.name} — ${agent.description}`);
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(`Label (optional; empty for ${agent.name}-<id>)`, "letters, digits, _ or -");
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
- agent,
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, and closes the popups of windows
3
- // that went. It runs while a delegation is running or a popup is open.
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, and close the popups of gone windows. */
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
- windows = await listWindows(tmux); // one ask per tick
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; track(popup: Popup): () => void } {
56
- /** The poll timer; set only while something is running or a popup is open. */
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 || popups.count() > 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, popups, () => live);
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 when something is running or a popup is open; it never keeps the process alive. */
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
  };
@@ -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>`. */
@@ -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 { Agent } from "./agents.ts";
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
- /** 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])];
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 agent = deps.roster(ctx).agents.find((item) => item.name === record.agent);
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, request } = input;
117
- const thinking = request.thinking ?? record.thinking;
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 preferred = preferredModels(record, agent, session.path);
121
- return { thinking, chosen: await deps.chooseModel(request.model, agent, ctx.modelRegistry, preferred) };
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([record.thinking, agent.name, preferredModels(record, agent, session.path)]);
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({ description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the delegation's.` }),
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." }),