pi-sessions 0.8.0 → 0.9.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 (104) hide show
  1. package/README.md +70 -37
  2. package/extensions/pi-sessions.ts +125 -0
  3. package/extensions/session-ask/agent.ts +34 -14
  4. package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
  5. package/extensions/session-ask/navigate.ts +3 -1
  6. package/extensions/session-ask/presenter.ts +23 -0
  7. package/extensions/session-ask/renderer.ts +63 -0
  8. package/extensions/session-ask/tool-contract.ts +27 -0
  9. package/extensions/session-ask/view-model.ts +18 -0
  10. package/extensions/session-auto-title/generate.ts +18 -18
  11. package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +60 -53
  12. package/extensions/session-auto-title/model.ts +25 -17
  13. package/extensions/session-auto-title/retitle.ts +17 -18
  14. package/extensions/session-auto-title/wizard.ts +12 -11
  15. package/extensions/session-handoff/board-view-model.ts +181 -0
  16. package/extensions/session-handoff/board.ts +663 -0
  17. package/extensions/session-handoff/bootstrap.ts +174 -0
  18. package/extensions/session-handoff/extract.ts +101 -122
  19. package/extensions/session-handoff/install.ts +225 -0
  20. package/extensions/session-handoff/kickoff.ts +92 -0
  21. package/extensions/session-handoff/launch/backend.ts +16 -0
  22. package/extensions/session-handoff/launch/deferred.ts +20 -0
  23. package/extensions/session-handoff/launch/ghostty.ts +99 -0
  24. package/extensions/session-handoff/launch/resolution.ts +57 -0
  25. package/extensions/session-handoff/launch/shell.ts +7 -0
  26. package/extensions/session-handoff/launch/tmux.ts +31 -0
  27. package/extensions/session-handoff/launch-options.ts +61 -0
  28. package/extensions/session-handoff/launch-target.ts +91 -0
  29. package/extensions/session-handoff/metadata.ts +152 -63
  30. package/extensions/session-handoff/model.ts +58 -0
  31. package/extensions/session-handoff/query.ts +0 -1
  32. package/extensions/session-handoff/receipt.ts +96 -0
  33. package/extensions/session-handoff/review.ts +2 -28
  34. package/extensions/session-handoff/spawn.ts +76 -181
  35. package/extensions/session-handoff/strong-modal.ts +13 -0
  36. package/extensions/session-handoff/tool-contract.ts +12 -0
  37. package/extensions/session-handoff/tool-presenter.ts +35 -0
  38. package/extensions/session-handoff/tool-renderer.ts +66 -0
  39. package/extensions/session-handoff/tool-schema.ts +54 -0
  40. package/extensions/session-handoff/tool-view-model.ts +49 -0
  41. package/extensions/session-handoff/tool.ts +206 -0
  42. package/extensions/session-handoff/ui.ts +66 -0
  43. package/extensions/session-hooks/install.ts +75 -0
  44. package/extensions/{session-index.ts → session-index/install.ts} +9 -7
  45. package/extensions/session-messaging/broker/process.ts +21 -28
  46. package/extensions/session-messaging/install.ts +78 -0
  47. package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
  48. package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
  49. package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
  50. package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
  51. package/extensions/session-messaging/pi/message-contracts.ts +32 -0
  52. package/extensions/session-messaging/pi/renderer.ts +19 -12
  53. package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
  54. package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
  55. package/extensions/session-messaging/pi/service.ts +262 -81
  56. package/extensions/session-messaging/pi/tools.ts +184 -40
  57. package/extensions/session-search/extract.ts +60 -14
  58. package/extensions/session-search/hooks.ts +10 -13
  59. package/extensions/session-search/install.ts +373 -0
  60. package/extensions/session-search/renderer.ts +195 -0
  61. package/extensions/session-search/tool-contract.ts +51 -0
  62. package/extensions/shared/composition.ts +20 -0
  63. package/extensions/shared/errors.ts +3 -0
  64. package/extensions/shared/model-resolution.ts +73 -0
  65. package/extensions/shared/model-runtime.ts +33 -0
  66. package/extensions/shared/model.ts +7 -10
  67. package/extensions/shared/rendering/collapsible-text.ts +89 -0
  68. package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
  69. package/extensions/shared/rendering/theme.ts +5 -0
  70. package/extensions/shared/session-broker/client.ts +30 -29
  71. package/extensions/shared/session-broker/protocol.ts +128 -10
  72. package/extensions/shared/session-index/common.ts +5 -5
  73. package/extensions/shared/session-index/lineage.ts +0 -3
  74. package/extensions/shared/session-index/schema.ts +0 -1
  75. package/extensions/shared/session-index/scoring.ts +1 -2
  76. package/extensions/shared/session-index/search.ts +11 -4
  77. package/extensions/shared/session-index/store.ts +4 -9
  78. package/extensions/shared/settings.ts +76 -38
  79. package/extensions/shared/text.ts +5 -0
  80. package/extensions/shared/thinking-levels.ts +22 -0
  81. package/extensions/shared/tmux.ts +205 -0
  82. package/extensions/subagents/cancel.ts +111 -0
  83. package/extensions/subagents/classify.ts +52 -0
  84. package/extensions/subagents/install.ts +344 -0
  85. package/extensions/subagents/launch-target.ts +75 -0
  86. package/extensions/subagents/ledger.ts +217 -0
  87. package/extensions/subagents/reconcile.ts +386 -0
  88. package/extensions/subagents/report-message-presenter.ts +36 -0
  89. package/extensions/subagents/report-message-renderer.ts +21 -0
  90. package/extensions/subagents/report-message-view-model.ts +29 -0
  91. package/extensions/subagents/report.ts +159 -0
  92. package/extensions/subagents/roster.ts +262 -0
  93. package/extensions/subagents/wake.ts +173 -0
  94. package/images/handoff-board-subagents.png +0 -0
  95. package/images/handoff-board-user-sessions.png +0 -0
  96. package/images/handoff-subagent-report.png +0 -0
  97. package/images/session-handoff-tool.png +0 -0
  98. package/package.json +10 -16
  99. package/extensions/session-handoff.ts +0 -741
  100. package/extensions/session-hooks.ts +0 -75
  101. package/extensions/session-messaging/pi/message-view.ts +0 -131
  102. package/extensions/session-messaging.ts +0 -30
  103. package/extensions/session-search.ts +0 -419
  104. package/extensions/shared/session-broker/active.ts +0 -26
@@ -1,208 +1,103 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import {
5
- CURRENT_SESSION_VERSION,
6
- type ExtensionAPI,
7
- type ExtensionContext,
8
- type SessionHeader,
9
- type SessionInfoEntry,
10
- } from "@earendil-works/pi-coding-agent";
11
- import { HANDOFF_BOOTSTRAP_ENV } from "./metadata.ts";
2
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import { shellQuote } from "./launch/shell.ts";
4
+ import { HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE, type HandoffBootstrap } from "./metadata.ts";
12
5
 
13
- const GHOSTTY_MACOS_ONLY_MESSAGE = "Split handoff currently supports Ghostty on macOS only.";
14
- const GHOSTTY_REQUIRED_MESSAGE = "Split handoff requires running inside Ghostty.";
15
- const GHOSTTY_SPLIT_TIMEOUT_MS = 15_000;
16
- const OSASCRIPT_PATH = "/usr/bin/osascript";
17
-
18
- export type HandoffSplitDirection = "left" | "right" | "up" | "down";
19
-
20
- export interface CreatedHandoffSession {
6
+ export interface PreparedHandoff {
21
7
  sessionId: string;
22
8
  sessionFile: string;
9
+ resumeCommand: string;
23
10
  }
24
11
 
25
- export function isGhosttyHandoffAvailable(): boolean {
26
- return process.platform === "darwin" && process.env.TERM_PROGRAM === "ghostty";
27
- }
28
-
29
- export async function validateSplitHandoffPrerequisites(
30
- _pi: ExtensionAPI,
31
- ctx: ExtensionContext,
32
- ): Promise<string | undefined> {
33
- if (!ctx.sessionManager.getSessionFile()) {
34
- return "Split handoff requires a persisted current session.";
35
- }
36
-
37
- if (process.platform !== "darwin") {
38
- return GHOSTTY_MACOS_ONLY_MESSAGE;
39
- }
40
-
41
- if (process.env.TERM_PROGRAM !== "ghostty") {
42
- return GHOSTTY_REQUIRED_MESSAGE;
43
- }
44
-
45
- return undefined;
46
- }
47
-
48
- export function createHandoffSession(options: {
49
- cwd: string;
50
- sessionDir: string;
12
+ /**
13
+ * Owns the backend-independent half of a handoff: create the prepared child
14
+ * session (bootstrap included) and build the self-locating resume command
15
+ * every failure path must surface.
16
+ */
17
+ export function prepareHandoffLaunch(options: {
18
+ targetCwd: string;
19
+ parentCwd: string;
20
+ parentSessionDir: string;
51
21
  parentSessionFile: string;
52
22
  title: string;
53
- }): CreatedHandoffSession {
54
- const sessionId = randomUUID();
55
- const timestamp = new Date().toISOString();
56
- const fileTimestamp = timestamp.replace(/[:.]/g, "-");
57
- const sessionFile = join(options.sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
58
-
59
- const header: SessionHeader = {
60
- type: "session",
61
- version: CURRENT_SESSION_VERSION,
62
- id: sessionId,
63
- timestamp,
64
- cwd: options.cwd,
65
- parentSession: options.parentSessionFile,
66
- };
67
-
68
- const titleEntry: SessionInfoEntry = {
69
- type: "session_info",
70
- id: randomUUID(),
71
- parentId: null,
72
- timestamp,
73
- name: options.title,
74
- };
75
-
76
- writeFileSync(
77
- sessionFile,
78
- `${[JSON.stringify(header), JSON.stringify(titleEntry)].join("\n")}\n`,
23
+ model: string | undefined;
24
+ buildBootstrap: (sessionId: string) => HandoffBootstrap;
25
+ prepareChild?: ((manager: SessionManager, sessionId: string) => void) | undefined;
26
+ }): PreparedHandoff {
27
+ // Same-cwd children stay in the parent's session directory, preserving a
28
+ // deliberate nondefault dir. Cross-cwd children live with their target
29
+ // project, so Pi computes that project's default storage location.
30
+ const sameCwd = options.targetCwd === options.parentCwd;
31
+ const manager = SessionManager.create(
32
+ options.targetCwd,
33
+ sameCwd ? options.parentSessionDir : undefined,
34
+ { parentSession: options.parentSessionFile },
79
35
  );
36
+ manager.appendSessionInfo(options.title);
37
+ manager.appendCustomEntry(
38
+ HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE,
39
+ options.buildBootstrap(manager.getSessionId()),
40
+ );
41
+ options.prepareChild?.(manager, manager.getSessionId());
42
+ flushPreparedSession(manager);
80
43
 
81
- return { sessionId, sessionFile };
82
- }
83
-
84
- export async function getFocusedGhosttyTerminalId(
85
- pi: ExtensionAPI,
86
- cwd: string,
87
- ): Promise<string | undefined> {
88
- const appleScript = [
89
- 'tell application "Ghostty"',
90
- " get id of focused terminal of selected tab of front window",
91
- "end tell",
92
- ].join("\n");
93
- const result = await pi.exec(OSASCRIPT_PATH, ["-e", appleScript], {
94
- cwd,
95
- timeout: GHOSTTY_SPLIT_TIMEOUT_MS,
96
- });
97
-
98
- if (result.code !== 0) {
99
- return undefined;
44
+ const sessionFile = manager.getSessionFile();
45
+ if (!sessionFile) {
46
+ throw new Error("Prepared handoff session has no session file.");
100
47
  }
101
48
 
102
- return result.stdout.trim() || undefined;
103
- }
104
-
105
- export async function launchSplitHandoffSession(
106
- pi: ExtensionAPI,
107
- options: {
108
- cwd: string;
109
- sessionDir: string;
110
- direction: HandoffSplitDirection;
111
- sessionId: string;
112
- bootstrapValue: string;
113
- title: string;
114
- terminalId?: string | undefined;
115
- restoreFocus?: boolean | undefined;
116
- model?: string | undefined;
117
- },
118
- ): Promise<{ success: true } | { success: false; error: string }> {
119
- const piCommand = buildPiLaunchCommand({
120
- sessionDir: options.sessionDir,
121
- sessionId: options.sessionId,
122
- bootstrapValue: options.bootstrapValue,
123
- title: options.title,
49
+ const resumeCommand = buildPiResumeCommand({
50
+ targetCwd: options.targetCwd,
51
+ parentCwd: options.parentCwd,
52
+ sessionId: manager.getSessionId(),
53
+ sessionDir: manager.usesDefaultSessionDir() ? undefined : manager.getSessionDir(),
124
54
  model: options.model,
125
55
  });
126
- const escapedCwd = escapeAppleScriptString(options.cwd);
127
- const escapedCommand = escapeAppleScriptString(piCommand);
128
- const targetTerminalLine = options.terminalId
129
- ? ` set targetTerminal to item 1 of (every terminal whose id is "${escapeAppleScriptString(options.terminalId)}")`
130
- : " set targetTerminal to focused terminal of selected tab of front window";
131
- const focusLine = options.restoreFocus === false ? [] : [" focus targetTerminal"];
132
- const appleScript = [
133
- 'tell application "Ghostty"',
134
- targetTerminalLine,
135
- " set cfg to new surface configuration",
136
- ` set initial working directory of cfg to "${escapedCwd}"`,
137
- ` set command of cfg to "${escapedCommand}"`,
138
- ` set newTerminal to split targetTerminal direction ${options.direction} with configuration cfg`,
139
- ...focusLine,
140
- "end tell",
141
- ].join("\n");
142
- const result = await pi.exec(OSASCRIPT_PATH, ["-e", appleScript], {
143
- cwd: options.cwd,
144
- timeout: GHOSTTY_SPLIT_TIMEOUT_MS,
145
- });
146
-
147
- if (result.code === 0) {
148
- return { success: true };
149
- }
150
56
 
151
- const details = `${result.stderr || result.stdout}`.trim() || `exit code ${result.code}`;
152
- return {
153
- success: false,
154
- error:
155
- `Failed to launch Ghostty split: ${details}. ` +
156
- "Split handoff currently supports Ghostty on macOS only.",
157
- };
57
+ return { sessionId: manager.getSessionId(), sessionFile, resumeCommand };
158
58
  }
159
59
 
160
- export function buildPiResumeCommand(
161
- sessionDir: string,
162
- sessionId: string,
163
- bootstrapValue: string,
164
- title: string,
165
- model?: string | undefined,
166
- ): string {
167
- const args = [
168
- `${HANDOFF_BOOTSTRAP_ENV}=${shellQuote(bootstrapValue)}`,
169
- "pi",
170
- "--session-dir",
171
- shellQuote(sessionDir),
172
- "--session-id",
173
- shellQuote(sessionId),
174
- "--name",
175
- shellQuote(title),
176
- ];
60
+ export function formatHandoffLaunchFailure(error: string, prepared: PreparedHandoff): string {
61
+ return `${error} Created handoff session ${prepared.sessionId}; start it manually with: ${prepared.resumeCommand}`;
62
+ }
177
63
 
178
- if (model) {
179
- args.push("--model", shellQuote(model));
64
+ // Pi intentionally defers writing a new session until an assistant response
65
+ // exists, so a prepared child needs one explicit initial flush of the
66
+ // manager-assembled state to be discoverable by `pi --session-id`.
67
+ function flushPreparedSession(manager: SessionManager): void {
68
+ const sessionFile = manager.getSessionFile();
69
+ const header = manager.getHeader();
70
+ if (!sessionFile || !header) {
71
+ throw new Error("Prepared handoff session is missing its file or header.");
180
72
  }
181
73
 
182
- return args.join(" ");
74
+ const lines = [header, ...manager.getEntries()].map((entry) => JSON.stringify(entry));
75
+ writeFileSync(sessionFile, `${lines.join("\n")}\n`, { flag: "wx" });
183
76
  }
184
77
 
185
- export function buildPiLaunchCommand(options: {
186
- sessionDir: string;
78
+ /**
79
+ * The canonical recovery artifact consumed by launch backends, failure
80
+ * messages, clipboard delivery, and renderers. Self-locating: when the target
81
+ * cwd differs from the parent's, the command starts with `cd <target> &&`.
82
+ */
83
+ export function buildPiResumeCommand(options: {
84
+ targetCwd: string;
85
+ parentCwd: string;
187
86
  sessionId: string;
188
- bootstrapValue: string;
189
- title: string;
87
+ sessionDir?: string | undefined;
190
88
  model?: string | undefined;
191
89
  }): string {
192
- const payload = `${buildPiResumeCommand(
193
- options.sessionDir,
194
- options.sessionId,
195
- options.bootstrapValue,
196
- options.title,
197
- options.model,
198
- )}; exec /bin/zsh -il`;
199
- return `/bin/zsh -ilc ${shellQuote(payload)}`;
200
- }
201
-
202
- function escapeAppleScriptString(value: string): string {
203
- return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
204
- }
90
+ const args = ["pi"];
91
+ if (options.sessionDir) {
92
+ args.push("--session-dir", shellQuote(options.sessionDir));
93
+ }
94
+ args.push("--session-id", shellQuote(options.sessionId));
95
+ if (options.model) {
96
+ args.push("--model", shellQuote(options.model));
97
+ }
205
98
 
206
- function shellQuote(value: string): string {
207
- return `'${value.replaceAll("'", `'"'"'`)}'`;
99
+ const command = args.join(" ");
100
+ return options.targetCwd === options.parentCwd
101
+ ? command
102
+ : `cd ${shellQuote(options.targetCwd)} && ${command}`;
208
103
  }
@@ -0,0 +1,13 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ export function renderStrongModal(lines: string[], width: number, theme: Theme): string[] {
5
+ const innerWidth = Math.max(20, width - 4);
6
+ const fillLine = (text: string) => {
7
+ const truncated = truncateToWidth(text, innerWidth, "…", true);
8
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
9
+ return theme.bg("customMessageBg", ` ${truncated}${" ".repeat(padding)} `);
10
+ };
11
+
12
+ return ["", ...lines, ""].map(fillLine);
13
+ }
@@ -0,0 +1,12 @@
1
+ import { type Static, Type } from "typebox";
2
+ import { HANDOFF_DIRECTION_LAUNCH_SCHEMA } from "./launch-target.ts";
3
+ import { HANDOFF_LAUNCH_RECEIPT_SCHEMA } from "./receipt.ts";
4
+
5
+ export const HANDOFF_TOOL_DETAILS_SCHEMA = Type.Intersect([
6
+ HANDOFF_LAUNCH_RECEIPT_SCHEMA,
7
+ Type.Object({
8
+ degradedFrom: Type.Optional(HANDOFF_DIRECTION_LAUNCH_SCHEMA),
9
+ }),
10
+ ]);
11
+
12
+ export type HandoffToolDetails = Static<typeof HANDOFF_TOOL_DETAILS_SCHEMA>;
@@ -0,0 +1,35 @@
1
+ import type { ExpandableContentPresentation } from "../shared/rendering/expandable-content-layout.ts";
2
+ import type { RenderTheme } from "../shared/rendering/theme.ts";
3
+ import type { HandoffToolViewModel } from "./tool-view-model.ts";
4
+
5
+ const PENDING = "…";
6
+
7
+ export function buildHandoffToolPresentation(
8
+ model: HandoffToolViewModel,
9
+ theme: RenderTheme,
10
+ ): ExpandableContentPresentation {
11
+ const expandedMetadata: string[] = [];
12
+ if (model.result) {
13
+ expandedMetadata.push(`${theme.bold("session")} ${model.result.sessionId}`);
14
+ if (model.result.cwd) {
15
+ expandedMetadata.push(`${theme.bold("cwd")} ${model.result.cwd}`);
16
+ }
17
+ }
18
+
19
+ return {
20
+ header: [
21
+ theme.fg("accent", "session_handoff"),
22
+ theme.fg("dim", `[${model.launch ?? PENDING}]`),
23
+ theme.bold(model.title ?? PENDING),
24
+ ].join(" "),
25
+ metadata: [
26
+ `${theme.fg("muted", "provider")} ${model.provider ?? PENDING} ${theme.fg("dim", "·")} ${theme.fg("muted", "model")} ${model.model ?? PENDING} ${theme.fg("dim", "·")} ${theme.fg("muted", "thinking")} ${model.thinkingLevel ?? PENDING}`,
27
+ ],
28
+ ...(expandedMetadata.length > 0 ? { expandedMetadata } : {}),
29
+ body: {
30
+ text: `${theme.bold("goal")} ${model.goal ?? PENDING}`,
31
+ collapsedRows: 1,
32
+ spacingBefore: 0,
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,66 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, Container, Spacer, Text } from "@earendil-works/pi-tui";
3
+ import { ExpandableContentLayout } from "../shared/rendering/expandable-content-layout.ts";
4
+ import type { ClipboardStatus } from "./launch/backend.ts";
5
+ import { createLaunchCommandComponent, formatDeferredCommandLabel } from "./receipt.ts";
6
+ import { buildHandoffToolPresentation } from "./tool-presenter.ts";
7
+ import type { HandoffToolViewModel } from "./tool-view-model.ts";
8
+
9
+ export class HandoffToolComponent implements Component {
10
+ private model: HandoffToolViewModel | undefined;
11
+ private clipboardStatus: ClipboardStatus | undefined;
12
+ private readonly layout: ExpandableContentLayout;
13
+
14
+ constructor(private readonly theme: Theme) {
15
+ this.layout = new ExpandableContentLayout(theme);
16
+ }
17
+
18
+ update(
19
+ model: HandoffToolViewModel,
20
+ expanded: boolean,
21
+ clipboardStatus?: ClipboardStatus | undefined,
22
+ ): void {
23
+ this.model = model;
24
+ this.clipboardStatus = clipboardStatus;
25
+ this.layout.update(buildHandoffToolPresentation(model, this.theme), expanded);
26
+ }
27
+
28
+ invalidate(): void {
29
+ this.layout.invalidate();
30
+ }
31
+
32
+ render(width: number): string[] {
33
+ if (!this.model) {
34
+ return [];
35
+ }
36
+
37
+ const container = new Container();
38
+ container.addChild(this.layout);
39
+ if (!this.model.result) {
40
+ return container.render(width);
41
+ }
42
+
43
+ const commandLabel =
44
+ this.model.result.launch === "deferred"
45
+ ? formatDeferredCommandLabel(this.clipboardStatus)
46
+ : "recovery command";
47
+ container.addChild(new Spacer(1));
48
+ container.addChild(
49
+ createLaunchCommandComponent(this.model.result.resumeCommand, commandLabel, this.theme),
50
+ );
51
+ if (this.model.result.degradedFrom) {
52
+ container.addChild(new Spacer(1));
53
+ container.addChild(
54
+ new Text(
55
+ this.theme.fg(
56
+ "muted",
57
+ `(requested ${this.model.result.degradedFrom}; split backend unavailable)`,
58
+ ),
59
+ 0,
60
+ 0,
61
+ ),
62
+ );
63
+ }
64
+ return container.render(width);
65
+ }
66
+ }
@@ -0,0 +1,54 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import { Type } from "typebox";
3
+ import { formatAvailableModelList } from "../shared/model-resolution.ts";
4
+ import { type HandoffLaunchTarget, SUBAGENT_LAUNCH } from "./launch-target.ts";
5
+
6
+ export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]) {
7
+ const descriptions = targets.flatMap((target) =>
8
+ target.description ? [target.description] : [],
9
+ );
10
+ return Type.Union(
11
+ targets.map((target) => Type.Literal(target.value)),
12
+ {
13
+ description: ["Where to launch the child session.", ...descriptions].join(" "),
14
+ },
15
+ );
16
+ }
17
+
18
+ const SUBAGENT_GUIDELINES = [
19
+ 'Use session_handoff with launch: "subagent" for a concrete, bounded task that can proceed independently while useful work continues in the current session.',
20
+ "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.",
21
+ "Tell every subagent explicitly whether it may modify files or should only investigate and report.",
22
+ "Give concurrent subagents disjoint responsibilities and non-overlapping write scopes.",
23
+ "After launching a subagent, do not sleep, poll, or repeatedly check its progress; continue useful work or finish the current turn and wait for its report.",
24
+ ];
25
+
26
+ const LAUNCH_TARGET_GUIDELINE =
27
+ "Use session_handoff directional or deferred launches only when the user requests one.";
28
+
29
+ const MODEL_INHERITANCE_GUIDELINES = [
30
+ "Leave provider and model unset to run the handoff on this session's current model.",
31
+ "To run the handoff on a different model, set both provider and model together (both are required).",
32
+ ];
33
+
34
+ const SUBAGENT_MODEL_OVERRIDE_GUIDELINE =
35
+ "For subagents, choose the model that best fits the delegated task; for directional or deferred handoffs, only override the model when the task clearly warrants it. When unsure, ask the user for their preference.";
36
+
37
+ const DEFAULT_MODEL_OVERRIDE_GUIDELINE =
38
+ "Only override the model when the task clearly warrants it.";
39
+
40
+ export function buildHandoffPromptGuidelines(
41
+ targets: readonly HandoffLaunchTarget[],
42
+ models: readonly Model<Api>[],
43
+ ): string[] {
44
+ const hasSubagent = targets.some((target) => target.value === SUBAGENT_LAUNCH);
45
+ return [
46
+ ...(hasSubagent ? SUBAGENT_GUIDELINES : []),
47
+ LAUNCH_TARGET_GUIDELINE,
48
+ ...MODEL_INHERITANCE_GUIDELINES,
49
+ hasSubagent ? SUBAGENT_MODEL_OVERRIDE_GUIDELINE : DEFAULT_MODEL_OVERRIDE_GUIDELINE,
50
+ ...(models.length > 0
51
+ ? [`Available models, given as provider/model-id: ${formatAvailableModelList(models)}.`]
52
+ : []),
53
+ ];
54
+ }
@@ -0,0 +1,49 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import { normalizeOptionalText } from "../shared/text.ts";
3
+ import { isThinkingLevel } from "../shared/thinking-levels.ts";
4
+ import type { HandoffToolDetails } from "./tool-contract.ts";
5
+
6
+ export interface HandoffToolViewModel {
7
+ launch?: string | undefined;
8
+ title?: string | undefined;
9
+ goal?: string | undefined;
10
+ provider?: string | undefined;
11
+ model?: string | undefined;
12
+ thinkingLevel?: ThinkingLevel | undefined;
13
+ result?: HandoffToolDetails | undefined;
14
+ }
15
+
16
+ export function buildHandoffToolView(
17
+ args: unknown,
18
+ result?: HandoffToolDetails | undefined,
19
+ ): HandoffToolViewModel {
20
+ const record = isRecord(args) ? args : {};
21
+ const provider = normalizeOptionalText(result?.provider) ?? readString(record.provider);
22
+ const model = normalizeOptionalText(result?.modelName) ?? readString(record.model);
23
+ const thinkingLevel = result?.thinkingLevel ?? readThinkingLevel(record.thinkingLevel);
24
+ return {
25
+ launch: readString(record.launch) ?? result?.launch,
26
+ title: readString(record.title) ?? result?.title,
27
+ goal: readString(record.goal),
28
+ ...(provider ? { provider } : {}),
29
+ ...(model ? { model } : {}),
30
+ ...(thinkingLevel ? { thinkingLevel } : {}),
31
+ ...(result ? { result } : {}),
32
+ };
33
+ }
34
+
35
+ function isRecord(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ function readThinkingLevel(value: unknown): ThinkingLevel | undefined {
40
+ const level = readString(value);
41
+ return isThinkingLevel(level) ? level : undefined;
42
+ }
43
+
44
+ function readString(value: unknown): string | undefined {
45
+ if (typeof value !== "string") {
46
+ return undefined;
47
+ }
48
+ return normalizeOptionalText(value);
49
+ }