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 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(launchTargets, models, roster),
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
- { ...params, launch },
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 }, params.thinkingLevel)
96
- : undefined;
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 ?? params.thinkingLevel ?? pi.getThinkingLevel();
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 normalizeIndexDir(value: string | undefined): string {
178
+ function normalizeDir(value: string | undefined, defaultDir: string, key: string): string {
172
179
  if (value === undefined) {
173
- return getDefaultIndexDir();
180
+ return defaultDir;
174
181
  }
175
182
 
176
183
  const trimmed = value.trim();
177
184
  if (trimmed.length === 0) {
178
- return getDefaultIndexDir();
185
+ return defaultDir;
179
186
  }
180
187
 
181
188
  const expanded = expandHome(trimmed);
182
189
  if (!path.isAbsolute(expanded)) {
183
- throw new Error('sessions.index.dir must be an absolute path or start with "~/".');
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 = normalizeIndexDir(fileSettings.index?.dir);
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
- event.systemPromptOptions.sections[SUBAGENT_PROMPT_SECTION] = SUBAGENT_PROMPT;
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 (_event, ctx) => {
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
- await settledChildLifecycle.settle(session.parent, session.child, reconciliation);
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.19.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.2.0",
42
- "@earendil-works/pi-ai": "^1.2.0",
43
- "@earendil-works/pi-coding-agent": "^1.2.0",
44
- "@earendil-works/pi-tui": "^1.2.0",
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",