@grrrck/pi-clear 0.3.1 → 0.5.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 +30 -0
  2. package/README.md +82 -12
  3. package/index.ts +953 -57
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ 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.5.0] - 2026-11-05
9
+
10
+ ### Added
11
+
12
+ - A **same-or-pick dialog** after the writer picker: *Same model and thinking level* (first, so Enter re-uses the current settings) or *Pick a model and thinking level…*, which opens the destination pickers.
13
+ - Flag `--same`: skips the same-or-pick dialog and re-uses the current session's model and thinking level, as if the first option was chosen.
14
+
15
+ ### Changed
16
+
17
+ - `/hand-off <instructions>` uses the inline steering text as-is: the steering editor only opens when no instructions were typed after the command, so passing steering on the command line no longer adds a dialog to confirm or edit it.
18
+
19
+ ## [0.4.0] - 2026-10-30
20
+
21
+ ### Added
22
+
23
+ - `/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…*.
24
+ - 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.
25
+ - 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.
26
+ - 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.
27
+ - 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.
28
+ - 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.
29
+ - CLI flags `--hand-off-summarizer=<provider/model>` and `--hand-off-max-source-chars=<n>`.
30
+ - 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.
31
+ - `scripts/minimal-transcript.test.mjs`: offline tests for the fresh-pass serializer, budget, file lists, provenance, and command-arg parsing.
32
+ - `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.
33
+
34
+ ### Changed
35
+
36
+ - `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.
37
+
8
38
  ## [0.3.1] - 2026-10-27
9
39
 
10
40
  ### 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] [--same] [steering instructions]
33
33
  ```
34
34
 
35
35
  ## `/clear` behavior
@@ -48,17 +48,79 @@ 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. asks whether the next session keeps the current model and thinking level — the first option, so pressing Enter keeps them — or opens a model picker, then a thinking level picker, then collects steering instructions: text you typed after `/hand-off` (and its flags) is used as-is, and the steering editor only opens when you passed none. `--same` skips the same-or-pick dialog and re-uses the current model and thinking level. 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
+ - `--same` skips the same-or-pick dialog and re-uses the current session's model and thinking level. It combines freely with `--fresh` / `--live` and inline steering.
82
+ - Flags are only recognized at the front of the command args: `/hand-off --same fix the tests` parses the flag, but `/hand-off fix the tests --same` passes `--same` through as steering text. Anything after the first non-flag token is steering.
83
+ - 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).
84
+ - The source is the post-compaction view of the session, so a compacted conversation is summarized from its summary rather than from nothing.
85
+ - 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.
86
+
87
+ ### Session provenance
88
+
89
+ Every hand-off prompt (from either writer) starts with a comment naming the session it came from:
90
+
91
+ ```markdown
92
+ <!-- Handed off from pi session 01a18da2-4eff-73b9-846d-5fd4c156e52e -->
93
+ ```
94
+
95
+ 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.
96
+
97
+ ## Configuration
98
+
99
+ Optional settings live in `<agent-dir>/pi-clear.json` (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`), read once per session:
100
+
101
+ ```json
102
+ {
103
+ "handoff": {
104
+ "summarizerModel": "anthropic/claude-haiku-4-5",
105
+ "askSummarizerModel": true,
106
+ "summarizerThinkingLevel": "low",
107
+ "maxSourceChars": 200000
108
+ }
109
+ }
110
+ ```
111
+
112
+ - `summarizerModel` — preselected in the writer-model picker, and offered as its own `Fresh pass — <model>` entry in the writer picker.
113
+ - `askSummarizerModel: false` — with `summarizerModel` set, skip the writer-model picker entirely and always use that model.
114
+ - `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.
115
+ - `maxSourceChars` — characters of conversation sent to the fresh pass (default `200000`, about 50k tokens). `0` disables the budget.
116
+
117
+ A malformed file is reported as a warning and ignored. The same knobs are available per pi launch as CLI flags:
118
+
119
+ ```sh
120
+ pi --hand-off-summarizer=anthropic/claude-haiku-4-5 --hand-off-max-source-chars=400000
121
+ ```
122
+
123
+ 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
124
 
63
125
  ## Notes
64
126
 
@@ -70,7 +132,9 @@ Two limitations worth knowing: if you queue another message while the agent is w
70
132
 
71
133
  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
134
 
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`.
135
+ `/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`.
136
+
137
+ 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
138
 
75
139
  ## Development
76
140
 
@@ -80,14 +144,20 @@ Load the extension directly for local testing:
80
144
  pi -e ./index.ts
81
145
  ```
82
146
 
83
- There are also RPC smoke tests that verify the behavior end-to-end by starting pi in RPC mode:
147
+ There are also smoke tests that verify the behavior end-to-end by starting pi in RPC mode:
84
148
 
85
149
  ```sh
86
150
  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
151
+ python3 scripts/rpc-handoff-smoke-test.py # /hand-off, both writers, dialogs included
152
+ node --test scripts/model-picker.test.mjs # offline: model picker behavior
153
+ node --test scripts/minimal-transcript.test.mjs # offline: fresh-pass transcript + budget
154
+ node --test scripts/fresh-pass-flow.test.mjs # offline: /hand-off flow, both writers
88
155
  ```
89
156
 
90
- Both require at least two models with configured auth.
157
+ The RPC smoke tests need at least two models with configured auth.
158
+ `rpc-handoff-smoke-test.py` makes **real, billed model requests** (the agent
159
+ writes the hand-off prompt); `rpc-smoke-test.py` only drives local RPC state
160
+ and makes none. The `node --test` scripts need neither auth nor a pi process.
91
161
 
92
162
  ## Related extensions
93
163
 
package/index.ts CHANGED
@@ -13,19 +13,38 @@
13
13
  * `/clear` starts an equally fresh session, then restores the model and
14
14
  * thinking level that were active when you cleared.
15
15
  *
16
- * `/hand-off`: hand the conversation to another model.
16
+ * `/hand-off`: hand the conversation to another model (or back to the same
17
+ * one, with `--same`).
17
18
  *
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.
19
+ * 1. The user runs `/hand-off`, optionally with `--fresh` or `--live` and
20
+ * steering text after them.
21
+ * 2. A dialog flow asks who writes the hand-off prompt, then asks whether the
22
+ * next session keeps the current model and thinking level (first option,
23
+ * so Enter keeps them) or opens the destination pickers — `--same` skips
24
+ * that dialog and re-uses the current values. Then steering instructions:
25
+ * text typed inline after `/hand-off` is used as-is, and an editor opens
26
+ * only when no inline steering was passed.
27
+ * 3. The hand-off prompt is written by one of two writers:
28
+ * - the **live agent** (default, first in the picker): the extension sends
29
+ * the current agent a custom message asking it to write its next message
30
+ * as the hand-off prompt, and `agent_end` captures the final assistant
31
+ * message. It sees the whole transcript and keeps tool access.
32
+ * - the **fresh pass**: a single out-of-band `modelRegistry.complete()`
33
+ * call over a *minimal reconstruction* of the conversation (no tool
34
+ * output, no thinking, head+tail budget), so long or stale sessions can
35
+ * still be handed off, and a cheap model can do the writing. It has no
36
+ * tool access, and its thinking level is chosen separately (provider
37
+ * default preselected).
38
+ * 4. The extension prepends a provenance comment naming the source session
39
+ * (both writers), replaces the session, applies the chosen model and
40
+ * thinking level, and inserts the prompt into the editor.
27
41
  * 5. The user reviews, edits, and submits the prompt themselves.
28
42
  *
43
+ * Settings live in `<agent-dir>/pi-clear.json` (read once per
44
+ * `session_start`); CLI flags `--hand-off-max-source-chars` and
45
+ * `--hand-off-summarizer` override the file. Without a configured writer
46
+ * model, `/hand-off` just asks: there is no command-arg model flag.
47
+ *
29
48
  * Mechanism:
30
49
  * Both commands capture state as plain data into a `globalThis` property.
31
50
  * Session replacement (`ctx.newSession()`) tears down the current runtime
@@ -40,11 +59,15 @@
40
59
  * into the editor with `ctx.ui.setEditorText()`.
41
60
  */
42
61
 
62
+ import { randomUUID } from "node:crypto";
63
+ import { readFileSync } from "node:fs";
64
+ import { join } from "node:path";
43
65
  import type {
44
66
  ExtensionAPI,
67
+ ExtensionCommandContext,
45
68
  ExtensionContext,
46
69
  } from "@earendil-works/pi-coding-agent";
47
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
70
+ import { BorderedLoader, DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
48
71
  import { Container, fuzzyFilter, Input, SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
49
72
 
50
73
  // Model and thinking-level types, derived from the API surface because the
@@ -87,6 +110,14 @@ const THINKING_LEVELS: ThinkingLevel[] = [
87
110
 
88
111
  const HANDOFF_CUSTOM_TYPE = "pi-clear:hand-off-request";
89
112
 
113
+ /**
114
+ * Reasoning levels offered for the fresh pass. "off" is not offered: it means
115
+ * the same thing as the provider default here, because the option is omitted
116
+ * either way.
117
+ */
118
+ const WRITER_REASONING_LEVELS: ThinkingLevel[] = THINKING_LEVELS.filter((level) => level !== "off");
119
+ const WRITER_DEFAULT_REASONING_LABEL = "provider default";
120
+
90
121
  function sameModel(a: AnyModel | undefined, b: AnyModel | undefined): boolean {
91
122
  return !!a && !!b && a.provider === b.provider && a.id === b.id;
92
123
  }
@@ -120,6 +151,14 @@ interface PickedModel {
120
151
  thinkingLevel?: ThinkingLevel;
121
152
  }
122
153
 
154
+ /** How the model pickers label themselves and which model they treat as current. */
155
+ interface ModelPickerOptions {
156
+ /** Preselected model; defaults to the session's model. */
157
+ current?: AnyModel;
158
+ title?: string;
159
+ providerTitle?: string;
160
+ }
161
+
123
162
  async function selectModel(
124
163
  ctx: ExtensionContext,
125
164
  models: AnyModel[],
@@ -135,6 +174,24 @@ async function selectModel(
135
174
  return index === -1 ? undefined : ordered[index];
136
175
  }
137
176
 
177
+ /**
178
+ * Same-or-pick picker between the writer and destination dialogs. The first
179
+ * option re-uses the current session's model and thinking level in one Enter
180
+ * press; the second opens the destination pickers. Without a current model
181
+ * there is nothing to re-use, so the destination pickers open directly.
182
+ */
183
+ async function chooseSameOrPick(ctx: ExtensionContext): Promise<"same" | "pick" | undefined> {
184
+ if (!ctx.model) return "pick";
185
+ const labels = [
186
+ `${SAME_SETTINGS_LABEL} (${ctx.model.provider}/${ctx.model.id}${ctx.thinkingLevel ? ` · ${ctx.thinkingLevel}` : ""})`,
187
+ PICK_SETTINGS_LABEL,
188
+ ];
189
+ const chosen = await ctx.ui.select("Model for the next session", labels);
190
+ if (chosen === undefined) return undefined;
191
+ const index = labelIndex(chosen, labels);
192
+ return index === 0 ? "same" : index === 1 ? "pick" : undefined;
193
+ }
194
+
138
195
  /**
139
196
  * Thinking level picker with the active level preselected, so Enter accepts
140
197
  * the current setting.
@@ -151,20 +208,54 @@ async function selectThinkingLevel(ctx: ExtensionContext): Promise<ThinkingLevel
151
208
  return index === -1 ? undefined : ordered[index];
152
209
  }
153
210
 
211
+ /**
212
+ * Thinking level for the fresh pass writer. The provider default comes first and
213
+ * is preselected unless the config file pins a level, so pressing Enter keeps
214
+ * today's behavior and the dialog is a no-op for most runs.
215
+ */
216
+ async function selectWriterReasoningLevel(
217
+ ctx: ExtensionContext,
218
+ preselected: ThinkingLevel | undefined,
219
+ ): Promise<{ reasoning?: ThinkingLevel } | undefined> {
220
+ const ordered = preselected
221
+ ? [preselected, ...WRITER_REASONING_LEVELS.filter((level) => level !== preselected)]
222
+ : [...WRITER_REASONING_LEVELS];
223
+ const options: Array<{ reasoning?: ThinkingLevel; label: string }> = ordered.map((level) => ({
224
+ reasoning: level,
225
+ label: `${level}${level === preselected ? " (current)" : ""}`,
226
+ }));
227
+ if (preselected) {
228
+ options.push({ label: WRITER_DEFAULT_REASONING_LABEL });
229
+ } else {
230
+ options.unshift({ label: `${WRITER_DEFAULT_REASONING_LABEL} (current)` });
231
+ }
232
+
233
+ const labels = options.map((option) => option.label);
234
+ const chosen = await ctx.ui.select("Thinking level for the hand-off writer", labels);
235
+ if (chosen === undefined) return undefined;
236
+ const index = labelIndex(chosen, labels);
237
+ return index === -1 ? undefined : { reasoning: options[index].reasoning };
238
+ }
239
+
154
240
  /**
155
241
  * Non-interactive fallback (RPC mode has no custom components): scoped
156
242
  * models in one dialog when scoping is configured, otherwise a two-level
157
243
  * provider → model pick over the auth-configured catalogue.
158
244
  */
159
- async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedModel | undefined> {
160
- const current = ctx.model;
245
+ async function pickTargetModelFallback(
246
+ ctx: ExtensionContext,
247
+ options: ModelPickerOptions = {},
248
+ ): Promise<PickedModel | undefined> {
249
+ const current = options.current ?? ctx.model;
250
+ const title = options.title ?? "Hand off to model";
251
+ const providerTitle = options.providerTitle ?? "Hand off to provider";
161
252
  const scoped = ctx.scopedModels.map((scoped) => scoped.model);
162
253
 
163
254
  if (scoped.length > 0) {
164
255
  // Keep the current model selectable and preselected even when scoping
165
256
  // excludes it.
166
257
  const models = current && !scoped.some((m) => sameModel(m, current)) ? [current, ...scoped] : scoped;
167
- const model = await selectModel(ctx, models, "Hand off to model", current);
258
+ const model = await selectModel(ctx, models, title, current);
168
259
  return model ? { model } : undefined;
169
260
  }
170
261
 
@@ -190,13 +281,13 @@ async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedMod
190
281
 
191
282
  const providerLabels = providers.map((p) => `${p} (${byProvider.get(p)!.length} models)`);
192
283
  const providerChoice =
193
- providers.length === 1 ? providerLabels[0] : await ctx.ui.select("Hand off to provider", providerLabels);
284
+ providers.length === 1 ? providerLabels[0] : await ctx.ui.select(providerTitle, providerLabels);
194
285
  if (providerChoice === undefined) return undefined;
195
286
  const providerIndex = labelIndex(providerChoice, providerLabels);
196
287
  if (providerIndex === -1) return undefined;
197
288
  const providerName = providers[providerIndex];
198
289
 
199
- const model = await selectModel(ctx, byProvider.get(providerName) ?? [], "Hand off to model", current);
290
+ const model = await selectModel(ctx, byProvider.get(providerName) ?? [], title, current);
200
291
  return model ? { model } : undefined;
201
292
  }
202
293
 
@@ -204,8 +295,12 @@ async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedMod
204
295
  * `/model`-style model picker (TUI mode only): a searchable list with Tab to
205
296
  * toggle between the session's scoped models and the full catalogue.
206
297
  */
207
- async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel | undefined> {
208
- const current = ctx.model;
298
+ async function pickTargetModelTui(
299
+ ctx: ExtensionContext,
300
+ options: ModelPickerOptions = {},
301
+ ): Promise<PickedModel | undefined> {
302
+ const current = options.current ?? ctx.model;
303
+ const title = options.title ?? "Hand off to model";
209
304
  const allEntries: PickedModel[] = ctx.modelRegistry.getAvailable().map((model) => ({ model }));
210
305
  if (allEntries.length === 0) {
211
306
  ctx.ui.notify("No models available to hand off to", "error");
@@ -279,7 +374,7 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
279
374
  theme.fg(
280
375
  "accent",
281
376
  theme.bold(
282
- `Hand off to model — ${scope === "scoped" ? `scoped (${scopedEntries.length})` : `all (${allEntries.length})`}`,
377
+ `${title} — ${scope === "scoped" ? `scoped (${scopedEntries.length})` : `all (${allEntries.length})`}`,
283
378
  ),
284
379
  ),
285
380
  );
@@ -331,12 +426,17 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
331
426
  *
332
427
  * TUI mode gets a `/model`-style searchable picker with Tab to toggle
333
428
  * between scoped and all models; other modes fall back to plain dialogs.
429
+ * The same picker also chooses the fresh pass's writer model, with the
430
+ * title and preselected model supplied by the caller.
334
431
  */
335
- async function pickTargetModel(ctx: ExtensionContext): Promise<PickedModel | undefined> {
432
+ async function pickTargetModel(
433
+ ctx: ExtensionContext,
434
+ options: ModelPickerOptions = {},
435
+ ): Promise<PickedModel | undefined> {
336
436
  if (ctx.mode === "tui") {
337
- return pickTargetModelTui(ctx);
437
+ return pickTargetModelTui(ctx, options);
338
438
  }
339
- return pickTargetModelFallback(ctx);
439
+ return pickTargetModelFallback(ctx, options);
340
440
  }
341
441
 
342
442
  /** Extract the text content of an agent message, ignoring thinking blocks and tool calls. */
@@ -371,12 +471,687 @@ function buildHandoffRequest(steering: string): string {
371
471
  return lines.join("\n");
372
472
  }
373
473
 
474
+ // ============================================================================
475
+ // Fresh pass: one out-of-band model call over a minimal reconstruction
476
+ // ============================================================================
477
+
478
+ 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.
479
+
480
+ Do not continue the conversation. Do not answer questions in it.
481
+
482
+ 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.`;
483
+
484
+ const DEFAULT_COVERAGE =
485
+ "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.";
486
+
487
+ /** Characters of conversation sent to the fresh pass (~50k tokens at 200k). */
488
+ const DEFAULT_MAX_SOURCE_CHARS = 200_000;
489
+ /** Share of the budget kept from the newest end of the conversation. */
490
+ const TAIL_SOURCE_SHARE = 0.6;
491
+ /** Share of the budget kept contiguously after the opening message. */
492
+ const HEAD_SOURCE_SHARE = 0.4;
493
+ const USER_MESSAGE_MAX_CHARS = 8_000;
494
+ const ASSISTANT_MESSAGE_MAX_CHARS = 4_000;
495
+ const SUMMARY_MAX_CHARS = 8_000;
496
+ const NOTE_MAX_CHARS = 2_000;
497
+ const HINT_MAX_CHARS = 200;
498
+
499
+ /** A projected session message, viewed loosely: only fields the serializer reads. */
500
+ interface TranscriptSource {
501
+ role?: string;
502
+ content?: unknown;
503
+ customType?: string;
504
+ summary?: string;
505
+ command?: string;
506
+ /** `!!` bash executions: never sent to a model, so never sent to the writer. */
507
+ excludeFromContext?: boolean;
508
+ }
509
+
510
+ interface ContentBlock {
511
+ type?: string;
512
+ text?: string;
513
+ name?: string;
514
+ arguments?: unknown;
515
+ }
516
+
517
+ /** One rendered unit of the minimal transcript; the budget never splits a block. */
518
+ interface TranscriptBlock {
519
+ text: string;
520
+ /** Marks the opening user message, which the budget always keeps. */
521
+ anchor?: boolean;
522
+ }
523
+
524
+ interface TranscriptResult {
525
+ text: string;
526
+ /** Blocks dropped from the middle of the conversation, 0 when nothing was dropped. */
527
+ omittedTurns: number;
528
+ /** Characters those blocks would have contributed. */
529
+ droppedChars: number;
530
+ }
531
+
532
+ function contentBlocks(content: unknown): ContentBlock[] {
533
+ if (typeof content === "string") return [{ type: "text", text: content }];
534
+ if (!Array.isArray(content)) return [];
535
+ const blocks: ContentBlock[] = [];
536
+ for (const block of content) {
537
+ if (typeof block === "object" && block !== null) blocks.push(block as ContentBlock);
538
+ }
539
+ return blocks;
540
+ }
541
+
542
+ /** Visible text only: thinking, tool calls, and images are dropped. */
543
+ function visibleText(content: unknown): string {
544
+ return contentBlocks(content)
545
+ .filter((block) => block.type === "text" && typeof block.text === "string")
546
+ .map((block) => block.text!.trim())
547
+ .filter((text) => text.length > 0)
548
+ .join("\n");
549
+ }
550
+
551
+ function toolCallsOf(content: unknown): ContentBlock[] {
552
+ return contentBlocks(content).filter(
553
+ (block) => block.type === "toolCall" && typeof block.name === "string",
554
+ );
555
+ }
556
+
557
+ function stringArg(args: unknown, key: string): string | undefined {
558
+ if (typeof args !== "object" || args === null) return undefined;
559
+ const value = (args as Record<string, unknown>)[key];
560
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
561
+ }
562
+
563
+ function firstStringArg(args: unknown): string | undefined {
564
+ if (typeof args !== "object" || args === null) return undefined;
565
+ for (const value of Object.values(args as Record<string, unknown>)) {
566
+ if (typeof value === "string" && value.trim()) return value.trim();
567
+ }
568
+ return undefined;
569
+ }
570
+
571
+ /** Keep both ends of an over-long text, and say how much was cut. */
572
+ function truncateMiddle(text: string, maxChars: number): string {
573
+ if (maxChars <= 0 || text.length <= maxChars) return text;
574
+ const headChars = Math.floor(maxChars * TAIL_SOURCE_SHARE);
575
+ const tailChars = maxChars - headChars;
576
+ const removed = text.length - maxChars;
577
+ return `${text.slice(0, headChars)}\n[... ${removed} chars truncated ...]\n${text.slice(text.length - tailChars)}`;
578
+ }
579
+
580
+ /** One line naming a tool call and its target — never its result. */
581
+ export function formatToolHint(name: string, args: unknown): string {
582
+ const path = stringArg(args, "path");
583
+ switch (name) {
584
+ case "read":
585
+ case "edit":
586
+ case "write":
587
+ return path ? `${name} ${path}` : name;
588
+ case "bash": {
589
+ const command = stringArg(args, "command")?.split("\n")[0].trim() ?? "";
590
+ return command ? `bash: ${command.slice(0, HINT_MAX_CHARS)}` : "bash";
591
+ }
592
+ case "grep": {
593
+ const pattern = stringArg(args, "pattern");
594
+ if (!pattern) return "grep";
595
+ return path ? `grep: /${pattern}/ in ${path}` : `grep: /${pattern}/`;
596
+ }
597
+ case "ls":
598
+ return path ? `ls: ${path}` : "ls";
599
+ case "find": {
600
+ const pattern = stringArg(args, "pattern");
601
+ if (pattern && path) return `find: ${pattern} in ${path}`;
602
+ if (path) return `find: ${path}`;
603
+ return pattern ? `find: ${pattern}` : "find";
604
+ }
605
+ default: {
606
+ const detail = path ?? firstStringArg(args);
607
+ return detail ? `tool: ${name} ${detail.slice(0, HINT_MAX_CHARS)}` : `tool: ${name}`;
608
+ }
609
+ }
610
+ }
611
+
612
+ interface FileLists {
613
+ readFiles: string[];
614
+ modifiedFiles: string[];
615
+ }
616
+
617
+ /** Same rules pi uses for compaction: read-only files, then written/edited ones. */
618
+ function fileListsFromMessages(messages: readonly TranscriptSource[]): FileLists {
619
+ const read = new Set<string>();
620
+ const written = new Set<string>();
621
+ const edited = new Set<string>();
622
+ for (const message of messages) {
623
+ if (message.role !== "assistant") continue;
624
+ for (const call of toolCallsOf(message.content)) {
625
+ const path = stringArg(call.arguments, "path");
626
+ if (!path) continue;
627
+ if (call.name === "read") read.add(path);
628
+ else if (call.name === "write") written.add(path);
629
+ else if (call.name === "edit") edited.add(path);
630
+ }
631
+ }
632
+ const modified = new Set([...edited, ...written]);
633
+ return {
634
+ readFiles: [...read].filter((file) => !modified.has(file)).sort(),
635
+ modifiedFiles: [...modified].sort(),
636
+ };
637
+ }
638
+
639
+ function formatFileLists(lists: FileLists): string {
640
+ const sections: string[] = [];
641
+ if (lists.readFiles.length > 0) sections.push(`<read-files>\n${lists.readFiles.join("\n")}\n</read-files>`);
642
+ if (lists.modifiedFiles.length > 0) {
643
+ sections.push(`<modified-files>\n${lists.modifiedFiles.join("\n")}\n</modified-files>`);
644
+ }
645
+ return sections.join("\n\n");
646
+ }
647
+
648
+ /**
649
+ * Render the conversation as labelled blocks: visible text, tool-call hints,
650
+ * summaries, and notes. Tool results, thinking, and stale hand-off requests
651
+ * are dropped outright.
652
+ */
653
+ export function renderTranscriptBlocks(messages: readonly TranscriptSource[]): TranscriptBlock[] {
654
+ const blocks: TranscriptBlock[] = [];
655
+ let sawUserMessage = false;
656
+
657
+ for (const message of messages) {
658
+ switch (message.role) {
659
+ case "user": {
660
+ const text = visibleText(message.content);
661
+ if (!text) break;
662
+ blocks.push({
663
+ text: `[User]: ${truncateMiddle(text, USER_MESSAGE_MAX_CHARS)}`,
664
+ anchor: !sawUserMessage,
665
+ });
666
+ sawUserMessage = true;
667
+ break;
668
+ }
669
+ case "assistant": {
670
+ const text = visibleText(message.content);
671
+ if (text) {
672
+ blocks.push({ text: `[Assistant]: ${truncateMiddle(text, ASSISTANT_MESSAGE_MAX_CHARS)}` });
673
+ }
674
+ const hints = toolCallsOf(message.content).map((call) => formatToolHint(call.name!, call.arguments));
675
+ if (hints.length > 0) blocks.push({ text: `[tools]:\n${hints.join("\n")}` });
676
+ break;
677
+ }
678
+ case "toolResult":
679
+ // Tool output never shapes the hand-off: it is the bulk of a transcript
680
+ // and the writer is told to have the next agent verify state itself.
681
+ break;
682
+ case "custom": {
683
+ if (message.customType === HANDOFF_CUSTOM_TYPE) break;
684
+ const text = visibleText(message.content);
685
+ if (text) blocks.push({ text: `[Note]: ${truncateMiddle(text, NOTE_MAX_CHARS)}` });
686
+ break;
687
+ }
688
+ case "compactionSummary":
689
+ case "branchSummary": {
690
+ const summary = typeof message.summary === "string" ? message.summary.trim() : "";
691
+ if (summary) {
692
+ blocks.push({
693
+ text: `[Summary of earlier conversation]: ${truncateMiddle(summary, SUMMARY_MAX_CHARS)}`,
694
+ });
695
+ }
696
+ break;
697
+ }
698
+ case "bashExecution": {
699
+ if (message.excludeFromContext) break;
700
+ const command = typeof message.command === "string" ? message.command.trim() : "";
701
+ if (command) blocks.push({ text: `[User ran]: ${truncateMiddle(command, NOTE_MAX_CHARS)}` });
702
+ break;
703
+ }
704
+ default:
705
+ break;
706
+ }
707
+ }
708
+
709
+ return blocks;
710
+ }
711
+
712
+ /**
713
+ * Keep the head and the tail of the conversation within `maxChars`, dropping
714
+ * the middle and leaving an elision marker in its place. `maxChars <= 0`
715
+ * disables the budget.
716
+ */
717
+ export function applyTranscriptBudget(blocks: readonly TranscriptBlock[], maxChars: number): TranscriptResult {
718
+ const parts = blocks.map((block) => block.text);
719
+ const joinBlocks = (items: string[]) => items.join("\n\n");
720
+ const full = joinBlocks(parts);
721
+ if (maxChars <= 0 || full.length <= maxChars) {
722
+ return { text: full, omittedTurns: 0, droppedChars: 0 };
723
+ }
724
+
725
+ // Head: everything up to and including the opening user message, always kept.
726
+ const anchor = blocks.findIndex((block) => block.anchor);
727
+ const anchorIndex = anchor === -1 ? 0 : anchor;
728
+ let headChars = parts.slice(0, anchorIndex + 1).reduce((sum, part) => sum + part.length + 2, 0);
729
+
730
+ // Tail: newest first, up to 60% of the budget.
731
+ const tailBudget = Math.floor(maxChars * TAIL_SOURCE_SHARE);
732
+ let tailStart = parts.length;
733
+ let tailChars = 0;
734
+ for (let i = parts.length - 1; i > anchorIndex; i--) {
735
+ const cost = parts[i].length + 2;
736
+ if (tailChars + cost > tailBudget) break;
737
+ tailChars += cost;
738
+ tailStart = i;
739
+ }
740
+ // Never drop the newest block: it is the most valuable one, and message-level
741
+ // caps normally keep it well inside the tail budget.
742
+ if (tailStart === parts.length && parts.length > anchorIndex + 1) tailStart = parts.length - 1;
743
+
744
+ // Gap: oldest first, contiguous from just after the head, while the head
745
+ // budget lasts.
746
+ const headBudget = Math.floor(maxChars * HEAD_SOURCE_SHARE);
747
+ let gapEnd = anchorIndex;
748
+ while (gapEnd + 1 < tailStart && headChars + parts[gapEnd + 1].length + 2 <= headBudget) {
749
+ headChars += parts[gapEnd + 1].length + 2;
750
+ gapEnd += 1;
751
+ }
752
+
753
+ const omitted = parts.slice(gapEnd + 1, tailStart);
754
+ const droppedChars = omitted.reduce((sum, part) => sum + part.length + 2, 0);
755
+ const kept = [...parts.slice(0, gapEnd + 1)];
756
+ if (omitted.length > 0) kept.push(`[... ${omitted.length} turns omitted — ${droppedChars} chars ...]`);
757
+ kept.push(...parts.slice(tailStart));
758
+
759
+ return { text: joinBlocks(kept), omittedTurns: omitted.length, droppedChars };
760
+ }
761
+
762
+ /** Minimal reconstruction of the conversation plus the file-operation footer. */
763
+ export function buildMinimalTranscript(
764
+ messages: readonly TranscriptSource[],
765
+ maxChars: number = DEFAULT_MAX_SOURCE_CHARS,
766
+ ): TranscriptResult {
767
+ const budget = applyTranscriptBudget(renderTranscriptBlocks(messages), maxChars);
768
+ const fileLists = formatFileLists(fileListsFromMessages(messages));
769
+ if (!fileLists) return budget;
770
+ return { ...budget, text: budget.text ? `${budget.text}\n\n${fileLists}` : fileLists };
771
+ }
772
+
773
+ /** The single user message of the fresh pass. */
774
+ export function buildFreshPassUserMessage(transcript: string, steering: string): string {
775
+ return [
776
+ "## Conversation (minimal reconstruction)",
777
+ transcript,
778
+ "",
779
+ "## What the hand-off must cover",
780
+ steering.trim() || DEFAULT_COVERAGE,
781
+ "",
782
+ "Requirements for your output:",
783
+ '- Written as instructions to the next agent ("you"), not a report about the past.',
784
+ "- 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.",
785
+ "- Name the files worth opening first.",
786
+ "- Reply with ONLY the hand-off message: no preamble, no postamble, no fences.",
787
+ ].join("\n");
788
+ }
789
+
790
+ // ============================================================================
791
+ // Session provenance (both writers)
792
+ // ============================================================================
793
+
794
+ /**
795
+ * HTML comment naming the session a hand-off came from, so the next agent can
796
+ * look it up when the prompt is not enough. It is a comment, not prose, so it
797
+ * does not read as part of the task, and it stays visible in the editor.
798
+ */
799
+ export function buildProvenanceComment(sessionId: string | undefined): string {
800
+ const id = sessionId?.trim();
801
+ return id ? `<!-- Handed off from pi session ${id} -->` : "";
802
+ }
803
+
804
+ /** Prepends the provenance comment; called right before `writeHandoff()`. */
805
+ function withSessionProvenance(prompt: string, ctx: ExtensionContext): string {
806
+ const comment = buildProvenanceComment(ctx.sessionManager.getSessionId());
807
+ return comment ? `${comment}\n\n${prompt}` : prompt;
808
+ }
809
+
810
+ // ============================================================================
811
+ // Fresh pass generation
812
+ // ============================================================================
813
+
814
+ /** Options for the one-shot writer call; `reasoning` is only set when supported. */
815
+ interface WriterRequestOptions {
816
+ signal?: AbortSignal;
817
+ cacheRetention: "none";
818
+ sessionId: string;
819
+ reasoning?: ThinkingLevel;
820
+ }
821
+
822
+ interface WriterOutcome {
823
+ prompt?: string;
824
+ error?: string;
825
+ cancelled?: boolean;
826
+ }
827
+
828
+ /** Strip a fence that wraps the whole reply, leaving inner code blocks alone. */
829
+ function stripSurroundingFence(text: string): string {
830
+ const trimmed = text.trim();
831
+ if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return trimmed;
832
+ const withoutOpen = trimmed.replace(/^```[^\n]*\n?/, "");
833
+ return withoutOpen.slice(0, withoutOpen.lastIndexOf("```")).trim();
834
+ }
835
+
836
+ function checkWrittenPrompt(response: { stopReason?: string; errorMessage?: string; content?: unknown }): WriterOutcome {
837
+ if (response.stopReason === "aborted") return { cancelled: true };
838
+ if (response.stopReason !== "stop") {
839
+ const detail = response.errorMessage ? `: ${response.errorMessage}` : "";
840
+ return { error: `stopped with reason "${response.stopReason}"${detail}` };
841
+ }
842
+ const text = stripSurroundingFence(visibleText(response.content));
843
+ if (!text) return { error: "returned an empty prompt" };
844
+ return { prompt: text };
845
+ }
846
+
847
+ function reportWriterOutcome(ctx: ExtensionContext, outcome: WriterOutcome, label: string): string | undefined {
848
+ if (outcome.prompt) return outcome.prompt;
849
+ if (outcome.cancelled) {
850
+ ctx.ui.notify("Hand-off cancelled", "info");
851
+ return undefined;
852
+ }
853
+ ctx.ui.notify(
854
+ `Hand-off writer (${label}) ${outcome.error ?? "failed"}; staying in this session`,
855
+ "error",
856
+ );
857
+ return undefined;
858
+ }
859
+
860
+ /**
861
+ * Run the fresh pass: one `complete()` call over the minimal transcript.
862
+ * TUI mode gets a cancellable loader; other modes get a notification.
863
+ */
864
+ async function generateFreshPassPrompt(
865
+ ctx: ExtensionContext,
866
+ model: AnyModel,
867
+ transcript: string,
868
+ steering: string,
869
+ reasoning: ThinkingLevel | undefined,
870
+ ): Promise<string | undefined> {
871
+ const userMessage = {
872
+ role: "user" as const,
873
+ content: [{ type: "text" as const, text: buildFreshPassUserMessage(transcript, steering) }],
874
+ timestamp: Date.now(),
875
+ };
876
+
877
+ const attempt = async (signal?: AbortSignal): Promise<WriterOutcome> => {
878
+ const options: WriterRequestOptions = { signal, cacheRetention: "none", sessionId: randomUUID() };
879
+ // Providers default when the option is absent, and models without
880
+ // reasoning controls would reject it; pi's own summarization does the
881
+ // same check before setting the level.
882
+ if (reasoning && model.reasoning) options.reasoning = reasoning;
883
+ try {
884
+ const response = await ctx.modelRegistry.complete(
885
+ model,
886
+ { systemPrompt: HANDOFF_SYSTEM_PROMPT, messages: [userMessage] },
887
+ options,
888
+ );
889
+ return checkWrittenPrompt(response);
890
+ } catch (err) {
891
+ return { error: `failed: ${err instanceof Error ? err.message : String(err)}` };
892
+ }
893
+ };
894
+
895
+ const label = `${model.provider}/${model.id}`;
896
+
897
+ if (ctx.mode !== "tui") {
898
+ ctx.ui.notify(`Writing the hand-off prompt with ${label}…`, "info");
899
+ return reportWriterOutcome(ctx, await attempt(), label);
900
+ }
901
+
902
+ let aborted = false;
903
+ let outcome: WriterOutcome | undefined;
904
+ const prompt = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
905
+ // Abort and completion can race; `done` must only be called once.
906
+ let finished = false;
907
+ const finish = (value: string | null) => {
908
+ if (finished) return;
909
+ finished = true;
910
+ done(value);
911
+ };
912
+ const loader = new BorderedLoader(tui, theme, `Writing the hand-off prompt with ${label}…`);
913
+ loader.onAbort = () => {
914
+ aborted = true;
915
+ finish(null);
916
+ };
917
+ void attempt(loader.signal).then((result) => {
918
+ outcome = result;
919
+ finish(result.prompt ?? null);
920
+ });
921
+ return loader;
922
+ });
923
+
924
+ if (prompt) return prompt;
925
+ if (aborted) {
926
+ ctx.ui.notify("Hand-off cancelled", "info");
927
+ return undefined;
928
+ }
929
+ return reportWriterOutcome(ctx, outcome ?? { error: "returned no prompt" }, label);
930
+ }
931
+
932
+ // ============================================================================
933
+ // Command arguments, writer choice, and settings
934
+ // ============================================================================
935
+
936
+ interface ParsedHandoffArgs {
937
+ writer?: "live" | "fresh";
938
+ /** `--same`: skip the destination pickers and reuse the current session's model and thinking level. */
939
+ same?: boolean;
940
+ /** Everything after the leading flags: the steering-editor prefill. */
941
+ steering: string;
942
+ error?: string;
943
+ }
944
+
945
+ /** Parse leading `/hand-off` flags; the first unrecognized token starts the steering text. */
946
+ export function parseHandoffArgs(args: string): ParsedHandoffArgs {
947
+ const tokens = args.split(/\s+/).filter(Boolean);
948
+ const parsed: ParsedHandoffArgs = { steering: "" };
949
+ let index = 0;
950
+
951
+ for (; index < tokens.length; index++) {
952
+ const token = tokens[index];
953
+ if (token === "--fresh" || token === "--live") {
954
+ const writer = token === "--fresh" ? "fresh" : "live";
955
+ if (parsed.writer && parsed.writer !== writer) {
956
+ return { ...parsed, error: "--fresh and --live cannot be combined; nothing was sent" };
957
+ }
958
+ parsed.writer = writer;
959
+ continue;
960
+ }
961
+ if (token === "--same") {
962
+ parsed.same = true;
963
+ continue;
964
+ }
965
+ break;
966
+ }
967
+
968
+ parsed.steering = tokens.slice(index).join(" ");
969
+ return parsed;
970
+ }
971
+
972
+ export interface HandoffSettings {
973
+ summarizerModel?: string;
974
+ askSummarizerModel?: boolean;
975
+ /** Thinking level for the fresh pass; absent means the provider default. */
976
+ summarizerThinkingLevel?: ThinkingLevel;
977
+ maxSourceChars?: number;
978
+ }
979
+
980
+ const SETTINGS_FILE_NAME = "pi-clear.json";
981
+ const FLAG_MAX_SOURCE_CHARS = "hand-off-max-source-chars";
982
+ const FLAG_SUMMARIZER = "hand-off-summarizer";
983
+
984
+ function isRecord(value: unknown): value is Record<string, unknown> {
985
+ return typeof value === "object" && value !== null && !Array.isArray(value);
986
+ }
987
+
988
+ /** Read `<agent-dir>/pi-clear.json`; a malformed file is a warning, not a failure. */
989
+ export function loadHandoffSettings(path: string = join(getAgentDir(), SETTINGS_FILE_NAME)): {
990
+ settings: HandoffSettings;
991
+ warning?: string;
992
+ } {
993
+ let raw: string;
994
+ try {
995
+ raw = readFileSync(path, "utf8");
996
+ } catch (err) {
997
+ if ((err as { code?: string }).code === "ENOENT") return { settings: {} };
998
+ return { settings: {}, warning: `pi-clear: could not read ${path}: ${messageOf(err)}` };
999
+ }
1000
+
1001
+ let parsed: unknown;
1002
+ try {
1003
+ parsed = JSON.parse(raw);
1004
+ } catch (err) {
1005
+ return { settings: {}, warning: `pi-clear: ignoring malformed ${path}: ${messageOf(err)}` };
1006
+ }
1007
+
1008
+ if (!isRecord(parsed) || !isRecord(parsed.handoff)) {
1009
+ return { settings: {}, warning: `pi-clear: ignoring ${path}: expected a "handoff" object` };
1010
+ }
1011
+
1012
+ const handoff = parsed.handoff;
1013
+ const settings: HandoffSettings = {};
1014
+ const problems: string[] = [];
1015
+
1016
+ if (handoff.summarizerModel !== undefined) {
1017
+ if (typeof handoff.summarizerModel === "string" && handoff.summarizerModel.includes("/")) {
1018
+ settings.summarizerModel = handoff.summarizerModel.trim();
1019
+ } else {
1020
+ problems.push('"summarizerModel" must be a "provider/model" string');
1021
+ }
1022
+ }
1023
+ if (handoff.summarizerThinkingLevel !== undefined) {
1024
+ const value = handoff.summarizerThinkingLevel;
1025
+ // "default" (and "off") mean "leave the choice to the provider", the same
1026
+ // as omitting the key.
1027
+ if (value === "default" || value === "off") {
1028
+ // Nothing to record.
1029
+ } else if (typeof value === "string" && WRITER_REASONING_LEVELS.includes(value as ThinkingLevel)) {
1030
+ settings.summarizerThinkingLevel = value as ThinkingLevel;
1031
+ } else {
1032
+ problems.push(
1033
+ `"summarizerThinkingLevel" must be "default" or one of ${WRITER_REASONING_LEVELS.join(", ")}`,
1034
+ );
1035
+ }
1036
+ }
1037
+ if (handoff.askSummarizerModel !== undefined) {
1038
+ if (typeof handoff.askSummarizerModel === "boolean") settings.askSummarizerModel = handoff.askSummarizerModel;
1039
+ else problems.push('"askSummarizerModel" must be true or false');
1040
+ }
1041
+ if (handoff.maxSourceChars !== undefined) {
1042
+ if (typeof handoff.maxSourceChars === "number" && Number.isFinite(handoff.maxSourceChars) && handoff.maxSourceChars >= 0) {
1043
+ settings.maxSourceChars = Math.floor(handoff.maxSourceChars);
1044
+ } else {
1045
+ problems.push('"maxSourceChars" must be a number >= 0');
1046
+ }
1047
+ }
1048
+
1049
+ return problems.length > 0
1050
+ ? { settings, warning: `pi-clear: ignoring ${path} ${problems.join(", ")}` }
1051
+ : { settings };
1052
+ }
1053
+
1054
+ function messageOf(err: unknown): string {
1055
+ return err instanceof Error ? err.message : String(err);
1056
+ }
1057
+
1058
+ /** Command arg > pi CLI flag > config file > built-in default. */
1059
+ export function resolveMaxSourceChars(flagValue: unknown, settings: HandoffSettings): number {
1060
+ if (typeof flagValue === "string" && flagValue.trim()) {
1061
+ const fromFlag = Number(flagValue);
1062
+ if (Number.isFinite(fromFlag) && fromFlag >= 0) return Math.floor(fromFlag);
1063
+ }
1064
+ if (settings.maxSourceChars !== undefined) return settings.maxSourceChars;
1065
+ return DEFAULT_MAX_SOURCE_CHARS;
1066
+ }
1067
+
1068
+ /** `118234` → `118k`, for the trimming notice. */
1069
+ function formatChars(chars: number): string {
1070
+ if (chars < 1000) return String(chars);
1071
+ if (chars < 1_000_000) return `${Math.round(chars / 1000)}k`;
1072
+ return `${(chars / 1_000_000).toFixed(1)}M`;
1073
+ }
1074
+
1075
+ type WriterChoice = { kind: "live" } | { kind: "fresh"; model: AnyModel };
1076
+
1077
+ interface WriterOptions {
1078
+ /** From `--fresh` / `--live`; skips the writer picker. */
1079
+ writer?: "live" | "fresh";
1080
+ /** Model pinned by command arg, pi flag, or config. */
1081
+ pinnedModel?: AnyModel;
1082
+ /** Whether the summarizer model picker still needs to open. */
1083
+ askSummarizer: boolean;
1084
+ }
1085
+
1086
+ const WRITER_LIVE_LABEL = "This agent — full context and tools";
1087
+ const WRITER_FRESH_OTHER_LABEL = "Fresh pass — another model…";
1088
+ const SAME_SETTINGS_LABEL = "Same model and thinking level";
1089
+ const PICK_SETTINGS_LABEL = "Pick a model and thinking level…";
1090
+
1091
+ /** Pick the hand-off writer: the live agent, or a fresh pass with a chosen model. */
1092
+ async function chooseWriter(ctx: ExtensionContext, options: WriterOptions): Promise<WriterChoice | undefined> {
1093
+ const withCurrentModel = (): WriterChoice | undefined => {
1094
+ const model = options.pinnedModel ?? ctx.model;
1095
+ if (!model) {
1096
+ ctx.ui.notify("No current model to write the hand-off; pick another model", "error");
1097
+ return undefined;
1098
+ }
1099
+ return { kind: "fresh", model };
1100
+ };
1101
+
1102
+ const askForModel = async (): Promise<WriterChoice | undefined> => {
1103
+ if (!options.askSummarizer) return withCurrentModel();
1104
+ const picked = await pickTargetModel(ctx, {
1105
+ current: options.pinnedModel ?? ctx.model,
1106
+ title: "Hand-off writer model",
1107
+ providerTitle: "Hand-off writer provider",
1108
+ });
1109
+ if (!picked) return undefined;
1110
+ if (!ctx.modelRegistry.hasConfiguredAuth(picked.model)) {
1111
+ ctx.ui.notify(
1112
+ `No auth configured for ${picked.model.provider}/${picked.model.id}; staying in this session`,
1113
+ "error",
1114
+ );
1115
+ return undefined;
1116
+ }
1117
+ return { kind: "fresh", model: picked.model };
1118
+ };
1119
+
1120
+ if (options.writer === "live") return { kind: "live" };
1121
+ if (options.writer === "fresh") return askForModel();
1122
+
1123
+ const labels = [
1124
+ WRITER_LIVE_LABEL,
1125
+ options.pinnedModel
1126
+ ? `Fresh pass — ${options.pinnedModel.provider}/${options.pinnedModel.id}`
1127
+ : "Fresh pass — current model",
1128
+ WRITER_FRESH_OTHER_LABEL,
1129
+ ];
1130
+ const chosen = await ctx.ui.select("Who writes the hand-off prompt?", labels);
1131
+ if (chosen === undefined) return undefined;
1132
+
1133
+ switch (labelIndex(chosen, labels)) {
1134
+ case 0:
1135
+ return { kind: "live" };
1136
+ case 1:
1137
+ return withCurrentModel();
1138
+ case 2:
1139
+ return askForModel();
1140
+ default:
1141
+ return undefined;
1142
+ }
1143
+ }
1144
+
374
1145
  export default function (pi: ExtensionAPI) {
375
1146
  // In-session state for the hand-off capture; fresh per extension instance,
376
1147
  // so it never leaks into a replacement session.
377
1148
  let awaitingHandoff = false;
378
1149
  let capturedPrompt: string | undefined;
379
1150
  let flowInProgress = false;
1151
+ // Config file contents, refreshed on every `session_start` (the file is read
1152
+ // once per session, not per hand-off). The pi CLI flag is read per hand-off
1153
+ // so it works even if a command somehow runs before `session_start`.
1154
+ let handoffSettings: HandoffSettings = {};
380
1155
 
381
1156
  /**
382
1157
  * Apply a captured model and thinking level, reporting problems as
@@ -409,6 +1184,67 @@ export default function (pi: ExtensionAPI) {
409
1184
  }
410
1185
  }
411
1186
 
1187
+ /** Ask the live agent to write its next message as the hand-off prompt. */
1188
+ async function captureFromLiveAgent(
1189
+ ctx: ExtensionCommandContext,
1190
+ steering: string,
1191
+ ): Promise<string | undefined> {
1192
+ awaitingHandoff = true;
1193
+ capturedPrompt = undefined;
1194
+ try {
1195
+ await pi.sendMessage(
1196
+ {
1197
+ customType: HANDOFF_CUSTOM_TYPE,
1198
+ content: buildHandoffRequest(steering),
1199
+ display: true,
1200
+ },
1201
+ { triggerTurn: true },
1202
+ );
1203
+ await ctx.waitForIdle();
1204
+ } finally {
1205
+ awaitingHandoff = false;
1206
+ }
1207
+
1208
+ if (!capturedPrompt) {
1209
+ ctx.ui.notify("The agent did not produce a hand-off prompt; staying in this session", "error");
1210
+ return undefined;
1211
+ }
1212
+ return capturedPrompt;
1213
+ }
1214
+
1215
+ /** One fresh pass over the minimal transcript; notifies when it trims. */
1216
+ async function captureFromFreshPass(
1217
+ ctx: ExtensionContext,
1218
+ writer: WriterChoice & { kind: "fresh" },
1219
+ reasoning: ThinkingLevel | undefined,
1220
+ steering: string,
1221
+ ): Promise<string | undefined> {
1222
+ const messages = ctx.sessionManager.buildSessionProjection().messages;
1223
+ const maxSourceChars = resolveMaxSourceChars(pi.getFlag(FLAG_MAX_SOURCE_CHARS), handoffSettings);
1224
+ const source = buildMinimalTranscript(messages, maxSourceChars);
1225
+ if (!source.text.trim()) {
1226
+ ctx.ui.notify("No conversation to hand off", "error");
1227
+ return undefined;
1228
+ }
1229
+ if (source.omittedTurns > 0) {
1230
+ ctx.ui.notify(
1231
+ `Hand-off source trimmed: ${source.omittedTurns} earlier turns omitted (~${formatChars(source.droppedChars)} chars dropped)`,
1232
+ "info",
1233
+ );
1234
+ }
1235
+ return generateFreshPassPrompt(ctx, writer.model, source.text, steering, reasoning);
1236
+ }
1237
+
1238
+ pi.registerFlag(FLAG_MAX_SOURCE_CHARS, {
1239
+ description:
1240
+ "pi-clear: max characters of conversation sent to the /hand-off writer (0 disables the budget)",
1241
+ type: "string",
1242
+ });
1243
+ pi.registerFlag(FLAG_SUMMARIZER, {
1244
+ description: "pi-clear: model that writes /hand-off prompts, as provider/model",
1245
+ type: "string",
1246
+ });
1247
+
412
1248
  pi.registerCommand("clear", {
413
1249
  description: "Start a fresh session, keeping the current model and thinking level",
414
1250
  handler: async (_args, ctx) => {
@@ -448,59 +1284,115 @@ export default function (pi: ExtensionAPI) {
448
1284
 
449
1285
  pi.registerCommand("hand-off", {
450
1286
  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",
1287
+ "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
1288
  handler: async (args, ctx) => {
1289
+ if (flowInProgress) {
1290
+ ctx.ui.notify("A hand-off is already in progress", "warning");
1291
+ return;
1292
+ }
453
1293
  try {
454
- if (flowInProgress) {
455
- ctx.ui.notify("A hand-off is already in progress", "warning");
1294
+ flowInProgress = true;
1295
+
1296
+ const parsed = parseHandoffArgs(args);
1297
+ if (parsed.error) {
1298
+ ctx.ui.notify(parsed.error, "error");
456
1299
  return;
457
1300
  }
458
- flowInProgress = true;
1301
+
459
1302
  if (!ctx.isIdle()) {
460
1303
  ctx.ui.notify("Waiting for the agent to finish before handing off", "info");
461
1304
  await ctx.waitForIdle();
462
1305
  }
463
1306
 
464
- // 1. Pick the destination model.
465
- const target = await pickTargetModel(ctx);
466
- if (!target) return;
467
-
468
- // 2. Pick the thinking level (a scoped `model:level` entry pins it).
469
- const level = target.thinkingLevel ?? (await selectThinkingLevel(ctx));
470
- if (level === undefined) return;
1307
+ // Writer model: pi CLI flag > config file. There is no command-arg
1308
+ // flag; without a configured preference the picker just opens.
1309
+ const flagSummarizer = pi.getFlag(FLAG_SUMMARIZER);
1310
+ const summarizerSpec =
1311
+ (typeof flagSummarizer === "string" && flagSummarizer.trim() ? flagSummarizer.trim() : undefined) ??
1312
+ handoffSettings.summarizerModel;
1313
+ const pinnedModel = summarizerSpec
1314
+ ? ctx.modelRegistry.find(
1315
+ summarizerSpec.slice(0, summarizerSpec.indexOf("/")),
1316
+ summarizerSpec.slice(summarizerSpec.indexOf("/") + 1),
1317
+ )
1318
+ : undefined;
1319
+ if (summarizerSpec && !pinnedModel) {
1320
+ ctx.ui.notify(
1321
+ `Hand-off writer model ${summarizerSpec} is not available; choose one instead`,
1322
+ "warning",
1323
+ );
1324
+ }
1325
+ // A pi CLI flag is not asked about again; a config-file model is only
1326
+ // silently accepted when `askSummarizerModel` is false.
1327
+ const pinnedByFlag = typeof flagSummarizer === "string";
1328
+ const askSummarizer =
1329
+ !pinnedModel || (!pinnedByFlag && handoffSettings.askSummarizerModel !== false);
471
1330
 
472
- // 3. Collect steering instructions, pre-filled from /hand-off args.
473
- const steering = await ctx.ui.editor(
474
- "Steering for the hand-off prompt (sent to the current agent)",
475
- args.trim(),
476
- );
477
- if (steering === undefined) return;
1331
+ // 1. Choose who writes the hand-off prompt, and (for the fresh pass)
1332
+ // how hard that model should think.
1333
+ const writer = await chooseWriter(ctx, {
1334
+ writer: parsed.writer,
1335
+ pinnedModel,
1336
+ askSummarizer,
1337
+ });
1338
+ if (!writer) return;
478
1339
 
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;
1340
+ let writerReasoning: ThinkingLevel | undefined;
1341
+ if (writer.kind === "fresh" && writer.model.reasoning) {
1342
+ const chosen = await selectWriterReasoningLevel(ctx, handoffSettings.summarizerThinkingLevel);
1343
+ if (!chosen) return;
1344
+ writerReasoning = chosen.reasoning;
1345
+ }
492
1346
 
493
- if (!capturedPrompt) {
494
- ctx.ui.notify("The agent did not produce a hand-off prompt; staying in this session", "error");
495
- return;
1347
+ // 2–3. Destination model and thinking level. The same-or-pick picker's
1348
+ // first option re-uses the current session's values in one Enter press;
1349
+ // `--same` skips that dialog. On the picked-model path a scoped
1350
+ // `model:level` entry pins the level.
1351
+ const reuse = parsed.same ? "same" : await chooseSameOrPick(ctx);
1352
+ if (!reuse) return;
1353
+ let target: PickedModel | undefined;
1354
+ let level: ThinkingLevel | undefined;
1355
+ if (reuse === "same") {
1356
+ if (!ctx.model) {
1357
+ ctx.ui.notify("--same: the current session has no model to reuse", "error");
1358
+ return;
1359
+ }
1360
+ target = { model: ctx.model };
1361
+ level = ctx.thinkingLevel;
1362
+ } else {
1363
+ target = await pickTargetModel(ctx);
1364
+ if (!target) return;
1365
+ level = target.thinkingLevel ?? (await selectThinkingLevel(ctx));
1366
+ if (level === undefined) return;
496
1367
  }
497
1368
 
498
- // 5. Carry everything across session replacement as plain data.
1369
+ // 4. Steering instructions: when passed inline as `/hand-off <instructions>`
1370
+ // they are used as-is; only an empty steering text opens the editor.
1371
+ const inlineSteering = parsed.steering.trim();
1372
+ const steering =
1373
+ inlineSteering ||
1374
+ (await ctx.ui.editor(
1375
+ writer.kind === "live"
1376
+ ? "Steering for the hand-off prompt (sent to the current agent)"
1377
+ : "Steering for the hand-off prompt (sent to the hand-off writer)",
1378
+ "",
1379
+ ));
1380
+ if (steering === undefined) return;
1381
+
1382
+ // 5. Write the hand-off prompt with the chosen writer.
1383
+ const prompt =
1384
+ writer.kind === "live"
1385
+ ? await captureFromLiveAgent(ctx, steering)
1386
+ : await captureFromFreshPass(ctx, writer, writerReasoning, steering);
1387
+ if (!prompt) return;
1388
+
1389
+ // 6. Carry everything across session replacement as plain data, with
1390
+ // the provenance comment added here (never by the writer model).
499
1391
  writeHandoff({
500
1392
  provider: target.model.provider,
501
1393
  modelId: target.model.id,
502
1394
  thinkingLevel: level,
503
- prompt: capturedPrompt,
1395
+ prompt: withSessionProvenance(prompt, ctx),
504
1396
  });
505
1397
 
506
1398
  const result = await ctx.newSession();
@@ -537,6 +1429,10 @@ export default function (pi: ExtensionAPI) {
537
1429
  });
538
1430
 
539
1431
  pi.on("session_start", async (event, ctx) => {
1432
+ const loaded = loadHandoffSettings();
1433
+ handoffSettings = loaded.settings;
1434
+ if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
1435
+
540
1436
  if (event.reason !== "new") return;
541
1437
 
542
1438
  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.5.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",