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.
- package/README.md +71 -37
- package/extensions/pi-sessions.ts +125 -0
- package/extensions/session-ask/agent.ts +34 -14
- package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
- package/extensions/session-ask/navigate.ts +3 -1
- package/extensions/session-ask/presenter.ts +23 -0
- package/extensions/session-ask/renderer.ts +63 -0
- package/extensions/session-ask/tool-contract.ts +27 -0
- package/extensions/session-ask/view-model.ts +18 -0
- package/extensions/session-auto-title/generate.ts +18 -18
- package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +67 -46
- package/extensions/session-auto-title/model.ts +25 -17
- package/extensions/session-auto-title/retitle.ts +26 -12
- package/extensions/session-auto-title/wizard.ts +12 -6
- package/extensions/session-handoff/board-view-model.ts +181 -0
- package/extensions/session-handoff/board.ts +663 -0
- package/extensions/session-handoff/bootstrap.ts +174 -0
- package/extensions/session-handoff/extract.ts +101 -122
- package/extensions/session-handoff/install.ts +225 -0
- package/extensions/session-handoff/kickoff.ts +92 -0
- package/extensions/session-handoff/launch/backend.ts +16 -0
- package/extensions/session-handoff/launch/deferred.ts +20 -0
- package/extensions/session-handoff/launch/ghostty.ts +99 -0
- package/extensions/session-handoff/launch/resolution.ts +57 -0
- package/extensions/session-handoff/launch/shell.ts +7 -0
- package/extensions/session-handoff/launch/tmux.ts +31 -0
- package/extensions/session-handoff/launch-options.ts +61 -0
- package/extensions/session-handoff/launch-target.ts +91 -0
- package/extensions/session-handoff/metadata.ts +152 -63
- package/extensions/session-handoff/model.ts +58 -0
- package/extensions/session-handoff/query.ts +0 -1
- package/extensions/session-handoff/receipt.ts +96 -0
- package/extensions/session-handoff/review.ts +2 -28
- package/extensions/session-handoff/spawn.ts +76 -181
- package/extensions/session-handoff/strong-modal.ts +13 -0
- package/extensions/session-handoff/tool-contract.ts +12 -0
- package/extensions/session-handoff/tool-presenter.ts +35 -0
- package/extensions/session-handoff/tool-renderer.ts +66 -0
- package/extensions/session-handoff/tool-schema.ts +54 -0
- package/extensions/session-handoff/tool-view-model.ts +49 -0
- package/extensions/session-handoff/tool.ts +206 -0
- package/extensions/session-handoff/ui.ts +66 -0
- package/extensions/session-hooks/install.ts +75 -0
- package/extensions/{session-index.ts → session-index/install.ts} +9 -7
- package/extensions/session-messaging/broker/process.ts +21 -28
- package/extensions/session-messaging/install.ts +78 -0
- package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
- package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
- package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
- package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
- package/extensions/session-messaging/pi/message-contracts.ts +32 -0
- package/extensions/session-messaging/pi/renderer.ts +19 -12
- package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
- package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
- package/extensions/session-messaging/pi/service.ts +262 -81
- package/extensions/session-messaging/pi/tools.ts +184 -40
- package/extensions/session-search/extract.ts +60 -14
- package/extensions/session-search/hooks.ts +10 -13
- package/extensions/session-search/install.ts +373 -0
- package/extensions/session-search/renderer.ts +195 -0
- package/extensions/session-search/tool-contract.ts +51 -0
- package/extensions/shared/composition.ts +20 -0
- package/extensions/shared/errors.ts +3 -0
- package/extensions/shared/model-resolution.ts +73 -0
- package/extensions/shared/model-runtime.ts +33 -0
- package/extensions/shared/model.ts +7 -10
- package/extensions/shared/rendering/collapsible-text.ts +89 -0
- package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
- package/extensions/shared/rendering/theme.ts +5 -0
- package/extensions/shared/session-broker/client.ts +30 -29
- package/extensions/shared/session-broker/protocol.ts +128 -10
- package/extensions/shared/session-index/common.ts +5 -5
- package/extensions/shared/session-index/lineage.ts +0 -3
- package/extensions/shared/session-index/schema.ts +0 -1
- package/extensions/shared/session-index/scoring.ts +1 -2
- package/extensions/shared/session-index/search.ts +11 -4
- package/extensions/shared/session-index/store.ts +4 -9
- package/extensions/shared/settings.ts +80 -38
- package/extensions/shared/text.ts +5 -0
- package/extensions/shared/thinking-levels.ts +22 -0
- package/extensions/shared/tmux.ts +205 -0
- package/extensions/subagents/cancel.ts +111 -0
- package/extensions/subagents/classify.ts +52 -0
- package/extensions/subagents/install.ts +344 -0
- package/extensions/subagents/launch-target.ts +75 -0
- package/extensions/subagents/ledger.ts +217 -0
- package/extensions/subagents/reconcile.ts +386 -0
- package/extensions/subagents/report-message-presenter.ts +36 -0
- package/extensions/subagents/report-message-renderer.ts +21 -0
- package/extensions/subagents/report-message-view-model.ts +29 -0
- package/extensions/subagents/report.ts +159 -0
- package/extensions/subagents/roster.ts +262 -0
- package/extensions/subagents/wake.ts +173 -0
- package/images/handoff-board-subagents.png +0 -0
- package/images/handoff-board-user-sessions.png +0 -0
- package/images/handoff-subagent-report.png +0 -0
- package/images/session-handoff-tool.png +0 -0
- package/package.json +10 -16
- package/extensions/session-handoff.ts +0 -741
- package/extensions/session-hooks.ts +0 -75
- package/extensions/session-messaging/pi/message-view.ts +0 -131
- package/extensions/session-messaging.ts +0 -30
- package/extensions/session-search.ts +0 -419
- 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 {
|
|
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
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 =
|
|
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(
|
|
146
|
-
|
|
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
|
-
"
|
|
146
|
+
"<conversation>",
|
|
155
147
|
conversationText,
|
|
148
|
+
"</conversation>",
|
|
156
149
|
"",
|
|
157
|
-
"
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
modelRuntime,
|
|
202
202
|
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
203
|
-
tools: ["
|
|
203
|
+
tools: ["create_handoff_context"],
|
|
204
204
|
customTools: [createHandoffContextTool],
|
|
205
205
|
resourceLoader,
|
|
206
|
-
sessionManager
|
|
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
|
-
|
|
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
|
|
246
|
+
const extraction = extractHandoffContextFromArguments(capturedArguments);
|
|
235
247
|
if (!extraction.context) {
|
|
236
248
|
throw new Error(extraction.error);
|
|
237
249
|
}
|
|
238
250
|
|
|
239
|
-
return
|
|
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
|
|
265
|
+
const task = goal.trim();
|
|
251
266
|
|
|
252
|
-
if (
|
|
253
|
-
sections.push(["## Task",
|
|
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
|
|
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
|
|
310
|
+
let extraction: HandoffExtractionArgs;
|
|
298
311
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
309
|
-
if (
|
|
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
|
-
|
|
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
|
|
345
|
+
return `${base} When this work is complete, send that session a completion report.`;
|
|
344
346
|
}
|
|
345
347
|
|
|
346
|
-
function normalizeStringArray(value: unknown
|
|
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 {
|