@grrrck/pi-clear 0.2.0 → 0.3.1

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 +30 -5
  3. package/index.ts +481 -38
  4. package/package.json +6 -4
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.3.1] - 2026-10-27
9
+
10
+ ### Fixed
11
+
12
+ - Improved `/hand-off` model search to match providers, model IDs, and names.
13
+
14
+ ## [0.3.0] - 2026-10-22
15
+
16
+ ### Added
17
+
18
+ - `/hand-off` command: pick the next model and thinking level in dialogs, optionally add steering instructions (or pass them inline as `/hand-off <instructions>`), and the current agent writes the hand-off prompt as its next message — with no preamble or postamble. The session is then replaced, the chosen model and thinking level are applied, and the hand-off prompt is inserted into the new session's editor for review before you submit it.
19
+ - An RPC smoke test for `/hand-off` (`scripts/rpc-handoff-smoke-test.py`) that verifies the full flow end-to-end via the extension UI protocol.
20
+
21
+ ### Changed
22
+
23
+ - `/hand-off` dialogs default to the current model and thinking level, so pressing Enter through the flow keeps the current settings.
24
+ - The `/hand-off` model picker prefers the session's scoped model set (`--models` / `enabledModels`, the same list `/scoped-models` shows) instead of listing every available model, with a searchable list and Tab toggle between scoped and all models in TUI mode. In non-TUI modes it falls back to a two-level provider → model pick so the dialog stays small.
25
+ - `/hand-off` only captures the hand-off prompt when the agent's response finishes cleanly; aborted or errored generation keeps the session intact. `/clear` and a second `/hand-off` are refused while a hand-off is in flight.
26
+
8
27
  ## [0.2.0] - 2026-10-02
9
28
 
10
29
  ### Added
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # pi-clear
2
2
 
3
- A [pi](https://github.com/earendil-works/pi) extension that adds a `/clear` command: start a fresh session **without losing your model**.
3
+ A [pi](https://github.com/earendil-works/pi) extension with two commands:
4
+
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
4
7
 
5
8
  ## Why?
6
9
 
@@ -26,9 +29,10 @@ pi -e npm:@grrrck/pi-clear
26
29
 
27
30
  ```text
28
31
  /clear
32
+ /hand-off [steering instructions]
29
33
  ```
30
34
 
31
- ## Behavior
35
+ ## `/clear` behavior
32
36
 
33
37
  When `/clear` runs, the extension:
34
38
 
@@ -40,6 +44,22 @@ When `/clear` runs, the extension:
40
44
 
41
45
  Because the model is applied with `pi.setModel()`, it's recorded in the new session's transcript — so it also survives `/resume`.
42
46
 
47
+ ## `/hand-off` behavior
48
+
49
+ When `/hand-off` runs, the extension:
50
+
51
+ 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()`
55
+ 5. stops there: the prompt sits in the editor for you to review, edit, and submit yourself
56
+
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
+
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
+
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
+
43
63
  ## Notes
44
64
 
45
65
  - `/clear` only registers the slash command; it does not bind a keyboard shortcut, so it won't conflict with other extensions (such as `pi-mono-clear`, which uses `Ctrl+Shift+L`).
@@ -48,7 +68,9 @@ Because the model is applied with `pi.setModel()`, it's recorded in the new sess
48
68
 
49
69
  ## How it works
50
70
 
51
- 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 `/clear` captures the model 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()`. The handoff is consumed exactly once, so it can never leak into a later session.
71
+ 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
+
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`.
52
74
 
53
75
  ## Development
54
76
 
@@ -58,12 +80,15 @@ Load the extension directly for local testing:
58
80
  pi -e ./index.ts
59
81
  ```
60
82
 
61
- There's also an RPC smoke test that verifies the core behavior end-to-end: it starts pi in RPC mode, switches to a different model and thinking level, runs `/clear`, and asserts both survive the new session:
83
+ There are also RPC smoke tests that verify the behavior end-to-end by starting pi in RPC mode:
62
84
 
63
85
  ```sh
64
- python3 scripts/rpc-smoke-test.py
86
+ 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
65
88
  ```
66
89
 
90
+ Both require at least two models with configured auth.
91
+
67
92
  ## Related extensions
68
93
 
69
94
  - [`pi-mono-clear`](https://github.com/emanuelcasco/pi-mono-extensions) — a `/clear` alias for `/new` (does not preserve the model)
package/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /**
2
- * pi-clear — `/clear` for pi: start a fresh session without losing your model.
2
+ * pi-clear — `/clear` and `/hand-off` for pi.
3
+ *
4
+ * `/clear`: start a fresh session without losing your model.
3
5
  *
4
6
  * The built-in `/new` command starts a new session, but the new runtime
5
7
  * resolves its model from the defaults chain (CLI args → scoped models →
@@ -11,49 +13,410 @@
11
13
  * `/clear` starts an equally fresh session, then restores the model and
12
14
  * thinking level that were active when you cleared.
13
15
  *
14
- * Usage:
15
- * /clear
16
+ * `/hand-off`: hand the conversation to another model.
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.
27
+ * 5. The user reviews, edits, and submits the prompt themselves.
16
28
  *
17
29
  * Mechanism:
18
- * 1. The `/clear` handler captures the active model (provider + id) and
19
- * thinking level as plain data into a `globalThis` property. Session
20
- * replacement (`ctx.newSession()`) tears down the current runtime and
21
- * recreates extension instances, but stays within the same process, so
22
- * a `globalThis` property safely crosses that boundary while remaining
23
- * invisible to other pi processes and dying with crashes.
24
- * 2. The new instance's `session_start` handler (reason: "new") consumes
25
- * the handoff and applies the captured model and thinking level via
26
- * `pi.setModel()` / `pi.setThinkingLevel()`, which record them in the
27
- * new session so they also survive `/resume`.
30
+ * Both commands capture state as plain data into a `globalThis` property.
31
+ * Session replacement (`ctx.newSession()`) tears down the current runtime
32
+ * and recreates extension instances, but stays within the same process, so
33
+ * a `globalThis` property safely crosses that boundary while remaining
34
+ * invisible to other pi processes and dying with crashes.
35
+ *
36
+ * The new instance's `session_start` handler (reason: "new") consumes the
37
+ * handoff and applies the model and thinking level via `pi.setModel()` /
38
+ * `pi.setThinkingLevel()`, which record them in the new session so they
39
+ * also survive `/resume`. If the handoff carries a prompt, it is inserted
40
+ * into the editor with `ctx.ui.setEditorText()`.
28
41
  */
29
42
 
30
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
43
+ import type {
44
+ ExtensionAPI,
45
+ ExtensionContext,
46
+ } from "@earendil-works/pi-coding-agent";
47
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
48
+ import { Container, fuzzyFilter, Input, SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
31
49
 
32
- interface ClearHandoff {
50
+ // Model and thinking-level types, derived from the API surface because the
51
+ // package root does not re-export them directly.
52
+ type AnyModel = Parameters<ExtensionAPI["setModel"]>[0];
53
+ type ThinkingLevel = Parameters<ExtensionAPI["setThinkingLevel"]>[0];
54
+
55
+ /** State carried across session replacement via `globalThis`. */
56
+ interface SessionHandoff {
33
57
  provider: string;
34
58
  modelId: string;
35
- thinkingLevel?: string;
59
+ thinkingLevel?: ThinkingLevel;
60
+ /** When present, inserted into the new session's editor (`/hand-off`). */
61
+ prompt?: string;
36
62
  }
37
63
 
38
64
  const HANDOFF_KEY = "__pi_clear_handoff__";
39
65
 
40
- function writeHandoff(handoff: ClearHandoff): void {
66
+ function writeHandoff(handoff: SessionHandoff): void {
41
67
  (globalThis as Record<string, unknown>)[HANDOFF_KEY] = handoff;
42
68
  }
43
69
 
44
70
  /** Consume the handoff: read it and remove it so it can only apply once. */
45
- function takeHandoff(): ClearHandoff | undefined {
71
+ function takeHandoff(): SessionHandoff | undefined {
46
72
  const store = globalThis as Record<string, unknown>;
47
73
  const handoff = store[HANDOFF_KEY];
48
74
  delete store[HANDOFF_KEY];
49
- return handoff as ClearHandoff | undefined;
75
+ return handoff as SessionHandoff | undefined;
76
+ }
77
+
78
+ const THINKING_LEVELS: ThinkingLevel[] = [
79
+ "off",
80
+ "minimal",
81
+ "low",
82
+ "medium",
83
+ "high",
84
+ "xhigh",
85
+ "max",
86
+ ];
87
+
88
+ const HANDOFF_CUSTOM_TYPE = "pi-clear:hand-off-request";
89
+
90
+ function sameModel(a: AnyModel | undefined, b: AnyModel | undefined): boolean {
91
+ return !!a && !!b && a.provider === b.provider && a.id === b.id;
92
+ }
93
+
94
+ function modelLabel(model: AnyModel, current: AnyModel | undefined): string {
95
+ return `${model.provider}/${model.id}${sameModel(model, current) ? " (current)" : ""}`;
96
+ }
97
+
98
+ /** Match a dialog result against option labels, tolerating " (current)"-style decorations. */
99
+ function labelIndex(chosen: string, labels: string[]): number {
100
+ const exact = labels.indexOf(chosen);
101
+ if (exact !== -1) return exact;
102
+ const bare = chosen.split(" (")[0];
103
+ return labels.findIndex((label) => label === bare || label.startsWith(`${bare} (`));
104
+ }
105
+
106
+ function orderCurrentFirst<T>(
107
+ items: T[],
108
+ current: AnyModel | undefined,
109
+ getModel: (item: T) => AnyModel,
110
+ ): T[] {
111
+ if (!current) return items;
112
+ const first = items.filter((item) => sameModel(getModel(item), current));
113
+ const rest = items.filter((item) => !sameModel(getModel(item), current));
114
+ return [...first, ...rest];
115
+ }
116
+
117
+ /** A hand-off destination: the model plus an optional scope-pinned thinking level. */
118
+ interface PickedModel {
119
+ model: AnyModel;
120
+ thinkingLevel?: ThinkingLevel;
121
+ }
122
+
123
+ async function selectModel(
124
+ ctx: ExtensionContext,
125
+ models: AnyModel[],
126
+ title: string,
127
+ current: AnyModel | undefined,
128
+ ): Promise<AnyModel | undefined> {
129
+ if (models.length === 1) return models[0];
130
+ const ordered = orderCurrentFirst(models, current, (m) => m);
131
+ const labels = ordered.map((m) => modelLabel(m, current));
132
+ const chosen = await ctx.ui.select(title, labels);
133
+ if (chosen === undefined) return undefined;
134
+ const index = labelIndex(chosen, labels);
135
+ return index === -1 ? undefined : ordered[index];
136
+ }
137
+
138
+ /**
139
+ * Thinking level picker with the active level preselected, so Enter accepts
140
+ * the current setting.
141
+ */
142
+ async function selectThinkingLevel(ctx: ExtensionContext): Promise<ThinkingLevel | undefined> {
143
+ const current = ctx.thinkingLevel;
144
+ const ordered = current
145
+ ? [current, ...THINKING_LEVELS.filter((level) => level !== current)]
146
+ : [...THINKING_LEVELS];
147
+ const labels = ordered.map((level) => `${level}${level === current ? " (current)" : ""}`);
148
+ const chosen = await ctx.ui.select("Thinking level", labels);
149
+ if (chosen === undefined) return undefined;
150
+ const index = labelIndex(chosen, labels);
151
+ return index === -1 ? undefined : ordered[index];
152
+ }
153
+
154
+ /**
155
+ * Non-interactive fallback (RPC mode has no custom components): scoped
156
+ * models in one dialog when scoping is configured, otherwise a two-level
157
+ * provider → model pick over the auth-configured catalogue.
158
+ */
159
+ async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedModel | undefined> {
160
+ const current = ctx.model;
161
+ const scoped = ctx.scopedModels.map((scoped) => scoped.model);
162
+
163
+ if (scoped.length > 0) {
164
+ // Keep the current model selectable and preselected even when scoping
165
+ // excludes it.
166
+ const models = current && !scoped.some((m) => sameModel(m, current)) ? [current, ...scoped] : scoped;
167
+ const model = await selectModel(ctx, models, "Hand off to model", current);
168
+ return model ? { model } : undefined;
169
+ }
170
+
171
+ const available = ctx.modelRegistry.getAvailable();
172
+ const usable = available.filter((m) => ctx.modelRegistry.hasConfiguredAuth(m));
173
+ const models = usable.length > 0 ? usable : available;
174
+ if (models.length === 0) {
175
+ ctx.ui.notify("No models available to hand off to", "error");
176
+ return undefined;
177
+ }
178
+
179
+ const byProvider = new Map<string, AnyModel[]>();
180
+ for (const model of models) {
181
+ const list = byProvider.get(model.provider) ?? [];
182
+ list.push(model);
183
+ byProvider.set(model.provider, list);
184
+ }
185
+ const providers = [...byProvider.keys()].sort();
186
+ if (current && byProvider.has(current.provider)) {
187
+ providers.splice(providers.indexOf(current.provider), 1);
188
+ providers.unshift(current.provider);
189
+ }
190
+
191
+ const providerLabels = providers.map((p) => `${p} (${byProvider.get(p)!.length} models)`);
192
+ const providerChoice =
193
+ providers.length === 1 ? providerLabels[0] : await ctx.ui.select("Hand off to provider", providerLabels);
194
+ if (providerChoice === undefined) return undefined;
195
+ const providerIndex = labelIndex(providerChoice, providerLabels);
196
+ if (providerIndex === -1) return undefined;
197
+ const providerName = providers[providerIndex];
198
+
199
+ const model = await selectModel(ctx, byProvider.get(providerName) ?? [], "Hand off to model", current);
200
+ return model ? { model } : undefined;
201
+ }
202
+
203
+ /**
204
+ * `/model`-style model picker (TUI mode only): a searchable list with Tab to
205
+ * toggle between the session's scoped models and the full catalogue.
206
+ */
207
+ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel | undefined> {
208
+ const current = ctx.model;
209
+ const allEntries: PickedModel[] = ctx.modelRegistry.getAvailable().map((model) => ({ model }));
210
+ if (allEntries.length === 0) {
211
+ ctx.ui.notify("No models available to hand off to", "error");
212
+ return undefined;
213
+ }
214
+
215
+ // Scoped entries may pin a thinking level (`provider/model:high`); dedupe
216
+ // by provider/id, keeping the first entry's pinned level.
217
+ const scopedByKey = new Map<string, PickedModel>();
218
+ for (const scoped of ctx.scopedModels) {
219
+ const key = `${scoped.model.provider}/${scoped.model.id}`;
220
+ if (!scopedByKey.has(key)) {
221
+ scopedByKey.set(key, { model: scoped.model, thinkingLevel: scoped.thinkingLevel });
222
+ }
223
+ }
224
+ const scopedEntries = [...scopedByKey.values()];
225
+
226
+ if (allEntries.length === 1) return allEntries[0];
227
+
228
+ const result = await ctx.ui.custom<PickedModel | null>((tui, theme, kb, done) => {
229
+ const MAX_VISIBLE = 12;
230
+ let scope: "scoped" | "all" = scopedEntries.length > 0 ? "scoped" : "all";
231
+ let selectList: SelectList;
232
+
233
+ const listTheme = {
234
+ selectedPrefix: (text: string) => theme.fg("accent", text),
235
+ selectedText: (text: string) => theme.fg("accent", text),
236
+ description: (text: string) => theme.fg("muted", text),
237
+ scrollInfo: (text: string) => theme.fg("dim", text),
238
+ noMatch: (text: string) => theme.fg("warning", text),
239
+ };
240
+
241
+ const entriesForScope = () => (scope === "scoped" ? scopedEntries : allEntries);
242
+
243
+ const makeList = (query = "") => {
244
+ const ordered = orderCurrentFirst(entriesForScope(), current, (entry) => entry.model);
245
+ // SelectList.setFilter() only prefix-matches item values, not labels.
246
+ const entries = fuzzyFilter(ordered, query, ({ model }) => `${model.provider} ${model.id} ${model.name}`);
247
+ const items: SelectItem[] = entries.map((entry, index) => ({
248
+ value: String(index),
249
+ label: modelLabel(entry.model, current),
250
+ description: entry.model.name,
251
+ }));
252
+ const list = new SelectList(items, MAX_VISIBLE, listTheme);
253
+ list.onSelect = (item) => done(entries[Number(item.value)] ?? null);
254
+ list.onCancel = () => done(null);
255
+ return list;
256
+ };
257
+
258
+ const container = new Container();
259
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
260
+ const header = new Text("");
261
+ container.addChild(header);
262
+ const searchInput = new Input({ placeholder: "Type to filter" });
263
+ container.addChild(searchInput);
264
+ const listContainer = new Container();
265
+ selectList = makeList();
266
+ listContainer.addChild(selectList);
267
+ container.addChild(listContainer);
268
+ const updateList = () => {
269
+ selectList = makeList(searchInput.getValue());
270
+ listContainer.clear();
271
+ listContainer.addChild(selectList);
272
+ };
273
+ const hint = new Text("");
274
+ container.addChild(hint);
275
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
276
+
277
+ const updateChrome = () => {
278
+ header.setText(
279
+ theme.fg(
280
+ "accent",
281
+ theme.bold(
282
+ `Hand off to model — ${scope === "scoped" ? `scoped (${scopedEntries.length})` : `all (${allEntries.length})`}`,
283
+ ),
284
+ ),
285
+ );
286
+ const parts = ["type to filter", "enter select", "esc cancel"];
287
+ if (scopedEntries.length > 0) {
288
+ parts.unshift(`tab → ${scope === "scoped" ? "all models" : "scoped"}`);
289
+ }
290
+ hint.setText(theme.fg("dim", parts.join(" • ")));
291
+ };
292
+ updateChrome();
293
+
294
+ return {
295
+ render(width: number) {
296
+ return container.render(width);
297
+ },
298
+ invalidate() {
299
+ container.invalidate();
300
+ },
301
+ handleInput(data: string) {
302
+ if (scopedEntries.length > 0 && kb.matches(data, "tui.input.tab")) {
303
+ scope = scope === "scoped" ? "all" : "scoped";
304
+ updateList();
305
+ updateChrome();
306
+ tui.requestRender();
307
+ return;
308
+ }
309
+ if (
310
+ kb.matches(data, "tui.select.up") ||
311
+ kb.matches(data, "tui.select.down") ||
312
+ kb.matches(data, "tui.select.confirm") ||
313
+ kb.matches(data, "tui.select.cancel")
314
+ ) {
315
+ selectList.handleInput(data);
316
+ tui.requestRender();
317
+ return;
318
+ }
319
+ searchInput.handleInput(data);
320
+ updateList();
321
+ tui.requestRender();
322
+ },
323
+ };
324
+ });
325
+
326
+ return result ?? undefined;
327
+ }
328
+
329
+ /**
330
+ * Pick the hand-off destination model without flooding the screen.
331
+ *
332
+ * TUI mode gets a `/model`-style searchable picker with Tab to toggle
333
+ * between scoped and all models; other modes fall back to plain dialogs.
334
+ */
335
+ async function pickTargetModel(ctx: ExtensionContext): Promise<PickedModel | undefined> {
336
+ if (ctx.mode === "tui") {
337
+ return pickTargetModelTui(ctx);
338
+ }
339
+ return pickTargetModelFallback(ctx);
340
+ }
341
+
342
+ /** Extract the text content of an agent message, ignoring thinking blocks and tool calls. */
343
+ function messageText(message: { role: string; content: string | Array<{ type: string; text?: string }> }): string {
344
+ if (typeof message.content === "string") return message.content;
345
+ return message.content
346
+ .filter((block) => block.type === "text" && typeof block.text === "string")
347
+ .map((block) => block.text)
348
+ .join("\n");
349
+ }
350
+
351
+ function buildHandoffRequest(steering: string): string {
352
+ const lines = [
353
+ "The user is handing this conversation off to a different model. Your next assistant message will be copied verbatim and submitted as the first user message of a brand-new session that has no memory of this conversation.",
354
+ "",
355
+ "Write that hand-off message now. It must:",
356
+ "- Be written as instructions to the next agent (\"you\"), not a report about this conversation.",
357
+ "- Assume the next agent has zero prior context: carry forward the current task and goal, what has been done so far (with concrete file paths, commands, and key decisions), and exactly what to do next.",
358
+ ];
359
+ if (steering.trim()) {
360
+ lines.push(
361
+ "",
362
+ "Additional guidance from the user for what the hand-off must cover:",
363
+ "",
364
+ steering.trim(),
365
+ );
366
+ }
367
+ lines.push(
368
+ "",
369
+ "Output rules: reply with ONLY the hand-off message itself. No preamble, no postamble, no surrounding markdown fences, no explanations, no questions.",
370
+ );
371
+ return lines.join("\n");
50
372
  }
51
373
 
52
374
  export default function (pi: ExtensionAPI) {
375
+ // In-session state for the hand-off capture; fresh per extension instance,
376
+ // so it never leaks into a replacement session.
377
+ let awaitingHandoff = false;
378
+ let capturedPrompt: string | undefined;
379
+ let flowInProgress = false;
380
+
381
+ /**
382
+ * Apply a captured model and thinking level, reporting problems as
383
+ * warnings. Returns true when the model was applied.
384
+ */
385
+ async function applyHandoffModel(handoff: SessionHandoff, ctx: ExtensionContext): Promise<boolean> {
386
+ try {
387
+ const model = ctx.modelRegistry.find(handoff.provider, handoff.modelId);
388
+ if (!model) {
389
+ ctx.ui.notify(`pi-clear: model ${handoff.provider}/${handoff.modelId} is no longer available`, "warning");
390
+ return false;
391
+ }
392
+
393
+ const applied = await pi.setModel(model);
394
+ if (!applied) {
395
+ ctx.ui.notify(`pi-clear: no auth configured for ${handoff.provider}/${handoff.modelId}`, "warning");
396
+ return false;
397
+ }
398
+
399
+ if (handoff.thinkingLevel) {
400
+ await pi.setThinkingLevel(handoff.thinkingLevel);
401
+ }
402
+ return true;
403
+ } catch (err) {
404
+ ctx.ui.notify(
405
+ `pi-clear: could not restore model ${handoff.provider}/${handoff.modelId}: ${err instanceof Error ? err.message : String(err)}`,
406
+ "warning",
407
+ );
408
+ return false;
409
+ }
410
+ }
411
+
53
412
  pi.registerCommand("clear", {
54
413
  description: "Start a fresh session, keeping the current model and thinking level",
55
414
  handler: async (_args, ctx) => {
56
415
  try {
416
+ if (flowInProgress) {
417
+ ctx.ui.notify("Hand-off in progress; wait for it to finish", "warning");
418
+ return;
419
+ }
57
420
  if (!ctx.isIdle()) {
58
421
  ctx.ui.notify("Waiting for the agent to finish before clearing", "info");
59
422
  await ctx.waitForIdle();
@@ -83,33 +446,113 @@ export default function (pi: ExtensionAPI) {
83
446
  },
84
447
  });
85
448
 
449
+ pi.registerCommand("hand-off", {
450
+ 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",
452
+ handler: async (args, ctx) => {
453
+ try {
454
+ if (flowInProgress) {
455
+ ctx.ui.notify("A hand-off is already in progress", "warning");
456
+ return;
457
+ }
458
+ flowInProgress = true;
459
+ if (!ctx.isIdle()) {
460
+ ctx.ui.notify("Waiting for the agent to finish before handing off", "info");
461
+ await ctx.waitForIdle();
462
+ }
463
+
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;
471
+
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;
478
+
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;
492
+
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.
499
+ writeHandoff({
500
+ provider: target.model.provider,
501
+ modelId: target.model.id,
502
+ thinkingLevel: level,
503
+ prompt: capturedPrompt,
504
+ });
505
+
506
+ const result = await ctx.newSession();
507
+ // Normally session_start already consumed the handoff; clear any
508
+ // leftover so it can never apply to a later, unrelated /new.
509
+ takeHandoff();
510
+ if (result.cancelled) {
511
+ ctx.ui.notify("Hand-off cancelled by extension", "warning");
512
+ }
513
+ } catch (err) {
514
+ takeHandoff();
515
+ awaitingHandoff = false;
516
+ ctx.ui.notify(`Hand-off failed: ${err instanceof Error ? err.message : String(err)}`, "error");
517
+ } finally {
518
+ flowInProgress = false;
519
+ }
520
+ },
521
+ });
522
+
523
+ pi.on("agent_end", (event) => {
524
+ if (!awaitingHandoff) return;
525
+ awaitingHandoff = false;
526
+ for (let i = event.messages.length - 1; i >= 0; i--) {
527
+ const message = event.messages[i];
528
+ if (message.role === "assistant") {
529
+ // Only accept a cleanly finished message: agent_end also fires on
530
+ // abort and error, where the text is partial or absent.
531
+ if ((message as { stopReason?: string }).stopReason !== "stop") break;
532
+ const text = messageText(message as { role: string; content: string | Array<{ type: string; text?: string }> });
533
+ if (text.trim()) capturedPrompt = text.trim();
534
+ break;
535
+ }
536
+ }
537
+ });
538
+
86
539
  pi.on("session_start", async (event, ctx) => {
87
540
  if (event.reason !== "new") return;
88
541
 
89
542
  const handoff = takeHandoff();
90
543
  if (!handoff) return;
91
544
 
92
- try {
93
- const model = ctx.modelRegistry.find(handoff.provider, handoff.modelId);
94
- if (!model) {
95
- ctx.ui.notify(`pi-clear: model ${handoff.provider}/${handoff.modelId} is no longer available`, "warning");
96
- return;
97
- }
98
-
99
- const applied = await pi.setModel(model);
100
- if (!applied) {
101
- ctx.ui.notify(`pi-clear: no auth configured for ${handoff.provider}/${handoff.modelId}`, "warning");
102
- return;
103
- }
545
+ await applyHandoffModel(handoff, ctx);
104
546
 
105
- if (handoff.thinkingLevel) {
106
- await pi.setThinkingLevel(handoff.thinkingLevel);
547
+ if (handoff.prompt) {
548
+ try {
549
+ ctx.ui.setEditorText(handoff.prompt);
550
+ } catch (err) {
551
+ ctx.ui.notify(
552
+ `pi-clear: could not insert the hand-off prompt into the editor: ${err instanceof Error ? err.message : String(err)}`,
553
+ "warning",
554
+ );
107
555
  }
108
- } catch (err) {
109
- ctx.ui.notify(
110
- `pi-clear: could not restore model ${handoff.provider}/${handoff.modelId}: ${err instanceof Error ? err.message : String(err)}`,
111
- "warning",
112
- );
113
556
  }
114
557
  });
115
558
  }
package/package.json CHANGED
@@ -1,19 +1,21 @@
1
1
  {
2
2
  "name": "@grrrck/pi-clear",
3
- "version": "0.2.0",
4
- "description": "Pi extension that starts a fresh session with /clear while keeping your current model and thinking level",
3
+ "version": "0.3.1",
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",
7
7
  "pi-extension",
8
8
  "clear",
9
9
  "session",
10
- "model"
10
+ "model",
11
+ "handoff"
11
12
  ],
12
13
  "license": "MIT",
13
14
  "author": "Garrick Aden-Buie (https://garrickadenbuie.com)",
14
15
  "type": "module",
15
16
  "peerDependencies": {
16
- "@earendil-works/pi-coding-agent": "*"
17
+ "@earendil-works/pi-coding-agent": "*",
18
+ "@earendil-works/pi-tui": "*"
17
19
  },
18
20
  "pi": {
19
21
  "extensions": [