@grrrck/pi-clear 0.3.1 → 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 +19 -0
  2. package/README.md +77 -12
  3. package/index.ts +898 -50
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ 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
+
8
27
  ## [0.3.1] - 2026-10-27
9
28
 
10
29
  ### Fixed
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,11 +55,15 @@
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";
66
+ import { BorderedLoader, DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
48
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
@@ -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");
@@ -279,7 +352,7 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
279
352
  theme.fg(
280
353
  "accent",
281
354
  theme.bold(
282
- `Hand off to model — ${scope === "scoped" ? `scoped (${scopedEntries.length})` : `all (${allEntries.length})`}`,
355
+ `${title} — ${scope === "scoped" ? `scoped (${scopedEntries.length})` : `all (${allEntries.length})`}`,
283
356
  ),
284
357
  ),
285
358
  );
@@ -331,12 +404,17 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
331
404
  *
332
405
  * TUI mode gets a `/model`-style searchable picker with Tab to toggle
333
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.
334
409
  */
335
- async function pickTargetModel(ctx: ExtensionContext): Promise<PickedModel | undefined> {
410
+ async function pickTargetModel(
411
+ ctx: ExtensionContext,
412
+ options: ModelPickerOptions = {},
413
+ ): Promise<PickedModel | undefined> {
336
414
  if (ctx.mode === "tui") {
337
- return pickTargetModelTui(ctx);
415
+ return pickTargetModelTui(ctx, options);
338
416
  }
339
- return pickTargetModelFallback(ctx);
417
+ return pickTargetModelFallback(ctx, options);
340
418
  }
341
419
 
342
420
  /** Extract the text content of an agent message, ignoring thinking blocks and tool calls. */
@@ -371,12 +449,679 @@ function buildHandoffRequest(steering: string): string {
371
449
  return lines.join("\n");
372
450
  }
373
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
+
374
1115
  export default function (pi: ExtensionAPI) {
375
1116
  // In-session state for the hand-off capture; fresh per extension instance,
376
1117
  // so it never leaks into a replacement session.
377
1118
  let awaitingHandoff = false;
378
1119
  let capturedPrompt: string | undefined;
379
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 = {};
380
1125
 
381
1126
  /**
382
1127
  * Apply a captured model and thinking level, reporting problems as
@@ -409,6 +1154,67 @@ export default function (pi: ExtensionAPI) {
409
1154
  }
410
1155
  }
411
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
+
412
1218
  pi.registerCommand("clear", {
413
1219
  description: "Start a fresh session, keeping the current model and thinking level",
414
1220
  handler: async (_args, ctx) => {
@@ -448,59 +1254,97 @@ export default function (pi: ExtensionAPI) {
448
1254
 
449
1255
  pi.registerCommand("hand-off", {
450
1256
  description:
451
- "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",
452
1258
  handler: async (args, ctx) => {
1259
+ if (flowInProgress) {
1260
+ ctx.ui.notify("A hand-off is already in progress", "warning");
1261
+ return;
1262
+ }
453
1263
  try {
454
- if (flowInProgress) {
455
- 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");
456
1269
  return;
457
1270
  }
458
- flowInProgress = true;
1271
+
459
1272
  if (!ctx.isIdle()) {
460
1273
  ctx.ui.notify("Waiting for the agent to finish before handing off", "info");
461
1274
  await ctx.waitForIdle();
462
1275
  }
463
1276
 
464
- // 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.
465
1318
  const target = await pickTargetModel(ctx);
466
1319
  if (!target) return;
467
1320
 
468
- // 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).
469
1322
  const level = target.thinkingLevel ?? (await selectThinkingLevel(ctx));
470
1323
  if (level === undefined) return;
471
1324
 
472
- // 3. Collect steering instructions, pre-filled from /hand-off args.
1325
+ // 4. Collect steering instructions, pre-filled from the remaining args.
473
1326
  const steering = await ctx.ui.editor(
474
- "Steering for the hand-off prompt (sent to the current agent)",
475
- 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(),
476
1331
  );
477
1332
  if (steering === undefined) return;
478
1333
 
479
- // 4. Ask the current agent to write the hand-off prompt.
480
- awaitingHandoff = true;
481
- capturedPrompt = undefined;
482
- await pi.sendMessage(
483
- {
484
- customType: HANDOFF_CUSTOM_TYPE,
485
- content: buildHandoffRequest(steering),
486
- display: true,
487
- },
488
- { triggerTurn: true },
489
- );
490
- await ctx.waitForIdle();
491
- 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;
492
1340
 
493
- if (!capturedPrompt) {
494
- ctx.ui.notify("The agent did not produce a hand-off prompt; staying in this session", "error");
495
- return;
496
- }
497
-
498
- // 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).
499
1343
  writeHandoff({
500
1344
  provider: target.model.provider,
501
1345
  modelId: target.model.id,
502
1346
  thinkingLevel: level,
503
- prompt: capturedPrompt,
1347
+ prompt: withSessionProvenance(prompt, ctx),
504
1348
  });
505
1349
 
506
1350
  const result = await ctx.newSession();
@@ -537,6 +1381,10 @@ export default function (pi: ExtensionAPI) {
537
1381
  });
538
1382
 
539
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
+
540
1388
  if (event.reason !== "new") return;
541
1389
 
542
1390
  const handoff = takeHandoff();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grrrck/pi-clear",
3
- "version": "0.3.1",
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",