pi-sessions 0.18.1 → 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.
Files changed (39) hide show
  1. package/README.md +64 -39
  2. package/extensions/hosts/contract.ts +10 -46
  3. package/extensions/hosts/registry.ts +4 -78
  4. package/extensions/pi-sessions.ts +27 -53
  5. package/extensions/session-handoff/agent-profiles.ts +110 -0
  6. package/extensions/session-handoff/board-loading.ts +4 -1
  7. package/extensions/session-handoff/board-view-model.ts +1 -0
  8. package/extensions/session-handoff/board.ts +4 -1
  9. package/extensions/session-handoff/install.ts +78 -34
  10. package/extensions/session-handoff/launch/backend.ts +16 -4
  11. package/extensions/session-handoff/launch-options.ts +2 -17
  12. package/extensions/session-handoff/launch-target.ts +8 -10
  13. package/extensions/session-handoff/metadata.ts +10 -0
  14. package/extensions/session-handoff/model.ts +22 -0
  15. package/extensions/session-handoff/receipt.ts +1 -1
  16. package/extensions/session-handoff/spawn.ts +6 -0
  17. package/extensions/session-handoff/tool-presenter.ts +1 -1
  18. package/extensions/session-handoff/tool-schema.ts +14 -9
  19. package/extensions/session-handoff/tool-view-model.ts +6 -2
  20. package/extensions/session-handoff/tool.ts +20 -6
  21. package/extensions/session-messaging/directory-list.ts +115 -0
  22. package/extensions/session-messaging/directory.ts +114 -0
  23. package/extensions/session-messaging/message-router.ts +3 -8
  24. package/extensions/session-messaging/pi/directory-tools.ts +41 -0
  25. package/extensions/session-messaging/pi/install.ts +102 -0
  26. package/extensions/session-messaging/pi/message-contracts.ts +4 -6
  27. package/extensions/session-messaging/pi/reachable-contract.ts +3 -4
  28. package/extensions/session-messaging/pi/reachable-tool.ts +25 -175
  29. package/extensions/session-messaging/pi/send-message-presenter.ts +2 -1
  30. package/extensions/session-messaging/pi/tools.ts +49 -91
  31. package/extensions/session-search/extract.ts +12 -2
  32. package/extensions/shared/lineage.ts +8 -0
  33. package/extensions/shared/session-index/sync.ts +3 -0
  34. package/extensions/shared/settings.ts +21 -5
  35. package/extensions/subagents/install.ts +11 -31
  36. package/extensions/subagents/launch-target.ts +2 -1
  37. package/extensions/subagents/reconcile.ts +10 -2
  38. package/extensions/subagents/settle.ts +2 -0
  39. package/package.json +5 -5
@@ -2,13 +2,17 @@ import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
- import type { Host } from "../hosts/contract.ts";
6
5
  import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
7
6
  import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
8
7
  import { isTuiMode } from "../shared/pi-mode.ts";
9
8
  import type { SessionSettings } from "../shared/settings.ts";
10
9
  import { THINKING_LEVELS } from "../shared/thinking-levels.ts";
11
10
  import { safeParseTypeBoxValue } from "../shared/typebox.ts";
11
+ import {
12
+ type AgentProfileCatalog,
13
+ EMPTY_AGENT_PROFILE_CATALOG,
14
+ loadAgentProfiles,
15
+ } from "./agent-profiles.ts";
12
16
  import { type HandoffBoardServices, openHandoffBoard } from "./board.ts";
13
17
  import { consumePendingHandoffBootstrap } from "./bootstrap.ts";
14
18
  import { renderHandoffBootstrapFailedEntry } from "./bootstrap-failed-renderer.ts";
@@ -21,11 +25,7 @@ import { HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE } from "./metadata.ts";
21
25
  import { openSessionReferencePicker } from "./picker.ts";
22
26
  import { SESSION_TOKEN_PREFIX } from "./query.ts";
23
27
  import { type HandoffRoster, resolveHandoffRoster } from "./roster.ts";
24
- import {
25
- executeSessionHandoffTool,
26
- type HandoffToolParams,
27
- MAX_HANDOFF_TITLE_LENGTH,
28
- } from "./tool.ts";
28
+ import { executeSessionHandoffTool, MAX_HANDOFF_TITLE_LENGTH } from "./tool.ts";
29
29
  import { HANDOFF_TOOL_DETAILS_SCHEMA } from "./tool-contract.ts";
30
30
  import { HandoffToolComponent } from "./tool-renderer.ts";
31
31
  import {
@@ -51,7 +51,7 @@ export function installHandoff(
51
51
  index: IndexHandle;
52
52
  getModelRuntime: ModelRuntimeProvider;
53
53
  getLaunchTargets?: (() => readonly HandoffLaunchTarget[]) | undefined;
54
- getHosts?: () => readonly Host[];
54
+ claimsPeers: () => boolean;
55
55
  board: HandoffBoardServices;
56
56
  },
57
57
  ): SessionLifecycle {
@@ -69,18 +69,26 @@ export function installHandoff(
69
69
  models: readonly Model<Api>[],
70
70
  launchTargets: readonly HandoffLaunchTarget[],
71
71
  roster: HandoffRoster | undefined,
72
+ agentProfiles: AgentProfileCatalog,
73
+ name: "session_handoff" | "subagent_launch",
72
74
  ): void {
73
75
  const offersSubagent = offersSubagentLaunch(launchTargets);
76
+ const offersAgents = name === "subagent_launch" && agentProfiles.profiles.length > 0;
74
77
  pi.registerTool({
75
- name: "session_handoff",
78
+ name,
76
79
  label: "Session Handoff",
77
80
  description: "Start a new Pi session with a self-contained task.",
78
81
  // Nested calls write no toolResult entry, so the board would never see the launch receipt.
79
82
  exposure: "model-only",
80
83
  promptSnippet: offersSubagent
81
- ? "Delegate bounded work to a background subagent or hand off context to another Pi session"
84
+ ? "Delegate bounded work to a background subagent"
82
85
  : "Hand off context to another Pi session",
83
- promptGuidelines: buildHandoffPromptGuidelines(launchTargets, models, roster),
86
+ promptGuidelines: buildHandoffPromptGuidelines(
87
+ launchTargets,
88
+ models,
89
+ roster,
90
+ offersAgents ? agentProfiles.profiles : [],
91
+ ),
84
92
  parameters: Type.Object({
85
93
  goal: Type.String({
86
94
  description:
@@ -91,17 +99,16 @@ export function installHandoff(
91
99
  description:
92
100
  "Short display title for the child session, 64 characters or less. Summarize the mission; do not repeat the full goal.",
93
101
  }),
94
- launch: buildHandoffLaunchSchema(launchTargets),
102
+ ...(name === "session_handoff" ? { launch: buildHandoffLaunchSchema(launchTargets) } : {}),
95
103
  cwd: Type.Optional(
96
104
  Type.String({
97
105
  description: "Optional target working directory (defaults to current).",
98
106
  }),
99
107
  ),
100
- requestResponse: Type.Optional(
101
- Type.Boolean({
102
- description: `Whether the child session should report completion/results of its task back to this session. ${offersSubagent ? "Defaults to true for subagent launches and false otherwise." : "Defaults to false."}`,
103
- }),
104
- ),
108
+ requestResponse: Type.Boolean({
109
+ description:
110
+ "Whether the child session should report completion/results of its task back to this session.",
111
+ }),
105
112
  provider: Type.Optional(
106
113
  Type.String({
107
114
  description: "Model provider for the child session.",
@@ -122,12 +129,22 @@ export function installHandoff(
122
129
  },
123
130
  ),
124
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
+ : {}),
125
142
  }),
126
143
  renderCall(args, theme, context) {
127
144
  const state = context.state as HandoffToolRendererState;
128
145
  const component = state.callComponent ?? new HandoffToolComponent(theme);
129
146
  state.callComponent = component;
130
- component.update(buildHandoffToolView(args), context.expanded);
147
+ component.update(buildHandoffToolView(args, undefined, name), context.expanded);
131
148
  return component;
132
149
  },
133
150
  renderResult(result, options, theme, context) {
@@ -143,21 +160,31 @@ export function installHandoff(
143
160
  }
144
161
 
145
162
  state.callComponent.update(
146
- buildHandoffToolView(context.args, details),
163
+ buildHandoffToolView(context.args, details, name),
147
164
  options.expanded,
148
165
  clipboardStatusBySessionId.get(details.sessionId),
149
166
  );
150
167
  return new Text("", 0, 0);
151
168
  },
152
- async execute(_toolCallId, params: HandoffToolParams, _signal, _onUpdate, ctx) {
169
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
170
+ if (name === "session_handoff" && deps.claimsPeers())
171
+ throw new Error("Peer tools belong to the current host.");
172
+ const launch = name === "subagent_launch" ? "subagent" : params.launch;
173
+ if (typeof launch !== "string") throw new Error("session_handoff requires launch.");
174
+ if (name === "subagent_launch" && !deps.getLaunchTargets?.().length)
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.");
153
179
  const modelRuntime = await deps.getModelRuntime(ctx.modelRegistry);
154
180
  return executeSessionHandoffTool(
155
181
  pi,
156
- params,
182
+ { ...rest, launch, ...(agent === undefined ? {} : { agent }) },
157
183
  ctx,
158
184
  modelRuntime,
159
185
  launchTargets,
160
186
  roster,
187
+ agentProfiles,
161
188
  (sessionId, status) => clipboardStatusBySessionId.set(sessionId, status),
162
189
  );
163
190
  },
@@ -205,7 +232,7 @@ export function installHandoff(
205
232
  });
206
233
 
207
234
  pi.on("before_agent_start", async (event, ctx) => {
208
- if (!deps.getHosts?.().length && splitBackend?.identifyTerminalId && ctx) {
235
+ if (!deps.claimsPeers() && splitBackend?.identifyTerminalId && ctx) {
209
236
  identifiedGhosttyTerminalId =
210
237
  (await splitBackend.identifyTerminalId(ctx.cwd)) ?? identifiedGhosttyTerminalId;
211
238
  }
@@ -216,21 +243,38 @@ export function installHandoff(
216
243
  return {
217
244
  async onSessionStart(_event, ctx) {
218
245
  const models = ctx.modelRegistry.getAvailable();
219
- registerHandoffTool(
246
+ const roster = resolveHandoffRoster({
220
247
  models,
221
- createHandoffLaunchTargets({
222
- pi,
223
- splitBackend,
224
- deferred: settings.handoff.deferred,
225
- additionalTargets: deps.getLaunchTargets?.() ?? [],
226
- hosts: deps.getHosts?.() ?? [],
227
- }),
228
- resolveHandoffRoster({
248
+ patterns: settings.handoff.roster,
249
+ scopedModels: ctx.scopedModels,
250
+ });
251
+ const subagentTargets = deps.getLaunchTargets?.() ?? [];
252
+ const agentProfiles = subagentTargets.length
253
+ ? loadAgentProfiles(settings.subagents.agentsDir)
254
+ : EMPTY_AGENT_PROFILE_CATALOG;
255
+ if (!deps.claimsPeers?.()) {
256
+ registerHandoffTool(
229
257
  models,
230
- patterns: settings.handoff.roster,
231
- scopedModels: ctx.scopedModels,
232
- }),
233
- );
258
+ createHandoffLaunchTargets({
259
+ pi,
260
+ splitBackend,
261
+ deferred: settings.handoff.deferred,
262
+ additionalTargets: [],
263
+ }),
264
+ roster,
265
+ EMPTY_AGENT_PROFILE_CATALOG,
266
+ "session_handoff",
267
+ );
268
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), "session_handoff"])]);
269
+ } else {
270
+ pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "session_handoff"));
271
+ }
272
+ if (subagentTargets.length) {
273
+ registerHandoffTool(models, subagentTargets, roster, agentProfiles, "subagent_launch");
274
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), "subagent_launch"])]);
275
+ } else {
276
+ pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "subagent_launch"));
277
+ }
234
278
 
235
279
  await consumePendingHandoffBootstrap(
236
280
  pi,
@@ -1,9 +1,21 @@
1
- import type { Host, HostLaunchInput } from "../../hosts/contract.ts";
2
-
3
1
  export type HandoffSplitDirection = "left" | "right" | "up" | "down";
4
2
 
5
- export type LaunchInput = HostLaunchInput;
3
+ export interface LaunchInput {
4
+ sessionId: string;
5
+ sessionFile: string;
6
+ cwd: string;
7
+ title: string;
8
+ model: string;
9
+ resumeCommand: string;
10
+ }
6
11
 
7
12
  export type ClipboardStatus = "copied" | "failed";
8
13
 
9
- export type LaunchBackend = Host;
14
+ export interface LaunchBackend {
15
+ name: string;
16
+ launch(
17
+ input: LaunchInput,
18
+ ): Promise<
19
+ { success: true; clipboardStatus?: ClipboardStatus } | { success: false; error: string }
20
+ >;
21
+ }
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { Host } from "../hosts/contract.ts";
3
2
  import type { HandoffSettings } from "../shared/settings.ts";
4
3
  import { createDeferredLaunchBackend } from "./launch/deferred.ts";
5
4
  import type { SplitLaunchBackend } from "./launch/resolution.ts";
@@ -21,10 +20,8 @@ export function createHandoffLaunchTargets(options: {
21
20
  splitBackend: SplitLaunchBackend | undefined;
22
21
  deferred: HandoffSettings["deferred"];
23
22
  additionalTargets: readonly HandoffLaunchTarget[];
24
- hosts?: readonly Host[];
25
23
  }): HandoffLaunchTarget[] {
26
- const hosts = options.hosts ?? [];
27
- const splitBackend = hosts.length ? undefined : options.splitBackend;
24
+ const splitBackend = options.splitBackend;
28
25
  const splitTargets = splitBackend
29
26
  ? LAUNCH_DIRECTIONS.map((direction, index) =>
30
27
  createBackendLaunchTarget(
@@ -45,19 +42,7 @@ export function createHandoffLaunchTargets(options: {
45
42
  ),
46
43
  ]
47
44
  : [];
48
- return [
49
- ...hosts.map((host) =>
50
- createBackendLaunchTarget(
51
- host.name,
52
- host,
53
- `'${host.name}' opens a session in ${host.name}.`,
54
- true,
55
- ),
56
- ),
57
- ...splitTargets,
58
- ...deferredTargets,
59
- ...options.additionalTargets,
60
- ];
45
+ return [...splitTargets, ...deferredTargets, ...options.additionalTargets];
61
46
  }
62
47
 
63
48
  export function resolveHandoffLaunchTarget(
@@ -1,9 +1,8 @@
1
1
  import type { SessionManager } from "@earendil-works/pi-coding-agent";
2
2
  import { type Static, Type } from "typebox";
3
- import type { Host } from "../hosts/contract.ts";
4
3
  import { formatError } from "../shared/errors.ts";
5
- import type { ClipboardStatus } from "./launch/backend.ts";
6
- import type { HandoffSubagent } from "./metadata.ts";
4
+ import type { ClipboardStatus, LaunchBackend } from "./launch/backend.ts";
5
+ import type { HandoffSubagent, HandoffSubagentAgent } from "./metadata.ts";
7
6
  import type { PreparedHandoff } from "./spawn.ts";
8
7
 
9
8
  export const DEFERRED_LAUNCH = "deferred" as const;
@@ -58,16 +57,18 @@ export type HandoffLaunchTargetOutcome =
58
57
  export interface HandoffLaunchTarget {
59
58
  value: HandoffLaunchValue;
60
59
  description?: string | undefined;
61
- requestResponseDefault: boolean;
62
60
  bootstrapMode: "review" | "automatic";
63
61
  /** Unattended children skip the startup trust prompt; watched ones let the user answer it. */
64
62
  approveProjectTrust: boolean;
65
63
  useDefaultSessionDir?: boolean;
64
+ /** Tools the child keeps when an agent profile narrows its tool allowlist. */
65
+ requiredTools?: readonly string[];
66
66
  /** Subagent launches stamp their child's identity into the child bootstrap. */
67
67
  describeSubagentChild?(input: {
68
68
  childSessionId: string;
69
69
  ownerSessionId: string;
70
70
  requestResponse: boolean;
71
+ agent: HandoffSubagentAgent | undefined;
71
72
  }): HandoffSubagent;
72
73
  prepareChild(input: HandoffLaunchTargetPreparation): void;
73
74
  launch(input: HandoffLaunchTargetInput): Promise<HandoffLaunchTargetOutcome>;
@@ -75,17 +76,14 @@ export interface HandoffLaunchTarget {
75
76
 
76
77
  export function createBackendLaunchTarget(
77
78
  value: string,
78
- host: Host,
79
+ host: LaunchBackend,
79
80
  description?: string | undefined,
80
- external = false,
81
81
  ): HandoffLaunchTarget {
82
82
  return {
83
83
  value,
84
84
  ...(description ? { description } : {}),
85
- requestResponseDefault: false,
86
- bootstrapMode: external ? "automatic" : "review",
87
- approveProjectTrust: external,
88
- useDefaultSessionDir: external,
85
+ bootstrapMode: "review",
86
+ approveProjectTrust: false,
89
87
  prepareChild() {},
90
88
  async launch(input) {
91
89
  try {
@@ -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.
@@ -35,7 +35,7 @@ export function parseHandoffLaunchReceiptEntry(
35
35
  if (
36
36
  entry.type === "message" &&
37
37
  entry.message.role === "toolResult" &&
38
- entry.message.toolName === "session_handoff"
38
+ (entry.message.toolName === "session_handoff" || entry.message.toolName === "subagent_launch")
39
39
  ) {
40
40
  return safeParseTypeBoxValue(HANDOFF_LAUNCH_RECEIPT_SCHEMA, entry.message.details);
41
41
  }
@@ -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
@@ -18,7 +18,7 @@ export function buildHandoffToolPresentation(
18
18
 
19
19
  return {
20
20
  header: [
21
- theme.fg("accent", "session_handoff"),
21
+ theme.fg("accent", model.toolName ?? "session_handoff"),
22
22
  theme.fg("dim", `[${model.launch ?? PENDING}]`),
23
23
  theme.bold(model.title ?? PENDING),
24
24
  ].join(" "),
@@ -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,
@@ -21,7 +22,7 @@ export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]
21
22
  }
22
23
 
23
24
  const SUBAGENT_GUIDELINES = [
24
- 'Use session_handoff with launch: "subagent" for a concrete, bounded task that can proceed independently while useful work continues in the current session.',
25
+ "Use subagent_launch for a concrete, bounded task that can proceed independently while useful work continues in the current session.",
25
26
  "Do not launch a subagent for work that is trivial, duplicates work already underway, requires frequent coordination, or is tightly coupled to the current session's immediate next step.",
26
27
  "Tell every subagent explicitly whether it may modify files or should only investigate and report.",
27
28
  "Give concurrent subagents disjoint responsibilities and non-overlapping write scopes.",
@@ -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
 
@@ -73,14 +86,6 @@ function describeUserFacingLaunchKinds(targets: readonly HandoffLaunchTarget[]):
73
86
  const isDirection = (value: string) => LAUNCH_DIRECTIONS.some((direction) => direction === value);
74
87
  const kinds = [
75
88
  targets.some((target) => isDirection(target.value)) ? "directional" : undefined,
76
- targets.some(
77
- (target) =>
78
- target.value !== SUBAGENT_LAUNCH &&
79
- target.value !== DEFERRED_LAUNCH &&
80
- !isDirection(target.value),
81
- )
82
- ? "host"
83
- : undefined,
84
89
  targets.some((target) => target.value === DEFERRED_LAUNCH) ? "deferred" : undefined,
85
90
  ];
86
91
  return kinds.filter((kind) => kind !== undefined).join(" or ");
@@ -4,6 +4,7 @@ import { isThinkingLevel } from "../shared/thinking-levels.ts";
4
4
  import type { HandoffToolDetails } from "./tool-contract.ts";
5
5
 
6
6
  export interface HandoffToolViewModel {
7
+ toolName: string;
7
8
  launch?: string | undefined;
8
9
  title?: string | undefined;
9
10
  goal?: string | undefined;
@@ -15,14 +16,17 @@ export interface HandoffToolViewModel {
15
16
 
16
17
  export function buildHandoffToolView(
17
18
  args: unknown,
18
- result?: HandoffToolDetails | undefined,
19
+ result: HandoffToolDetails | undefined,
20
+ toolName: string,
19
21
  ): HandoffToolViewModel {
20
22
  const record = isRecord(args) ? args : {};
21
23
  const provider = normalizeOptionalText(result?.provider) ?? readString(record.provider);
22
24
  const model = normalizeOptionalText(result?.modelName) ?? readString(record.model);
23
25
  const thinkingLevel = result?.thinkingLevel ?? readThinkingLevel(record.thinkingLevel);
24
26
  return {
25
- launch: readString(record.launch) ?? result?.launch,
27
+ toolName,
28
+ launch:
29
+ toolName === "subagent_launch" ? "subagent" : (readString(record.launch) ?? result?.launch),
26
30
  title: readString(record.title) ?? result?.title,
27
31
  goal: readString(record.goal),
28
32
  ...(provider ? { provider } : {}),
@@ -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";
@@ -28,10 +30,11 @@ export interface HandoffToolParams {
28
30
  title: string;
29
31
  launch: HandoffLaunchValue;
30
32
  cwd?: string | undefined;
31
- requestResponse?: boolean | undefined;
33
+ requestResponse: boolean;
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) {
@@ -84,18 +93,21 @@ export async function executeSessionHandoffTool(
84
93
 
85
94
  resolveHandoffSource(ctx.sessionManager, sourceLeafId);
86
95
 
87
- const requestResponse = params.requestResponse ?? target.requestResponseDefault;
96
+ const requestResponse = params.requestResponse;
88
97
  const provider = params.provider?.trim();
89
98
  const modelId = params.model?.trim();
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) =>