pi-sessions 0.7.2 → 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 +71 -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} +67 -46
  12. package/extensions/session-auto-title/model.ts +25 -17
  13. package/extensions/session-auto-title/retitle.ts +26 -12
  14. package/extensions/session-auto-title/wizard.ts +12 -6
  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 +80 -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
@@ -0,0 +1,174 @@
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 { generateHandoffDraftFromSessionManager } from "./extract.ts";
6
+ import { buildHandoffKickoffMessage, buildHandoffKickoffSource } from "./kickoff.ts";
7
+ import { SUBAGENT_LAUNCH } from "./launch-target.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
+ thinkingLevel: ThinkingLevel | undefined,
27
+ persistRuns: boolean,
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
+ thinkingLevel,
60
+ persistRuns,
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
+ thinkingLevel: ThinkingLevel | undefined,
72
+ persistRuns: boolean,
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
+ bootstrap.sourceLeafId,
104
+ bootstrap.goal,
105
+ thinkingLevel,
106
+ persistRuns,
107
+ signal,
108
+ 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 subagent = bootstrap.launch === SUBAGENT_LAUNCH ? bootstrap.subagent : undefined;
144
+ const metadata = createHandoffSessionMetadata(
145
+ bootstrap.goal,
146
+ prompt,
147
+ bootstrap.title,
148
+ bootstrap.launch,
149
+ subagent,
150
+ );
151
+ if (!getHandoffMetadataFromEntries(ctx.sessionManager.getEntries())) {
152
+ pi.appendEntry(HANDOFF_METADATA_CUSTOM_TYPE, metadata);
153
+ }
154
+ pi.setSessionName(metadata.title);
155
+ const sourceSessionName = sourceSessionManager.getSessionName();
156
+ pi.sendMessage(
157
+ buildHandoffKickoffMessage({
158
+ prompt,
159
+ title: metadata.title,
160
+ source: buildHandoffKickoffSource({
161
+ sessionId: sourceSessionManager.getSessionId(),
162
+ ...(sourceSessionName ? { sessionName: sourceSessionName } : {}),
163
+ }),
164
+ bootstrapEntryId,
165
+ }),
166
+ { triggerTurn: true },
167
+ );
168
+ } catch (error) {
169
+ ctx.ui.notify(formatHandoffError(error), "error");
170
+ if (bootstrap.bootstrapMode === "automatic") {
171
+ ctx.shutdown();
172
+ }
173
+ }
174
+ }
@@ -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,43 @@ 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 } from "../shared/settings.ts";
22
28
  import { parseTypeBoxValue } from "../shared/typebox.ts";
23
29
 
24
- const MAX_RELEVANT_FILES = 12;
25
- const MAX_OPEN_QUESTIONS = 8;
26
- const MAX_HANDOFF_TITLE_LENGTH = 64;
30
+ const MAX_HANDOFF_EXTRACTION_ATTEMPTS = 3;
31
+ const HANDOFF_EXTRACTION_RETRY_PROMPT =
32
+ "You did not call create_handoff_context. Call it exactly once now with the completed briefing.";
33
+
34
+ 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.
27
35
 
28
- const HANDOFF_SYSTEM_PROMPT = `You extract context for a deliberate session handoff.
36
+ 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.
29
37
 
30
- You must call create_handoff_context exactly once.
38
+ 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.
31
39
 
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.`;
40
+ The parent may be coordinating several parallel sessions. Do not include any references to those other sessions.`;
41
41
 
42
42
  const HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
43
- title: Type.String({
44
- description: "Short display title for the new handoff session.",
45
- }),
46
43
  summary: Type.String({
47
- description: "Only the context relevant to the next task.",
44
+ description:
45
+ "Context from the parent task that is directly relevant to the Handoff Goal. Include only details that further support the goal.",
48
46
  }),
49
47
  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.",
48
+ description: "Relevant file paths.",
54
49
  }),
55
50
  openQuestions: Type.Optional(
56
51
  Type.Array(Type.String(), {
57
- description: "Open questions that matter to the next task. Omit when there are none.",
52
+ description:
53
+ "Unresolved questions that materially affect the destination task. Omit when there are none.",
58
54
  }),
59
55
  ),
60
56
  });
61
57
 
62
58
  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
59
 
71
60
  export interface HandoffContext {
72
- title: string;
73
61
  summary: string;
74
62
  relevantFiles: string[];
75
- nextTask: string;
76
63
  openQuestions: string[];
77
64
  }
78
65
 
@@ -81,34 +68,34 @@ export interface HandoffDraftResult {
81
68
  context: HandoffContext;
82
69
  sessionId: string;
83
70
  sessionPath?: string | undefined;
71
+ debugSessionPath?: string | undefined;
84
72
  }
85
73
 
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.");
74
+ /** Validate an anchored source branch and return its built context. Throws a user-facing Error otherwise. */
75
+ export function resolveHandoffSource(
76
+ sessionManager: ExtensionContext["sessionManager"],
77
+ sourceLeafId: string,
78
+ ): SessionContext {
79
+ if (!sessionManager.getEntry(sourceLeafId)) {
80
+ throw new Error(`Handoff source snapshot entry ${sourceLeafId} was not found.`);
95
81
  }
96
82
 
97
- return generateHandoffDraftFromSessionManager(
98
- ctx,
99
- ctx.sessionManager,
100
- goal,
101
- thinkingLevel,
102
- signal,
103
- requestResponse,
104
- );
83
+ const sessionContext = buildSessionContext(sessionManager.getEntries(), sourceLeafId);
84
+ if (sessionContext.messages.length === 0) {
85
+ throw new Error("No conversation to hand off.");
86
+ }
87
+
88
+ return sessionContext;
105
89
  }
106
90
 
107
91
  export async function generateHandoffDraftFromSessionManager(
108
92
  ctx: ExtensionContext,
93
+ modelRuntime: ModelRuntime,
109
94
  sourceSessionManager: ExtensionContext["sessionManager"],
95
+ sourceLeafId: string,
110
96
  goal: string,
111
97
  thinkingLevel: ThinkingLevel | undefined,
98
+ persistRuns: boolean,
112
99
  signal?: AbortSignal,
113
100
  requestResponse = false,
114
101
  ): Promise<HandoffDraftResult | undefined> {
@@ -116,22 +103,18 @@ export async function generateHandoffDraftFromSessionManager(
116
103
  throw new Error("No model is available for handoff.");
117
104
  }
118
105
 
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.");
126
- }
106
+ const model = freshenModel(modelRuntime, ctx.model);
107
+ const sessionContext = resolveHandoffSource(sourceSessionManager, sourceLeafId);
127
108
 
128
109
  const conversationText = serializeConversation(convertToLlm(sessionContext.messages));
129
110
  const handoffContext = await runHandoffExtractionAgent(
130
111
  ctx,
112
+ modelRuntime,
131
113
  model,
132
114
  conversationText,
133
115
  goal,
134
116
  thinkingLevel,
117
+ persistRuns,
135
118
  signal,
136
119
  );
137
120
  if (!handoffContext) {
@@ -142,38 +125,51 @@ export async function generateHandoffDraftFromSessionManager(
142
125
  const sessionPath = sourceSessionManager.getSessionFile();
143
126
 
144
127
  return {
145
- draft: assembleHandoffDraft(sessionId, sessionPath, handoffContext, goal, requestResponse),
146
- context: handoffContext,
128
+ draft: assembleHandoffDraft(
129
+ sessionId,
130
+ sessionPath,
131
+ handoffContext.context,
132
+ goal,
133
+ requestResponse,
134
+ ),
135
+ context: handoffContext.context,
147
136
  sessionId,
148
137
  sessionPath,
138
+ ...(handoffContext.debugSessionPath
139
+ ? { debugSessionPath: handoffContext.debugSessionPath }
140
+ : {}),
149
141
  };
150
142
  }
151
143
 
152
144
  export function buildExtractionPrompt(conversationText: string, goal: string): string {
153
145
  return [
154
- "## Conversation",
146
+ "<conversation>",
155
147
  conversationText,
148
+ "</conversation>",
156
149
  "",
157
- "## Goal",
150
+ "<handoff-goal>",
158
151
  goal,
159
- "",
160
- "Call create_handoff_context exactly once.",
152
+ "</handoff-goal>",
161
153
  ].join("\n");
162
154
  }
163
155
 
164
156
  async function runHandoffExtractionAgent(
165
157
  ctx: ExtensionContext,
158
+ modelRuntime: ModelRuntime,
166
159
  model: Model<Api>,
167
160
  conversationText: string,
168
161
  goal: string,
169
162
  thinkingLevel: ThinkingLevel | undefined,
163
+ persistRuns: boolean,
170
164
  signal?: AbortSignal,
171
- ): Promise<HandoffContext | undefined> {
165
+ ): Promise<{ context: HandoffContext; debugSessionPath?: string | undefined } | undefined> {
172
166
  let capturedArguments: HandoffExtractionArgs | undefined;
167
+ // Pi does not inject promptSnippet or promptGuidelines when a custom system prompt is active.
173
168
  const createHandoffContextTool = defineTool({
174
169
  name: "create_handoff_context",
175
170
  label: "Create handoff context",
176
- description: "Extract the structured handoff context for the next session.",
171
+ description:
172
+ "Submit the completed structured briefing for the destination session. Call this tool exactly once to complete extraction.",
177
173
  parameters: HANDOFF_EXTRACTION_PARAMETERS,
178
174
  execute: async (_toolCallId, params) => {
179
175
  capturedArguments = params;
@@ -191,19 +187,23 @@ async function runHandoffExtractionAgent(
191
187
  noExtensions: true,
192
188
  noPromptTemplates: true,
193
189
  noSkills: true,
194
- appendSystemPromptOverride: (base) => [...base, HANDOFF_SYSTEM_PROMPT],
190
+ systemPromptOverride: () => HANDOFF_SYSTEM_PROMPT,
195
191
  });
196
192
  await resourceLoader.reload();
197
193
 
194
+ const sessionManager = persistRuns
195
+ ? SessionManager.create(ctx.cwd, getDefaultHandoffRunsDir())
196
+ : SessionManager.inMemory(ctx.cwd);
197
+ const debugSessionPath = persistRuns ? sessionManager.getSessionFile() : undefined;
198
198
  const { session } = await createAgentSession({
199
199
  cwd: ctx.cwd,
200
200
  model,
201
- modelRegistry: ctx.modelRegistry,
201
+ modelRuntime,
202
202
  ...(thinkingLevel ? { thinkingLevel } : {}),
203
- tools: ["read", "grep", "find", "ls", "create_handoff_context"],
203
+ tools: ["create_handoff_context"],
204
204
  customTools: [createHandoffContextTool],
205
205
  resourceLoader,
206
- sessionManager: SessionManager.inMemory(ctx.cwd),
206
+ sessionManager,
207
207
  });
208
208
 
209
209
  const abortHandler = (): void => {
@@ -217,7 +217,19 @@ async function runHandoffExtractionAgent(
217
217
  return undefined;
218
218
  }
219
219
 
220
- await session.prompt(buildExtractionPrompt(conversationText, goal));
220
+ for (let attempt = 1; attempt <= MAX_HANDOFF_EXTRACTION_ATTEMPTS; attempt += 1) {
221
+ if (signal?.aborted) {
222
+ break;
223
+ }
224
+ await session.prompt(
225
+ attempt === 1
226
+ ? buildExtractionPrompt(conversationText, goal)
227
+ : HANDOFF_EXTRACTION_RETRY_PROMPT,
228
+ );
229
+ if (capturedArguments) {
230
+ break;
231
+ }
232
+ }
221
233
  } finally {
222
234
  signal?.removeEventListener("abort", abortHandler);
223
235
  session.dispose();
@@ -231,12 +243,15 @@ async function runHandoffExtractionAgent(
231
243
  throw new Error("Handoff extraction did not return structured context.");
232
244
  }
233
245
 
234
- const extraction = extractHandoffContextFromArguments(capturedArguments, goal);
246
+ const extraction = extractHandoffContextFromArguments(capturedArguments);
235
247
  if (!extraction.context) {
236
248
  throw new Error(extraction.error);
237
249
  }
238
250
 
239
- return extraction.context;
251
+ return {
252
+ context: extraction.context,
253
+ ...(debugSessionPath ? { debugSessionPath } : {}),
254
+ };
240
255
  }
241
256
 
242
257
  export function assembleHandoffDraft(
@@ -247,10 +262,10 @@ export function assembleHandoffDraft(
247
262
  requestResponse = false,
248
263
  ): string {
249
264
  const sections = [buildContinuityLine(sessionId, sessionPath, requestResponse)];
250
- const nextTask = handoffContext.nextTask.trim() || goal.trim();
265
+ const task = goal.trim();
251
266
 
252
- if (nextTask) {
253
- sections.push(["## Task", nextTask].join("\n"));
267
+ if (task) {
268
+ sections.push(["## Task", task].join("\n"));
254
269
  }
255
270
 
256
271
  if (handoffContext.relevantFiles.length > 0) {
@@ -280,24 +295,22 @@ export function assembleHandoffDraft(
280
295
 
281
296
  export function extractHandoffContext(
282
297
  response: AssistantMessage,
283
- goal: string,
284
298
  ): { context: HandoffContext; error?: undefined } | { context?: undefined; error: string } {
285
299
  const toolCall = response.content.find(isCreateHandoffContextToolCall);
286
300
  if (!toolCall) {
287
301
  return { error: "Handoff extraction did not return structured context." };
288
302
  }
289
303
 
290
- return extractHandoffContextFromArguments(toolCall.arguments, goal);
304
+ return extractHandoffContextFromArguments(toolCall.arguments);
291
305
  }
292
306
 
293
307
  function extractHandoffContextFromArguments(
294
308
  argumentsValue: unknown,
295
- goal: string,
296
309
  ): { context: HandoffContext; error?: undefined } | { context?: undefined; error: string } {
297
- let requiredArguments: RequiredHandoffExtractionArgs;
310
+ let extraction: HandoffExtractionArgs;
298
311
  try {
299
- requiredArguments = parseTypeBoxValue(
300
- REQUIRED_HANDOFF_EXTRACTION_PARAMETERS,
312
+ extraction = parseTypeBoxValue(
313
+ HANDOFF_EXTRACTION_PARAMETERS,
301
314
  argumentsValue,
302
315
  "Invalid create_handoff_context arguments",
303
316
  );
@@ -305,27 +318,16 @@ function extractHandoffContextFromArguments(
305
318
  return { error: "Handoff extraction did not return structured context." };
306
319
  }
307
320
 
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) {
321
+ const summary = normalizeText(extraction.summary);
322
+ if (!summary) {
319
323
  return { error: "Handoff extraction did not return structured context." };
320
324
  }
321
325
 
322
326
  return {
323
327
  context: {
324
- title,
325
328
  summary,
326
- relevantFiles,
327
- nextTask,
328
- openQuestions,
329
+ relevantFiles: normalizeStringArray(extraction.relevantFiles),
330
+ openQuestions: normalizeStringArray(extraction.openQuestions),
329
331
  },
330
332
  };
331
333
  }
@@ -340,10 +342,10 @@ function buildContinuityLine(
340
342
  return base;
341
343
  }
342
344
 
343
- return `${base} When this work is complete, send that session a completion report with session_send_message.`;
345
+ return `${base} When this work is complete, send that session a completion report.`;
344
346
  }
345
347
 
346
- function normalizeStringArray(value: unknown, limit: number): string[] {
348
+ function normalizeStringArray(value: unknown): string[] {
347
349
  if (!Array.isArray(value)) {
348
350
  return [];
349
351
  }
@@ -356,38 +358,15 @@ function normalizeStringArray(value: unknown, limit: number): string[] {
356
358
  }
357
359
 
358
360
  uniqueValues.add(normalized);
359
- if (uniqueValues.size >= limit) {
360
- break;
361
- }
362
361
  }
363
362
 
364
363
  return [...uniqueValues];
365
364
  }
366
365
 
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
366
  function normalizeText(value: unknown): string {
384
367
  return typeof value === "string" ? value.trim() : "";
385
368
  }
386
369
 
387
- function isRecord(value: unknown): value is Record<string, unknown> {
388
- return typeof value === "object" && value !== null;
389
- }
390
-
391
370
  function isCreateHandoffContextToolCall(
392
371
  content: TextContent | ThinkingContent | ToolCall,
393
372
  ): content is ToolCall {