pi-sessions 0.8.0 → 0.10.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 (111) hide show
  1. package/README.md +74 -37
  2. package/dist/session-messaging/broker/process.js +234 -0
  3. package/dist/shared/session-broker/framing.js +58 -0
  4. package/dist/shared/session-broker/protocol.js +163 -0
  5. package/dist/shared/session-broker/socket-path.js +19 -0
  6. package/dist/shared/typebox.js +36 -0
  7. package/extensions/pi-sessions.ts +125 -0
  8. package/extensions/session-ask/agent.ts +34 -14
  9. package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
  10. package/extensions/session-ask/navigate.ts +3 -1
  11. package/extensions/session-ask/presenter.ts +23 -0
  12. package/extensions/session-ask/renderer.ts +63 -0
  13. package/extensions/session-ask/tool-contract.ts +27 -0
  14. package/extensions/session-ask/view-model.ts +18 -0
  15. package/extensions/session-auto-title/generate.ts +18 -18
  16. package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +60 -53
  17. package/extensions/session-auto-title/model.ts +25 -17
  18. package/extensions/session-auto-title/retitle.ts +17 -18
  19. package/extensions/session-auto-title/wizard.ts +12 -11
  20. package/extensions/session-handoff/board-view-model.ts +179 -0
  21. package/extensions/session-handoff/board.ts +663 -0
  22. package/extensions/session-handoff/bootstrap.ts +172 -0
  23. package/extensions/session-handoff/extract.ts +129 -132
  24. package/extensions/session-handoff/install.ts +225 -0
  25. package/extensions/session-handoff/kickoff.ts +92 -0
  26. package/extensions/session-handoff/launch/backend.ts +16 -0
  27. package/extensions/session-handoff/launch/deferred.ts +20 -0
  28. package/extensions/session-handoff/launch/ghostty.ts +99 -0
  29. package/extensions/session-handoff/launch/resolution.ts +57 -0
  30. package/extensions/session-handoff/launch/shell.ts +7 -0
  31. package/extensions/session-handoff/launch/tmux.ts +31 -0
  32. package/extensions/session-handoff/launch-options.ts +61 -0
  33. package/extensions/session-handoff/launch-target.ts +91 -0
  34. package/extensions/session-handoff/metadata.ts +194 -56
  35. package/extensions/session-handoff/model.ts +58 -0
  36. package/extensions/session-handoff/query.ts +0 -1
  37. package/extensions/session-handoff/receipt.ts +96 -0
  38. package/extensions/session-handoff/review.ts +2 -28
  39. package/extensions/session-handoff/spawn.ts +76 -181
  40. package/extensions/session-handoff/strong-modal.ts +13 -0
  41. package/extensions/session-handoff/tool-contract.ts +12 -0
  42. package/extensions/session-handoff/tool-presenter.ts +35 -0
  43. package/extensions/session-handoff/tool-renderer.ts +66 -0
  44. package/extensions/session-handoff/tool-schema.ts +54 -0
  45. package/extensions/session-handoff/tool-view-model.ts +49 -0
  46. package/extensions/session-handoff/tool.ts +206 -0
  47. package/extensions/session-handoff/ui.ts +66 -0
  48. package/extensions/session-hooks/install.ts +75 -0
  49. package/extensions/{session-index.ts → session-index/install.ts} +9 -7
  50. package/extensions/session-messaging/broker/process.ts +21 -28
  51. package/extensions/session-messaging/broker/spawn.ts +12 -7
  52. package/extensions/session-messaging/install.ts +78 -0
  53. package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
  54. package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
  55. package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
  56. package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
  57. package/extensions/session-messaging/pi/message-contracts.ts +32 -0
  58. package/extensions/session-messaging/pi/renderer.ts +19 -12
  59. package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
  60. package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
  61. package/extensions/session-messaging/pi/service.ts +262 -81
  62. package/extensions/session-messaging/pi/tools.ts +184 -40
  63. package/extensions/session-search/extract.ts +60 -14
  64. package/extensions/session-search/hooks.ts +10 -13
  65. package/extensions/session-search/install.ts +373 -0
  66. package/extensions/session-search/renderer.ts +195 -0
  67. package/extensions/session-search/tool-contract.ts +51 -0
  68. package/extensions/shared/composition.ts +20 -0
  69. package/extensions/shared/errors.ts +3 -0
  70. package/extensions/shared/model-resolution.ts +73 -0
  71. package/extensions/shared/model-runtime.ts +33 -0
  72. package/extensions/shared/model.ts +7 -10
  73. package/extensions/shared/rendering/collapsible-text.ts +89 -0
  74. package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
  75. package/extensions/shared/rendering/theme.ts +5 -0
  76. package/extensions/shared/session-broker/client.ts +30 -29
  77. package/extensions/shared/session-broker/protocol.ts +128 -10
  78. package/extensions/shared/session-index/common.ts +5 -5
  79. package/extensions/shared/session-index/lineage.ts +0 -3
  80. package/extensions/shared/session-index/schema.ts +0 -1
  81. package/extensions/shared/session-index/scoring.ts +1 -2
  82. package/extensions/shared/session-index/search.ts +11 -4
  83. package/extensions/shared/session-index/store.ts +4 -9
  84. package/extensions/shared/settings.ts +83 -40
  85. package/extensions/shared/text.ts +5 -0
  86. package/extensions/shared/thinking-levels.ts +22 -0
  87. package/extensions/shared/tmux.ts +205 -0
  88. package/extensions/subagents/cancel.ts +111 -0
  89. package/extensions/subagents/classify.ts +58 -0
  90. package/extensions/subagents/install.ts +229 -0
  91. package/extensions/subagents/launch-target.ts +75 -0
  92. package/extensions/subagents/ledger.ts +217 -0
  93. package/extensions/subagents/reconcile.ts +386 -0
  94. package/extensions/subagents/report-message-presenter.ts +36 -0
  95. package/extensions/subagents/report-message-renderer.ts +21 -0
  96. package/extensions/subagents/report-message-view-model.ts +29 -0
  97. package/extensions/subagents/report.ts +159 -0
  98. package/extensions/subagents/roster.ts +262 -0
  99. package/extensions/subagents/settle.ts +182 -0
  100. package/extensions/subagents/wake.ts +173 -0
  101. package/images/handoff-board-subagents.png +0 -0
  102. package/images/handoff-board-user-sessions.png +0 -0
  103. package/images/handoff-subagent-report.png +0 -0
  104. package/images/session-handoff-tool.png +0 -0
  105. package/package.json +16 -18
  106. package/extensions/session-handoff.ts +0 -741
  107. package/extensions/session-hooks.ts +0 -75
  108. package/extensions/session-messaging/pi/message-view.ts +0 -131
  109. package/extensions/session-messaging.ts +0 -30
  110. package/extensions/session-search.ts +0 -419
  111. package/extensions/shared/session-broker/active.ts +0 -26
@@ -0,0 +1,172 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
5
+ import type { HandoffSettings } from "../shared/settings.ts";
6
+ import { generateHandoffDraftFromSessionManager } from "./extract.ts";
7
+ import { buildHandoffKickoffMessage, buildHandoffKickoffSource } from "./kickoff.ts";
8
+ import {
9
+ type ChildGeneratedHandoffBootstrap,
10
+ createHandoffSessionMetadata,
11
+ findPendingHandoffBootstrap,
12
+ getHandoffMetadataFromEntries,
13
+ HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE,
14
+ HANDOFF_METADATA_CUSTOM_TYPE,
15
+ HANDOFF_STALE_SESSION_MESSAGE,
16
+ type HandoffBootstrapConsumedReason,
17
+ hasStartedConversation,
18
+ } from "./metadata.ts";
19
+ import { reviewHandoffDraftForSend } from "./review.ts";
20
+ import { formatHandoffError, runHandoffTaskWithLoader } from "./ui.ts";
21
+
22
+ export async function consumePendingHandoffBootstrap(
23
+ pi: ExtensionAPI,
24
+ ctx: ExtensionContext,
25
+ getModelRuntime: ModelRuntimeProvider,
26
+ destinationThinkingLevel: ThinkingLevel | undefined,
27
+ handoffSettings: HandoffSettings,
28
+ ): Promise<void> {
29
+ const scan = findPendingHandoffBootstrap(ctx.sessionManager.getBranch());
30
+ if (!scan) {
31
+ return;
32
+ }
33
+
34
+ const consumeBootstrap = (reason: HandoffBootstrapConsumedReason): void => {
35
+ pi.appendEntry(HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE, {
36
+ bootstrapEntryId: scan.entryId,
37
+ reason,
38
+ });
39
+ };
40
+
41
+ if (scan.kind === "invalid") {
42
+ consumeBootstrap("invalid");
43
+ return;
44
+ }
45
+
46
+ const bootstrap = scan.bootstrap;
47
+ if (bootstrap.sessionId !== ctx.sessionManager.getSessionId()) {
48
+ consumeBootstrap("invalid");
49
+ return;
50
+ }
51
+
52
+ await startChildGeneratedHandoff(
53
+ pi,
54
+ ctx,
55
+ bootstrap,
56
+ scan.entryId,
57
+ consumeBootstrap,
58
+ getModelRuntime,
59
+ destinationThinkingLevel,
60
+ handoffSettings,
61
+ );
62
+ }
63
+
64
+ async function startChildGeneratedHandoff(
65
+ pi: ExtensionAPI,
66
+ ctx: ExtensionContext,
67
+ bootstrap: ChildGeneratedHandoffBootstrap,
68
+ bootstrapEntryId: string,
69
+ consumeBootstrap: (reason: HandoffBootstrapConsumedReason) => void,
70
+ getModelRuntime: ModelRuntimeProvider,
71
+ destinationThinkingLevel: ThinkingLevel | undefined,
72
+ handoffSettings: HandoffSettings,
73
+ ): Promise<void> {
74
+ const entries = ctx.sessionManager.getEntries();
75
+ if (hasStartedConversation(entries)) {
76
+ consumeBootstrap("stale");
77
+ if (ctx.hasUI) {
78
+ ctx.ui.notify(HANDOFF_STALE_SESSION_MESSAGE, "error");
79
+ }
80
+ return;
81
+ }
82
+
83
+ if (!ctx.hasUI) {
84
+ if (bootstrap.bootstrapMode === "automatic") {
85
+ ctx.shutdown();
86
+ }
87
+ return;
88
+ }
89
+
90
+ // A thrown error leaves the bootstrap pending so the next resume offers
91
+ // review again; explicit user decisions consume it.
92
+ try {
93
+ const sourceSessionManager = SessionManager.open(bootstrap.parentSessionFile);
94
+ const modelRuntime = await getModelRuntime(ctx.modelRegistry);
95
+ const generatedDraft = await runHandoffTaskWithLoader(
96
+ ctx,
97
+ "Generating handoff draft...",
98
+ async (signal: AbortSignal) =>
99
+ generateHandoffDraftFromSessionManager({
100
+ ctx,
101
+ modelRuntime,
102
+ sourceSessionManager,
103
+ sourceLeafId: bootstrap.sourceLeafId,
104
+ goal: bootstrap.goal,
105
+ settings: handoffSettings,
106
+ destinationThinkingLevel,
107
+ signal,
108
+ requestResponse: bootstrap.requestResponse,
109
+ }),
110
+ );
111
+ if (!generatedDraft) {
112
+ consumeBootstrap("cancelled");
113
+ ctx.ui.notify("Cancelled", "info");
114
+ return;
115
+ }
116
+
117
+ if (generatedDraft.debugSessionPath) {
118
+ ctx.ui.notify(
119
+ `Handoff extraction session saved to ${generatedDraft.debugSessionPath}`,
120
+ "info",
121
+ );
122
+ }
123
+
124
+ let prompt = generatedDraft.draft;
125
+ if (bootstrap.bootstrapMode === "review") {
126
+ const review = await reviewHandoffDraftForSend(ctx.ui, generatedDraft.draft);
127
+ if (review.action === "prefill") {
128
+ consumeBootstrap("prefilled");
129
+ ctx.ui.setEditorText(review.prompt);
130
+ ctx.ui.notify("Handoff prompt ready in editor.", "info");
131
+ return;
132
+ }
133
+ if (review.action === "cancel") {
134
+ consumeBootstrap("cancelled");
135
+ ctx.ui.notify("Cancelled", "info");
136
+ return;
137
+ }
138
+ prompt = review.prompt;
139
+ }
140
+
141
+ // The tool-provided bootstrap title is authoritative; extraction does not
142
+ // replace it with a second generated title.
143
+ const metadata = createHandoffSessionMetadata(
144
+ bootstrap.goal,
145
+ prompt,
146
+ bootstrap.title,
147
+ bootstrap.launch,
148
+ );
149
+ if (!getHandoffMetadataFromEntries(ctx.sessionManager.getEntries())) {
150
+ pi.appendEntry(HANDOFF_METADATA_CUSTOM_TYPE, metadata);
151
+ }
152
+ pi.setSessionName(metadata.title);
153
+ const sourceSessionName = sourceSessionManager.getSessionName();
154
+ pi.sendMessage(
155
+ buildHandoffKickoffMessage({
156
+ prompt,
157
+ title: metadata.title,
158
+ source: buildHandoffKickoffSource({
159
+ sessionId: sourceSessionManager.getSessionId(),
160
+ ...(sourceSessionName ? { sessionName: sourceSessionName } : {}),
161
+ }),
162
+ bootstrapEntryId,
163
+ }),
164
+ { triggerTurn: true },
165
+ );
166
+ } catch (error) {
167
+ ctx.ui.notify(formatHandoffError(error), "error");
168
+ if (bootstrap.bootstrapMode === "automatic") {
169
+ ctx.shutdown();
170
+ }
171
+ }
172
+ }
@@ -7,7 +7,11 @@ import type {
7
7
  ThinkingContent,
8
8
  ToolCall,
9
9
  } from "@earendil-works/pi-ai";
10
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type {
11
+ ExtensionContext,
12
+ ModelRuntime,
13
+ SessionContext,
14
+ } from "@earendil-works/pi-coding-agent";
11
15
  import {
12
16
  buildSessionContext,
13
17
  convertToLlm,
@@ -19,60 +23,44 @@ import {
19
23
  serializeConversation,
20
24
  } from "@earendil-works/pi-coding-agent";
21
25
  import { type Static, Type } from "typebox";
26
+ import { freshenModel } from "../shared/model-runtime.ts";
27
+ import { getDefaultHandoffRunsDir, type HandoffSettings } from "../shared/settings.ts";
22
28
  import { parseTypeBoxValue } from "../shared/typebox.ts";
29
+ import { resolveModelOverride } from "./model.ts";
23
30
 
24
- const MAX_RELEVANT_FILES = 12;
25
- const MAX_OPEN_QUESTIONS = 8;
26
- const MAX_HANDOFF_TITLE_LENGTH = 64;
31
+ const MAX_HANDOFF_EXTRACTION_ATTEMPTS = 3;
32
+ const HANDOFF_EXTRACTION_RETRY_PROMPT =
33
+ "You did not call create_handoff_context. Call it exactly once now with the completed briefing.";
27
34
 
28
- const HANDOFF_SYSTEM_PROMPT = `You extract context for a deliberate session handoff.
35
+ const HANDOFF_SYSTEM_PROMPT = `You extract supporting context for a deliberate session handoff. You are preparing a briefing for a new destination session from a snapshot of its ongoing parent session.
29
36
 
30
- You must call create_handoff_context exactly once.
37
+ The Handoff Goal is the authoritative and exclusive task of the destination session, and will be directly passed into that session. Do not rewrite, expand the scope of, or replace the goal.
31
38
 
32
- Rules:
33
- - Extract only context that is relevant to the next task.
34
- - Keep the summary compact and concrete.
35
- - Prefer workspace-relative file paths when possible.
36
- - title must be a short session title for the new handoff thread, 64 characters or less, without prefixes like "Handoff:" or otherwise referencing the current thread.
37
- - nextTask must be the concrete next action for the new session.
38
- - openQuestions should contain only unresolved items that materially affect the next task.
39
- - If there are no meaningful open questions, omit openQuestions entirely.
40
- - Do not write the final handoff prompt yourself.`;
39
+ The parent snapshot is reference material used only to provide evidence that supports that goal. Do not continue the parent conversation, respond to any questions in it, or carry forward parent tasks, unresolved work, instructions, or plans outside the goal. Only identify relevant context, files, constraints, and known decisions for the Handoff Goal.
40
+
41
+ The parent may be coordinating several parallel sessions. Do not include any references to those other sessions.`;
41
42
 
42
43
  const HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
43
- title: Type.String({
44
- description: "Short display title for the new handoff session.",
45
- }),
46
44
  summary: Type.String({
47
- description: "Only the context relevant to the next task.",
45
+ description:
46
+ "Context from the parent task that is directly relevant to the Handoff Goal. Include only details that further support the goal.",
48
47
  }),
49
48
  relevantFiles: Type.Array(Type.String(), {
50
- description: "Relevant workspace-relative file paths when possible.",
51
- }),
52
- nextTask: Type.String({
53
- description: "The concrete next task for the new session.",
49
+ description: "Relevant file paths.",
54
50
  }),
55
51
  openQuestions: Type.Optional(
56
52
  Type.Array(Type.String(), {
57
- description: "Open questions that matter to the next task. Omit when there are none.",
53
+ description:
54
+ "Unresolved questions that materially affect the destination task. Omit when there are none.",
58
55
  }),
59
56
  ),
60
57
  });
61
58
 
62
59
  type HandoffExtractionArgs = Static<typeof HANDOFF_EXTRACTION_PARAMETERS>;
63
- type RequiredHandoffExtractionArgs = Static<typeof REQUIRED_HANDOFF_EXTRACTION_PARAMETERS>;
64
-
65
- const REQUIRED_HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
66
- title: HANDOFF_EXTRACTION_PARAMETERS.properties.title,
67
- summary: HANDOFF_EXTRACTION_PARAMETERS.properties.summary,
68
- nextTask: HANDOFF_EXTRACTION_PARAMETERS.properties.nextTask,
69
- });
70
60
 
71
61
  export interface HandoffContext {
72
- title: string;
73
62
  summary: string;
74
63
  relevantFiles: string[];
75
- nextTask: string;
76
64
  openQuestions: string[];
77
65
  }
78
66
 
@@ -81,57 +69,70 @@ export interface HandoffDraftResult {
81
69
  context: HandoffContext;
82
70
  sessionId: string;
83
71
  sessionPath?: string | undefined;
72
+ debugSessionPath?: string | undefined;
84
73
  }
85
74
 
86
- export async function generateHandoffDraft(
87
- ctx: ExtensionContext,
88
- goal: string,
89
- thinkingLevel: ThinkingLevel | undefined,
90
- signal?: AbortSignal,
91
- requestResponse = false,
92
- ): Promise<HandoffDraftResult | undefined> {
93
- if (!ctx.model) {
94
- throw new Error("No model is available for handoff.");
75
+ /** Validate an anchored source branch and return its built context. Throws a user-facing Error otherwise. */
76
+ export function resolveHandoffSource(
77
+ sessionManager: ExtensionContext["sessionManager"],
78
+ sourceLeafId: string,
79
+ ): SessionContext {
80
+ if (!sessionManager.getEntry(sourceLeafId)) {
81
+ throw new Error(`Handoff source snapshot entry ${sourceLeafId} was not found.`);
95
82
  }
96
83
 
97
- return generateHandoffDraftFromSessionManager(
98
- ctx,
99
- ctx.sessionManager,
100
- goal,
101
- thinkingLevel,
102
- signal,
103
- requestResponse,
104
- );
84
+ const sessionContext = buildSessionContext(sessionManager.getEntries(), sourceLeafId);
85
+ if (sessionContext.messages.length === 0) {
86
+ throw new Error("No conversation to hand off.");
87
+ }
88
+
89
+ return sessionContext;
105
90
  }
106
91
 
107
- export async function generateHandoffDraftFromSessionManager(
108
- ctx: ExtensionContext,
109
- sourceSessionManager: ExtensionContext["sessionManager"],
110
- goal: string,
111
- thinkingLevel: ThinkingLevel | undefined,
112
- signal?: AbortSignal,
92
+ export async function generateHandoffDraftFromSessionManager({
93
+ ctx,
94
+ modelRuntime,
95
+ sourceSessionManager,
96
+ sourceLeafId,
97
+ goal,
98
+ settings,
99
+ destinationThinkingLevel,
100
+ signal,
113
101
  requestResponse = false,
114
- ): Promise<HandoffDraftResult | undefined> {
115
- if (!ctx.model) {
116
- throw new Error("No model is available for handoff.");
117
- }
118
-
119
- const model = ctx.model;
120
- const sessionContext = buildSessionContext(
121
- sourceSessionManager.getEntries(),
122
- sourceSessionManager.getLeafId(),
123
- );
124
- if (sessionContext.messages.length === 0) {
125
- throw new Error("No conversation is available to hand off.");
102
+ }: {
103
+ ctx: ExtensionContext;
104
+ modelRuntime: ModelRuntime;
105
+ sourceSessionManager: ExtensionContext["sessionManager"];
106
+ sourceLeafId: string;
107
+ goal: string;
108
+ settings: HandoffSettings;
109
+ destinationThinkingLevel: ThinkingLevel | undefined;
110
+ signal?: AbortSignal | undefined;
111
+ requestResponse?: boolean | undefined;
112
+ }): Promise<HandoffDraftResult | undefined> {
113
+ const modelOverride = settings.model
114
+ ? resolveModelOverride(modelRuntime, settings.model, settings.thinkingLevel)
115
+ : undefined;
116
+ let model = modelOverride?.model;
117
+ if (!model) {
118
+ if (!ctx.model) {
119
+ throw new Error("No model is available for handoff.");
120
+ }
121
+ model = freshenModel(modelRuntime, ctx.model);
126
122
  }
123
+ const thinkingLevel =
124
+ modelOverride?.thinkingLevel ?? settings.thinkingLevel ?? destinationThinkingLevel;
125
+ const sessionContext = resolveHandoffSource(sourceSessionManager, sourceLeafId);
127
126
 
128
127
  const conversationText = serializeConversation(convertToLlm(sessionContext.messages));
129
128
  const handoffContext = await runHandoffExtractionAgent(
130
129
  ctx,
130
+ modelRuntime,
131
131
  model,
132
132
  conversationText,
133
133
  goal,
134
134
  thinkingLevel,
135
+ settings.persistRuns,
135
136
  signal,
136
137
  );
137
138
  if (!handoffContext) {
@@ -142,38 +143,51 @@ export async function generateHandoffDraftFromSessionManager(
142
143
  const sessionPath = sourceSessionManager.getSessionFile();
143
144
 
144
145
  return {
145
- draft: assembleHandoffDraft(sessionId, sessionPath, handoffContext, goal, requestResponse),
146
- context: handoffContext,
146
+ draft: assembleHandoffDraft(
147
+ sessionId,
148
+ sessionPath,
149
+ handoffContext.context,
150
+ goal,
151
+ requestResponse,
152
+ ),
153
+ context: handoffContext.context,
147
154
  sessionId,
148
155
  sessionPath,
156
+ ...(handoffContext.debugSessionPath
157
+ ? { debugSessionPath: handoffContext.debugSessionPath }
158
+ : {}),
149
159
  };
150
160
  }
151
161
 
152
162
  export function buildExtractionPrompt(conversationText: string, goal: string): string {
153
163
  return [
154
- "## Conversation",
164
+ "<conversation>",
155
165
  conversationText,
166
+ "</conversation>",
156
167
  "",
157
- "## Goal",
168
+ "<handoff-goal>",
158
169
  goal,
159
- "",
160
- "Call create_handoff_context exactly once.",
170
+ "</handoff-goal>",
161
171
  ].join("\n");
162
172
  }
163
173
 
164
174
  async function runHandoffExtractionAgent(
165
175
  ctx: ExtensionContext,
176
+ modelRuntime: ModelRuntime,
166
177
  model: Model<Api>,
167
178
  conversationText: string,
168
179
  goal: string,
169
180
  thinkingLevel: ThinkingLevel | undefined,
181
+ persistRuns: boolean,
170
182
  signal?: AbortSignal,
171
- ): Promise<HandoffContext | undefined> {
183
+ ): Promise<{ context: HandoffContext; debugSessionPath?: string | undefined } | undefined> {
172
184
  let capturedArguments: HandoffExtractionArgs | undefined;
185
+ // Pi does not inject promptSnippet or promptGuidelines when a custom system prompt is active.
173
186
  const createHandoffContextTool = defineTool({
174
187
  name: "create_handoff_context",
175
188
  label: "Create handoff context",
176
- description: "Extract the structured handoff context for the next session.",
189
+ description:
190
+ "Submit the completed structured briefing for the destination session. Call this tool exactly once to complete extraction.",
177
191
  parameters: HANDOFF_EXTRACTION_PARAMETERS,
178
192
  execute: async (_toolCallId, params) => {
179
193
  capturedArguments = params;
@@ -191,19 +205,23 @@ async function runHandoffExtractionAgent(
191
205
  noExtensions: true,
192
206
  noPromptTemplates: true,
193
207
  noSkills: true,
194
- appendSystemPromptOverride: (base) => [...base, HANDOFF_SYSTEM_PROMPT],
208
+ systemPromptOverride: () => HANDOFF_SYSTEM_PROMPT,
195
209
  });
196
210
  await resourceLoader.reload();
197
211
 
212
+ const sessionManager = persistRuns
213
+ ? SessionManager.create(ctx.cwd, getDefaultHandoffRunsDir())
214
+ : SessionManager.inMemory(ctx.cwd);
215
+ const debugSessionPath = persistRuns ? sessionManager.getSessionFile() : undefined;
198
216
  const { session } = await createAgentSession({
199
217
  cwd: ctx.cwd,
200
218
  model,
201
- modelRegistry: ctx.modelRegistry,
219
+ modelRuntime,
202
220
  ...(thinkingLevel ? { thinkingLevel } : {}),
203
- tools: ["read", "grep", "find", "ls", "create_handoff_context"],
221
+ tools: ["create_handoff_context"],
204
222
  customTools: [createHandoffContextTool],
205
223
  resourceLoader,
206
- sessionManager: SessionManager.inMemory(ctx.cwd),
224
+ sessionManager,
207
225
  });
208
226
 
209
227
  const abortHandler = (): void => {
@@ -217,7 +235,19 @@ async function runHandoffExtractionAgent(
217
235
  return undefined;
218
236
  }
219
237
 
220
- await session.prompt(buildExtractionPrompt(conversationText, goal));
238
+ for (let attempt = 1; attempt <= MAX_HANDOFF_EXTRACTION_ATTEMPTS; attempt += 1) {
239
+ if (signal?.aborted) {
240
+ break;
241
+ }
242
+ await session.prompt(
243
+ attempt === 1
244
+ ? buildExtractionPrompt(conversationText, goal)
245
+ : HANDOFF_EXTRACTION_RETRY_PROMPT,
246
+ );
247
+ if (capturedArguments) {
248
+ break;
249
+ }
250
+ }
221
251
  } finally {
222
252
  signal?.removeEventListener("abort", abortHandler);
223
253
  session.dispose();
@@ -231,12 +261,15 @@ async function runHandoffExtractionAgent(
231
261
  throw new Error("Handoff extraction did not return structured context.");
232
262
  }
233
263
 
234
- const extraction = extractHandoffContextFromArguments(capturedArguments, goal);
264
+ const extraction = extractHandoffContextFromArguments(capturedArguments);
235
265
  if (!extraction.context) {
236
266
  throw new Error(extraction.error);
237
267
  }
238
268
 
239
- return extraction.context;
269
+ return {
270
+ context: extraction.context,
271
+ ...(debugSessionPath ? { debugSessionPath } : {}),
272
+ };
240
273
  }
241
274
 
242
275
  export function assembleHandoffDraft(
@@ -247,10 +280,10 @@ export function assembleHandoffDraft(
247
280
  requestResponse = false,
248
281
  ): string {
249
282
  const sections = [buildContinuityLine(sessionId, sessionPath, requestResponse)];
250
- const nextTask = handoffContext.nextTask.trim() || goal.trim();
283
+ const task = goal.trim();
251
284
 
252
- if (nextTask) {
253
- sections.push(["## Task", nextTask].join("\n"));
285
+ if (task) {
286
+ sections.push(["## Task", task].join("\n"));
254
287
  }
255
288
 
256
289
  if (handoffContext.relevantFiles.length > 0) {
@@ -280,24 +313,22 @@ export function assembleHandoffDraft(
280
313
 
281
314
  export function extractHandoffContext(
282
315
  response: AssistantMessage,
283
- goal: string,
284
316
  ): { context: HandoffContext; error?: undefined } | { context?: undefined; error: string } {
285
317
  const toolCall = response.content.find(isCreateHandoffContextToolCall);
286
318
  if (!toolCall) {
287
319
  return { error: "Handoff extraction did not return structured context." };
288
320
  }
289
321
 
290
- return extractHandoffContextFromArguments(toolCall.arguments, goal);
322
+ return extractHandoffContextFromArguments(toolCall.arguments);
291
323
  }
292
324
 
293
325
  function extractHandoffContextFromArguments(
294
326
  argumentsValue: unknown,
295
- goal: string,
296
327
  ): { context: HandoffContext; error?: undefined } | { context?: undefined; error: string } {
297
- let requiredArguments: RequiredHandoffExtractionArgs;
328
+ let extraction: HandoffExtractionArgs;
298
329
  try {
299
- requiredArguments = parseTypeBoxValue(
300
- REQUIRED_HANDOFF_EXTRACTION_PARAMETERS,
330
+ extraction = parseTypeBoxValue(
331
+ HANDOFF_EXTRACTION_PARAMETERS,
301
332
  argumentsValue,
302
333
  "Invalid create_handoff_context arguments",
303
334
  );
@@ -305,27 +336,16 @@ function extractHandoffContextFromArguments(
305
336
  return { error: "Handoff extraction did not return structured context." };
306
337
  }
307
338
 
308
- const title = normalizeText(requiredArguments.title);
309
- if (title.length > MAX_HANDOFF_TITLE_LENGTH) {
310
- return { error: "Handoff title must be 64 characters or less." };
311
- }
312
-
313
- const summary = normalizeText(requiredArguments.summary);
314
- const relevantFiles = getRelevantFiles(argumentsValue);
315
- const nextTask = normalizeText(requiredArguments.nextTask) || goal.trim();
316
- const openQuestions = getOpenQuestions(argumentsValue);
317
-
318
- if (!summary || !nextTask || !title) {
339
+ const summary = normalizeText(extraction.summary);
340
+ if (!summary) {
319
341
  return { error: "Handoff extraction did not return structured context." };
320
342
  }
321
343
 
322
344
  return {
323
345
  context: {
324
- title,
325
346
  summary,
326
- relevantFiles,
327
- nextTask,
328
- openQuestions,
347
+ relevantFiles: normalizeStringArray(extraction.relevantFiles),
348
+ openQuestions: normalizeStringArray(extraction.openQuestions),
329
349
  },
330
350
  };
331
351
  }
@@ -340,10 +360,10 @@ function buildContinuityLine(
340
360
  return base;
341
361
  }
342
362
 
343
- return `${base} When this work is complete, send that session a completion report with session_send_message.`;
363
+ return `${base} When this work is complete, send that session a completion report.`;
344
364
  }
345
365
 
346
- function normalizeStringArray(value: unknown, limit: number): string[] {
366
+ function normalizeStringArray(value: unknown): string[] {
347
367
  if (!Array.isArray(value)) {
348
368
  return [];
349
369
  }
@@ -356,38 +376,15 @@ function normalizeStringArray(value: unknown, limit: number): string[] {
356
376
  }
357
377
 
358
378
  uniqueValues.add(normalized);
359
- if (uniqueValues.size >= limit) {
360
- break;
361
- }
362
379
  }
363
380
 
364
381
  return [...uniqueValues];
365
382
  }
366
383
 
367
- function getRelevantFiles(argumentsValue: unknown): string[] {
368
- if (!isRecord(argumentsValue)) {
369
- return [];
370
- }
371
-
372
- return normalizeStringArray(argumentsValue.relevantFiles, MAX_RELEVANT_FILES);
373
- }
374
-
375
- function getOpenQuestions(argumentsValue: unknown): string[] {
376
- if (!isRecord(argumentsValue)) {
377
- return [];
378
- }
379
-
380
- return normalizeStringArray(argumentsValue.openQuestions, MAX_OPEN_QUESTIONS);
381
- }
382
-
383
384
  function normalizeText(value: unknown): string {
384
385
  return typeof value === "string" ? value.trim() : "";
385
386
  }
386
387
 
387
- function isRecord(value: unknown): value is Record<string, unknown> {
388
- return typeof value === "object" && value !== null;
389
- }
390
-
391
388
  function isCreateHandoffContextToolCall(
392
389
  content: TextContent | ThinkingContent | ToolCall,
393
390
  ): content is ToolCall {