pi-sessions 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/extensions/session-handoff/bootstrap-failed-renderer.ts +28 -0
- package/extensions/session-handoff/bootstrap.ts +18 -3
- package/extensions/session-handoff/extract.ts +18 -3
- package/extensions/session-handoff/install.ts +14 -2
- package/extensions/session-handoff/launch-target.ts +3 -0
- package/extensions/session-handoff/metadata.ts +28 -6
- package/extensions/session-handoff/model.ts +43 -23
- package/extensions/session-handoff/roster.ts +162 -0
- package/extensions/session-handoff/spawn.ts +11 -0
- package/extensions/session-handoff/tool-renderer.ts +13 -8
- package/extensions/session-handoff/tool-schema.ts +10 -3
- package/extensions/session-handoff/tool.ts +5 -2
- package/extensions/shared/model.ts +41 -0
- package/extensions/shared/settings.ts +18 -0
- package/extensions/subagents/launch-target.ts +1 -0
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Use `kind: "user"` or `kind: "subagent"` to filter by session type across the wh
|
|
|
108
108
|
|
|
109
109
|
The `session_handoff` tool lets the agent create a child session with a self-contained task. Ask for a direction when you want a visible split, ask for a deferred handoff when you only want the prepared session, or let the agent delegate suitable independent work to a background subagent.
|
|
110
110
|
|
|
111
|
-
Directional launches use tmux when the current terminal is inside tmux, or Ghostty on macOS. Deferred launches create the child without starting it and copy its resume command to the clipboard. A background subagent runs in a detached tmux window and reports back when finished.
|
|
111
|
+
Directional launches use tmux when the current terminal is inside tmux, or Ghostty on macOS. Deferred launches create the child without starting it and copy its resume command to the clipboard. A background subagent runs in a detached tmux window and reports back when finished; note that subagents are started with `--approve`, meaning pi will always start in the directory as trusted.
|
|
112
112
|
|
|
113
113
|
Run `/handoff` to open the **Handoffs** board. The Subagents and User sessions tabs show status, age, launch details, and the actions currently available: stop, copy an observation command, or copy a resume command.
|
|
114
114
|
|
|
@@ -142,6 +142,7 @@ If you want to override the shortcut, put this in your `~/.pi/agent/settings.jso
|
|
|
142
142
|
"pickerShortcut": "alt+p",
|
|
143
143
|
"model": "openai-codex/gpt-5.6-terra",
|
|
144
144
|
"thinkingLevel": "low",
|
|
145
|
+
"roster": ["anthropic/*", "openai-codex/gpt-5.6-terra:high"],
|
|
145
146
|
"deferred": {
|
|
146
147
|
"copyToClipboard": true
|
|
147
148
|
}
|
|
@@ -152,6 +153,8 @@ If you want to override the shortcut, put this in your `~/.pi/agent/settings.jso
|
|
|
152
153
|
|
|
153
154
|
`model` and `thinkingLevel` configure the agent that builds handoff prompts. They default to the new session's values when absent.
|
|
154
155
|
|
|
156
|
+
`roster` limits which models a handoff may launch a child session on. Defaults to pi's own `enabledModels` scoping, then to all configured models. May optionally include a thinking level; listing a model more than once adds to the levels it allows.
|
|
157
|
+
|
|
155
158
|
`deferred.copyToClipboard` (default `true`) controls whether deferred handoffs copy the resume command to the clipboard. When off, the resume command is only shown in the tool call.
|
|
156
159
|
|
|
157
160
|
Subagents require the handoff and messaging features. Limit recursive delegation depth with `sessions.subagents.maxDepth` (default `2`):
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EntryRenderer } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { RenderTheme } from "../shared/rendering/theme.ts";
|
|
4
|
+
import { safeParseTypeBoxValue } from "../shared/typebox.ts";
|
|
5
|
+
import { HANDOFF_BOOTSTRAP_FAILED_SCHEMA, type HandoffBootstrapFailed } from "./metadata.ts";
|
|
6
|
+
|
|
7
|
+
export const renderHandoffBootstrapFailedEntry: EntryRenderer = (entry, _options, theme) => {
|
|
8
|
+
const failure = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_FAILED_SCHEMA, entry.data);
|
|
9
|
+
if (!failure) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
14
|
+
box.addChild(new Text(presentHandoffBootstrapFailure(failure, theme), 0, 0));
|
|
15
|
+
return box;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function presentHandoffBootstrapFailure(
|
|
19
|
+
failure: HandoffBootstrapFailed,
|
|
20
|
+
theme: RenderTheme,
|
|
21
|
+
): string {
|
|
22
|
+
const header = `${theme.fg("error", theme.bold("handoff failed"))} ${theme.fg("muted", "draft generation did not complete")}`;
|
|
23
|
+
const retry = theme.fg(
|
|
24
|
+
"dim",
|
|
25
|
+
"The handoff is retried every time this session starts, until a message is sent.",
|
|
26
|
+
);
|
|
27
|
+
return [header, "", theme.fg("toolOutput", failure.error), "", retry].join("\n");
|
|
28
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
findPendingHandoffBootstrap,
|
|
12
12
|
getHandoffMetadataFromEntries,
|
|
13
13
|
HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE,
|
|
14
|
+
HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE,
|
|
14
15
|
HANDOFF_METADATA_CUSTOM_TYPE,
|
|
15
16
|
HANDOFF_STALE_SESSION_MESSAGE,
|
|
16
17
|
type HandoffBootstrapConsumedReason,
|
|
@@ -54,6 +55,7 @@ export async function consumePendingHandoffBootstrap(
|
|
|
54
55
|
ctx,
|
|
55
56
|
bootstrap,
|
|
56
57
|
scan.entryId,
|
|
58
|
+
scan.failure !== undefined,
|
|
57
59
|
consumeBootstrap,
|
|
58
60
|
getModelRuntime,
|
|
59
61
|
destinationThinkingLevel,
|
|
@@ -66,6 +68,7 @@ async function startChildGeneratedHandoff(
|
|
|
66
68
|
ctx: ExtensionContext,
|
|
67
69
|
bootstrap: ChildGeneratedHandoffBootstrap,
|
|
68
70
|
bootstrapEntryId: string,
|
|
71
|
+
hasEarlierFailure: boolean,
|
|
69
72
|
consumeBootstrap: (reason: HandoffBootstrapConsumedReason) => void,
|
|
70
73
|
getModelRuntime: ModelRuntimeProvider,
|
|
71
74
|
destinationThinkingLevel: ThinkingLevel | undefined,
|
|
@@ -80,8 +83,13 @@ async function startChildGeneratedHandoff(
|
|
|
80
83
|
return;
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
// An automatic bootstrap exits on its first failure because only an
|
|
87
|
+
// unattended worker ever reaches that attempt. Every later start is someone
|
|
88
|
+
// opening the session deliberately, so it latches open for them instead.
|
|
89
|
+
const exitOnFailure = bootstrap.bootstrapMode === "automatic" && !hasEarlierFailure;
|
|
90
|
+
|
|
83
91
|
if (!ctx.hasUI) {
|
|
84
|
-
if (
|
|
92
|
+
if (exitOnFailure) {
|
|
85
93
|
ctx.shutdown();
|
|
86
94
|
}
|
|
87
95
|
return;
|
|
@@ -164,8 +172,15 @@ async function startChildGeneratedHandoff(
|
|
|
164
172
|
{ triggerTurn: true },
|
|
165
173
|
);
|
|
166
174
|
} catch (error) {
|
|
167
|
-
|
|
168
|
-
|
|
175
|
+
// An exiting bootstrap takes the notification down with it, so the reason
|
|
176
|
+
// has to live in the transcript instead.
|
|
177
|
+
const message = formatHandoffError(error);
|
|
178
|
+
pi.appendEntry(HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE, {
|
|
179
|
+
bootstrapEntryId,
|
|
180
|
+
error: message,
|
|
181
|
+
});
|
|
182
|
+
ctx.ui.notify(message, "error");
|
|
183
|
+
if (exitOnFailure) {
|
|
169
184
|
ctx.shutdown();
|
|
170
185
|
}
|
|
171
186
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
1
|
+
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type {
|
|
3
3
|
Api,
|
|
4
4
|
AssistantMessage,
|
|
@@ -26,7 +26,7 @@ import { type Static, Type } from "typebox";
|
|
|
26
26
|
import { freshenModel } from "../shared/model-runtime.ts";
|
|
27
27
|
import { getDefaultHandoffRunsDir, type HandoffSettings } from "../shared/settings.ts";
|
|
28
28
|
import { parseTypeBoxValue } from "../shared/typebox.ts";
|
|
29
|
-
import {
|
|
29
|
+
import { resolveModelPattern } from "./model.ts";
|
|
30
30
|
|
|
31
31
|
const MAX_HANDOFF_EXTRACTION_ATTEMPTS = 3;
|
|
32
32
|
const HANDOFF_EXTRACTION_RETRY_PROMPT =
|
|
@@ -111,7 +111,7 @@ export async function generateHandoffDraftFromSessionManager({
|
|
|
111
111
|
requestResponse?: boolean | undefined;
|
|
112
112
|
}): Promise<HandoffDraftResult | undefined> {
|
|
113
113
|
const modelOverride = settings.model
|
|
114
|
-
?
|
|
114
|
+
? resolveModelPattern(modelRuntime, settings.model, settings.thinkingLevel)
|
|
115
115
|
: undefined;
|
|
116
116
|
let model = modelOverride?.model;
|
|
117
117
|
if (!model) {
|
|
@@ -247,6 +247,13 @@ async function runHandoffExtractionAgent(
|
|
|
247
247
|
if (capturedArguments) {
|
|
248
248
|
break;
|
|
249
249
|
}
|
|
250
|
+
// A failed turn carries no refusal to re-prompt: retrying only burns the
|
|
251
|
+
// remaining attempts and buries the provider's reason behind the generic
|
|
252
|
+
// "did not return structured context" error.
|
|
253
|
+
const providerError = findProviderError(session.messages);
|
|
254
|
+
if (providerError) {
|
|
255
|
+
throw new Error(`Handoff extraction failed: ${providerError}`);
|
|
256
|
+
}
|
|
250
257
|
}
|
|
251
258
|
} finally {
|
|
252
259
|
signal?.removeEventListener("abort", abortHandler);
|
|
@@ -272,6 +279,14 @@ async function runHandoffExtractionAgent(
|
|
|
272
279
|
};
|
|
273
280
|
}
|
|
274
281
|
|
|
282
|
+
function findProviderError(messages: readonly AgentMessage[]): string | undefined {
|
|
283
|
+
const lastAssistant = messages.findLast((message) => message.role === "assistant");
|
|
284
|
+
if (lastAssistant?.role !== "assistant" || lastAssistant.stopReason !== "error") {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
return lastAssistant.errorMessage || "the provider returned an error.";
|
|
288
|
+
}
|
|
289
|
+
|
|
275
290
|
export function assembleHandoffDraft(
|
|
276
291
|
sessionId: string,
|
|
277
292
|
sessionPath: string | undefined,
|
|
@@ -10,13 +10,16 @@ import { THINKING_LEVELS } from "../shared/thinking-levels.ts";
|
|
|
10
10
|
import { safeParseTypeBoxValue } from "../shared/typebox.ts";
|
|
11
11
|
import { type HandoffBoardServices, openHandoffBoard } from "./board.ts";
|
|
12
12
|
import { consumePendingHandoffBootstrap } from "./bootstrap.ts";
|
|
13
|
+
import { renderHandoffBootstrapFailedEntry } from "./bootstrap-failed-renderer.ts";
|
|
13
14
|
import { HANDOFF_KICKOFF_CUSTOM_TYPE, renderHandoffKickoffMessage } from "./kickoff.ts";
|
|
14
15
|
import type { ClipboardStatus } from "./launch/backend.ts";
|
|
15
16
|
import { resolveSplitLaunchBackend } from "./launch/resolution.ts";
|
|
16
17
|
import { createHandoffLaunchTargets } from "./launch-options.ts";
|
|
17
18
|
import type { HandoffLaunchTarget } from "./launch-target.ts";
|
|
19
|
+
import { HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE } from "./metadata.ts";
|
|
18
20
|
import { openSessionReferencePicker } from "./picker.ts";
|
|
19
21
|
import { SESSION_TOKEN_PREFIX } from "./query.ts";
|
|
22
|
+
import { type HandoffRoster, resolveHandoffRoster } from "./roster.ts";
|
|
20
23
|
import {
|
|
21
24
|
executeSessionHandoffTool,
|
|
22
25
|
type HandoffToolParams,
|
|
@@ -51,9 +54,11 @@ export function installHandoff(
|
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
pi.registerMessageRenderer(HANDOFF_KICKOFF_CUSTOM_TYPE, renderHandoffKickoffMessage);
|
|
57
|
+
pi.registerEntryRenderer(HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE, renderHandoffBootstrapFailedEntry);
|
|
54
58
|
function registerHandoffTool(
|
|
55
59
|
models: readonly Model<Api>[],
|
|
56
60
|
launchTargets: readonly HandoffLaunchTarget[],
|
|
61
|
+
roster: HandoffRoster | undefined,
|
|
57
62
|
): void {
|
|
58
63
|
pi.registerTool({
|
|
59
64
|
name: "session_handoff",
|
|
@@ -61,7 +66,7 @@ export function installHandoff(
|
|
|
61
66
|
description: "Start a new Pi session with a self-contained task.",
|
|
62
67
|
promptSnippet:
|
|
63
68
|
"Delegate bounded work to a background subagent or hand off context to another Pi session",
|
|
64
|
-
promptGuidelines: buildHandoffPromptGuidelines(launchTargets, models),
|
|
69
|
+
promptGuidelines: buildHandoffPromptGuidelines(launchTargets, models, roster),
|
|
65
70
|
parameters: Type.Object({
|
|
66
71
|
goal: Type.String({
|
|
67
72
|
description:
|
|
@@ -138,6 +143,7 @@ export function installHandoff(
|
|
|
138
143
|
ctx,
|
|
139
144
|
modelRuntime,
|
|
140
145
|
launchTargets,
|
|
146
|
+
roster,
|
|
141
147
|
(sessionId, status) => clipboardStatusBySessionId.set(sessionId, status),
|
|
142
148
|
);
|
|
143
149
|
},
|
|
@@ -199,14 +205,20 @@ export function installHandoff(
|
|
|
199
205
|
|
|
200
206
|
return {
|
|
201
207
|
async onSessionStart(_event, ctx) {
|
|
208
|
+
const models = ctx.modelRegistry.getAvailable();
|
|
202
209
|
registerHandoffTool(
|
|
203
|
-
|
|
210
|
+
models,
|
|
204
211
|
createHandoffLaunchTargets({
|
|
205
212
|
pi,
|
|
206
213
|
splitBackend,
|
|
207
214
|
copyDeferredToClipboard: settings.handoff.deferred.copyToClipboard,
|
|
208
215
|
additionalTargets: deps.getLaunchTargets?.() ?? [],
|
|
209
216
|
}),
|
|
217
|
+
resolveHandoffRoster({
|
|
218
|
+
models,
|
|
219
|
+
patterns: settings.handoff.roster,
|
|
220
|
+
scopedModels: ctx.scopedModels,
|
|
221
|
+
}),
|
|
210
222
|
);
|
|
211
223
|
|
|
212
224
|
await consumePendingHandoffBootstrap(
|
|
@@ -59,6 +59,8 @@ export interface HandoffLaunchTarget {
|
|
|
59
59
|
description?: string | undefined;
|
|
60
60
|
requestResponseDefault: boolean;
|
|
61
61
|
bootstrapMode: "review" | "automatic";
|
|
62
|
+
/** Unattended children skip the startup trust prompt; watched ones let the user answer it. */
|
|
63
|
+
approveProjectTrust: boolean;
|
|
62
64
|
/** Subagent launches stamp their child's identity into the child bootstrap. */
|
|
63
65
|
describeSubagentChild?(input: {
|
|
64
66
|
childSessionId: string;
|
|
@@ -79,6 +81,7 @@ export function createBackendLaunchTarget(
|
|
|
79
81
|
...(description ? { description } : {}),
|
|
80
82
|
requestResponseDefault: false,
|
|
81
83
|
bootstrapMode: "review",
|
|
84
|
+
approveProjectTrust: false,
|
|
82
85
|
prepareChild() {},
|
|
83
86
|
launch(input) {
|
|
84
87
|
return backend.launch({
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
|
|
13
13
|
export const HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap";
|
|
14
14
|
export const HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-consumed";
|
|
15
|
+
export const HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-failed";
|
|
15
16
|
export const HANDOFF_STALE_SESSION_MESSAGE =
|
|
16
17
|
"Session handoff failed: target session already has user input.";
|
|
17
18
|
export const SESSION_STARTING_MESSAGE =
|
|
@@ -62,6 +63,14 @@ export const HANDOFF_BOOTSTRAP_SCHEMA = Type.Union([
|
|
|
62
63
|
}),
|
|
63
64
|
]);
|
|
64
65
|
|
|
66
|
+
// A bootstrap whose generation failed stays pending so a resume can retry it,
|
|
67
|
+
// but the failure is recorded so the session stops reading as one that is
|
|
68
|
+
// still starting.
|
|
69
|
+
export const HANDOFF_BOOTSTRAP_FAILED_SCHEMA = Type.Object({
|
|
70
|
+
bootstrapEntryId: Type.String(),
|
|
71
|
+
error: Type.String(),
|
|
72
|
+
});
|
|
73
|
+
|
|
65
74
|
export const HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA = Type.Object({
|
|
66
75
|
bootstrapEntryId: Type.String(),
|
|
67
76
|
reason: Type.Union([
|
|
@@ -77,6 +86,7 @@ export type HandoffSessionMetadata = Static<typeof HANDOFF_SESSION_METADATA_SCHE
|
|
|
77
86
|
export type HandoffBootstrap = Static<typeof HANDOFF_BOOTSTRAP_SCHEMA>;
|
|
78
87
|
export type ChildGeneratedHandoffBootstrap = HandoffBootstrap;
|
|
79
88
|
export type HandoffBootstrapConsumed = Static<typeof HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA>;
|
|
89
|
+
export type HandoffBootstrapFailed = Static<typeof HANDOFF_BOOTSTRAP_FAILED_SCHEMA>;
|
|
80
90
|
export type HandoffBootstrapConsumedReason = HandoffBootstrapConsumed["reason"];
|
|
81
91
|
|
|
82
92
|
export function createHandoffSessionMetadata(
|
|
@@ -125,7 +135,7 @@ export function createChildGeneratedHandoffBootstrap(options: {
|
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
export type PendingHandoffBootstrapScan =
|
|
128
|
-
| { kind: "pending"; entryId: string; bootstrap: HandoffBootstrap }
|
|
138
|
+
| { kind: "pending"; entryId: string; bootstrap: HandoffBootstrap; failure?: string | undefined }
|
|
129
139
|
| { kind: "invalid"; entryId: string };
|
|
130
140
|
|
|
131
141
|
/**
|
|
@@ -137,6 +147,7 @@ export function findPendingHandoffBootstrap(
|
|
|
137
147
|
branch: readonly SessionEntry[],
|
|
138
148
|
): PendingHandoffBootstrapScan | undefined {
|
|
139
149
|
const consumedEntryIds = new Set<string>();
|
|
150
|
+
const failuresByEntryId = new Map<string, string>();
|
|
140
151
|
for (const entry of branch) {
|
|
141
152
|
if (entry.type === "custom_message" && entry.customType === HANDOFF_KICKOFF_CUSTOM_TYPE) {
|
|
142
153
|
const details = safeParseTypeBoxValue(HANDOFF_KICKOFF_DETAILS_SCHEMA, entry.details);
|
|
@@ -150,6 +161,12 @@ export function findPendingHandoffBootstrap(
|
|
|
150
161
|
consumedEntryIds.add(consumed.bootstrapEntryId);
|
|
151
162
|
}
|
|
152
163
|
}
|
|
164
|
+
if (entry.type === "custom" && entry.customType === HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE) {
|
|
165
|
+
const failed = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_FAILED_SCHEMA, entry.data);
|
|
166
|
+
if (failed) {
|
|
167
|
+
failuresByEntryId.set(failed.bootstrapEntryId, failed.error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
153
170
|
}
|
|
154
171
|
|
|
155
172
|
for (let i = branch.length - 1; i >= 0; i -= 1) {
|
|
@@ -163,9 +180,11 @@ export function findPendingHandoffBootstrap(
|
|
|
163
180
|
}
|
|
164
181
|
|
|
165
182
|
const bootstrap = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_SCHEMA, entry.data);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
183
|
+
if (!bootstrap) {
|
|
184
|
+
return { kind: "invalid", entryId: entry.id };
|
|
185
|
+
}
|
|
186
|
+
const failure = failuresByEntryId.get(entry.id);
|
|
187
|
+
return { kind: "pending", entryId: entry.id, bootstrap, ...(failure ? { failure } : {}) };
|
|
169
188
|
}
|
|
170
189
|
|
|
171
190
|
return undefined;
|
|
@@ -175,10 +194,13 @@ export function findPendingHandoffBootstrap(
|
|
|
175
194
|
* A session is *starting* when its active branch still carries an unconsumed,
|
|
176
195
|
* well-formed handoff bootstrap: it has been prepared but has not received its
|
|
177
196
|
* kickoff. An invalid bootstrap does not count — the child consumes it and
|
|
178
|
-
* proceeds as an ordinary session rather than kicking off a handoff.
|
|
197
|
+
* proceeds as an ordinary session rather than kicking off a handoff. Neither
|
|
198
|
+
* does one whose generation already failed: it stays retryable on resume, but
|
|
199
|
+
* nothing is starting right now.
|
|
179
200
|
*/
|
|
180
201
|
export function isSessionStarting(branch: readonly SessionEntry[]): boolean {
|
|
181
|
-
|
|
202
|
+
const scan = findPendingHandoffBootstrap(branch);
|
|
203
|
+
return scan?.kind === "pending" && scan.failure === undefined;
|
|
182
204
|
}
|
|
183
205
|
|
|
184
206
|
/**
|
|
@@ -1,34 +1,20 @@
|
|
|
1
1
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
type ExactModelReference,
|
|
6
|
+
findModelByReference,
|
|
7
|
+
formatModelSelection,
|
|
8
|
+
selectModel,
|
|
9
|
+
} from "../shared/model.ts";
|
|
4
10
|
import { formatAvailableModelList, resolveAuthenticatedModel } from "../shared/model-resolution.ts";
|
|
5
|
-
import {
|
|
11
|
+
import { type HandoffRoster, selectFromRoster } from "./roster.ts";
|
|
6
12
|
|
|
7
13
|
export function formatModelArgument(
|
|
8
14
|
model: Model<Api> | undefined,
|
|
9
15
|
thinkingLevel: ThinkingLevel | undefined,
|
|
10
16
|
): string | undefined {
|
|
11
|
-
|
|
12
|
-
return undefined;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const base = `${model.provider}/${model.id}`;
|
|
16
|
-
return thinkingLevel ? `${base}:${thinkingLevel}` : base;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function parseModelArgument(value: string): {
|
|
20
|
-
model: string;
|
|
21
|
-
thinkingLevel?: ThinkingLevel | undefined;
|
|
22
|
-
} {
|
|
23
|
-
const separator = value.lastIndexOf(":");
|
|
24
|
-
const suffix = separator >= 0 ? value.slice(separator + 1) : undefined;
|
|
25
|
-
if (!isThinkingLevel(suffix)) {
|
|
26
|
-
return { model: value };
|
|
27
|
-
}
|
|
28
|
-
return {
|
|
29
|
-
model: value.slice(0, separator),
|
|
30
|
-
thinkingLevel: suffix,
|
|
31
|
-
};
|
|
17
|
+
return model ? formatModelSelection(selectModel(model, thinkingLevel)) : undefined;
|
|
32
18
|
}
|
|
33
19
|
|
|
34
20
|
export interface HandoffModelOverride {
|
|
@@ -36,7 +22,41 @@ export interface HandoffModelOverride {
|
|
|
36
22
|
thinkingLevel: ThinkingLevel | undefined;
|
|
37
23
|
}
|
|
38
24
|
|
|
39
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the model a handoff launches its child on. A roster replaces Pi's CLI
|
|
27
|
+
* matching entirely: fuzzy aliases resolve across every authenticated provider,
|
|
28
|
+
* which is how delegated work lands on a metered provider that happens to serve
|
|
29
|
+
* the same model id, so a rostered handoff accepts only an exact roster entry.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveChildModel(
|
|
32
|
+
modelRuntime: ModelRuntime,
|
|
33
|
+
roster: HandoffRoster | undefined,
|
|
34
|
+
requested: ExactModelReference,
|
|
35
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
36
|
+
): HandoffModelOverride {
|
|
37
|
+
if (!roster) {
|
|
38
|
+
return resolveModelPattern(
|
|
39
|
+
modelRuntime,
|
|
40
|
+
formatModelSelection({ ...requested, thinkingLevel: undefined }),
|
|
41
|
+
thinkingLevel,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const level = selectFromRoster(roster, requested, thinkingLevel);
|
|
46
|
+
const model = findModelByReference(modelRuntime.getAvailableSnapshot(), requested);
|
|
47
|
+
if (!model) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Model "${requested.provider}/${requested.modelId}" is no longer an available authenticated model.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return { model, thinkingLevel: level };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves a user-configured model pattern with Pi's CLI grammar, including fuzzy
|
|
57
|
+
* aliases. Only for settings the user writes by hand; never for a model's choice.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveModelPattern(
|
|
40
60
|
modelRuntime: ModelRuntime,
|
|
41
61
|
modelPattern: string,
|
|
42
62
|
thinkingLevel?: ThinkingLevel | undefined,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ScopedModel } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ExactModelReference, ModelSelection } from "../shared/model.ts";
|
|
5
|
+
|
|
6
|
+
export interface HandoffRosterEntry extends ExactModelReference {
|
|
7
|
+
/** The thinking levels this model may run at, or undefined for the agent's choice. */
|
|
8
|
+
thinkingLevels: readonly ThinkingLevel[] | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type HandoffRoster = readonly HandoffRosterEntry[];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Chooses the roster a handoff selects from, in descending priority: an explicit
|
|
15
|
+
* `sessions.handoff.roster`, the session's own scoped models (`enabledModels`),
|
|
16
|
+
* then everything authenticated. The middle tier lets a curated model cycle
|
|
17
|
+
* govern delegation too; the top tier exists for when the two should disagree.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveHandoffRoster(options: {
|
|
20
|
+
models: readonly Model<Api>[];
|
|
21
|
+
patterns: readonly ModelSelection[];
|
|
22
|
+
scopedModels: readonly ScopedModel[];
|
|
23
|
+
}): HandoffRoster | undefined {
|
|
24
|
+
const configured = buildHandoffRoster(options.models, options.patterns);
|
|
25
|
+
if (configured) {
|
|
26
|
+
return configured;
|
|
27
|
+
}
|
|
28
|
+
return options.scopedModels.length > 0 ? options.scopedModels.map(scopedEntry) : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scopedEntry(scoped: ScopedModel): HandoffRosterEntry {
|
|
32
|
+
return {
|
|
33
|
+
provider: scoped.model.provider,
|
|
34
|
+
modelId: scoped.model.id,
|
|
35
|
+
thinkingLevels: scoped.thinkingLevel ? [scoped.thinkingLevel] : undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Expands configured roster patterns against the authenticated models. A roster
|
|
41
|
+
* only ever narrows what a handoff may launch, so patterns that match nothing
|
|
42
|
+
* are dropped rather than raised; the resulting roster is still safe, just
|
|
43
|
+
* smaller. An unconfigured roster returns undefined, deferring to the next tier.
|
|
44
|
+
*/
|
|
45
|
+
export function buildHandoffRoster(
|
|
46
|
+
models: readonly Model<Api>[],
|
|
47
|
+
patterns: readonly ModelSelection[],
|
|
48
|
+
): HandoffRoster | undefined {
|
|
49
|
+
if (patterns.length === 0) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const entries = new Map<string, HandoffRosterEntry>();
|
|
54
|
+
for (const pattern of patterns) {
|
|
55
|
+
const provider = globMatcher(pattern.provider);
|
|
56
|
+
const modelId = globMatcher(pattern.modelId);
|
|
57
|
+
for (const model of models) {
|
|
58
|
+
if (!provider.test(model.provider) || !modelId.test(model.id)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const key = `${model.provider}/${model.id}`;
|
|
62
|
+
const existing = entries.get(key);
|
|
63
|
+
entries.set(key, {
|
|
64
|
+
provider: model.provider,
|
|
65
|
+
modelId: model.id,
|
|
66
|
+
thinkingLevels: mergeThinkingLevels(existing, pattern.thinkingLevel),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return [...entries.values()];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Narrows a requested model to its roster entry. An entry listing a single
|
|
76
|
+
* thinking level pins it, so the agent may leave the level out; an entry listing
|
|
77
|
+
* several makes the choice mandatory.
|
|
78
|
+
*/
|
|
79
|
+
export function selectFromRoster(
|
|
80
|
+
roster: HandoffRoster,
|
|
81
|
+
requested: ExactModelReference,
|
|
82
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
83
|
+
): ThinkingLevel | undefined {
|
|
84
|
+
if (roster.length === 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"session_handoff cannot override the model: sessions.handoff.roster matches no authenticated model.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const entry = roster.find(
|
|
91
|
+
(candidate) =>
|
|
92
|
+
candidate.provider === requested.provider && candidate.modelId === requested.modelId,
|
|
93
|
+
);
|
|
94
|
+
if (!entry) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Model "${requested.provider}/${requested.modelId}" is not on the handoff roster. Allowed models: ${formatRoster(roster)}.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const allowed = entry.thinkingLevels;
|
|
101
|
+
if (!allowed) {
|
|
102
|
+
return thinkingLevel;
|
|
103
|
+
}
|
|
104
|
+
if (!thinkingLevel) {
|
|
105
|
+
if (allowed.length > 1) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`The handoff roster requires a thinking level for "${formatEntryReference(entry)}": one of ${allowed.join(", ")}.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return allowed[0];
|
|
111
|
+
}
|
|
112
|
+
if (!allowed.includes(thinkingLevel)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`The handoff roster does not allow thinking level ${thinkingLevel} for "${formatEntryReference(entry)}"; allowed: ${allowed.join(", ")}.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return thinkingLevel;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function formatRoster(roster: HandoffRoster): string {
|
|
121
|
+
return roster.map(formatRosterEntry).join(", ");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function formatRosterEntry(entry: HandoffRosterEntry): string {
|
|
125
|
+
const reference = formatEntryReference(entry);
|
|
126
|
+
if (!entry.thinkingLevels) {
|
|
127
|
+
return reference;
|
|
128
|
+
}
|
|
129
|
+
return entry.thinkingLevels.length === 1
|
|
130
|
+
? `${reference}:${entry.thinkingLevels[0]}`
|
|
131
|
+
: `${reference}:{${entry.thinkingLevels.join(",")}}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatEntryReference(entry: ExactModelReference): string {
|
|
135
|
+
return `${entry.provider}/${entry.modelId}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function mergeThinkingLevels(
|
|
139
|
+
existing: HandoffRosterEntry | undefined,
|
|
140
|
+
added: ThinkingLevel | undefined,
|
|
141
|
+
): readonly ThinkingLevel[] | undefined {
|
|
142
|
+
if (!added) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (!existing) {
|
|
146
|
+
return [added];
|
|
147
|
+
}
|
|
148
|
+
if (!existing.thinkingLevels) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
return existing.thinkingLevels.includes(added)
|
|
152
|
+
? existing.thinkingLevels
|
|
153
|
+
: [...existing.thinkingLevels, added];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function globMatcher(pattern: string): RegExp {
|
|
157
|
+
const source = pattern
|
|
158
|
+
.split("*")
|
|
159
|
+
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
160
|
+
.join(".*");
|
|
161
|
+
return new RegExp(`^${source}$`);
|
|
162
|
+
}
|
|
@@ -21,6 +21,7 @@ export function prepareHandoffLaunch(options: {
|
|
|
21
21
|
parentSessionFile: string;
|
|
22
22
|
title: string;
|
|
23
23
|
model: string | undefined;
|
|
24
|
+
approveProjectTrust: boolean;
|
|
24
25
|
buildBootstrap: (sessionId: string) => HandoffBootstrap;
|
|
25
26
|
prepareChild?: ((manager: SessionManager, sessionId: string) => void) | undefined;
|
|
26
27
|
}): PreparedHandoff {
|
|
@@ -52,6 +53,7 @@ export function prepareHandoffLaunch(options: {
|
|
|
52
53
|
sessionId: manager.getSessionId(),
|
|
53
54
|
sessionDir: manager.usesDefaultSessionDir() ? undefined : manager.getSessionDir(),
|
|
54
55
|
model: options.model,
|
|
56
|
+
approveProjectTrust: options.approveProjectTrust,
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
return { sessionId: manager.getSessionId(), sessionFile, resumeCommand };
|
|
@@ -79,6 +81,11 @@ function flushPreparedSession(manager: SessionManager): void {
|
|
|
79
81
|
* The canonical recovery artifact consumed by launch backends, failure
|
|
80
82
|
* messages, clipboard delivery, and renderers. Self-locating: when the target
|
|
81
83
|
* cwd differs from the parent's, the command starts with `cd <target> &&`.
|
|
84
|
+
*
|
|
85
|
+
* Unattended children carry `--approve`, which resolves project trust for that
|
|
86
|
+
* process without prompting: nobody is watching a detached worker's window, so
|
|
87
|
+
* a blocked trust selector there stalls the launch indefinitely. The override
|
|
88
|
+
* is process-scoped and never written to the trust store.
|
|
82
89
|
*/
|
|
83
90
|
export function buildPiResumeCommand(options: {
|
|
84
91
|
targetCwd: string;
|
|
@@ -86,12 +93,16 @@ export function buildPiResumeCommand(options: {
|
|
|
86
93
|
sessionId: string;
|
|
87
94
|
sessionDir?: string | undefined;
|
|
88
95
|
model?: string | undefined;
|
|
96
|
+
approveProjectTrust: boolean;
|
|
89
97
|
}): string {
|
|
90
98
|
const args = ["pi"];
|
|
91
99
|
if (options.sessionDir) {
|
|
92
100
|
args.push("--session-dir", shellQuote(options.sessionDir));
|
|
93
101
|
}
|
|
94
102
|
args.push("--session-id", shellQuote(options.sessionId));
|
|
103
|
+
if (options.approveProjectTrust) {
|
|
104
|
+
args.push("--approve");
|
|
105
|
+
}
|
|
95
106
|
if (options.model) {
|
|
96
107
|
args.push("--model", shellQuote(options.model));
|
|
97
108
|
}
|
|
@@ -40,14 +40,19 @@ export class HandoffToolComponent implements Component {
|
|
|
40
40
|
return container.render(width);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
// Only a deferred handoff needs its command in the transcript, because the
|
|
44
|
+
// user is the one who has to run it. A launched child is already running;
|
|
45
|
+
// the board is where its command belongs.
|
|
46
|
+
if (this.model.result.launch === "deferred") {
|
|
47
|
+
container.addChild(new Spacer(1));
|
|
48
|
+
container.addChild(
|
|
49
|
+
createLaunchCommandComponent(
|
|
50
|
+
this.model.result.resumeCommand,
|
|
51
|
+
formatDeferredCommandLabel(this.clipboardStatus),
|
|
52
|
+
this.theme,
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
51
56
|
if (this.model.result.degradedFrom) {
|
|
52
57
|
container.addChild(new Spacer(1));
|
|
53
58
|
container.addChild(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { formatAvailableModelList } from "../shared/model-resolution.ts";
|
|
4
3
|
import { type HandoffLaunchTarget, SUBAGENT_LAUNCH } from "./launch-target.ts";
|
|
4
|
+
import { formatRosterEntry, type HandoffRoster } from "./roster.ts";
|
|
5
5
|
|
|
6
6
|
export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]) {
|
|
7
7
|
const descriptions = targets.flatMap((target) =>
|
|
@@ -31,6 +31,9 @@ const MODEL_INHERITANCE_GUIDELINES = [
|
|
|
31
31
|
"To run the handoff on a different model, set both provider and model together (both are required).",
|
|
32
32
|
];
|
|
33
33
|
|
|
34
|
+
const RESTRICTED_THINKING_GUIDELINE =
|
|
35
|
+
'A ":level" suffix on a listed model limits it to those thinking levels; set thinkingLevel to one of them, or leave it unset when only one is listed.';
|
|
36
|
+
|
|
34
37
|
const SUBAGENT_MODEL_OVERRIDE_GUIDELINE =
|
|
35
38
|
"For subagents, choose the model that best fits the delegated task; for directional or deferred handoffs, only override the model when the task clearly warrants it. When unsure, ask the user for their preference.";
|
|
36
39
|
|
|
@@ -40,15 +43,19 @@ const DEFAULT_MODEL_OVERRIDE_GUIDELINE =
|
|
|
40
43
|
export function buildHandoffPromptGuidelines(
|
|
41
44
|
targets: readonly HandoffLaunchTarget[],
|
|
42
45
|
models: readonly Model<Api>[],
|
|
46
|
+
roster: HandoffRoster | undefined,
|
|
43
47
|
): string[] {
|
|
44
48
|
const hasSubagent = targets.some((target) => target.value === SUBAGENT_LAUNCH);
|
|
49
|
+
const selectable =
|
|
50
|
+
roster?.map(formatRosterEntry) ?? models.map((model) => `${model.provider}/${model.id}`);
|
|
45
51
|
return [
|
|
46
52
|
...(hasSubagent ? SUBAGENT_GUIDELINES : []),
|
|
47
53
|
LAUNCH_TARGET_GUIDELINE,
|
|
48
54
|
...MODEL_INHERITANCE_GUIDELINES,
|
|
49
55
|
hasSubagent ? SUBAGENT_MODEL_OVERRIDE_GUIDELINE : DEFAULT_MODEL_OVERRIDE_GUIDELINE,
|
|
50
|
-
...(
|
|
51
|
-
? [`Available models, given as provider/model-id: ${
|
|
56
|
+
...(selectable.length > 0
|
|
57
|
+
? [`Available models, given as provider/model-id: ${selectable.join(", ")}.`]
|
|
52
58
|
: []),
|
|
59
|
+
...(roster?.some((entry) => entry.thinkingLevels) ? [RESTRICTED_THINKING_GUIDELINE] : []),
|
|
53
60
|
];
|
|
54
61
|
}
|
|
@@ -12,8 +12,9 @@ import {
|
|
|
12
12
|
type HandoffLaunchValue,
|
|
13
13
|
} from "./launch-target.ts";
|
|
14
14
|
import { createChildGeneratedHandoffBootstrap } from "./metadata.ts";
|
|
15
|
-
import { formatModelArgument,
|
|
15
|
+
import { formatModelArgument, resolveChildModel } from "./model.ts";
|
|
16
16
|
import { buildLaunchReceipt } from "./receipt.ts";
|
|
17
|
+
import type { HandoffRoster } from "./roster.ts";
|
|
17
18
|
import { formatHandoffLaunchFailure, prepareHandoffLaunch } from "./spawn.ts";
|
|
18
19
|
import type { HandoffToolDetails } from "./tool-contract.ts";
|
|
19
20
|
|
|
@@ -39,6 +40,7 @@ export async function executeSessionHandoffTool(
|
|
|
39
40
|
ctx: ExtensionContext,
|
|
40
41
|
modelRuntime: ModelRuntime,
|
|
41
42
|
launchTargets: readonly HandoffLaunchTarget[],
|
|
43
|
+
roster: HandoffRoster | undefined,
|
|
42
44
|
recordClipboardStatus: (sessionId: string, status: ClipboardStatus) => void,
|
|
43
45
|
) {
|
|
44
46
|
const goal = params.goal.trim();
|
|
@@ -90,7 +92,7 @@ export async function executeSessionHandoffTool(
|
|
|
90
92
|
}
|
|
91
93
|
const override =
|
|
92
94
|
provider && modelId
|
|
93
|
-
?
|
|
95
|
+
? resolveChildModel(modelRuntime, roster, { provider, modelId }, params.thinkingLevel)
|
|
94
96
|
: undefined;
|
|
95
97
|
const childModel = override?.model ?? ctx.model;
|
|
96
98
|
const thinkingLevel = override?.thinkingLevel ?? params.thinkingLevel ?? pi.getThinkingLevel();
|
|
@@ -105,6 +107,7 @@ export async function executeSessionHandoffTool(
|
|
|
105
107
|
parentSessionFile,
|
|
106
108
|
title,
|
|
107
109
|
model,
|
|
110
|
+
approveProjectTrust: target.approveProjectTrust,
|
|
108
111
|
buildBootstrap: (sessionId) =>
|
|
109
112
|
createChildGeneratedHandoffBootstrap({
|
|
110
113
|
sessionId,
|
|
@@ -1,10 +1,51 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
1
2
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { isThinkingLevel } from "./thinking-levels.ts";
|
|
2
4
|
|
|
3
5
|
export interface ExactModelReference {
|
|
4
6
|
provider: string;
|
|
5
7
|
modelId: string;
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The structured representation of "which model, at which thinking level". Pi's
|
|
12
|
+
* CLI grammar spells this `provider/model-id[:level]`; that string is an edge
|
|
13
|
+
* format, parsed on the way in and rebuilt on the way out.
|
|
14
|
+
*/
|
|
15
|
+
export interface ModelSelection extends ExactModelReference {
|
|
16
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseModelSelection(value: string): ModelSelection {
|
|
20
|
+
const separator = value.lastIndexOf(":");
|
|
21
|
+
const suffix = separator >= 0 ? value.slice(separator + 1) : undefined;
|
|
22
|
+
const thinkingLevel = isThinkingLevel(suffix) ? suffix : undefined;
|
|
23
|
+
const reference = thinkingLevel ? value.slice(0, separator) : value;
|
|
24
|
+
|
|
25
|
+
const boundary = reference.indexOf("/");
|
|
26
|
+
if (boundary <= 0 || boundary === reference.length - 1) {
|
|
27
|
+
throw new Error(`Expected a "provider/model-id" reference; got "${value}".`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
provider: reference.slice(0, boundary),
|
|
32
|
+
modelId: reference.slice(boundary + 1),
|
|
33
|
+
thinkingLevel,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatModelSelection(selection: ModelSelection): string {
|
|
38
|
+
const reference = `${selection.provider}/${selection.modelId}`;
|
|
39
|
+
return selection.thinkingLevel ? `${reference}:${selection.thinkingLevel}` : reference;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function selectModel(
|
|
43
|
+
model: Model<Api>,
|
|
44
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
45
|
+
): ModelSelection {
|
|
46
|
+
return { provider: model.provider, modelId: model.id, thinkingLevel };
|
|
47
|
+
}
|
|
48
|
+
|
|
8
49
|
export function findModelByReference(
|
|
9
50
|
availableModels: readonly Model<Api>[],
|
|
10
51
|
reference: ExactModelReference,
|
|
@@ -4,6 +4,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
|
4
4
|
import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { KeyId } from "@earendil-works/pi-tui";
|
|
6
6
|
import { type Static, Type } from "typebox";
|
|
7
|
+
import { type ModelSelection, parseModelSelection } from "./model.ts";
|
|
7
8
|
import { isThinkingLevel } from "./thinking-levels.ts";
|
|
8
9
|
import { parseTypeBoxValue } from "./typebox.ts";
|
|
9
10
|
|
|
@@ -30,6 +31,7 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
|
|
|
30
31
|
persistRuns: Type.Optional(Type.Boolean()),
|
|
31
32
|
model: Type.Optional(Type.String()),
|
|
32
33
|
thinkingLevel: Type.Optional(Type.String()),
|
|
34
|
+
roster: Type.Optional(Type.Array(Type.String())),
|
|
33
35
|
deferred: Type.Optional(
|
|
34
36
|
Type.Object({
|
|
35
37
|
copyToClipboard: Type.Optional(Type.Boolean()),
|
|
@@ -97,6 +99,8 @@ export interface AskSettings extends AgentModelSettings {
|
|
|
97
99
|
export interface HandoffSettings extends AgentModelSettings {
|
|
98
100
|
pickerShortcut: KeyId;
|
|
99
101
|
persistRuns: boolean;
|
|
102
|
+
/** Patterns naming the models a handoff may launch a child on. Empty means unrestricted. */
|
|
103
|
+
roster: readonly ModelSelection[];
|
|
100
104
|
deferred: {
|
|
101
105
|
copyToClipboard: boolean;
|
|
102
106
|
};
|
|
@@ -182,6 +186,19 @@ function normalizePickerShortcut(value: string | undefined): KeyId {
|
|
|
182
186
|
return (trimmed ? trimmed : "alt+o") as KeyId;
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
function normalizeRoster(value: readonly string[] | undefined): readonly ModelSelection[] {
|
|
190
|
+
return (value ?? [])
|
|
191
|
+
.map((pattern) => pattern.trim())
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.map((pattern) => {
|
|
194
|
+
try {
|
|
195
|
+
return parseModelSelection(pattern);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
throw new Error(`Invalid sessions.handoff.roster entry: ${(error as Error).message}`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
185
202
|
function parseThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
|
|
186
203
|
const trimmed = value?.trim();
|
|
187
204
|
return isThinkingLevel(trimmed) ? trimmed : undefined;
|
|
@@ -238,6 +255,7 @@ function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSetti
|
|
|
238
255
|
...resolveAgentModelSettings(fileSettings.handoff),
|
|
239
256
|
pickerShortcut: normalizePickerShortcut(fileSettings.handoff?.pickerShortcut),
|
|
240
257
|
persistRuns: fileSettings.handoff?.persistRuns ?? false,
|
|
258
|
+
roster: normalizeRoster(fileSettings.handoff?.roster),
|
|
241
259
|
deferred: {
|
|
242
260
|
copyToClipboard: fileSettings.handoff?.deferred?.copyToClipboard ?? true,
|
|
243
261
|
},
|
|
@@ -21,6 +21,7 @@ export function createSubagentLaunchTarget(
|
|
|
21
21
|
"'subagent' delegates one task to a detached tmux worker and requests a report by default.",
|
|
22
22
|
requestResponseDefault: true,
|
|
23
23
|
bootstrapMode: "automatic",
|
|
24
|
+
approveProjectTrust: true,
|
|
24
25
|
describeSubagentChild(input) {
|
|
25
26
|
return {
|
|
26
27
|
childSessionId: input.childSessionId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,20 +38,20 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@biomejs/biome": "^2.4.14",
|
|
41
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
42
|
-
"@earendil-works/pi-ai": "^0.
|
|
43
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
44
|
-
"@earendil-works/pi-tui": "^0.
|
|
41
|
+
"@earendil-works/pi-agent-core": "^0.83.0",
|
|
42
|
+
"@earendil-works/pi-ai": "^0.83.0",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "^0.83.0",
|
|
44
|
+
"@earendil-works/pi-tui": "^0.83.0",
|
|
45
45
|
"@types/node": "^25.6.2",
|
|
46
46
|
"strip-ansi": "^7.2.0",
|
|
47
47
|
"typebox": "^1.1.38",
|
|
48
|
-
"typescript": "^
|
|
48
|
+
"typescript": "^7.0.0",
|
|
49
49
|
"vitest": "^4.1.5"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@earendil-works/pi-agent-core": ">=0.
|
|
53
|
-
"@earendil-works/pi-ai": ">=0.
|
|
54
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
55
|
-
"@earendil-works/pi-tui": ">=0.
|
|
52
|
+
"@earendil-works/pi-agent-core": ">=0.83.0",
|
|
53
|
+
"@earendil-works/pi-ai": ">=0.83.0",
|
|
54
|
+
"@earendil-works/pi-coding-agent": ">=0.83.0",
|
|
55
|
+
"@earendil-works/pi-tui": ">=0.83.0"
|
|
56
56
|
}
|
|
57
57
|
}
|