@grrrck/pi-clear 0.3.0 → 0.4.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +77 -12
  3. package/index.ts +913 -59
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-10-30
9
+
10
+ ### Added
11
+
12
+ - `/hand-off` asks **who writes the hand-off prompt** before it picks the destination model: *This agent — full context and tools* (the existing behavior, first in the list so Enter keeps it), *Fresh pass — current model*, or *Fresh pass — another model…*.
13
+ - A **fresh pass** writer: one out-of-band `modelRegistry.complete()` call over a minimal reconstruction of the conversation (user text, assistant text, one-line tool-call hints, file-operation footer; no tool output, no thinking) that can use a cheap model and does not inherit the session's context window. It has no tool access.
14
+ - Fresh-pass transcript budget keeps the opening message and the newest turns, elides the middle with a `[... <n> turns omitted — <k> chars ...]` marker, and notifies how much was dropped. Default `200000` characters; `0` disables it.
15
+ - The fresh pass asks for the writer model's thinking level, with the provider default preselected; models without reasoning controls skip the dialog. A standing preference for it comes from `handoff.summarizerThinkingLevel` in the config file.
16
+ - Command flags `--fresh` and `--live`: parsed off the front of the command args, with the rest still pre-filling the steering editor. Passing both is refused before any dialog. There is no command-arg model flag — the model that writes the prompt comes from the config file or the picker.
17
+ - Optional settings in `<agent-dir>/pi-clear.json` (`handoff.summarizerModel`, `handoff.askSummarizerModel`, `handoff.summarizerThinkingLevel`, `handoff.maxSourceChars`), read once per session; a malformed file warns and is ignored.
18
+ - CLI flags `--hand-off-summarizer=<provider/model>` and `--hand-off-max-source-chars=<n>`.
19
+ - Every hand-off prompt, from either writer, now starts with a provenance comment naming the source session: `<!-- Handed off from pi session <id> -->`. It is added by the extension after the prompt is written, so the writing model cannot reword it.
20
+ - `scripts/minimal-transcript.test.mjs`: offline tests for the fresh-pass serializer, budget, file lists, provenance, and command-arg parsing.
21
+ - `scripts/fresh-pass-flow.test.mjs`: offline tests for the whole `/hand-off` flow with mocked pi APIs — both writers, the writer thinking picker and its config preference, trimming, rejection, and the flag conflict.
22
+
23
+ ### Changed
24
+
25
+ - `scripts/rpc-handoff-smoke-test.py` scripts the new writer picker and adds a `/hand-off --fresh` scenario that asserts no agent turn starts and that the provenance comment carries the previous session id.
26
+
27
+ ## [0.3.1] - 2026-10-27
28
+
29
+ ### Fixed
30
+
31
+ - Improved `/hand-off` model search to match providers, model IDs, and names.
32
+
8
33
  ## [0.3.0] - 2026-10-22
9
34
 
10
35
  ### Added
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  A [pi](https://github.com/earendil-works/pi) extension with two commands:
4
4
 
5
5
  - `/clear` — start a fresh session **without losing your model**
6
- - `/hand-off` — hand the conversation to another model with a prompt written by the current agent
6
+ - `/hand-off` — hand the conversation to another model with a prompt written by the current agent, or by a cheap one-off "fresh pass"
7
7
 
8
8
  ## Why?
9
9
 
@@ -29,7 +29,7 @@ pi -e npm:@grrrck/pi-clear
29
29
 
30
30
  ```text
31
31
  /clear
32
- /hand-off [steering instructions]
32
+ /hand-off [--fresh | --live] [steering instructions]
33
33
  ```
34
34
 
35
35
  ## `/clear` behavior
@@ -48,17 +48,77 @@ Because the model is applied with `pi.setModel()`, it's recorded in the new sess
48
48
 
49
49
  When `/hand-off` runs, the extension:
50
50
 
51
+ 0. **chooses who writes the hand-off prompt** — see [Choosing the writer](#choosing-the-writer). The picker is skipped when `--fresh` or `--live` was passed
51
52
  1. waits for the agent to become idle if needed
52
- 2. opens a model picker, then a thinking level picker, then a steering editor pre-filled with any text you typed after `/hand-off`. Defaults are sensible: the current model and thinking level are preselected, so pressing Enter at each step keeps them. The model picker respects your session's model scoping (`--models` / `enabledModels`, the same set `/scoped-models` shows) and offers a Tab toggle between scoped and all models. In non-TUI modes it falls back to plain dialogs: the scoped list when configured, otherwise a two-level provider → model pick so the dialog never floods the screen
53
- 3. sends the current agent a message asking it to write its **next message as the hand-off prompt** — instructions to the next agent, with no preamble or postamble
54
- 4. captures that final assistant message, starts a new session, applies the model and thinking level you picked, and inserts the hand-off prompt into the editor via `ctx.ui.setEditorText()`
53
+ 2. opens a model picker, then a thinking level picker, then a steering editor pre-filled with any text you typed after `/hand-off` (and its flags). Defaults are sensible: the current model and thinking level are preselected, so pressing Enter at each step keeps them. The model picker respects your session's model scoping (`--models` / `enabledModels`, the same set `/scoped-models` shows) and offers a Tab toggle between scoped and all models. In non-TUI modes it falls back to plain dialogs: the scoped list when configured, otherwise a two-level provider → model pick so the dialog never floods the screen
54
+ 3. writes the hand-off prompt with the chosen writer — the live agent, or a single off-screen model call over a minimal reconstruction of the conversation
55
+ 4. prepends a provenance comment naming the source session, starts a new session, applies the model and thinking level you picked, and inserts the prompt into the editor via `ctx.ui.setEditorText()`
55
56
  5. stops there: the prompt sits in the editor for you to review, edit, and submit yourself
56
57
 
57
- Cancelling any dialog aborts the hand-off before anything is sent to the agent. If the agent doesn't produce a hand-off prompt, the extension stays in the current session and reports an error.
58
+ Cancelling any dialog aborts the hand-off before anything is sent. If the writer doesn't produce a prompt, the extension stays in the current session and reports why.
58
59
 
59
- The `/hand-off` request is recorded in the current session's transcript, and the hand-off prompt sits in the new session's editor — it becomes the first user message once you review and submit it.
60
+ On the live-agent path the request itself is recorded in the current session's transcript; either way, the hand-off prompt sits in the new session's editor — it becomes the first user message once you review and submit it.
60
61
 
61
- Two limitations worth knowing: if you queue another message while the agent is writing the hand-off prompt, the capture may pick up the wrong response — avoid queueing during generation. And `/clear` and `/hand-off` guard against running at the same time.
62
+ Two limitations worth knowing: on the live-agent path, if you queue another message while the agent is writing the hand-off prompt, the capture may pick up the wrong response — avoid queueing during generation. And `/clear` and `/hand-off` guard against running at the same time.
63
+
64
+ ### Choosing the writer
65
+
66
+ There is deliberately no configured default: which writer to use depends on the conversation in front of you, so `/hand-off` asks every time.
67
+
68
+ | Writer | How it writes the prompt |
69
+ |---|---|
70
+ | **This agent — full context and tools** (first, so Enter keeps the old behavior) | The current agent writes its next message as the hand-off prompt. It sees the whole transcript and keeps its tools. |
71
+ | **Fresh pass — current model** | One out-of-band `modelRegistry.complete()` call over a *minimal reconstruction* of the conversation: your messages, the assistant's visible text, and one-line hints for tool calls. No tool output, no thinking, and a head+tail character budget. **No tool access.** |
72
+ | **Fresh pass — another model…** | Same, but you pick the model that writes the prompt first (a cheap, fast model is a good choice). |
73
+
74
+ The fresh pass exists because the live agent's prompt inherits the session's whole context window: an old or long conversation may no longer fit, and a session whose model lost auth can still be handed off. Because it never reads tool output, it is told to have the next agent verify anything it cannot see in the transcript.
75
+
76
+ When the writer model supports reasoning, the fresh pass also asks for its **thinking level**, with **provider default** preselected — so pressing Enter passes straight through. Set a standing preference with `handoff.summarizerThinkingLevel` in the config file; the picker then preselects that level instead, and the level is passed to the model as `reasoning`. Models without reasoning controls skip the dialog entirely.
77
+
78
+ Fresh pass flags and limits:
79
+
80
+ - `--fresh` skips the writer picker and uses the fresh pass; `--live` skips it and uses the current agent. Passing both is refused before any dialog opens.
81
+ - There is no command-arg model flag: if you want a specific model to write the prompt, set it in the [config file](#configuration) (or pick it from the menu).
82
+ - The source is the post-compaction view of the session, so a compacted conversation is summarized from its summary rather than from nothing.
83
+ - Trimming keeps the opening message, the newest turns, and a contiguous run after the opening message; the middle is replaced by `[... <n> turns omitted — <k> chars ...]` and you get a notification saying how much was dropped. Raise the budget with the settings below if that happens.
84
+
85
+ ### Session provenance
86
+
87
+ Every hand-off prompt (from either writer) starts with a comment naming the session it came from:
88
+
89
+ ```markdown
90
+ <!-- Handed off from pi session 01a18da2-4eff-73b9-846d-5fd4c156e52e -->
91
+ ```
92
+
93
+ It's a comment, so it doesn't read as part of the task and stays visible in the editor for you to check. The extension adds it after the prompt is written, so the writing model can't reword or drop it.
94
+
95
+ ## Configuration
96
+
97
+ Optional settings live in `<agent-dir>/pi-clear.json` (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`), read once per session:
98
+
99
+ ```json
100
+ {
101
+ "handoff": {
102
+ "summarizerModel": "anthropic/claude-haiku-4-5",
103
+ "askSummarizerModel": true,
104
+ "summarizerThinkingLevel": "low",
105
+ "maxSourceChars": 200000
106
+ }
107
+ }
108
+ ```
109
+
110
+ - `summarizerModel` — preselected in the writer-model picker, and offered as its own `Fresh pass — <model>` entry in the writer picker.
111
+ - `askSummarizerModel: false` — with `summarizerModel` set, skip the writer-model picker entirely and always use that model.
112
+ - `summarizerThinkingLevel` — preselected in the writer's thinking-level picker: `"default"` (or `"off"`) for the provider default, otherwise `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. The dialog stays available either way.
113
+ - `maxSourceChars` — characters of conversation sent to the fresh pass (default `200000`, about 50k tokens). `0` disables the budget.
114
+
115
+ A malformed file is reported as a warning and ignored. The same knobs are available per pi launch as CLI flags:
116
+
117
+ ```sh
118
+ pi --hand-off-summarizer=anthropic/claude-haiku-4-5 --hand-off-max-source-chars=400000
119
+ ```
120
+
121
+ Precedence: pi CLI flag > config file > built-in default. On the command line, only the writer choice (`--fresh` / `--live`) is available — a model ID belongs in the config file or the picker.
62
122
 
63
123
  ## Notes
64
124
 
@@ -70,7 +130,9 @@ Two limitations worth knowing: if you queue another message while the agent is w
70
130
 
71
131
  pi tears down the current runtime when a session is replaced, and extension instances are recreated for the new session. Session-bound objects can't be reused across that boundary, and the post-switch context has no model setter — but the replacement happens within the same process. So both commands capture state as plain data in a `globalThis` property, and the new session's extension instance consumes it on `session_start` (reason `new`) and applies it with `pi.setModel()` (plus `ctx.ui.setEditorText()` for the hand-off prompt). The handoff is consumed exactly once, so it can never leak into a later session.
72
132
 
73
- `/hand-off` captures the agent's response by watching the `agent_end` event while the hand-off request is in flight, using per-instance state that dies with the session — only the final payload crosses into the new session via `globalThis`.
133
+ `/hand-off` captures the live agent's response by watching the `agent_end` event while the hand-off request is in flight, using per-instance state that dies with the session — only the final payload crosses into the new session via `globalThis`.
134
+
135
+ The fresh pass never sends a message to the agent at all: it calls `ctx.modelRegistry.complete()` once with a system prompt and a single user message built from `buildSessionProjection().messages`, runs the reply through the same validation as the live path (`stopReason === "stop"`, non-empty, surrounding fences stripped), and carries the result across the same `globalThis` handoff.
74
136
 
75
137
  ## Development
76
138
 
@@ -80,14 +142,17 @@ Load the extension directly for local testing:
80
142
  pi -e ./index.ts
81
143
  ```
82
144
 
83
- There are also RPC smoke tests that verify the behavior end-to-end by starting pi in RPC mode:
145
+ There are also smoke tests that verify the behavior end-to-end by starting pi in RPC mode:
84
146
 
85
147
  ```sh
86
148
  python3 scripts/rpc-smoke-test.py # /clear keeps model + thinking level
87
- python3 scripts/rpc-handoff-smoke-test.py # /hand-off full flow, dialogs included
149
+ python3 scripts/rpc-handoff-smoke-test.py # /hand-off, both writers, dialogs included
150
+ node --test scripts/model-picker.test.mjs # offline: model picker behavior
151
+ node --test scripts/minimal-transcript.test.mjs # offline: fresh-pass transcript + budget
152
+ node --test scripts/fresh-pass-flow.test.mjs # offline: /hand-off flow, both writers
88
153
  ```
89
154
 
90
- Both require at least two models with configured auth.
155
+ The RPC smoke tests require at least two models with configured auth, and they make **real, billed model requests**. The `node --test` scripts need neither.
91
156
 
92
157
  ## Related extensions
93
158
 
package/index.ts CHANGED
@@ -15,17 +15,32 @@
15
15
  *
16
16
  * `/hand-off`: hand the conversation to another model.
17
17
  *
18
- * 1. The user runs `/hand-off` (optionally with steering text after it).
19
- * 2. A dialog flow collects the destination model and thinking level, then
20
- * opens an editor pre-filled with the steering text.
21
- * 3. The extension sends the current agent a custom message asking it to
22
- * write its next message as a hand-off prompt for the next model — no
23
- * preamble, no postamble.
24
- * 4. When the run ends, the final assistant message is captured, the session
25
- * is replaced, the chosen model and thinking level are applied, and the
26
- * hand-off prompt is inserted into the editor.
18
+ * 1. The user runs `/hand-off`, optionally with `--fresh` or `--live` and
19
+ * steering text after them.
20
+ * 2. A dialog flow asks who writes the hand-off prompt, then collects the
21
+ * destination model and thinking level, then opens an editor pre-filled
22
+ * with the remaining command args.
23
+ * 3. The hand-off prompt is written by one of two writers:
24
+ * - the **live agent** (default, first in the picker): the extension sends
25
+ * the current agent a custom message asking it to write its next message
26
+ * as the hand-off prompt, and `agent_end` captures the final assistant
27
+ * message. It sees the whole transcript and keeps tool access.
28
+ * - the **fresh pass**: a single out-of-band `modelRegistry.complete()`
29
+ * call over a *minimal reconstruction* of the conversation (no tool
30
+ * output, no thinking, head+tail budget), so long or stale sessions can
31
+ * still be handed off, and a cheap model can do the writing. It has no
32
+ * tool access, and its thinking level is chosen separately (provider
33
+ * default preselected).
34
+ * 4. The extension prepends a provenance comment naming the source session
35
+ * (both writers), replaces the session, applies the chosen model and
36
+ * thinking level, and inserts the prompt into the editor.
27
37
  * 5. The user reviews, edits, and submits the prompt themselves.
28
38
  *
39
+ * Settings live in `<agent-dir>/pi-clear.json` (read once per
40
+ * `session_start`); CLI flags `--hand-off-max-source-chars` and
41
+ * `--hand-off-summarizer` override the file. Without a configured writer
42
+ * model, `/hand-off` just asks: there is no command-arg model flag.
43
+ *
29
44
  * Mechanism:
30
45
  * Both commands capture state as plain data into a `globalThis` property.
31
46
  * Session replacement (`ctx.newSession()`) tears down the current runtime
@@ -40,12 +55,16 @@
40
55
  * into the editor with `ctx.ui.setEditorText()`.
41
56
  */
42
57
 
58
+ import { randomUUID } from "node:crypto";
59
+ import { readFileSync } from "node:fs";
60
+ import { join } from "node:path";
43
61
  import type {
44
62
  ExtensionAPI,
63
+ ExtensionCommandContext,
45
64
  ExtensionContext,
46
65
  } from "@earendil-works/pi-coding-agent";
47
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
48
- import { Container, Input, SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
66
+ import { BorderedLoader, DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
67
+ import { Container, fuzzyFilter, Input, SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
49
68
 
50
69
  // Model and thinking-level types, derived from the API surface because the
51
70
  // package root does not re-export them directly.
@@ -87,6 +106,14 @@ const THINKING_LEVELS: ThinkingLevel[] = [
87
106
 
88
107
  const HANDOFF_CUSTOM_TYPE = "pi-clear:hand-off-request";
89
108
 
109
+ /**
110
+ * Reasoning levels offered for the fresh pass. "off" is not offered: it means
111
+ * the same thing as the provider default here, because the option is omitted
112
+ * either way.
113
+ */
114
+ const WRITER_REASONING_LEVELS: ThinkingLevel[] = THINKING_LEVELS.filter((level) => level !== "off");
115
+ const WRITER_DEFAULT_REASONING_LABEL = "provider default";
116
+
90
117
  function sameModel(a: AnyModel | undefined, b: AnyModel | undefined): boolean {
91
118
  return !!a && !!b && a.provider === b.provider && a.id === b.id;
92
119
  }
@@ -120,6 +147,14 @@ interface PickedModel {
120
147
  thinkingLevel?: ThinkingLevel;
121
148
  }
122
149
 
150
+ /** How the model pickers label themselves and which model they treat as current. */
151
+ interface ModelPickerOptions {
152
+ /** Preselected model; defaults to the session's model. */
153
+ current?: AnyModel;
154
+ title?: string;
155
+ providerTitle?: string;
156
+ }
157
+
123
158
  async function selectModel(
124
159
  ctx: ExtensionContext,
125
160
  models: AnyModel[],
@@ -151,20 +186,54 @@ async function selectThinkingLevel(ctx: ExtensionContext): Promise<ThinkingLevel
151
186
  return index === -1 ? undefined : ordered[index];
152
187
  }
153
188
 
189
+ /**
190
+ * Thinking level for the fresh pass writer. The provider default comes first and
191
+ * is preselected unless the config file pins a level, so pressing Enter keeps
192
+ * today's behavior and the dialog is a no-op for most runs.
193
+ */
194
+ async function selectWriterReasoningLevel(
195
+ ctx: ExtensionContext,
196
+ preselected: ThinkingLevel | undefined,
197
+ ): Promise<{ reasoning?: ThinkingLevel } | undefined> {
198
+ const ordered = preselected
199
+ ? [preselected, ...WRITER_REASONING_LEVELS.filter((level) => level !== preselected)]
200
+ : [...WRITER_REASONING_LEVELS];
201
+ const options: Array<{ reasoning?: ThinkingLevel; label: string }> = ordered.map((level) => ({
202
+ reasoning: level,
203
+ label: `${level}${level === preselected ? " (current)" : ""}`,
204
+ }));
205
+ if (preselected) {
206
+ options.push({ label: WRITER_DEFAULT_REASONING_LABEL });
207
+ } else {
208
+ options.unshift({ label: `${WRITER_DEFAULT_REASONING_LABEL} (current)` });
209
+ }
210
+
211
+ const labels = options.map((option) => option.label);
212
+ const chosen = await ctx.ui.select("Thinking level for the hand-off writer", labels);
213
+ if (chosen === undefined) return undefined;
214
+ const index = labelIndex(chosen, labels);
215
+ return index === -1 ? undefined : { reasoning: options[index].reasoning };
216
+ }
217
+
154
218
  /**
155
219
  * Non-interactive fallback (RPC mode has no custom components): scoped
156
220
  * models in one dialog when scoping is configured, otherwise a two-level
157
221
  * provider → model pick over the auth-configured catalogue.
158
222
  */
159
- async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedModel | undefined> {
160
- const current = ctx.model;
223
+ async function pickTargetModelFallback(
224
+ ctx: ExtensionContext,
225
+ options: ModelPickerOptions = {},
226
+ ): Promise<PickedModel | undefined> {
227
+ const current = options.current ?? ctx.model;
228
+ const title = options.title ?? "Hand off to model";
229
+ const providerTitle = options.providerTitle ?? "Hand off to provider";
161
230
  const scoped = ctx.scopedModels.map((scoped) => scoped.model);
162
231
 
163
232
  if (scoped.length > 0) {
164
233
  // Keep the current model selectable and preselected even when scoping
165
234
  // excludes it.
166
235
  const models = current && !scoped.some((m) => sameModel(m, current)) ? [current, ...scoped] : scoped;
167
- const model = await selectModel(ctx, models, "Hand off to model", current);
236
+ const model = await selectModel(ctx, models, title, current);
168
237
  return model ? { model } : undefined;
169
238
  }
170
239
 
@@ -190,13 +259,13 @@ async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedMod
190
259
 
191
260
  const providerLabels = providers.map((p) => `${p} (${byProvider.get(p)!.length} models)`);
192
261
  const providerChoice =
193
- providers.length === 1 ? providerLabels[0] : await ctx.ui.select("Hand off to provider", providerLabels);
262
+ providers.length === 1 ? providerLabels[0] : await ctx.ui.select(providerTitle, providerLabels);
194
263
  if (providerChoice === undefined) return undefined;
195
264
  const providerIndex = labelIndex(providerChoice, providerLabels);
196
265
  if (providerIndex === -1) return undefined;
197
266
  const providerName = providers[providerIndex];
198
267
 
199
- const model = await selectModel(ctx, byProvider.get(providerName) ?? [], "Hand off to model", current);
268
+ const model = await selectModel(ctx, byProvider.get(providerName) ?? [], title, current);
200
269
  return model ? { model } : undefined;
201
270
  }
202
271
 
@@ -204,8 +273,12 @@ async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedMod
204
273
  * `/model`-style model picker (TUI mode only): a searchable list with Tab to
205
274
  * toggle between the session's scoped models and the full catalogue.
206
275
  */
207
- async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel | undefined> {
208
- const current = ctx.model;
276
+ async function pickTargetModelTui(
277
+ ctx: ExtensionContext,
278
+ options: ModelPickerOptions = {},
279
+ ): Promise<PickedModel | undefined> {
280
+ const current = options.current ?? ctx.model;
281
+ const title = options.title ?? "Hand off to model";
209
282
  const allEntries: PickedModel[] = ctx.modelRegistry.getAvailable().map((model) => ({ model }));
210
283
  if (allEntries.length === 0) {
211
284
  ctx.ui.notify("No models available to hand off to", "error");
@@ -240,8 +313,10 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
240
313
 
241
314
  const entriesForScope = () => (scope === "scoped" ? scopedEntries : allEntries);
242
315
 
243
- const makeList = () => {
244
- const entries = orderCurrentFirst(entriesForScope(), current, (entry) => entry.model);
316
+ const makeList = (query = "") => {
317
+ const ordered = orderCurrentFirst(entriesForScope(), current, (entry) => entry.model);
318
+ // SelectList.setFilter() only prefix-matches item values, not labels.
319
+ const entries = fuzzyFilter(ordered, query, ({ model }) => `${model.provider} ${model.id} ${model.name}`);
245
320
  const items: SelectItem[] = entries.map((entry, index) => ({
246
321
  value: String(index),
247
322
  label: modelLabel(entry.model, current),
@@ -259,8 +334,15 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
259
334
  container.addChild(header);
260
335
  const searchInput = new Input({ placeholder: "Type to filter" });
261
336
  container.addChild(searchInput);
337
+ const listContainer = new Container();
262
338
  selectList = makeList();
263
- container.addChild(selectList);
339
+ listContainer.addChild(selectList);
340
+ container.addChild(listContainer);
341
+ const updateList = () => {
342
+ selectList = makeList(searchInput.getValue());
343
+ listContainer.clear();
344
+ listContainer.addChild(selectList);
345
+ };
264
346
  const hint = new Text("");
265
347
  container.addChild(hint);
266
348
  container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
@@ -270,7 +352,7 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
270
352
  theme.fg(
271
353
  "accent",
272
354
  theme.bold(
273
- `Hand off to model — ${scope === "scoped" ? `scoped (${scopedEntries.length})` : `all (${allEntries.length})`}`,
355
+ `${title} — ${scope === "scoped" ? `scoped (${scopedEntries.length})` : `all (${allEntries.length})`}`,
274
356
  ),
275
357
  ),
276
358
  );
@@ -292,10 +374,7 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
292
374
  handleInput(data: string) {
293
375
  if (scopedEntries.length > 0 && kb.matches(data, "tui.input.tab")) {
294
376
  scope = scope === "scoped" ? "all" : "scoped";
295
- container.removeChild(selectList);
296
- selectList = makeList();
297
- container.addChild(selectList);
298
- selectList.setFilter(searchInput.getValue());
377
+ updateList();
299
378
  updateChrome();
300
379
  tui.requestRender();
301
380
  return;
@@ -311,7 +390,7 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
311
390
  return;
312
391
  }
313
392
  searchInput.handleInput(data);
314
- selectList.setFilter(searchInput.getValue());
393
+ updateList();
315
394
  tui.requestRender();
316
395
  },
317
396
  };
@@ -325,12 +404,17 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
325
404
  *
326
405
  * TUI mode gets a `/model`-style searchable picker with Tab to toggle
327
406
  * between scoped and all models; other modes fall back to plain dialogs.
407
+ * The same picker also chooses the fresh pass's writer model, with the
408
+ * title and preselected model supplied by the caller.
328
409
  */
329
- async function pickTargetModel(ctx: ExtensionContext): Promise<PickedModel | undefined> {
410
+ async function pickTargetModel(
411
+ ctx: ExtensionContext,
412
+ options: ModelPickerOptions = {},
413
+ ): Promise<PickedModel | undefined> {
330
414
  if (ctx.mode === "tui") {
331
- return pickTargetModelTui(ctx);
415
+ return pickTargetModelTui(ctx, options);
332
416
  }
333
- return pickTargetModelFallback(ctx);
417
+ return pickTargetModelFallback(ctx, options);
334
418
  }
335
419
 
336
420
  /** Extract the text content of an agent message, ignoring thinking blocks and tool calls. */
@@ -365,12 +449,679 @@ function buildHandoffRequest(steering: string): string {
365
449
  return lines.join("\n");
366
450
  }
367
451
 
452
+ // ============================================================================
453
+ // Fresh pass: one out-of-band model call over a minimal reconstruction
454
+ // ============================================================================
455
+
456
+ const HANDOFF_SYSTEM_PROMPT = `You are a context transfer assistant. You will receive a minimal reconstruction of a long conversation between a user and an AI agent, and you must write the hand-off message that starts the next session.
457
+
458
+ Do not continue the conversation. Do not answer questions in it.
459
+
460
+ The reconstruction is lossy: tool output, thinking, and file contents are omitted, and a middle section may be elided. Do not assert file contents or the state of the working tree from memory — when something cannot be verified from the transcript, tell the next agent to verify it.`;
461
+
462
+ const DEFAULT_COVERAGE =
463
+ "Cover the conversation thoroughly: the current task and goal, what has been done so far (with concrete file paths, commands, and decisions), and exactly what to do next.";
464
+
465
+ /** Characters of conversation sent to the fresh pass (~50k tokens at 200k). */
466
+ const DEFAULT_MAX_SOURCE_CHARS = 200_000;
467
+ /** Share of the budget kept from the newest end of the conversation. */
468
+ const TAIL_SOURCE_SHARE = 0.6;
469
+ /** Share of the budget kept contiguously after the opening message. */
470
+ const HEAD_SOURCE_SHARE = 0.4;
471
+ const USER_MESSAGE_MAX_CHARS = 8_000;
472
+ const ASSISTANT_MESSAGE_MAX_CHARS = 4_000;
473
+ const SUMMARY_MAX_CHARS = 8_000;
474
+ const NOTE_MAX_CHARS = 2_000;
475
+ const HINT_MAX_CHARS = 200;
476
+
477
+ /** A projected session message, viewed loosely: only fields the serializer reads. */
478
+ interface TranscriptSource {
479
+ role?: string;
480
+ content?: unknown;
481
+ customType?: string;
482
+ summary?: string;
483
+ command?: string;
484
+ /** `!!` bash executions: never sent to a model, so never sent to the writer. */
485
+ excludeFromContext?: boolean;
486
+ }
487
+
488
+ interface ContentBlock {
489
+ type?: string;
490
+ text?: string;
491
+ name?: string;
492
+ arguments?: unknown;
493
+ }
494
+
495
+ /** One rendered unit of the minimal transcript; the budget never splits a block. */
496
+ interface TranscriptBlock {
497
+ text: string;
498
+ /** Marks the opening user message, which the budget always keeps. */
499
+ anchor?: boolean;
500
+ }
501
+
502
+ interface TranscriptResult {
503
+ text: string;
504
+ /** Blocks dropped from the middle of the conversation, 0 when nothing was dropped. */
505
+ omittedTurns: number;
506
+ /** Characters those blocks would have contributed. */
507
+ droppedChars: number;
508
+ }
509
+
510
+ function contentBlocks(content: unknown): ContentBlock[] {
511
+ if (typeof content === "string") return [{ type: "text", text: content }];
512
+ if (!Array.isArray(content)) return [];
513
+ const blocks: ContentBlock[] = [];
514
+ for (const block of content) {
515
+ if (typeof block === "object" && block !== null) blocks.push(block as ContentBlock);
516
+ }
517
+ return blocks;
518
+ }
519
+
520
+ /** Visible text only: thinking, tool calls, and images are dropped. */
521
+ function visibleText(content: unknown): string {
522
+ return contentBlocks(content)
523
+ .filter((block) => block.type === "text" && typeof block.text === "string")
524
+ .map((block) => block.text!.trim())
525
+ .filter((text) => text.length > 0)
526
+ .join("\n");
527
+ }
528
+
529
+ function toolCallsOf(content: unknown): ContentBlock[] {
530
+ return contentBlocks(content).filter(
531
+ (block) => block.type === "toolCall" && typeof block.name === "string",
532
+ );
533
+ }
534
+
535
+ function stringArg(args: unknown, key: string): string | undefined {
536
+ if (typeof args !== "object" || args === null) return undefined;
537
+ const value = (args as Record<string, unknown>)[key];
538
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
539
+ }
540
+
541
+ function firstStringArg(args: unknown): string | undefined {
542
+ if (typeof args !== "object" || args === null) return undefined;
543
+ for (const value of Object.values(args as Record<string, unknown>)) {
544
+ if (typeof value === "string" && value.trim()) return value.trim();
545
+ }
546
+ return undefined;
547
+ }
548
+
549
+ /** Keep both ends of an over-long text, and say how much was cut. */
550
+ function truncateMiddle(text: string, maxChars: number): string {
551
+ if (maxChars <= 0 || text.length <= maxChars) return text;
552
+ const headChars = Math.floor(maxChars * TAIL_SOURCE_SHARE);
553
+ const tailChars = maxChars - headChars;
554
+ const removed = text.length - maxChars;
555
+ return `${text.slice(0, headChars)}\n[... ${removed} chars truncated ...]\n${text.slice(text.length - tailChars)}`;
556
+ }
557
+
558
+ /** One line naming a tool call and its target — never its result. */
559
+ export function formatToolHint(name: string, args: unknown): string {
560
+ const path = stringArg(args, "path");
561
+ switch (name) {
562
+ case "read":
563
+ case "edit":
564
+ case "write":
565
+ return path ? `${name} ${path}` : name;
566
+ case "bash": {
567
+ const command = stringArg(args, "command")?.split("\n")[0].trim() ?? "";
568
+ return command ? `bash: ${command.slice(0, HINT_MAX_CHARS)}` : "bash";
569
+ }
570
+ case "grep": {
571
+ const pattern = stringArg(args, "pattern");
572
+ if (!pattern) return "grep";
573
+ return path ? `grep: /${pattern}/ in ${path}` : `grep: /${pattern}/`;
574
+ }
575
+ case "ls":
576
+ return path ? `ls: ${path}` : "ls";
577
+ case "find": {
578
+ const pattern = stringArg(args, "pattern");
579
+ if (pattern && path) return `find: ${pattern} in ${path}`;
580
+ if (path) return `find: ${path}`;
581
+ return pattern ? `find: ${pattern}` : "find";
582
+ }
583
+ default: {
584
+ const detail = path ?? firstStringArg(args);
585
+ return detail ? `tool: ${name} ${detail.slice(0, HINT_MAX_CHARS)}` : `tool: ${name}`;
586
+ }
587
+ }
588
+ }
589
+
590
+ interface FileLists {
591
+ readFiles: string[];
592
+ modifiedFiles: string[];
593
+ }
594
+
595
+ /** Same rules pi uses for compaction: read-only files, then written/edited ones. */
596
+ function fileListsFromMessages(messages: readonly TranscriptSource[]): FileLists {
597
+ const read = new Set<string>();
598
+ const written = new Set<string>();
599
+ const edited = new Set<string>();
600
+ for (const message of messages) {
601
+ if (message.role !== "assistant") continue;
602
+ for (const call of toolCallsOf(message.content)) {
603
+ const path = stringArg(call.arguments, "path");
604
+ if (!path) continue;
605
+ if (call.name === "read") read.add(path);
606
+ else if (call.name === "write") written.add(path);
607
+ else if (call.name === "edit") edited.add(path);
608
+ }
609
+ }
610
+ const modified = new Set([...edited, ...written]);
611
+ return {
612
+ readFiles: [...read].filter((file) => !modified.has(file)).sort(),
613
+ modifiedFiles: [...modified].sort(),
614
+ };
615
+ }
616
+
617
+ function formatFileLists(lists: FileLists): string {
618
+ const sections: string[] = [];
619
+ if (lists.readFiles.length > 0) sections.push(`<read-files>\n${lists.readFiles.join("\n")}\n</read-files>`);
620
+ if (lists.modifiedFiles.length > 0) {
621
+ sections.push(`<modified-files>\n${lists.modifiedFiles.join("\n")}\n</modified-files>`);
622
+ }
623
+ return sections.join("\n\n");
624
+ }
625
+
626
+ /**
627
+ * Render the conversation as labelled blocks: visible text, tool-call hints,
628
+ * summaries, and notes. Tool results, thinking, and stale hand-off requests
629
+ * are dropped outright.
630
+ */
631
+ export function renderTranscriptBlocks(messages: readonly TranscriptSource[]): TranscriptBlock[] {
632
+ const blocks: TranscriptBlock[] = [];
633
+ let sawUserMessage = false;
634
+
635
+ for (const message of messages) {
636
+ switch (message.role) {
637
+ case "user": {
638
+ const text = visibleText(message.content);
639
+ if (!text) break;
640
+ blocks.push({
641
+ text: `[User]: ${truncateMiddle(text, USER_MESSAGE_MAX_CHARS)}`,
642
+ anchor: !sawUserMessage,
643
+ });
644
+ sawUserMessage = true;
645
+ break;
646
+ }
647
+ case "assistant": {
648
+ const text = visibleText(message.content);
649
+ if (text) {
650
+ blocks.push({ text: `[Assistant]: ${truncateMiddle(text, ASSISTANT_MESSAGE_MAX_CHARS)}` });
651
+ }
652
+ const hints = toolCallsOf(message.content).map((call) => formatToolHint(call.name!, call.arguments));
653
+ if (hints.length > 0) blocks.push({ text: `[tools]:\n${hints.join("\n")}` });
654
+ break;
655
+ }
656
+ case "toolResult":
657
+ // Tool output never shapes the hand-off: it is the bulk of a transcript
658
+ // and the writer is told to have the next agent verify state itself.
659
+ break;
660
+ case "custom": {
661
+ if (message.customType === HANDOFF_CUSTOM_TYPE) break;
662
+ const text = visibleText(message.content);
663
+ if (text) blocks.push({ text: `[Note]: ${truncateMiddle(text, NOTE_MAX_CHARS)}` });
664
+ break;
665
+ }
666
+ case "compactionSummary":
667
+ case "branchSummary": {
668
+ const summary = typeof message.summary === "string" ? message.summary.trim() : "";
669
+ if (summary) {
670
+ blocks.push({
671
+ text: `[Summary of earlier conversation]: ${truncateMiddle(summary, SUMMARY_MAX_CHARS)}`,
672
+ });
673
+ }
674
+ break;
675
+ }
676
+ case "bashExecution": {
677
+ if (message.excludeFromContext) break;
678
+ const command = typeof message.command === "string" ? message.command.trim() : "";
679
+ if (command) blocks.push({ text: `[User ran]: ${truncateMiddle(command, NOTE_MAX_CHARS)}` });
680
+ break;
681
+ }
682
+ default:
683
+ break;
684
+ }
685
+ }
686
+
687
+ return blocks;
688
+ }
689
+
690
+ /**
691
+ * Keep the head and the tail of the conversation within `maxChars`, dropping
692
+ * the middle and leaving an elision marker in its place. `maxChars <= 0`
693
+ * disables the budget.
694
+ */
695
+ export function applyTranscriptBudget(blocks: readonly TranscriptBlock[], maxChars: number): TranscriptResult {
696
+ const parts = blocks.map((block) => block.text);
697
+ const joinBlocks = (items: string[]) => items.join("\n\n");
698
+ const full = joinBlocks(parts);
699
+ if (maxChars <= 0 || full.length <= maxChars) {
700
+ return { text: full, omittedTurns: 0, droppedChars: 0 };
701
+ }
702
+
703
+ // Head: everything up to and including the opening user message, always kept.
704
+ const anchor = blocks.findIndex((block) => block.anchor);
705
+ const anchorIndex = anchor === -1 ? 0 : anchor;
706
+ let headChars = parts.slice(0, anchorIndex + 1).reduce((sum, part) => sum + part.length + 2, 0);
707
+
708
+ // Tail: newest first, up to 60% of the budget.
709
+ const tailBudget = Math.floor(maxChars * TAIL_SOURCE_SHARE);
710
+ let tailStart = parts.length;
711
+ let tailChars = 0;
712
+ for (let i = parts.length - 1; i > anchorIndex; i--) {
713
+ const cost = parts[i].length + 2;
714
+ if (tailChars + cost > tailBudget) break;
715
+ tailChars += cost;
716
+ tailStart = i;
717
+ }
718
+ // Never drop the newest block: it is the most valuable one, and message-level
719
+ // caps normally keep it well inside the tail budget.
720
+ if (tailStart === parts.length && parts.length > anchorIndex + 1) tailStart = parts.length - 1;
721
+
722
+ // Gap: oldest first, contiguous from just after the head, while the head
723
+ // budget lasts.
724
+ const headBudget = Math.floor(maxChars * HEAD_SOURCE_SHARE);
725
+ let gapEnd = anchorIndex;
726
+ while (gapEnd + 1 < tailStart && headChars + parts[gapEnd + 1].length + 2 <= headBudget) {
727
+ headChars += parts[gapEnd + 1].length + 2;
728
+ gapEnd += 1;
729
+ }
730
+
731
+ const omitted = parts.slice(gapEnd + 1, tailStart);
732
+ const droppedChars = omitted.reduce((sum, part) => sum + part.length + 2, 0);
733
+ const kept = [...parts.slice(0, gapEnd + 1)];
734
+ if (omitted.length > 0) kept.push(`[... ${omitted.length} turns omitted — ${droppedChars} chars ...]`);
735
+ kept.push(...parts.slice(tailStart));
736
+
737
+ return { text: joinBlocks(kept), omittedTurns: omitted.length, droppedChars };
738
+ }
739
+
740
+ /** Minimal reconstruction of the conversation plus the file-operation footer. */
741
+ export function buildMinimalTranscript(
742
+ messages: readonly TranscriptSource[],
743
+ maxChars: number = DEFAULT_MAX_SOURCE_CHARS,
744
+ ): TranscriptResult {
745
+ const budget = applyTranscriptBudget(renderTranscriptBlocks(messages), maxChars);
746
+ const fileLists = formatFileLists(fileListsFromMessages(messages));
747
+ if (!fileLists) return budget;
748
+ return { ...budget, text: budget.text ? `${budget.text}\n\n${fileLists}` : fileLists };
749
+ }
750
+
751
+ /** The single user message of the fresh pass. */
752
+ export function buildFreshPassUserMessage(transcript: string, steering: string): string {
753
+ return [
754
+ "## Conversation (minimal reconstruction)",
755
+ transcript,
756
+ "",
757
+ "## What the hand-off must cover",
758
+ steering.trim() || DEFAULT_COVERAGE,
759
+ "",
760
+ "Requirements for your output:",
761
+ '- Written as instructions to the next agent ("you"), not a report about the past.',
762
+ "- Assume zero prior context: current task and goal, what was done so far with concrete file paths, commands, and decisions, and exactly what to do next.",
763
+ "- Name the files worth opening first.",
764
+ "- Reply with ONLY the hand-off message: no preamble, no postamble, no fences.",
765
+ ].join("\n");
766
+ }
767
+
768
+ // ============================================================================
769
+ // Session provenance (both writers)
770
+ // ============================================================================
771
+
772
+ /**
773
+ * HTML comment naming the session a hand-off came from, so the next agent can
774
+ * look it up when the prompt is not enough. It is a comment, not prose, so it
775
+ * does not read as part of the task, and it stays visible in the editor.
776
+ */
777
+ export function buildProvenanceComment(sessionId: string | undefined): string {
778
+ const id = sessionId?.trim();
779
+ return id ? `<!-- Handed off from pi session ${id} -->` : "";
780
+ }
781
+
782
+ /** Prepends the provenance comment; called right before `writeHandoff()`. */
783
+ function withSessionProvenance(prompt: string, ctx: ExtensionContext): string {
784
+ const comment = buildProvenanceComment(ctx.sessionManager.getSessionId());
785
+ return comment ? `${comment}\n\n${prompt}` : prompt;
786
+ }
787
+
788
+ // ============================================================================
789
+ // Fresh pass generation
790
+ // ============================================================================
791
+
792
+ /** Options for the one-shot writer call; `reasoning` is only set when supported. */
793
+ interface WriterRequestOptions {
794
+ signal?: AbortSignal;
795
+ cacheRetention: "none";
796
+ sessionId: string;
797
+ reasoning?: ThinkingLevel;
798
+ }
799
+
800
+ interface WriterOutcome {
801
+ prompt?: string;
802
+ error?: string;
803
+ cancelled?: boolean;
804
+ }
805
+
806
+ /** Strip a fence that wraps the whole reply, leaving inner code blocks alone. */
807
+ function stripSurroundingFence(text: string): string {
808
+ const trimmed = text.trim();
809
+ if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return trimmed;
810
+ const withoutOpen = trimmed.replace(/^```[^\n]*\n?/, "");
811
+ return withoutOpen.slice(0, withoutOpen.lastIndexOf("```")).trim();
812
+ }
813
+
814
+ function checkWrittenPrompt(response: { stopReason?: string; errorMessage?: string; content?: unknown }): WriterOutcome {
815
+ if (response.stopReason === "aborted") return { cancelled: true };
816
+ if (response.stopReason !== "stop") {
817
+ const detail = response.errorMessage ? `: ${response.errorMessage}` : "";
818
+ return { error: `stopped with reason "${response.stopReason}"${detail}` };
819
+ }
820
+ const text = stripSurroundingFence(visibleText(response.content));
821
+ if (!text) return { error: "returned an empty prompt" };
822
+ return { prompt: text };
823
+ }
824
+
825
+ function reportWriterOutcome(ctx: ExtensionContext, outcome: WriterOutcome, label: string): string | undefined {
826
+ if (outcome.prompt) return outcome.prompt;
827
+ if (outcome.cancelled) {
828
+ ctx.ui.notify("Hand-off cancelled", "info");
829
+ return undefined;
830
+ }
831
+ ctx.ui.notify(
832
+ `Hand-off writer (${label}) ${outcome.error ?? "failed"}; staying in this session`,
833
+ "error",
834
+ );
835
+ return undefined;
836
+ }
837
+
838
+ /**
839
+ * Run the fresh pass: one `complete()` call over the minimal transcript.
840
+ * TUI mode gets a cancellable loader; other modes get a notification.
841
+ */
842
+ async function generateFreshPassPrompt(
843
+ ctx: ExtensionContext,
844
+ model: AnyModel,
845
+ transcript: string,
846
+ steering: string,
847
+ reasoning: ThinkingLevel | undefined,
848
+ ): Promise<string | undefined> {
849
+ const userMessage = {
850
+ role: "user" as const,
851
+ content: [{ type: "text" as const, text: buildFreshPassUserMessage(transcript, steering) }],
852
+ timestamp: Date.now(),
853
+ };
854
+
855
+ const attempt = async (signal?: AbortSignal): Promise<WriterOutcome> => {
856
+ const options: WriterRequestOptions = { signal, cacheRetention: "none", sessionId: randomUUID() };
857
+ // Providers default when the option is absent, and models without
858
+ // reasoning controls would reject it; pi's own summarization does the
859
+ // same check before setting the level.
860
+ if (reasoning && model.reasoning) options.reasoning = reasoning;
861
+ try {
862
+ const response = await ctx.modelRegistry.complete(
863
+ model,
864
+ { systemPrompt: HANDOFF_SYSTEM_PROMPT, messages: [userMessage] },
865
+ options,
866
+ );
867
+ return checkWrittenPrompt(response);
868
+ } catch (err) {
869
+ return { error: `failed: ${err instanceof Error ? err.message : String(err)}` };
870
+ }
871
+ };
872
+
873
+ const label = `${model.provider}/${model.id}`;
874
+
875
+ if (ctx.mode !== "tui") {
876
+ ctx.ui.notify(`Writing the hand-off prompt with ${label}…`, "info");
877
+ return reportWriterOutcome(ctx, await attempt(), label);
878
+ }
879
+
880
+ let aborted = false;
881
+ let outcome: WriterOutcome | undefined;
882
+ const prompt = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
883
+ // Abort and completion can race; `done` must only be called once.
884
+ let finished = false;
885
+ const finish = (value: string | null) => {
886
+ if (finished) return;
887
+ finished = true;
888
+ done(value);
889
+ };
890
+ const loader = new BorderedLoader(tui, theme, `Writing the hand-off prompt with ${label}…`);
891
+ loader.onAbort = () => {
892
+ aborted = true;
893
+ finish(null);
894
+ };
895
+ void attempt(loader.signal).then((result) => {
896
+ outcome = result;
897
+ finish(result.prompt ?? null);
898
+ });
899
+ return loader;
900
+ });
901
+
902
+ if (prompt) return prompt;
903
+ if (aborted) {
904
+ ctx.ui.notify("Hand-off cancelled", "info");
905
+ return undefined;
906
+ }
907
+ return reportWriterOutcome(ctx, outcome ?? { error: "returned no prompt" }, label);
908
+ }
909
+
910
+ // ============================================================================
911
+ // Command arguments, writer choice, and settings
912
+ // ============================================================================
913
+
914
+ interface ParsedHandoffArgs {
915
+ writer?: "live" | "fresh";
916
+ /** Everything after the leading flags: the steering-editor prefill. */
917
+ steering: string;
918
+ error?: string;
919
+ }
920
+
921
+ /** Parse leading `/hand-off` flags; the first unrecognized token starts the steering text. */
922
+ export function parseHandoffArgs(args: string): ParsedHandoffArgs {
923
+ const tokens = args.split(/\s+/).filter(Boolean);
924
+ const parsed: ParsedHandoffArgs = { steering: "" };
925
+ let index = 0;
926
+
927
+ for (; index < tokens.length; index++) {
928
+ const token = tokens[index];
929
+ if (token === "--fresh" || token === "--live") {
930
+ const writer = token === "--fresh" ? "fresh" : "live";
931
+ if (parsed.writer && parsed.writer !== writer) {
932
+ return { ...parsed, error: "--fresh and --live cannot be combined; nothing was sent" };
933
+ }
934
+ parsed.writer = writer;
935
+ continue;
936
+ }
937
+ break;
938
+ }
939
+
940
+ parsed.steering = tokens.slice(index).join(" ");
941
+ return parsed;
942
+ }
943
+
944
+ export interface HandoffSettings {
945
+ summarizerModel?: string;
946
+ askSummarizerModel?: boolean;
947
+ /** Thinking level for the fresh pass; absent means the provider default. */
948
+ summarizerThinkingLevel?: ThinkingLevel;
949
+ maxSourceChars?: number;
950
+ }
951
+
952
+ const SETTINGS_FILE_NAME = "pi-clear.json";
953
+ const FLAG_MAX_SOURCE_CHARS = "hand-off-max-source-chars";
954
+ const FLAG_SUMMARIZER = "hand-off-summarizer";
955
+
956
+ function isRecord(value: unknown): value is Record<string, unknown> {
957
+ return typeof value === "object" && value !== null && !Array.isArray(value);
958
+ }
959
+
960
+ /** Read `<agent-dir>/pi-clear.json`; a malformed file is a warning, not a failure. */
961
+ export function loadHandoffSettings(path: string = join(getAgentDir(), SETTINGS_FILE_NAME)): {
962
+ settings: HandoffSettings;
963
+ warning?: string;
964
+ } {
965
+ let raw: string;
966
+ try {
967
+ raw = readFileSync(path, "utf8");
968
+ } catch (err) {
969
+ if ((err as { code?: string }).code === "ENOENT") return { settings: {} };
970
+ return { settings: {}, warning: `pi-clear: could not read ${path}: ${messageOf(err)}` };
971
+ }
972
+
973
+ let parsed: unknown;
974
+ try {
975
+ parsed = JSON.parse(raw);
976
+ } catch (err) {
977
+ return { settings: {}, warning: `pi-clear: ignoring malformed ${path}: ${messageOf(err)}` };
978
+ }
979
+
980
+ if (!isRecord(parsed) || !isRecord(parsed.handoff)) {
981
+ return { settings: {}, warning: `pi-clear: ignoring ${path}: expected a "handoff" object` };
982
+ }
983
+
984
+ const handoff = parsed.handoff;
985
+ const settings: HandoffSettings = {};
986
+ const problems: string[] = [];
987
+
988
+ if (handoff.summarizerModel !== undefined) {
989
+ if (typeof handoff.summarizerModel === "string" && handoff.summarizerModel.includes("/")) {
990
+ settings.summarizerModel = handoff.summarizerModel.trim();
991
+ } else {
992
+ problems.push('"summarizerModel" must be a "provider/model" string');
993
+ }
994
+ }
995
+ if (handoff.summarizerThinkingLevel !== undefined) {
996
+ const value = handoff.summarizerThinkingLevel;
997
+ // "default" (and "off") mean "leave the choice to the provider", the same
998
+ // as omitting the key.
999
+ if (value === "default" || value === "off") {
1000
+ // Nothing to record.
1001
+ } else if (typeof value === "string" && WRITER_REASONING_LEVELS.includes(value as ThinkingLevel)) {
1002
+ settings.summarizerThinkingLevel = value as ThinkingLevel;
1003
+ } else {
1004
+ problems.push(
1005
+ `"summarizerThinkingLevel" must be "default" or one of ${WRITER_REASONING_LEVELS.join(", ")}`,
1006
+ );
1007
+ }
1008
+ }
1009
+ if (handoff.askSummarizerModel !== undefined) {
1010
+ if (typeof handoff.askSummarizerModel === "boolean") settings.askSummarizerModel = handoff.askSummarizerModel;
1011
+ else problems.push('"askSummarizerModel" must be true or false');
1012
+ }
1013
+ if (handoff.maxSourceChars !== undefined) {
1014
+ if (typeof handoff.maxSourceChars === "number" && Number.isFinite(handoff.maxSourceChars) && handoff.maxSourceChars >= 0) {
1015
+ settings.maxSourceChars = Math.floor(handoff.maxSourceChars);
1016
+ } else {
1017
+ problems.push('"maxSourceChars" must be a number >= 0');
1018
+ }
1019
+ }
1020
+
1021
+ return problems.length > 0
1022
+ ? { settings, warning: `pi-clear: ignoring ${path} ${problems.join(", ")}` }
1023
+ : { settings };
1024
+ }
1025
+
1026
+ function messageOf(err: unknown): string {
1027
+ return err instanceof Error ? err.message : String(err);
1028
+ }
1029
+
1030
+ /** Command arg > pi CLI flag > config file > built-in default. */
1031
+ export function resolveMaxSourceChars(flagValue: unknown, settings: HandoffSettings): number {
1032
+ if (typeof flagValue === "string" && flagValue.trim()) {
1033
+ const fromFlag = Number(flagValue);
1034
+ if (Number.isFinite(fromFlag) && fromFlag >= 0) return Math.floor(fromFlag);
1035
+ }
1036
+ if (settings.maxSourceChars !== undefined) return settings.maxSourceChars;
1037
+ return DEFAULT_MAX_SOURCE_CHARS;
1038
+ }
1039
+
1040
+ /** `118234` → `118k`, for the trimming notice. */
1041
+ function formatChars(chars: number): string {
1042
+ if (chars < 1000) return String(chars);
1043
+ if (chars < 1_000_000) return `${Math.round(chars / 1000)}k`;
1044
+ return `${(chars / 1_000_000).toFixed(1)}M`;
1045
+ }
1046
+
1047
+ type WriterChoice = { kind: "live" } | { kind: "fresh"; model: AnyModel };
1048
+
1049
+ interface WriterOptions {
1050
+ /** From `--fresh` / `--live`; skips the writer picker. */
1051
+ writer?: "live" | "fresh";
1052
+ /** Model pinned by command arg, pi flag, or config. */
1053
+ pinnedModel?: AnyModel;
1054
+ /** Whether the summarizer model picker still needs to open. */
1055
+ askSummarizer: boolean;
1056
+ }
1057
+
1058
+ const WRITER_LIVE_LABEL = "This agent — full context and tools";
1059
+ const WRITER_FRESH_OTHER_LABEL = "Fresh pass — another model…";
1060
+
1061
+ /** Pick the hand-off writer: the live agent, or a fresh pass with a chosen model. */
1062
+ async function chooseWriter(ctx: ExtensionContext, options: WriterOptions): Promise<WriterChoice | undefined> {
1063
+ const withCurrentModel = (): WriterChoice | undefined => {
1064
+ const model = options.pinnedModel ?? ctx.model;
1065
+ if (!model) {
1066
+ ctx.ui.notify("No current model to write the hand-off; pick another model", "error");
1067
+ return undefined;
1068
+ }
1069
+ return { kind: "fresh", model };
1070
+ };
1071
+
1072
+ const askForModel = async (): Promise<WriterChoice | undefined> => {
1073
+ if (!options.askSummarizer) return withCurrentModel();
1074
+ const picked = await pickTargetModel(ctx, {
1075
+ current: options.pinnedModel ?? ctx.model,
1076
+ title: "Hand-off writer model",
1077
+ providerTitle: "Hand-off writer provider",
1078
+ });
1079
+ if (!picked) return undefined;
1080
+ if (!ctx.modelRegistry.hasConfiguredAuth(picked.model)) {
1081
+ ctx.ui.notify(
1082
+ `No auth configured for ${picked.model.provider}/${picked.model.id}; staying in this session`,
1083
+ "error",
1084
+ );
1085
+ return undefined;
1086
+ }
1087
+ return { kind: "fresh", model: picked.model };
1088
+ };
1089
+
1090
+ if (options.writer === "live") return { kind: "live" };
1091
+ if (options.writer === "fresh") return askForModel();
1092
+
1093
+ const labels = [
1094
+ WRITER_LIVE_LABEL,
1095
+ options.pinnedModel
1096
+ ? `Fresh pass — ${options.pinnedModel.provider}/${options.pinnedModel.id}`
1097
+ : "Fresh pass — current model",
1098
+ WRITER_FRESH_OTHER_LABEL,
1099
+ ];
1100
+ const chosen = await ctx.ui.select("Who writes the hand-off prompt?", labels);
1101
+ if (chosen === undefined) return undefined;
1102
+
1103
+ switch (labelIndex(chosen, labels)) {
1104
+ case 0:
1105
+ return { kind: "live" };
1106
+ case 1:
1107
+ return withCurrentModel();
1108
+ case 2:
1109
+ return askForModel();
1110
+ default:
1111
+ return undefined;
1112
+ }
1113
+ }
1114
+
368
1115
  export default function (pi: ExtensionAPI) {
369
1116
  // In-session state for the hand-off capture; fresh per extension instance,
370
1117
  // so it never leaks into a replacement session.
371
1118
  let awaitingHandoff = false;
372
1119
  let capturedPrompt: string | undefined;
373
1120
  let flowInProgress = false;
1121
+ // Config file contents, refreshed on every `session_start` (the file is read
1122
+ // once per session, not per hand-off). The pi CLI flag is read per hand-off
1123
+ // so it works even if a command somehow runs before `session_start`.
1124
+ let handoffSettings: HandoffSettings = {};
374
1125
 
375
1126
  /**
376
1127
  * Apply a captured model and thinking level, reporting problems as
@@ -403,6 +1154,67 @@ export default function (pi: ExtensionAPI) {
403
1154
  }
404
1155
  }
405
1156
 
1157
+ /** Ask the live agent to write its next message as the hand-off prompt. */
1158
+ async function captureFromLiveAgent(
1159
+ ctx: ExtensionCommandContext,
1160
+ steering: string,
1161
+ ): Promise<string | undefined> {
1162
+ awaitingHandoff = true;
1163
+ capturedPrompt = undefined;
1164
+ try {
1165
+ await pi.sendMessage(
1166
+ {
1167
+ customType: HANDOFF_CUSTOM_TYPE,
1168
+ content: buildHandoffRequest(steering),
1169
+ display: true,
1170
+ },
1171
+ { triggerTurn: true },
1172
+ );
1173
+ await ctx.waitForIdle();
1174
+ } finally {
1175
+ awaitingHandoff = false;
1176
+ }
1177
+
1178
+ if (!capturedPrompt) {
1179
+ ctx.ui.notify("The agent did not produce a hand-off prompt; staying in this session", "error");
1180
+ return undefined;
1181
+ }
1182
+ return capturedPrompt;
1183
+ }
1184
+
1185
+ /** One fresh pass over the minimal transcript; notifies when it trims. */
1186
+ async function captureFromFreshPass(
1187
+ ctx: ExtensionContext,
1188
+ writer: WriterChoice & { kind: "fresh" },
1189
+ reasoning: ThinkingLevel | undefined,
1190
+ steering: string,
1191
+ ): Promise<string | undefined> {
1192
+ const messages = ctx.sessionManager.buildSessionProjection().messages;
1193
+ const maxSourceChars = resolveMaxSourceChars(pi.getFlag(FLAG_MAX_SOURCE_CHARS), handoffSettings);
1194
+ const source = buildMinimalTranscript(messages, maxSourceChars);
1195
+ if (!source.text.trim()) {
1196
+ ctx.ui.notify("No conversation to hand off", "error");
1197
+ return undefined;
1198
+ }
1199
+ if (source.omittedTurns > 0) {
1200
+ ctx.ui.notify(
1201
+ `Hand-off source trimmed: ${source.omittedTurns} earlier turns omitted (~${formatChars(source.droppedChars)} chars dropped)`,
1202
+ "info",
1203
+ );
1204
+ }
1205
+ return generateFreshPassPrompt(ctx, writer.model, source.text, steering, reasoning);
1206
+ }
1207
+
1208
+ pi.registerFlag(FLAG_MAX_SOURCE_CHARS, {
1209
+ description:
1210
+ "pi-clear: max characters of conversation sent to the /hand-off writer (0 disables the budget)",
1211
+ type: "string",
1212
+ });
1213
+ pi.registerFlag(FLAG_SUMMARIZER, {
1214
+ description: "pi-clear: model that writes /hand-off prompts, as provider/model",
1215
+ type: "string",
1216
+ });
1217
+
406
1218
  pi.registerCommand("clear", {
407
1219
  description: "Start a fresh session, keeping the current model and thinking level",
408
1220
  handler: async (_args, ctx) => {
@@ -442,59 +1254,97 @@ export default function (pi: ExtensionAPI) {
442
1254
 
443
1255
  pi.registerCommand("hand-off", {
444
1256
  description:
445
- "Hand the conversation to another model: pick the next model and thinking level, the agent writes the hand-off prompt, and it lands pre-filled in a fresh session's editor",
1257
+ "Hand the conversation to another model: choose who writes the hand-off prompt (this agent or a fresh pass), pick the next model and thinking level, then review the prompt in a fresh session",
446
1258
  handler: async (args, ctx) => {
1259
+ if (flowInProgress) {
1260
+ ctx.ui.notify("A hand-off is already in progress", "warning");
1261
+ return;
1262
+ }
447
1263
  try {
448
- if (flowInProgress) {
449
- ctx.ui.notify("A hand-off is already in progress", "warning");
1264
+ flowInProgress = true;
1265
+
1266
+ const parsed = parseHandoffArgs(args);
1267
+ if (parsed.error) {
1268
+ ctx.ui.notify(parsed.error, "error");
450
1269
  return;
451
1270
  }
452
- flowInProgress = true;
1271
+
453
1272
  if (!ctx.isIdle()) {
454
1273
  ctx.ui.notify("Waiting for the agent to finish before handing off", "info");
455
1274
  await ctx.waitForIdle();
456
1275
  }
457
1276
 
458
- // 1. Pick the destination model.
1277
+ // Writer model: pi CLI flag > config file. There is no command-arg
1278
+ // flag; without a configured preference the picker just opens.
1279
+ const flagSummarizer = pi.getFlag(FLAG_SUMMARIZER);
1280
+ const summarizerSpec =
1281
+ (typeof flagSummarizer === "string" && flagSummarizer.trim() ? flagSummarizer.trim() : undefined) ??
1282
+ handoffSettings.summarizerModel;
1283
+ const pinnedModel = summarizerSpec
1284
+ ? ctx.modelRegistry.find(
1285
+ summarizerSpec.slice(0, summarizerSpec.indexOf("/")),
1286
+ summarizerSpec.slice(summarizerSpec.indexOf("/") + 1),
1287
+ )
1288
+ : undefined;
1289
+ if (summarizerSpec && !pinnedModel) {
1290
+ ctx.ui.notify(
1291
+ `Hand-off writer model ${summarizerSpec} is not available; choose one instead`,
1292
+ "warning",
1293
+ );
1294
+ }
1295
+ // A pi CLI flag is not asked about again; a config-file model is only
1296
+ // silently accepted when `askSummarizerModel` is false.
1297
+ const pinnedByFlag = typeof flagSummarizer === "string";
1298
+ const askSummarizer =
1299
+ !pinnedModel || (!pinnedByFlag && handoffSettings.askSummarizerModel !== false);
1300
+
1301
+ // 1. Choose who writes the hand-off prompt, and (for the fresh pass)
1302
+ // how hard that model should think.
1303
+ const writer = await chooseWriter(ctx, {
1304
+ writer: parsed.writer,
1305
+ pinnedModel,
1306
+ askSummarizer,
1307
+ });
1308
+ if (!writer) return;
1309
+
1310
+ let writerReasoning: ThinkingLevel | undefined;
1311
+ if (writer.kind === "fresh" && writer.model.reasoning) {
1312
+ const chosen = await selectWriterReasoningLevel(ctx, handoffSettings.summarizerThinkingLevel);
1313
+ if (!chosen) return;
1314
+ writerReasoning = chosen.reasoning;
1315
+ }
1316
+
1317
+ // 2. Pick the destination model.
459
1318
  const target = await pickTargetModel(ctx);
460
1319
  if (!target) return;
461
1320
 
462
- // 2. Pick the thinking level (a scoped `model:level` entry pins it).
1321
+ // 3. Pick the thinking level (a scoped `model:level` entry pins it).
463
1322
  const level = target.thinkingLevel ?? (await selectThinkingLevel(ctx));
464
1323
  if (level === undefined) return;
465
1324
 
466
- // 3. Collect steering instructions, pre-filled from /hand-off args.
1325
+ // 4. Collect steering instructions, pre-filled from the remaining args.
467
1326
  const steering = await ctx.ui.editor(
468
- "Steering for the hand-off prompt (sent to the current agent)",
469
- args.trim(),
1327
+ writer.kind === "live"
1328
+ ? "Steering for the hand-off prompt (sent to the current agent)"
1329
+ : "Steering for the hand-off prompt (sent to the hand-off writer)",
1330
+ parsed.steering.trim(),
470
1331
  );
471
1332
  if (steering === undefined) return;
472
1333
 
473
- // 4. Ask the current agent to write the hand-off prompt.
474
- awaitingHandoff = true;
475
- capturedPrompt = undefined;
476
- await pi.sendMessage(
477
- {
478
- customType: HANDOFF_CUSTOM_TYPE,
479
- content: buildHandoffRequest(steering),
480
- display: true,
481
- },
482
- { triggerTurn: true },
483
- );
484
- await ctx.waitForIdle();
485
- awaitingHandoff = false;
1334
+ // 5. Write the hand-off prompt with the chosen writer.
1335
+ const prompt =
1336
+ writer.kind === "live"
1337
+ ? await captureFromLiveAgent(ctx, steering)
1338
+ : await captureFromFreshPass(ctx, writer, writerReasoning, steering);
1339
+ if (!prompt) return;
486
1340
 
487
- if (!capturedPrompt) {
488
- ctx.ui.notify("The agent did not produce a hand-off prompt; staying in this session", "error");
489
- return;
490
- }
491
-
492
- // 5. Carry everything across session replacement as plain data.
1341
+ // 6. Carry everything across session replacement as plain data, with
1342
+ // the provenance comment added here (never by the writer model).
493
1343
  writeHandoff({
494
1344
  provider: target.model.provider,
495
1345
  modelId: target.model.id,
496
1346
  thinkingLevel: level,
497
- prompt: capturedPrompt,
1347
+ prompt: withSessionProvenance(prompt, ctx),
498
1348
  });
499
1349
 
500
1350
  const result = await ctx.newSession();
@@ -531,6 +1381,10 @@ export default function (pi: ExtensionAPI) {
531
1381
  });
532
1382
 
533
1383
  pi.on("session_start", async (event, ctx) => {
1384
+ const loaded = loadHandoffSettings();
1385
+ handoffSettings = loaded.settings;
1386
+ if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
1387
+
534
1388
  if (event.reason !== "new") return;
535
1389
 
536
1390
  const handoff = takeHandoff();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grrrck/pi-clear",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension that starts a fresh session with /clear while keeping your current model and thinking level, and hands conversations off between models with /hand-off",
5
5
  "keywords": [
6
6
  "pi-package",