pi-sessions 0.19.0 → 0.20.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 +22 -1
- package/extensions/session-handoff/agent-profiles.ts +110 -0
- package/extensions/session-handoff/install.ts +34 -4
- package/extensions/session-handoff/launch-target.ts +4 -1
- package/extensions/session-handoff/metadata.ts +10 -0
- package/extensions/session-handoff/model.ts +22 -0
- package/extensions/session-handoff/spawn.ts +6 -0
- package/extensions/session-handoff/tool-schema.ts +13 -0
- package/extensions/session-handoff/tool.ts +18 -4
- package/extensions/shared/settings.ts +21 -5
- package/extensions/subagents/install.ts +11 -4
- package/extensions/subagents/launch-target.ts +2 -0
- package/extensions/subagents/settle.ts +2 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ When a host claims peers, pi-sessions leaves all user-facing coordination and la
|
|
|
116
116
|
|
|
117
117
|
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. With a peer-claiming host, only Subagents are shown.
|
|
118
118
|
|
|
119
|
-
Both launch tools take `goal`, `title` (at most 64 characters), and `requestResponse`, with optional `cwd`, `provider`, `model`, and `thinkingLevel` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`). `session_handoff` additionally requires `launch` (`left`, `right`, `up`, `down` when a split backend is available, or `deferred` when enabled). `subagent_launch` has no `launch` parameter. Provider and model must be supplied together.
|
|
119
|
+
Both launch tools take `goal`, `title` (at most 64 characters), and `requestResponse`, with optional `cwd`, `provider`, `model`, and `thinkingLevel` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`). `session_handoff` additionally requires `launch` (`left`, `right`, `up`, `down` when a split backend is available, or `deferred` when enabled). `subagent_launch` has no `launch` parameter, but takes an optional `agent` naming an [agent profile](#agent-profiles). Provider and model must be supplied together.
|
|
120
120
|
|
|
121
121
|
## Session Messaging
|
|
122
122
|
|
|
@@ -236,6 +236,27 @@ Subagents require the handoff and messaging features. Limit recursive delegation
|
|
|
236
236
|
|
|
237
237
|
`contextLimit` sets a max context limit before compaction is triggered. It works identically to setting a global value in pi itself, but only applies to subagents.
|
|
238
238
|
|
|
239
|
+
### Agent profiles
|
|
240
|
+
|
|
241
|
+
An agent profile lets `subagent_launch` start a subagent with extra instructions, a pinned model, and a thinking level, by name. Profiles are Markdown files in `~/.pi/agent/agents/`, in Claude Code's subagent format, so the same file works in both:
|
|
242
|
+
|
|
243
|
+
```markdown
|
|
244
|
+
---
|
|
245
|
+
name: oracle
|
|
246
|
+
description: Read-only second opinion on a plan, diff, or hard bug. Use for reviews and high-impact judgment calls.
|
|
247
|
+
model: anthropic/claude-fable-5-1:high
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
You are the Oracle: a read-only reviewer. Do not modify files. ...
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- `name` must match the file name, and `description` tells the calling agent when to use the profile; both are required. The `subagent_launch` prompt guidelines list every valid profile with its description.
|
|
254
|
+
- `model` is a pi model pattern (`provider/id` or a bare id, optionally with `:<thinking>`), and `thinkingLevel` sets the level separately. Explicit `provider`/`model` and `thinkingLevel` on the launch win, and the resolved model must still be on the handoff roster.
|
|
255
|
+
- `tools` is a comma-separated allowlist passed to `--tools`; `submit_task_report` is always kept. Without it, the subagent has every tool.
|
|
256
|
+
- The body is added to the subagent's system prompt, in the subagent section after its standard instructions; `AGENTS.md`, extensions, and the report flow are unchanged. The instructions are stored in the child session when it launches, so later edits to the file don't change a running subagent.
|
|
257
|
+
|
|
258
|
+
Naming a missing or malformed profile fails the launch with the reason. Profiles apply only to subagents, not to `session_handoff`. Move the directory with `sessions.subagents.agentsDir` (an absolute path or one starting with `~/`).
|
|
259
|
+
|
|
239
260
|
## Session Index
|
|
240
261
|
|
|
241
262
|
The index is a shared cache of your transcripts, used by search, messaging, and the other session features. Pi checks transcripts at startup and syncs files that changed, including sessions run without pi-sessions loaded. Missing, older-schema, or unreadable indexes rebuild automatically in the background; unreadable databases are moved aside rather than overwritten. A malformed transcript is skipped and reported.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { formatError } from "../shared/errors.ts";
|
|
7
|
+
import { THINKING_LEVELS } from "../shared/thinking-levels.ts";
|
|
8
|
+
import { parseTypeBoxValue } from "../shared/typebox.ts";
|
|
9
|
+
|
|
10
|
+
// Claude Code's subagent file format, so one file can serve several harnesses.
|
|
11
|
+
// Keys other harnesses define, such as Claude Code's `color`, are ignored.
|
|
12
|
+
const AGENT_PROFILE_FRONTMATTER_SCHEMA = Type.Object({
|
|
13
|
+
name: Type.String({ minLength: 1 }),
|
|
14
|
+
description: Type.String({ minLength: 1 }),
|
|
15
|
+
model: Type.Optional(Type.String({ minLength: 1 })),
|
|
16
|
+
thinkingLevel: Type.Optional(Type.Union(THINKING_LEVELS.map((level) => Type.Literal(level)))),
|
|
17
|
+
tools: Type.Optional(Type.String({ minLength: 1 })),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export interface AgentProfile {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
/** A Pi model pattern; a `:<thinking>` suffix travels with the model it names. */
|
|
24
|
+
model: string | undefined;
|
|
25
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
26
|
+
/** An allowlist for the child's `--tools`, or undefined to inherit every tool. */
|
|
27
|
+
tools: readonly string[] | undefined;
|
|
28
|
+
instructions: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AgentProfileCatalog {
|
|
32
|
+
profiles: readonly AgentProfile[];
|
|
33
|
+
/** Validation errors by file name, so a launch naming a broken profile reports why. */
|
|
34
|
+
invalid: ReadonlyMap<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const EMPTY_AGENT_PROFILE_CATALOG: AgentProfileCatalog = {
|
|
38
|
+
profiles: [],
|
|
39
|
+
invalid: new Map(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function loadAgentProfiles(dir: string): AgentProfileCatalog {
|
|
43
|
+
let fileNames: string[];
|
|
44
|
+
try {
|
|
45
|
+
fileNames = readdirSync(dir)
|
|
46
|
+
.filter((fileName) => fileName.endsWith(".md"))
|
|
47
|
+
.sort();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
50
|
+
return EMPTY_AGENT_PROFILE_CATALOG;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const profiles: AgentProfile[] = [];
|
|
56
|
+
const invalid = new Map<string, string>();
|
|
57
|
+
for (const fileName of fileNames) {
|
|
58
|
+
const path = join(dir, fileName);
|
|
59
|
+
try {
|
|
60
|
+
profiles.push(parseAgentProfile(path, readFileSync(path, "utf8")));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
invalid.set(basename(fileName, ".md"), `${path}: ${formatError(error)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { profiles, invalid };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseAgentProfile(path: string, content: string): AgentProfile {
|
|
69
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
70
|
+
const fields = parseTypeBoxValue(AGENT_PROFILE_FRONTMATTER_SCHEMA, frontmatter, "frontmatter");
|
|
71
|
+
const fileName = basename(path, ".md");
|
|
72
|
+
if (fields.name !== fileName) {
|
|
73
|
+
throw new Error(`name "${fields.name}" must match the file name "${fileName}".`);
|
|
74
|
+
}
|
|
75
|
+
const instructions = body.trim();
|
|
76
|
+
if (!instructions) {
|
|
77
|
+
throw new Error("the body must hold the profile's instructions.");
|
|
78
|
+
}
|
|
79
|
+
const tools = fields.tools
|
|
80
|
+
?.split(",")
|
|
81
|
+
.map((tool) => tool.trim())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
if (tools?.length === 0) {
|
|
84
|
+
throw new Error("tools must name at least one tool.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
name: fields.name,
|
|
89
|
+
description: fields.description.replace(/\s+/g, " ").trim(),
|
|
90
|
+
model: fields.model?.trim(),
|
|
91
|
+
thinkingLevel: fields.thinkingLevel,
|
|
92
|
+
tools,
|
|
93
|
+
instructions,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function requireAgentProfile(catalog: AgentProfileCatalog, name: string): AgentProfile {
|
|
98
|
+
const profile = catalog.profiles.find((candidate) => candidate.name === name);
|
|
99
|
+
if (profile) {
|
|
100
|
+
return profile;
|
|
101
|
+
}
|
|
102
|
+
const error = catalog.invalid.get(name);
|
|
103
|
+
if (error) {
|
|
104
|
+
throw new Error(`Agent profile "${name}" is invalid: ${error}`);
|
|
105
|
+
}
|
|
106
|
+
const available = catalog.profiles.map((candidate) => candidate.name).join(", ");
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Unknown agent profile "${name}". Available agent profiles: ${available || "none"}.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -8,6 +8,11 @@ import { isTuiMode } from "../shared/pi-mode.ts";
|
|
|
8
8
|
import type { SessionSettings } from "../shared/settings.ts";
|
|
9
9
|
import { THINKING_LEVELS } from "../shared/thinking-levels.ts";
|
|
10
10
|
import { safeParseTypeBoxValue } from "../shared/typebox.ts";
|
|
11
|
+
import {
|
|
12
|
+
type AgentProfileCatalog,
|
|
13
|
+
EMPTY_AGENT_PROFILE_CATALOG,
|
|
14
|
+
loadAgentProfiles,
|
|
15
|
+
} from "./agent-profiles.ts";
|
|
11
16
|
import { type HandoffBoardServices, openHandoffBoard } from "./board.ts";
|
|
12
17
|
import { consumePendingHandoffBootstrap } from "./bootstrap.ts";
|
|
13
18
|
import { renderHandoffBootstrapFailedEntry } from "./bootstrap-failed-renderer.ts";
|
|
@@ -64,9 +69,11 @@ export function installHandoff(
|
|
|
64
69
|
models: readonly Model<Api>[],
|
|
65
70
|
launchTargets: readonly HandoffLaunchTarget[],
|
|
66
71
|
roster: HandoffRoster | undefined,
|
|
72
|
+
agentProfiles: AgentProfileCatalog,
|
|
67
73
|
name: "session_handoff" | "subagent_launch",
|
|
68
74
|
): void {
|
|
69
75
|
const offersSubagent = offersSubagentLaunch(launchTargets);
|
|
76
|
+
const offersAgents = name === "subagent_launch" && agentProfiles.profiles.length > 0;
|
|
70
77
|
pi.registerTool({
|
|
71
78
|
name,
|
|
72
79
|
label: "Session Handoff",
|
|
@@ -76,7 +83,12 @@ export function installHandoff(
|
|
|
76
83
|
promptSnippet: offersSubagent
|
|
77
84
|
? "Delegate bounded work to a background subagent"
|
|
78
85
|
: "Hand off context to another Pi session",
|
|
79
|
-
promptGuidelines: buildHandoffPromptGuidelines(
|
|
86
|
+
promptGuidelines: buildHandoffPromptGuidelines(
|
|
87
|
+
launchTargets,
|
|
88
|
+
models,
|
|
89
|
+
roster,
|
|
90
|
+
offersAgents ? agentProfiles.profiles : [],
|
|
91
|
+
),
|
|
80
92
|
parameters: Type.Object({
|
|
81
93
|
goal: Type.String({
|
|
82
94
|
description:
|
|
@@ -117,6 +129,16 @@ export function installHandoff(
|
|
|
117
129
|
},
|
|
118
130
|
),
|
|
119
131
|
),
|
|
132
|
+
...(offersAgents
|
|
133
|
+
? {
|
|
134
|
+
agent: Type.Optional(
|
|
135
|
+
Type.String({
|
|
136
|
+
description:
|
|
137
|
+
"Agent profile to run the subagent as. The profile adds instructions to the subagent's system prompt and may set its model and thinking level; explicit provider/model and thinkingLevel take precedence.",
|
|
138
|
+
}),
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
120
142
|
}),
|
|
121
143
|
renderCall(args, theme, context) {
|
|
122
144
|
const state = context.state as HandoffToolRendererState;
|
|
@@ -151,14 +173,18 @@ export function installHandoff(
|
|
|
151
173
|
if (typeof launch !== "string") throw new Error("session_handoff requires launch.");
|
|
152
174
|
if (name === "subagent_launch" && !deps.getLaunchTargets?.().length)
|
|
153
175
|
throw new Error("Subagent launch is unavailable.");
|
|
176
|
+
const { agent, ...rest } = params;
|
|
177
|
+
if (agent !== undefined && typeof agent !== "string")
|
|
178
|
+
throw new Error("agent must name an agent profile.");
|
|
154
179
|
const modelRuntime = await deps.getModelRuntime(ctx.modelRegistry);
|
|
155
180
|
return executeSessionHandoffTool(
|
|
156
181
|
pi,
|
|
157
|
-
{ ...
|
|
182
|
+
{ ...rest, launch, ...(agent === undefined ? {} : { agent }) },
|
|
158
183
|
ctx,
|
|
159
184
|
modelRuntime,
|
|
160
185
|
launchTargets,
|
|
161
186
|
roster,
|
|
187
|
+
agentProfiles,
|
|
162
188
|
(sessionId, status) => clipboardStatusBySessionId.set(sessionId, status),
|
|
163
189
|
);
|
|
164
190
|
},
|
|
@@ -222,6 +248,10 @@ export function installHandoff(
|
|
|
222
248
|
patterns: settings.handoff.roster,
|
|
223
249
|
scopedModels: ctx.scopedModels,
|
|
224
250
|
});
|
|
251
|
+
const subagentTargets = deps.getLaunchTargets?.() ?? [];
|
|
252
|
+
const agentProfiles = subagentTargets.length
|
|
253
|
+
? loadAgentProfiles(settings.subagents.agentsDir)
|
|
254
|
+
: EMPTY_AGENT_PROFILE_CATALOG;
|
|
225
255
|
if (!deps.claimsPeers?.()) {
|
|
226
256
|
registerHandoffTool(
|
|
227
257
|
models,
|
|
@@ -232,15 +262,15 @@ export function installHandoff(
|
|
|
232
262
|
additionalTargets: [],
|
|
233
263
|
}),
|
|
234
264
|
roster,
|
|
265
|
+
EMPTY_AGENT_PROFILE_CATALOG,
|
|
235
266
|
"session_handoff",
|
|
236
267
|
);
|
|
237
268
|
pi.setActiveTools([...new Set([...pi.getActiveTools(), "session_handoff"])]);
|
|
238
269
|
} else {
|
|
239
270
|
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "session_handoff"));
|
|
240
271
|
}
|
|
241
|
-
const subagentTargets = deps.getLaunchTargets?.() ?? [];
|
|
242
272
|
if (subagentTargets.length) {
|
|
243
|
-
registerHandoffTool(models, subagentTargets, roster, "subagent_launch");
|
|
273
|
+
registerHandoffTool(models, subagentTargets, roster, agentProfiles, "subagent_launch");
|
|
244
274
|
pi.setActiveTools([...new Set([...pi.getActiveTools(), "subagent_launch"])]);
|
|
245
275
|
} else {
|
|
246
276
|
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "subagent_launch"));
|
|
@@ -2,7 +2,7 @@ import type { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { type Static, Type } from "typebox";
|
|
3
3
|
import { formatError } from "../shared/errors.ts";
|
|
4
4
|
import type { ClipboardStatus, LaunchBackend } from "./launch/backend.ts";
|
|
5
|
-
import type { HandoffSubagent } from "./metadata.ts";
|
|
5
|
+
import type { HandoffSubagent, HandoffSubagentAgent } from "./metadata.ts";
|
|
6
6
|
import type { PreparedHandoff } from "./spawn.ts";
|
|
7
7
|
|
|
8
8
|
export const DEFERRED_LAUNCH = "deferred" as const;
|
|
@@ -61,11 +61,14 @@ export interface HandoffLaunchTarget {
|
|
|
61
61
|
/** Unattended children skip the startup trust prompt; watched ones let the user answer it. */
|
|
62
62
|
approveProjectTrust: boolean;
|
|
63
63
|
useDefaultSessionDir?: boolean;
|
|
64
|
+
/** Tools the child keeps when an agent profile narrows its tool allowlist. */
|
|
65
|
+
requiredTools?: readonly string[];
|
|
64
66
|
/** Subagent launches stamp their child's identity into the child bootstrap. */
|
|
65
67
|
describeSubagentChild?(input: {
|
|
66
68
|
childSessionId: string;
|
|
67
69
|
ownerSessionId: string;
|
|
68
70
|
requestResponse: boolean;
|
|
71
|
+
agent: HandoffSubagentAgent | undefined;
|
|
69
72
|
}): HandoffSubagent;
|
|
70
73
|
prepareChild(input: HandoffLaunchTargetPreparation): void;
|
|
71
74
|
launch(input: HandoffLaunchTargetInput): Promise<HandoffLaunchTargetOutcome>;
|
|
@@ -20,6 +20,14 @@ export const HANDOFF_STALE_SESSION_MESSAGE =
|
|
|
20
20
|
export const SESSION_STARTING_MESSAGE =
|
|
21
21
|
"Target session is still starting. wait for session_reachable to report it as live, then resend.";
|
|
22
22
|
|
|
23
|
+
// An agent profile's instructions travel with the child, which adds them to
|
|
24
|
+
// its subagent prompt section, so they survive wakes and restarts without
|
|
25
|
+
// depending on the profile file.
|
|
26
|
+
export const HANDOFF_SUBAGENT_AGENT_SCHEMA = Type.Object({
|
|
27
|
+
name: Type.String(),
|
|
28
|
+
instructions: Type.String(),
|
|
29
|
+
});
|
|
30
|
+
|
|
23
31
|
// A subagent's self-knowledge, stamped into its child-local bootstrap so the
|
|
24
32
|
// child never opens the parent transcript to learn who it is.
|
|
25
33
|
export const HANDOFF_SUBAGENT_SCHEMA = Type.Object({
|
|
@@ -27,6 +35,7 @@ export const HANDOFF_SUBAGENT_SCHEMA = Type.Object({
|
|
|
27
35
|
ownerSessionId: Type.String(),
|
|
28
36
|
depth: Type.Integer({ minimum: 1 }),
|
|
29
37
|
requestResponse: Type.Boolean(),
|
|
38
|
+
agent: Type.Optional(HANDOFF_SUBAGENT_AGENT_SCHEMA),
|
|
30
39
|
});
|
|
31
40
|
|
|
32
41
|
const HANDOFF_METADATA_BASE = {
|
|
@@ -84,6 +93,7 @@ export const HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA = Type.Object({
|
|
|
84
93
|
});
|
|
85
94
|
|
|
86
95
|
export type HandoffSubagent = Static<typeof HANDOFF_SUBAGENT_SCHEMA>;
|
|
96
|
+
export type HandoffSubagentAgent = Static<typeof HANDOFF_SUBAGENT_AGENT_SCHEMA>;
|
|
87
97
|
export type HandoffSessionMetadata = Static<typeof HANDOFF_SESSION_METADATA_SCHEMA>;
|
|
88
98
|
export type HandoffBootstrap = Static<typeof HANDOFF_BOOTSTRAP_SCHEMA>;
|
|
89
99
|
export type ChildGeneratedHandoffBootstrap = HandoffBootstrap;
|
|
@@ -52,6 +52,28 @@ export function resolveChildModel(
|
|
|
52
52
|
return { model, thinkingLevel: level };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Resolves an agent profile's model. The pattern is user-written, so it gets Pi's
|
|
57
|
+
* CLI grammar, but the model it lands on must still clear the roster.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveProfileModel(
|
|
60
|
+
modelRuntime: ModelRuntime,
|
|
61
|
+
roster: HandoffRoster | undefined,
|
|
62
|
+
modelPattern: string,
|
|
63
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
64
|
+
): HandoffModelOverride {
|
|
65
|
+
const resolved = resolveModelPattern(modelRuntime, modelPattern, thinkingLevel);
|
|
66
|
+
if (!roster) {
|
|
67
|
+
return resolved;
|
|
68
|
+
}
|
|
69
|
+
return resolveChildModel(
|
|
70
|
+
modelRuntime,
|
|
71
|
+
roster,
|
|
72
|
+
{ provider: resolved.model.provider, modelId: resolved.model.id },
|
|
73
|
+
resolved.thinkingLevel,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
/**
|
|
56
78
|
* Resolves a user-configured model pattern with Pi's CLI grammar, including fuzzy
|
|
57
79
|
* aliases. Only for settings the user writes by hand; never for a model's choice.
|
|
@@ -21,6 +21,7 @@ export function prepareHandoffLaunch(options: {
|
|
|
21
21
|
parentSessionFile: string;
|
|
22
22
|
title: string;
|
|
23
23
|
model: string | undefined;
|
|
24
|
+
tools: readonly string[] | undefined;
|
|
24
25
|
approveProjectTrust: boolean;
|
|
25
26
|
useDefaultSessionDir?: boolean;
|
|
26
27
|
buildBootstrap: (sessionId: string) => HandoffBootstrap;
|
|
@@ -54,6 +55,7 @@ export function prepareHandoffLaunch(options: {
|
|
|
54
55
|
sessionId: manager.getSessionId(),
|
|
55
56
|
sessionDir: manager.usesDefaultSessionDir() ? undefined : manager.getSessionDir(),
|
|
56
57
|
model: options.model,
|
|
58
|
+
tools: options.tools,
|
|
57
59
|
approveProjectTrust: options.approveProjectTrust,
|
|
58
60
|
});
|
|
59
61
|
|
|
@@ -94,6 +96,7 @@ export function buildPiResumeCommand(options: {
|
|
|
94
96
|
sessionId: string;
|
|
95
97
|
sessionDir?: string | undefined;
|
|
96
98
|
model?: string | undefined;
|
|
99
|
+
tools?: readonly string[] | undefined;
|
|
97
100
|
approveProjectTrust: boolean;
|
|
98
101
|
}): string {
|
|
99
102
|
const args = ["pi"];
|
|
@@ -107,6 +110,9 @@ export function buildPiResumeCommand(options: {
|
|
|
107
110
|
if (options.model) {
|
|
108
111
|
args.push("--model", shellQuote(options.model));
|
|
109
112
|
}
|
|
113
|
+
if (options.tools) {
|
|
114
|
+
args.push("--tools", shellQuote(options.tools.join(",")));
|
|
115
|
+
}
|
|
110
116
|
|
|
111
117
|
const command = args.join(" ");
|
|
112
118
|
return options.targetCwd === options.parentCwd
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import type { AgentProfile } from "./agent-profiles.ts";
|
|
3
4
|
import {
|
|
4
5
|
DEFERRED_LAUNCH,
|
|
5
6
|
type HandoffLaunchTarget,
|
|
@@ -42,10 +43,14 @@ const SUBAGENT_MODEL_OVERRIDE_GUIDELINE =
|
|
|
42
43
|
const DEFAULT_MODEL_OVERRIDE_GUIDELINE =
|
|
43
44
|
"Only override the model when the task clearly warrants it.";
|
|
44
45
|
|
|
46
|
+
const AGENT_PROFILE_GUIDELINE =
|
|
47
|
+
"Set agent to run a subagent as one of the agent profiles below when its description fits the task; the profile supplies the model, so leave provider, model, and thinkingLevel unset unless the user asks otherwise.";
|
|
48
|
+
|
|
45
49
|
export function buildHandoffPromptGuidelines(
|
|
46
50
|
targets: readonly HandoffLaunchTarget[],
|
|
47
51
|
models: readonly Model<Api>[],
|
|
48
52
|
roster: HandoffRoster | undefined,
|
|
53
|
+
agentProfiles: readonly AgentProfile[],
|
|
49
54
|
): string[] {
|
|
50
55
|
const hasSubagent = offersSubagentLaunch(targets);
|
|
51
56
|
const userFacingKinds = describeUserFacingLaunchKinds(targets);
|
|
@@ -62,6 +67,14 @@ export function buildHandoffPromptGuidelines(
|
|
|
62
67
|
? [`Available models, given as provider/model-id: ${selectable.join(", ")}.`]
|
|
63
68
|
: []),
|
|
64
69
|
...(roster?.some((entry) => entry.thinkingLevels) ? [RESTRICTED_THINKING_GUIDELINE] : []),
|
|
70
|
+
...(agentProfiles.length > 0
|
|
71
|
+
? [
|
|
72
|
+
AGENT_PROFILE_GUIDELINE,
|
|
73
|
+
...agentProfiles.map(
|
|
74
|
+
(profile) => `Agent profile "${profile.name}": ${profile.description}`,
|
|
75
|
+
),
|
|
76
|
+
]
|
|
77
|
+
: []),
|
|
65
78
|
];
|
|
66
79
|
}
|
|
67
80
|
|
|
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { isAbsolute, resolve } from "node:path";
|
|
4
4
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
5
5
|
import type { ExtensionAPI, ExtensionContext, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { type AgentProfileCatalog, requireAgentProfile } from "./agent-profiles.ts";
|
|
6
7
|
import { resolveHandoffSource } from "./extract.ts";
|
|
7
8
|
import type { ClipboardStatus } from "./launch/backend.ts";
|
|
8
9
|
import { resolveHandoffLaunchTarget } from "./launch-options.ts";
|
|
@@ -10,9 +11,10 @@ import {
|
|
|
10
11
|
DEFERRED_LAUNCH,
|
|
11
12
|
type HandoffLaunchTarget,
|
|
12
13
|
type HandoffLaunchValue,
|
|
14
|
+
SUBAGENT_LAUNCH,
|
|
13
15
|
} from "./launch-target.ts";
|
|
14
16
|
import { createChildGeneratedHandoffBootstrap } from "./metadata.ts";
|
|
15
|
-
import { formatModelArgument, resolveChildModel } from "./model.ts";
|
|
17
|
+
import { formatModelArgument, resolveChildModel, resolveProfileModel } from "./model.ts";
|
|
16
18
|
import { buildLaunchReceipt } from "./receipt.ts";
|
|
17
19
|
import type { HandoffRoster } from "./roster.ts";
|
|
18
20
|
import { formatHandoffLaunchFailure, prepareHandoffLaunch } from "./spawn.ts";
|
|
@@ -32,6 +34,7 @@ export interface HandoffToolParams {
|
|
|
32
34
|
provider?: string | undefined;
|
|
33
35
|
model?: string | undefined;
|
|
34
36
|
thinkingLevel?: ThinkingLevel | undefined;
|
|
37
|
+
agent?: string | undefined;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export async function executeSessionHandoffTool(
|
|
@@ -41,6 +44,7 @@ export async function executeSessionHandoffTool(
|
|
|
41
44
|
modelRuntime: ModelRuntime,
|
|
42
45
|
launchTargets: readonly HandoffLaunchTarget[],
|
|
43
46
|
roster: HandoffRoster | undefined,
|
|
47
|
+
agentProfiles: AgentProfileCatalog,
|
|
44
48
|
recordClipboardStatus: (sessionId: string, status: ClipboardStatus) => void,
|
|
45
49
|
) {
|
|
46
50
|
const goal = params.goal.trim();
|
|
@@ -61,6 +65,11 @@ export async function executeSessionHandoffTool(
|
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
const { target, degradedFrom } = resolveHandoffLaunchTarget(params.launch, launchTargets);
|
|
68
|
+
if (params.agent !== undefined && target.value !== SUBAGENT_LAUNCH) {
|
|
69
|
+
throw new Error("Agent profiles apply only to subagent launches.");
|
|
70
|
+
}
|
|
71
|
+
const profile =
|
|
72
|
+
params.agent === undefined ? undefined : requireAgentProfile(agentProfiles, params.agent);
|
|
64
73
|
|
|
65
74
|
const targetCwd = resolveHandoffCwd(ctx.cwd, params.cwd);
|
|
66
75
|
if (targetCwd.error) {
|
|
@@ -90,12 +99,15 @@ export async function executeSessionHandoffTool(
|
|
|
90
99
|
if (Boolean(provider) !== Boolean(modelId)) {
|
|
91
100
|
throw new Error("session_handoff requires provider and model together, or neither.");
|
|
92
101
|
}
|
|
102
|
+
const requestedThinkingLevel = params.thinkingLevel ?? profile?.thinkingLevel;
|
|
93
103
|
const override =
|
|
94
104
|
provider && modelId
|
|
95
|
-
? resolveChildModel(modelRuntime, roster, { provider, modelId },
|
|
96
|
-
:
|
|
105
|
+
? resolveChildModel(modelRuntime, roster, { provider, modelId }, requestedThinkingLevel)
|
|
106
|
+
: profile?.model
|
|
107
|
+
? resolveProfileModel(modelRuntime, roster, profile.model, requestedThinkingLevel)
|
|
108
|
+
: undefined;
|
|
97
109
|
const childModel = override?.model ?? ctx.model;
|
|
98
|
-
const thinkingLevel = override?.thinkingLevel ??
|
|
110
|
+
const thinkingLevel = override?.thinkingLevel ?? requestedThinkingLevel ?? pi.getThinkingLevel();
|
|
99
111
|
const model = formatModelArgument(childModel, thinkingLevel);
|
|
100
112
|
if (!model) {
|
|
101
113
|
throw new Error("No active model is available for the handoff.");
|
|
@@ -107,6 +119,7 @@ export async function executeSessionHandoffTool(
|
|
|
107
119
|
parentSessionFile,
|
|
108
120
|
title,
|
|
109
121
|
model,
|
|
122
|
+
tools: profile?.tools && [...new Set([...profile.tools, ...(target.requiredTools ?? [])])],
|
|
110
123
|
approveProjectTrust: target.approveProjectTrust,
|
|
111
124
|
useDefaultSessionDir: target.useDefaultSessionDir ?? false,
|
|
112
125
|
buildBootstrap: (sessionId) =>
|
|
@@ -123,6 +136,7 @@ export async function executeSessionHandoffTool(
|
|
|
123
136
|
childSessionId: sessionId,
|
|
124
137
|
ownerSessionId: ctx.sessionManager.getSessionId(),
|
|
125
138
|
requestResponse,
|
|
139
|
+
agent: profile && { name: profile.name, instructions: profile.instructions },
|
|
126
140
|
}),
|
|
127
141
|
}),
|
|
128
142
|
prepareChild: (manager, childSessionId) =>
|
|
@@ -23,6 +23,7 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
|
|
|
23
23
|
enable: Type.Optional(Type.Boolean()),
|
|
24
24
|
maxDepth: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
25
25
|
contextLimit: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
26
|
+
agentsDir: Type.Optional(Type.String()),
|
|
26
27
|
}),
|
|
27
28
|
),
|
|
28
29
|
handoff: Type.Optional(
|
|
@@ -125,6 +126,8 @@ export interface SessionSettings {
|
|
|
125
126
|
maxDepth: number;
|
|
126
127
|
/** Effective context window for a subagent, when smaller than its model's. */
|
|
127
128
|
contextLimit?: number;
|
|
129
|
+
/** Where agent profiles for subagent launches live. */
|
|
130
|
+
agentsDir: string;
|
|
128
131
|
};
|
|
129
132
|
handoff: HandoffSettings;
|
|
130
133
|
index: {
|
|
@@ -140,6 +143,10 @@ export function getDefaultIndexDir(): string {
|
|
|
140
143
|
return path.join(getAgentDir(), "pi-sessions");
|
|
141
144
|
}
|
|
142
145
|
|
|
146
|
+
export function getDefaultAgentsDir(): string {
|
|
147
|
+
return path.join(getAgentDir(), "agents");
|
|
148
|
+
}
|
|
149
|
+
|
|
143
150
|
export function getDefaultIndexPath(): string {
|
|
144
151
|
return path.join(getDefaultIndexDir(), "index.sqlite");
|
|
145
152
|
}
|
|
@@ -168,19 +175,19 @@ function expandHome(rawPath: string): string {
|
|
|
168
175
|
return rawPath;
|
|
169
176
|
}
|
|
170
177
|
|
|
171
|
-
function
|
|
178
|
+
function normalizeDir(value: string | undefined, defaultDir: string, key: string): string {
|
|
172
179
|
if (value === undefined) {
|
|
173
|
-
return
|
|
180
|
+
return defaultDir;
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
const trimmed = value.trim();
|
|
177
184
|
if (trimmed.length === 0) {
|
|
178
|
-
return
|
|
185
|
+
return defaultDir;
|
|
179
186
|
}
|
|
180
187
|
|
|
181
188
|
const expanded = expandHome(trimmed);
|
|
182
189
|
if (!path.isAbsolute(expanded)) {
|
|
183
|
-
throw new Error(
|
|
190
|
+
throw new Error(`${key} must be an absolute path or start with "~/".`);
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
return path.normalize(expanded);
|
|
@@ -254,7 +261,11 @@ function resolveFeatureToggles(value: SessionFileSettings): FeatureToggles {
|
|
|
254
261
|
}
|
|
255
262
|
|
|
256
263
|
function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSettings {
|
|
257
|
-
const indexDir =
|
|
264
|
+
const indexDir = normalizeDir(
|
|
265
|
+
fileSettings.index?.dir,
|
|
266
|
+
getDefaultIndexDir(),
|
|
267
|
+
"sessions.index.dir",
|
|
268
|
+
);
|
|
258
269
|
|
|
259
270
|
return {
|
|
260
271
|
features: resolveFeatureToggles(fileSettings),
|
|
@@ -263,6 +274,11 @@ function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSetti
|
|
|
263
274
|
...(fileSettings.subagents?.contextLimit === undefined
|
|
264
275
|
? {}
|
|
265
276
|
: { contextLimit: fileSettings.subagents.contextLimit }),
|
|
277
|
+
agentsDir: normalizeDir(
|
|
278
|
+
fileSettings.subagents?.agentsDir,
|
|
279
|
+
getDefaultAgentsDir(),
|
|
280
|
+
"sessions.subagents.agentsDir",
|
|
281
|
+
),
|
|
266
282
|
},
|
|
267
283
|
handoff: {
|
|
268
284
|
...resolveAgentModelSettings(fileSettings.handoff),
|
|
@@ -149,7 +149,10 @@ export function installSubagents(
|
|
|
149
149
|
|
|
150
150
|
pi.on("before_agent_start", (event) => {
|
|
151
151
|
if (current?.child) {
|
|
152
|
-
|
|
152
|
+
const agent = current.child.identity.agent;
|
|
153
|
+
event.systemPromptOptions.sections[SUBAGENT_PROMPT_SECTION] = agent
|
|
154
|
+
? `${SUBAGENT_PROMPT}\n\n${agent.instructions}`
|
|
155
|
+
: SUBAGENT_PROMPT;
|
|
153
156
|
}
|
|
154
157
|
});
|
|
155
158
|
|
|
@@ -166,12 +169,12 @@ export function installSubagents(
|
|
|
166
169
|
|
|
167
170
|
pi.on("agent_start", (_event, ctx) => {
|
|
168
171
|
settledChildLifecycle.cancel();
|
|
169
|
-
if (current?.child) {
|
|
172
|
+
if (current?.child && !current.child.paused) {
|
|
170
173
|
current.child.reportsAtTurnStart = countSubagentReports(ctx.sessionManager.getBranch());
|
|
171
174
|
}
|
|
172
175
|
});
|
|
173
176
|
|
|
174
|
-
pi.on("agent_settled", async (
|
|
177
|
+
pi.on("agent_settled", async (event, ctx) => {
|
|
175
178
|
const session = current;
|
|
176
179
|
if (!session || session.parent.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
177
180
|
return;
|
|
@@ -187,7 +190,11 @@ export function installSubagents(
|
|
|
187
190
|
}
|
|
188
191
|
if (session.child) {
|
|
189
192
|
await contextLimit.compactIfOverLimit(ctx);
|
|
190
|
-
|
|
193
|
+
// Escape in the child's pane pauses the delegated turn; it still owes its parent a report.
|
|
194
|
+
session.child.paused = event.aborted;
|
|
195
|
+
if (!event.aborted) {
|
|
196
|
+
await settledChildLifecycle.settle(session.parent, session.child, reconciliation);
|
|
197
|
+
}
|
|
191
198
|
}
|
|
192
199
|
});
|
|
193
200
|
|
|
@@ -21,12 +21,14 @@ export function createSubagentLaunchTarget(
|
|
|
21
21
|
"'subagent' delegates one task to a detached tmux worker and requests a report by default.",
|
|
22
22
|
bootstrapMode: "automatic",
|
|
23
23
|
approveProjectTrust: true,
|
|
24
|
+
requiredTools: ["submit_task_report"],
|
|
24
25
|
describeSubagentChild(input) {
|
|
25
26
|
return {
|
|
26
27
|
childSessionId: input.childSessionId,
|
|
27
28
|
ownerSessionId: input.ownerSessionId,
|
|
28
29
|
depth: state.depth + 1,
|
|
29
30
|
requestResponse: input.requestResponse,
|
|
31
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
30
32
|
};
|
|
31
33
|
},
|
|
32
34
|
prepareChild(input) {
|
|
@@ -17,6 +17,7 @@ export interface SubagentChildSessionState {
|
|
|
17
17
|
identity: HandoffSubagent;
|
|
18
18
|
requestResponse: boolean;
|
|
19
19
|
reportsAtTurnStart: number;
|
|
20
|
+
paused: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface SettledChildParentSession {
|
|
@@ -159,6 +160,7 @@ export function refreshSubagentChildState(
|
|
|
159
160
|
identity,
|
|
160
161
|
requestResponse: identity.requestResponse,
|
|
161
162
|
reportsAtTurnStart: reports,
|
|
163
|
+
paused: false,
|
|
162
164
|
};
|
|
163
165
|
}
|
|
164
166
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,10 +38,10 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@biomejs/biome": "^2.5.10",
|
|
41
|
-
"@earendil-works/pi-agent-core": "^1.
|
|
42
|
-
"@earendil-works/pi-ai": "^1.
|
|
43
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
44
|
-
"@earendil-works/pi-tui": "^1.
|
|
41
|
+
"@earendil-works/pi-agent-core": "^1.3.0",
|
|
42
|
+
"@earendil-works/pi-ai": "^1.3.0",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
44
|
+
"@earendil-works/pi-tui": "^1.3.0",
|
|
45
45
|
"@types/node": "^24.13.3",
|
|
46
46
|
"strip-ansi": "^7.2.0",
|
|
47
47
|
"typebox": "1.3.27",
|