pi-sessions 0.1.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/LICENSE +21 -0
- package/README.md +173 -0
- package/extensions/session-ask.ts +297 -0
- package/extensions/session-auto-title/command.ts +109 -0
- package/extensions/session-auto-title/context.ts +41 -0
- package/extensions/session-auto-title/controller.ts +298 -0
- package/extensions/session-auto-title/model.ts +61 -0
- package/extensions/session-auto-title/prompt.ts +54 -0
- package/extensions/session-auto-title/retitle.ts +641 -0
- package/extensions/session-auto-title/state.ts +69 -0
- package/extensions/session-auto-title/wizard.ts +583 -0
- package/extensions/session-auto-title.ts +209 -0
- package/extensions/session-handoff/extract.ts +278 -0
- package/extensions/session-handoff/metadata.ts +96 -0
- package/extensions/session-handoff/picker.ts +394 -0
- package/extensions/session-handoff/query.ts +318 -0
- package/extensions/session-handoff/refs.ts +151 -0
- package/extensions/session-handoff/review.ts +268 -0
- package/extensions/session-handoff.ts +203 -0
- package/extensions/session-hooks.ts +55 -0
- package/extensions/session-index.ts +159 -0
- package/extensions/session-search/extract.ts +997 -0
- package/extensions/session-search/hooks.ts +350 -0
- package/extensions/session-search/normalize.ts +170 -0
- package/extensions/session-search/reindex.ts +93 -0
- package/extensions/session-search.ts +390 -0
- package/extensions/shared/search-snippet.ts +40 -0
- package/extensions/shared/session-index/common.ts +222 -0
- package/extensions/shared/session-index/index.ts +5 -0
- package/extensions/shared/session-index/lineage.ts +417 -0
- package/extensions/shared/session-index/schema.ts +178 -0
- package/extensions/shared/session-index/search.ts +688 -0
- package/extensions/shared/session-index/store.ts +173 -0
- package/extensions/shared/session-ui.ts +15 -0
- package/extensions/shared/settings.ts +141 -0
- package/extensions/shared/time.ts +38 -0
- package/extensions/shared/typebox.ts +61 -0
- package/images/handoff.png +0 -0
- package/images/session-title.png +0 -0
- package/images/session_ask.png +0 -0
- package/images/session_picker.png +0 -0
- package/images/session_search.png +0 -0
- package/package.json +64 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { parseSessionFile } from "../session-search/extract.js";
|
|
3
|
+
import {
|
|
4
|
+
getIndexStatus,
|
|
5
|
+
getSessionById,
|
|
6
|
+
INDEX_SCHEMA_VERSION,
|
|
7
|
+
openIndexDatabase,
|
|
8
|
+
type SessionLineageRow,
|
|
9
|
+
type SessionOrigin,
|
|
10
|
+
} from "../shared/session-index/index.js";
|
|
11
|
+
|
|
12
|
+
const HANDOFF_REF_PREFIX = "@handoff/";
|
|
13
|
+
const SESSION_REFERENCE_HELP =
|
|
14
|
+
"Expected an absolute session path, a raw session id, or @handoff/<full-session-id>.";
|
|
15
|
+
|
|
16
|
+
export type SessionReferenceKind = "path" | "session_id" | "handoff_ref";
|
|
17
|
+
|
|
18
|
+
export interface ResolvedSessionReference {
|
|
19
|
+
input: string;
|
|
20
|
+
kind: SessionReferenceKind;
|
|
21
|
+
canonicalRef: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
sessionPath: string;
|
|
24
|
+
sessionName: string;
|
|
25
|
+
parentSessionPath?: string | undefined;
|
|
26
|
+
parentSessionId?: string | undefined;
|
|
27
|
+
sessionOrigin?: SessionOrigin | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SessionReferenceResolution {
|
|
31
|
+
resolved?: ResolvedSessionReference | undefined;
|
|
32
|
+
error?: string | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatHandoffRef(sessionId: string): string {
|
|
36
|
+
return `${HANDOFF_REF_PREFIX}${sessionId}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isHandoffRef(value: string): boolean {
|
|
40
|
+
return value.startsWith(HANDOFF_REF_PREFIX);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveSessionReference(
|
|
44
|
+
reference: string,
|
|
45
|
+
options: { indexPath: string },
|
|
46
|
+
): SessionReferenceResolution {
|
|
47
|
+
const trimmed = reference.trim();
|
|
48
|
+
if (!trimmed) {
|
|
49
|
+
return { error: `Missing session reference. ${SESSION_REFERENCE_HELP}` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (path.isAbsolute(trimmed)) {
|
|
53
|
+
return resolveAbsoluteSessionPath(trimmed);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isHandoffRef(trimmed)) {
|
|
57
|
+
return resolveIndexedReference(
|
|
58
|
+
trimmed,
|
|
59
|
+
trimmed.slice(HANDOFF_REF_PREFIX.length),
|
|
60
|
+
"handoff_ref",
|
|
61
|
+
options,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return resolveIndexedReference(trimmed, trimmed, "session_id", options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveAbsoluteSessionPath(sessionPath: string): SessionReferenceResolution {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = parseSessionFile(sessionPath);
|
|
71
|
+
if (!parsed) {
|
|
72
|
+
return { error: `Unable to parse session file: ${sessionPath}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
resolved: {
|
|
77
|
+
input: sessionPath,
|
|
78
|
+
kind: "path",
|
|
79
|
+
canonicalRef: formatHandoffRef(parsed.header.id),
|
|
80
|
+
sessionId: parsed.header.id,
|
|
81
|
+
sessionPath,
|
|
82
|
+
sessionName: parsed.sessionName,
|
|
83
|
+
parentSessionPath: normalizeOptionalString(parsed.header.parentSession),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
88
|
+
return { error: `Session file not found: ${sessionPath}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { error: `Unable to load session file: ${sessionPath}` };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveIndexedReference(
|
|
96
|
+
input: string,
|
|
97
|
+
rawValue: string,
|
|
98
|
+
kind: SessionReferenceKind,
|
|
99
|
+
options: { indexPath: string },
|
|
100
|
+
): SessionReferenceResolution {
|
|
101
|
+
const value = rawValue.trim();
|
|
102
|
+
if (!value) {
|
|
103
|
+
return { error: `Invalid session reference: ${input}. ${SESSION_REFERENCE_HELP}` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const status = getIndexStatus(options.indexPath);
|
|
107
|
+
if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
108
|
+
return {
|
|
109
|
+
error: `Session reference resolution requires a current index at ${status.dbPath}. Run /session-index and press r to rebuild it.`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const db = openIndexDatabase(status.dbPath, { create: false });
|
|
114
|
+
try {
|
|
115
|
+
const exactMatch = getSessionById(db, value);
|
|
116
|
+
if (exactMatch) {
|
|
117
|
+
return { resolved: buildResolvedReference(input, kind, exactMatch) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { error: `No session found for reference: ${input}. ${SESSION_REFERENCE_HELP}` };
|
|
121
|
+
} finally {
|
|
122
|
+
db.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildResolvedReference(
|
|
127
|
+
input: string,
|
|
128
|
+
kind: SessionReferenceKind,
|
|
129
|
+
session: SessionLineageRow,
|
|
130
|
+
): ResolvedSessionReference {
|
|
131
|
+
return {
|
|
132
|
+
input,
|
|
133
|
+
kind,
|
|
134
|
+
canonicalRef: formatHandoffRef(session.sessionId),
|
|
135
|
+
sessionId: session.sessionId,
|
|
136
|
+
sessionPath: session.sessionPath,
|
|
137
|
+
sessionName: session.sessionName,
|
|
138
|
+
parentSessionPath: session.parentSessionPath,
|
|
139
|
+
parentSessionId: session.parentSessionId,
|
|
140
|
+
sessionOrigin: session.sessionOrigin,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeOptionalString(value: string | undefined): string | undefined {
|
|
145
|
+
if (typeof value !== "string") {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const trimmed = value.trim();
|
|
150
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
151
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionCommandContext,
|
|
3
|
+
ExtensionUIContext,
|
|
4
|
+
Theme,
|
|
5
|
+
} from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
7
|
+
import {
|
|
8
|
+
Key,
|
|
9
|
+
matchesKey,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
wrapTextWithAnsi,
|
|
13
|
+
} from "@mariozechner/pi-tui";
|
|
14
|
+
|
|
15
|
+
const PREVIEW_TIMEOUT_MS = 8_000;
|
|
16
|
+
const PREVIEW_BODY_LINE_LIMIT = 16;
|
|
17
|
+
|
|
18
|
+
type ReviewAction = "accept" | "edit" | "cancel";
|
|
19
|
+
|
|
20
|
+
interface TimerHandle {
|
|
21
|
+
stop(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PreviewClock {
|
|
25
|
+
now(): number;
|
|
26
|
+
setInterval(callback: () => void, delayMs: number): TimerHandle;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaultPreviewClock: PreviewClock = {
|
|
30
|
+
now() {
|
|
31
|
+
return Date.now();
|
|
32
|
+
},
|
|
33
|
+
setInterval(callback, delayMs) {
|
|
34
|
+
const timer = globalThis.setInterval(callback, delayMs);
|
|
35
|
+
return {
|
|
36
|
+
stop() {
|
|
37
|
+
globalThis.clearInterval(timer);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface HandoffPreviewOptions {
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
clock?: PreviewClock;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class HandoffPreviewComponent implements Component {
|
|
49
|
+
private readonly draft: string;
|
|
50
|
+
private readonly theme: Theme;
|
|
51
|
+
private readonly onDone: (action: ReviewAction) => void;
|
|
52
|
+
private readonly requestRender: () => void;
|
|
53
|
+
private readonly deadlineAt: number;
|
|
54
|
+
private readonly clock: PreviewClock;
|
|
55
|
+
private readonly intervalHandle: TimerHandle;
|
|
56
|
+
private isDone = false;
|
|
57
|
+
private isTimerActive = true;
|
|
58
|
+
private scrollOffset = 0;
|
|
59
|
+
private previewWidth = 80;
|
|
60
|
+
|
|
61
|
+
public constructor(
|
|
62
|
+
draft: string,
|
|
63
|
+
theme: Theme,
|
|
64
|
+
requestRender: () => void,
|
|
65
|
+
onDone: (action: ReviewAction) => void,
|
|
66
|
+
options: HandoffPreviewOptions = {},
|
|
67
|
+
) {
|
|
68
|
+
this.draft = draft;
|
|
69
|
+
this.theme = theme;
|
|
70
|
+
this.requestRender = requestRender;
|
|
71
|
+
this.onDone = onDone;
|
|
72
|
+
this.clock = options.clock ?? defaultPreviewClock;
|
|
73
|
+
this.deadlineAt = this.clock.now() + (options.timeoutMs ?? PREVIEW_TIMEOUT_MS);
|
|
74
|
+
this.intervalHandle = this.clock.setInterval(() => {
|
|
75
|
+
if (this.isDone) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!this.isTimerActive) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.clock.now() >= this.deadlineAt) {
|
|
84
|
+
this.finish("accept");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.requestRender();
|
|
89
|
+
}, 200);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public handleInput(data: string): void {
|
|
93
|
+
if (matchesKey(data, Key.enter)) {
|
|
94
|
+
this.finish("accept");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (matchesKey(data, Key.escape)) {
|
|
99
|
+
this.finish("cancel");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (matchesKey(data, "e")) {
|
|
104
|
+
this.finish("edit");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (matchesKey(data, "j")) {
|
|
109
|
+
this.disableTimer();
|
|
110
|
+
this.scrollBy(1);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (matchesKey(data, "k")) {
|
|
115
|
+
this.disableTimer();
|
|
116
|
+
this.scrollBy(-1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public render(width: number): string[] {
|
|
121
|
+
const modalWidth = Math.max(24, width - 4);
|
|
122
|
+
const promptBoxWidth = Math.max(20, modalWidth - 4);
|
|
123
|
+
this.previewWidth = Math.max(16, promptBoxWidth - 4);
|
|
124
|
+
|
|
125
|
+
const promptLines = renderPromptSection(
|
|
126
|
+
this.buildPreviewLines(this.previewWidth),
|
|
127
|
+
promptBoxWidth,
|
|
128
|
+
this.theme,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const contentLines = [
|
|
132
|
+
this.getTitleLine(),
|
|
133
|
+
"",
|
|
134
|
+
this.theme.fg("muted", formatKeymapLine("Enter: start session", "Esc: cancel")),
|
|
135
|
+
this.theme.fg("muted", formatKeymapLine("e: edit prompt", "j/k: scroll")),
|
|
136
|
+
"",
|
|
137
|
+
...promptLines,
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
return renderStrongModal(contentLines, width, this.theme);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public invalidate(): void {}
|
|
144
|
+
|
|
145
|
+
public stop(): void {
|
|
146
|
+
this.intervalHandle.stop();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private buildPreviewLines(width: number): string[] {
|
|
150
|
+
const wrappedLines = wrapTextWithAnsi(this.draft, Math.max(20, width));
|
|
151
|
+
const maxOffset = Math.max(0, wrappedLines.length - PREVIEW_BODY_LINE_LIMIT);
|
|
152
|
+
this.scrollOffset = Math.min(this.scrollOffset, maxOffset);
|
|
153
|
+
|
|
154
|
+
return wrappedLines
|
|
155
|
+
.slice(this.scrollOffset, this.scrollOffset + PREVIEW_BODY_LINE_LIMIT)
|
|
156
|
+
.map((line) => truncateToWidth(line, width));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private finish(action: ReviewAction): void {
|
|
160
|
+
if (this.isDone) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.isDone = true;
|
|
165
|
+
this.intervalHandle.stop();
|
|
166
|
+
this.onDone(action);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private scrollBy(delta: number): void {
|
|
170
|
+
const wrappedLines = wrapTextWithAnsi(this.draft, Math.max(20, this.previewWidth));
|
|
171
|
+
const maxOffset = Math.max(0, wrappedLines.length - PREVIEW_BODY_LINE_LIMIT);
|
|
172
|
+
const nextOffset = Math.max(0, Math.min(maxOffset, this.scrollOffset + delta));
|
|
173
|
+
if (nextOffset !== this.scrollOffset) {
|
|
174
|
+
this.scrollOffset = nextOffset;
|
|
175
|
+
this.requestRender();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private disableTimer(): void {
|
|
180
|
+
if (!this.isTimerActive) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.isTimerActive = false;
|
|
185
|
+
this.requestRender();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private getTitleLine(): string {
|
|
189
|
+
const title = this.theme.fg("accent", this.theme.bold("Handoff preview"));
|
|
190
|
+
if (!this.isTimerActive) {
|
|
191
|
+
return title;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return `${title}${this.theme.fg("dim", ` (autostart in ${this.getRemainingSeconds()}s)`)}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private getRemainingSeconds(): number {
|
|
198
|
+
const remainingMs = Math.max(0, this.deadlineAt - this.clock.now());
|
|
199
|
+
return Math.ceil(remainingMs / 1_000);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function renderStrongModal(lines: string[], width: number, theme: Theme): string[] {
|
|
204
|
+
const innerWidth = Math.max(20, width - 4);
|
|
205
|
+
const fillLine = (text: string) => {
|
|
206
|
+
const truncated = truncateToWidth(text, innerWidth, "…", true);
|
|
207
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
208
|
+
return theme.bg("customMessageBg", ` ${truncated}${" ".repeat(padding)} `);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return ["", ...lines, ""].map(fillLine);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatKeymapLine(left: string, right: string): string {
|
|
215
|
+
return `${left.padEnd(27, " ")}${right}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderPromptSection(lines: string[], width: number, _theme: Theme): string[] {
|
|
219
|
+
const innerWidth = Math.max(16, width - 4);
|
|
220
|
+
const line = (left: string, text: string, right: string) => {
|
|
221
|
+
const truncated = truncateToWidth(text, innerWidth, "…", true);
|
|
222
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
223
|
+
return `${left} ${truncated}${" ".repeat(padding)} ${right}`;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return [
|
|
227
|
+
`┌${"─".repeat(innerWidth + 2)}┐`,
|
|
228
|
+
...lines.map((text) => line("│", text, "│")),
|
|
229
|
+
`└${"─".repeat(innerWidth + 2)}┘`,
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function reviewHandoffDraft(
|
|
234
|
+
ctx: ExtensionCommandContext,
|
|
235
|
+
draft: string,
|
|
236
|
+
): Promise<string | undefined> {
|
|
237
|
+
const action = await runPreviewGate(ctx.ui, draft);
|
|
238
|
+
if (action === "cancel") {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (action === "accept") {
|
|
243
|
+
return draft;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const editedDraft = await ctx.ui.editor("Edit handoff prompt", draft);
|
|
247
|
+
if (!editedDraft?.trim()) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return editedDraft;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function runPreviewGate(ui: ExtensionUIContext, draft: string): Promise<ReviewAction> {
|
|
255
|
+
return ui.custom<ReviewAction>(
|
|
256
|
+
(tui, theme, _keybindings, done) =>
|
|
257
|
+
new HandoffPreviewComponent(draft, theme, () => tui.requestRender(), done),
|
|
258
|
+
{
|
|
259
|
+
overlay: true,
|
|
260
|
+
overlayOptions: {
|
|
261
|
+
anchor: "center",
|
|
262
|
+
width: "80%",
|
|
263
|
+
maxHeight: "80%",
|
|
264
|
+
margin: 2,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
SessionManager,
|
|
5
|
+
} from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { buildSessionContext } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import { Key, matchesKey } from "@mariozechner/pi-tui";
|
|
8
|
+
import { generateHandoffDraft, type HandoffDraftResult } from "./session-handoff/extract.js";
|
|
9
|
+
import {
|
|
10
|
+
createHandoffSessionMetadata,
|
|
11
|
+
createPendingSendConsumedEntry,
|
|
12
|
+
getPendingInitialPromptFromEntries,
|
|
13
|
+
HANDOFF_METADATA_CUSTOM_TYPE,
|
|
14
|
+
PENDING_SEND_CONSUMED_CUSTOM_TYPE,
|
|
15
|
+
} from "./session-handoff/metadata.js";
|
|
16
|
+
import { openSessionReferencePicker } from "./session-handoff/picker.js";
|
|
17
|
+
import { SESSION_TOKEN_PREFIX } from "./session-handoff/query.js";
|
|
18
|
+
import { renderStrongModal, reviewHandoffDraft } from "./session-handoff/review.js";
|
|
19
|
+
import { loadSettings } from "./shared/settings.js";
|
|
20
|
+
|
|
21
|
+
export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
22
|
+
const settings = loadSettings();
|
|
23
|
+
|
|
24
|
+
pi.registerCommand("handoff", {
|
|
25
|
+
description: "Transfer context to a new focused session",
|
|
26
|
+
handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
27
|
+
if (!ctx.hasUI) {
|
|
28
|
+
ctx.ui.notify("handoff requires interactive mode", "error");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!ctx.model) {
|
|
33
|
+
ctx.ui.notify("No model selected", "error");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const goal = args.trim();
|
|
38
|
+
if (!goal) {
|
|
39
|
+
ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sessionContext = buildSessionContext(
|
|
44
|
+
ctx.sessionManager.getEntries(),
|
|
45
|
+
ctx.sessionManager.getLeafId(),
|
|
46
|
+
);
|
|
47
|
+
if (sessionContext.messages.length === 0) {
|
|
48
|
+
ctx.ui.notify("No conversation to hand off", "error");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let generatedDraft: HandoffDraftResult | undefined;
|
|
53
|
+
try {
|
|
54
|
+
generatedDraft = await runWithLoader(
|
|
55
|
+
ctx,
|
|
56
|
+
"Generating handoff draft...",
|
|
57
|
+
async (signal: AbortSignal) => generateHandoffDraft(ctx, goal, signal),
|
|
58
|
+
);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
ctx.ui.notify(formatHandoffError(error), "error");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!generatedDraft) {
|
|
65
|
+
ctx.ui.notify("Cancelled", "info");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const approvedDraft = await reviewHandoffDraft(ctx, generatedDraft.draft);
|
|
70
|
+
if (!approvedDraft) {
|
|
71
|
+
ctx.ui.notify("Cancelled", "info");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
76
|
+
|
|
77
|
+
const handoffMetadata = createHandoffSessionMetadata(
|
|
78
|
+
goal,
|
|
79
|
+
generatedDraft.context.nextTask,
|
|
80
|
+
approvedDraft,
|
|
81
|
+
);
|
|
82
|
+
const writeMetadata = async (sm: SessionManager) => {
|
|
83
|
+
sm.appendCustomEntry(HANDOFF_METADATA_CUSTOM_TYPE, handoffMetadata);
|
|
84
|
+
};
|
|
85
|
+
const newSessionResult = parentSession
|
|
86
|
+
? await ctx.newSession({ parentSession, setup: writeMetadata })
|
|
87
|
+
: await ctx.newSession({ setup: writeMetadata });
|
|
88
|
+
|
|
89
|
+
if (newSessionResult.cancelled) {
|
|
90
|
+
ctx.ui.notify("New session cancelled", "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ctx.ui.notify("Handoff started in a new session.", "info");
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.registerShortcut(settings.handoff.pickerShortcut, {
|
|
99
|
+
description: "Open the session reference picker",
|
|
100
|
+
handler: async (ctx) => {
|
|
101
|
+
if (!ctx.hasUI) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await openSessionReferencePicker(
|
|
106
|
+
ctx,
|
|
107
|
+
settings.index.path,
|
|
108
|
+
settings.handoff.pickerShortcut,
|
|
109
|
+
);
|
|
110
|
+
if (result.kind !== "insert-session-token") {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ctx.ui.pasteToEditor(`${SESSION_TOKEN_PREFIX}${result.sessionId}`);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
119
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
120
|
+
const pending = getPendingInitialPromptFromEntries(ctx.sessionManager.getEntries());
|
|
121
|
+
|
|
122
|
+
if (!sessionFile || !pending) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pi.appendEntry(
|
|
127
|
+
PENDING_SEND_CONSUMED_CUSTOM_TYPE,
|
|
128
|
+
createPendingSendConsumedEntry(pending.initial_prompt_nonce),
|
|
129
|
+
);
|
|
130
|
+
pi.sendUserMessage(pending.initial_prompt);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
pi.on("before_agent_start", async () => {
|
|
134
|
+
return {
|
|
135
|
+
systemPrompt:
|
|
136
|
+
"When the user references @session:<uuid>, treat it as a session token. If you call session_ask, pass only the UUID value, not the @session: prefix.",
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function runWithLoader<T>(
|
|
142
|
+
ctx: ExtensionCommandContext,
|
|
143
|
+
label: string,
|
|
144
|
+
task: (signal: AbortSignal) => Promise<T>,
|
|
145
|
+
): Promise<T | undefined> {
|
|
146
|
+
let taskError: unknown;
|
|
147
|
+
|
|
148
|
+
const result = await ctx.ui.custom<T | undefined>(
|
|
149
|
+
(tui, theme, _keybindings, done) => {
|
|
150
|
+
const abortController = new AbortController();
|
|
151
|
+
|
|
152
|
+
task(abortController.signal)
|
|
153
|
+
.then(done)
|
|
154
|
+
.catch((error: unknown) => {
|
|
155
|
+
if (!abortController.signal.aborted) {
|
|
156
|
+
taskError = error;
|
|
157
|
+
}
|
|
158
|
+
done(undefined);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
render(width: number): string[] {
|
|
163
|
+
return renderStrongModal(
|
|
164
|
+
[theme.fg("accent", theme.bold(label)), "", theme.fg("muted", "Press Esc to cancel.")],
|
|
165
|
+
width,
|
|
166
|
+
theme,
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
invalidate(): void {},
|
|
170
|
+
handleInput(data: string): void {
|
|
171
|
+
if (matchesKey(data, Key.escape)) {
|
|
172
|
+
abortController.abort();
|
|
173
|
+
done(undefined);
|
|
174
|
+
tui.requestRender();
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
overlay: true,
|
|
181
|
+
overlayOptions: {
|
|
182
|
+
anchor: "center",
|
|
183
|
+
width: "70%",
|
|
184
|
+
maxHeight: "40%",
|
|
185
|
+
margin: 2,
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if (taskError) {
|
|
191
|
+
throw taskError;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatHandoffError(error: unknown): string {
|
|
198
|
+
if (error instanceof Error && error.message.trim()) {
|
|
199
|
+
return error.message;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return "Handoff generation failed.";
|
|
203
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { createSessionHookController } from "./session-search/hooks.js";
|
|
3
|
+
import { loadSettings } from "./shared/settings.js";
|
|
4
|
+
|
|
5
|
+
interface SessionStartLifecycleEvent {
|
|
6
|
+
reason?: "startup" | "reload" | "new" | "resume" | "fork";
|
|
7
|
+
previousSessionFile?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function sessionHooksExtension(pi: ExtensionAPI): void {
|
|
11
|
+
const settings = loadSettings();
|
|
12
|
+
const controller = createSessionHookController({ indexPath: settings.index.path });
|
|
13
|
+
|
|
14
|
+
pi.on("session_start", async (event, ctx) => {
|
|
15
|
+
const { reason, previousSessionFile } = event as SessionStartLifecycleEvent;
|
|
16
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
17
|
+
|
|
18
|
+
switch (reason) {
|
|
19
|
+
case "new":
|
|
20
|
+
case "resume":
|
|
21
|
+
await controller.handleSessionSwitch(previousSessionFile, sessionFile, ctx.cwd);
|
|
22
|
+
break;
|
|
23
|
+
case "fork":
|
|
24
|
+
await controller.handleSessionFork(previousSessionFile, sessionFile, ctx.cwd);
|
|
25
|
+
break;
|
|
26
|
+
default:
|
|
27
|
+
await controller.handleSessionStart(sessionFile, ctx.cwd);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
33
|
+
controller.handleToolCall(event, ctx.sessionManager.getSessionFile(), ctx.cwd);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
pi.on("tool_result", async (event) => {
|
|
37
|
+
controller.handleToolResult(event);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
41
|
+
await controller.handleTurnEnd(ctx.sessionManager.getSessionFile(), ctx.cwd);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
45
|
+
await controller.handleSessionTree(ctx.sessionManager.getSessionFile(), ctx.cwd);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
49
|
+
await controller.handleSessionCompact(ctx.sessionManager.getSessionFile(), ctx.cwd);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
53
|
+
await controller.handleSessionShutdown(ctx.sessionManager.getSessionFile(), ctx.cwd);
|
|
54
|
+
});
|
|
55
|
+
}
|