pi-sessions 0.1.0 → 0.2.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 +12 -5
- package/extensions/session-handoff/metadata.ts +46 -42
- package/extensions/session-handoff/spawn.ts +142 -0
- package/extensions/session-handoff.ts +172 -32
- package/extensions/session-hooks.ts +22 -2
- package/extensions/session-search/extract.ts +2 -2
- package/package.json +14 -13
package/README.md
CHANGED
|
@@ -62,11 +62,20 @@ What session did I implement the db layer?
|
|
|
62
62
|
|
|
63
63
|
`/handoff <goal>` starts a focused new session. Give pi a goal, and it will generate a prompt for you to review before kicking it off.
|
|
64
64
|
|
|
65
|
+
You can either start a new session directly in your current one, or if you have Ghostty on macOS, you can spawn a new one in a split-pane and continue in your current:
|
|
66
|
+
|
|
67
|
+
- `/handoff --left <goal>`
|
|
68
|
+
- `/handoff --right <goal>`
|
|
69
|
+
- `/handoff --up <goal>`
|
|
70
|
+
- `/handoff --down <goal>`
|
|
71
|
+
|
|
72
|
+
The flag indicates the Ghostty split direction.
|
|
73
|
+
|
|
65
74
|
Flow:
|
|
66
75
|
|
|
67
|
-
- run `/handoff <goal>`
|
|
76
|
+
- run `/handoff [--<direction>] <goal>`
|
|
68
77
|
- review the generated prompt preview
|
|
69
|
-
- optionally edit the prompt
|
|
78
|
+
- optionally edit the prompt
|
|
70
79
|
- start the new session
|
|
71
80
|
|
|
72
81
|
If you do nothing, the preview autostarts after a short countdown.
|
|
@@ -138,8 +147,6 @@ To manage existing titles, run `/title`, where you can:
|
|
|
138
147
|
|
|
139
148
|
Note that generating titles for all sessions can take some time, and will hit your configured model with the full contents of all sessions.
|
|
140
149
|
|
|
141
|
-
Behavior notes:
|
|
142
|
-
|
|
143
150
|
- automatic retitles run every few turns
|
|
144
151
|
- if you manually rename a session with `/name`, automatic retitling pauses for that session
|
|
145
152
|
- Regenerate the title for the current session to resume automatic retitling
|
|
@@ -147,7 +154,7 @@ Behavior notes:
|
|
|
147
154
|
- `google/gemini-flash-lite-latest`
|
|
148
155
|
- `anthropic/claude-haiku-4-5`
|
|
149
156
|
- `openai/gpt-5.4-mini`
|
|
150
|
-
- your currently configured model
|
|
157
|
+
- your currently configured model
|
|
151
158
|
|
|
152
159
|
To change auto-titling settings, edit `~/.pi/agent/settings.json`:
|
|
153
160
|
|
|
@@ -1,25 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
2
|
import type { CustomEntry, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
3
3
|
import { type Static, Type } from "@sinclair/typebox";
|
|
4
4
|
import { safeParseTypeBoxValue } from "../shared/typebox.js";
|
|
5
5
|
|
|
6
6
|
export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
|
|
7
|
-
export const
|
|
7
|
+
export const HANDOFF_BOOTSTRAP_ENV = "PI_SESSIONS_HANDOFF_BOOTSTRAP";
|
|
8
|
+
export const HANDOFF_STALE_SESSION_MESSAGE =
|
|
9
|
+
"Session handoff failed: target session already has user input.";
|
|
8
10
|
|
|
9
11
|
export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Object({
|
|
10
12
|
origin: Type.Literal("handoff"),
|
|
11
13
|
goal: Type.String(),
|
|
12
14
|
nextTask: Type.String(),
|
|
13
15
|
initial_prompt: Type.String(),
|
|
14
|
-
initial_prompt_nonce: Type.String(),
|
|
15
16
|
});
|
|
16
17
|
|
|
17
|
-
export const
|
|
18
|
-
|
|
18
|
+
export const HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
|
|
19
|
+
sessionId: Type.String(),
|
|
20
|
+
goal: Type.String(),
|
|
21
|
+
nextTask: Type.String(),
|
|
22
|
+
initialPrompt: Type.String(),
|
|
19
23
|
});
|
|
20
24
|
|
|
21
25
|
export type HandoffSessionMetadata = Static<typeof HANDOFF_SESSION_METADATA_SCHEMA>;
|
|
22
|
-
export type
|
|
26
|
+
export type HandoffBootstrap = Static<typeof HANDOFF_BOOTSTRAP_SCHEMA>;
|
|
23
27
|
|
|
24
28
|
export function createHandoffSessionMetadata(
|
|
25
29
|
goal: string,
|
|
@@ -28,69 +32,69 @@ export function createHandoffSessionMetadata(
|
|
|
28
32
|
): HandoffSessionMetadata {
|
|
29
33
|
const normalizedGoal = goal.trim();
|
|
30
34
|
const normalizedNextTask = nextTask.trim() || normalizedGoal;
|
|
31
|
-
const normalizedInitialPrompt = initialPrompt.trim();
|
|
32
35
|
|
|
33
36
|
return {
|
|
34
37
|
origin: "handoff",
|
|
35
38
|
goal: normalizedGoal,
|
|
36
39
|
nextTask: normalizedNextTask,
|
|
37
|
-
initial_prompt:
|
|
38
|
-
|
|
40
|
+
initial_prompt: initialPrompt.trim(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createHandoffBootstrap(
|
|
45
|
+
sessionId: string,
|
|
46
|
+
metadata: HandoffSessionMetadata,
|
|
47
|
+
): HandoffBootstrap {
|
|
48
|
+
return {
|
|
49
|
+
sessionId,
|
|
50
|
+
goal: metadata.goal,
|
|
51
|
+
nextTask: metadata.nextTask,
|
|
52
|
+
initialPrompt: metadata.initial_prompt,
|
|
39
53
|
};
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
export function
|
|
43
|
-
return
|
|
56
|
+
export function encodeHandoffBootstrap(bootstrap: HandoffBootstrap): string {
|
|
57
|
+
return Buffer.from(JSON.stringify(bootstrap), "utf8").toString("base64");
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
export function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
export function parseHandoffBootstrap(value: string): HandoffBootstrap | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const decoded = Buffer.from(value, "base64").toString("utf8");
|
|
63
|
+
return safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_SCHEMA, JSON.parse(decoded));
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
export function parseHandoffSessionMetadata(value: unknown): HandoffSessionMetadata | undefined {
|
|
53
70
|
return safeParseTypeBoxValue(HANDOFF_SESSION_METADATA_SCHEMA, value);
|
|
54
71
|
}
|
|
55
72
|
|
|
56
|
-
export function
|
|
73
|
+
export function getHandoffMetadataFromEntries(
|
|
57
74
|
entries: readonly SessionEntry[],
|
|
58
75
|
): HandoffSessionMetadata | undefined {
|
|
59
|
-
const consumed = new Set<string>();
|
|
60
|
-
let pending: HandoffSessionMetadata | undefined;
|
|
61
|
-
|
|
62
76
|
for (const entry of entries) {
|
|
63
77
|
if (entry.type !== "custom") {
|
|
64
78
|
continue;
|
|
65
79
|
}
|
|
66
80
|
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
if (consumedEntry) {
|
|
71
|
-
consumed.add(consumedEntry.nonce);
|
|
72
|
-
if (pending?.initial_prompt_nonce === consumedEntry.nonce) {
|
|
73
|
-
pending = undefined;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (customEntry.customType !== HANDOFF_METADATA_CUSTOM_TYPE) {
|
|
80
|
-
continue;
|
|
81
|
+
const metadata = parseCustomHandoffMetadata(entry);
|
|
82
|
+
if (metadata) {
|
|
83
|
+
return metadata;
|
|
81
84
|
}
|
|
85
|
+
}
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
export function hasUserMessages(entries: readonly SessionEntry[]): boolean {
|
|
91
|
+
return entries.some((entry) => entry.type === "message" && entry.message.role === "user");
|
|
92
|
+
}
|
|
91
93
|
|
|
92
|
-
|
|
94
|
+
function parseCustomHandoffMetadata(entry: CustomEntry): HandoffSessionMetadata | undefined {
|
|
95
|
+
if (entry.customType !== HANDOFF_METADATA_CUSTOM_TYPE) {
|
|
96
|
+
return undefined;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
return
|
|
99
|
+
return parseHandoffSessionMetadata(entry.data);
|
|
96
100
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
CURRENT_SESSION_VERSION,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type ExtensionCommandContext,
|
|
8
|
+
type SessionHeader,
|
|
9
|
+
} from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { HANDOFF_BOOTSTRAP_ENV } from "./metadata.js";
|
|
11
|
+
|
|
12
|
+
const GHOSTTY_MACOS_ONLY_MESSAGE = "Split handoff currently supports Ghostty on macOS only.";
|
|
13
|
+
const GHOSTTY_REQUIRED_MESSAGE = "Split handoff requires running inside Ghostty.";
|
|
14
|
+
const GHOSTTY_SPLIT_TIMEOUT_MS = 15_000;
|
|
15
|
+
const OSASCRIPT_PATH = "/usr/bin/osascript";
|
|
16
|
+
|
|
17
|
+
export type HandoffSplitDirection = "left" | "right" | "up" | "down";
|
|
18
|
+
|
|
19
|
+
export interface CreatedHandoffSession {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
sessionFile: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function validateSplitHandoffPrerequisites(
|
|
25
|
+
_pi: ExtensionAPI,
|
|
26
|
+
ctx: ExtensionCommandContext,
|
|
27
|
+
): Promise<string | undefined> {
|
|
28
|
+
if (!ctx.sessionManager.getSessionFile()) {
|
|
29
|
+
return "Split handoff requires a persisted current session.";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (process.platform !== "darwin") {
|
|
33
|
+
return GHOSTTY_MACOS_ONLY_MESSAGE;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (process.env.TERM_PROGRAM !== "ghostty") {
|
|
37
|
+
return GHOSTTY_REQUIRED_MESSAGE;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createHandoffSession(options: {
|
|
44
|
+
cwd: string;
|
|
45
|
+
sessionDir: string;
|
|
46
|
+
parentSessionFile: string;
|
|
47
|
+
}): CreatedHandoffSession {
|
|
48
|
+
const sessionId = randomUUID();
|
|
49
|
+
const timestamp = new Date().toISOString();
|
|
50
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
51
|
+
const sessionFile = join(options.sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
|
|
52
|
+
|
|
53
|
+
const header: SessionHeader = {
|
|
54
|
+
type: "session",
|
|
55
|
+
version: CURRENT_SESSION_VERSION,
|
|
56
|
+
id: sessionId,
|
|
57
|
+
timestamp,
|
|
58
|
+
cwd: options.cwd,
|
|
59
|
+
parentSession: options.parentSessionFile,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
writeFileSync(sessionFile, `${JSON.stringify(header)}\n`);
|
|
63
|
+
|
|
64
|
+
return { sessionId, sessionFile };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function launchSplitHandoffSession(
|
|
68
|
+
pi: ExtensionAPI,
|
|
69
|
+
options: {
|
|
70
|
+
cwd: string;
|
|
71
|
+
sessionDir: string;
|
|
72
|
+
direction: HandoffSplitDirection;
|
|
73
|
+
sessionId: string;
|
|
74
|
+
bootstrapValue: string;
|
|
75
|
+
},
|
|
76
|
+
): Promise<{ success: true } | { success: false; error: string }> {
|
|
77
|
+
const piCommand = buildPiLaunchCommand(
|
|
78
|
+
options.sessionDir,
|
|
79
|
+
options.sessionId,
|
|
80
|
+
options.bootstrapValue,
|
|
81
|
+
);
|
|
82
|
+
const escapedCwd = escapeAppleScriptString(options.cwd);
|
|
83
|
+
const escapedCommand = escapeAppleScriptString(piCommand);
|
|
84
|
+
const appleScript = [
|
|
85
|
+
'tell application "Ghostty"',
|
|
86
|
+
" set targetTerminal to focused terminal of selected tab of front window",
|
|
87
|
+
" set cfg to new surface configuration",
|
|
88
|
+
` set initial working directory of cfg to "${escapedCwd}"`,
|
|
89
|
+
` set command of cfg to "${escapedCommand}"`,
|
|
90
|
+
` set newTerminal to split targetTerminal direction ${options.direction} with configuration cfg`,
|
|
91
|
+
" focus targetTerminal",
|
|
92
|
+
"end tell",
|
|
93
|
+
].join("\n");
|
|
94
|
+
const result = await pi.exec(OSASCRIPT_PATH, ["-e", appleScript], {
|
|
95
|
+
cwd: options.cwd,
|
|
96
|
+
timeout: GHOSTTY_SPLIT_TIMEOUT_MS,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (result.code === 0) {
|
|
100
|
+
return { success: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const details = `${result.stderr || result.stdout}`.trim() || `exit code ${result.code}`;
|
|
104
|
+
return {
|
|
105
|
+
success: false,
|
|
106
|
+
error:
|
|
107
|
+
`Failed to launch Ghostty split: ${details}. ` +
|
|
108
|
+
"Split handoff currently supports Ghostty on macOS only.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function buildPiResumeCommand(
|
|
113
|
+
sessionDir: string,
|
|
114
|
+
sessionId: string,
|
|
115
|
+
bootstrapValue: string,
|
|
116
|
+
): string {
|
|
117
|
+
return [
|
|
118
|
+
`${HANDOFF_BOOTSTRAP_ENV}=${shellQuote(bootstrapValue)}`,
|
|
119
|
+
"pi",
|
|
120
|
+
"--session-dir",
|
|
121
|
+
shellQuote(sessionDir),
|
|
122
|
+
"--session",
|
|
123
|
+
shellQuote(sessionId),
|
|
124
|
+
].join(" ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function buildPiLaunchCommand(
|
|
128
|
+
sessionDir: string,
|
|
129
|
+
sessionId: string,
|
|
130
|
+
bootstrapValue: string,
|
|
131
|
+
): string {
|
|
132
|
+
const payload = `${buildPiResumeCommand(sessionDir, sessionId, bootstrapValue)}; exec /bin/zsh -il`;
|
|
133
|
+
return `/bin/zsh -ilc ${shellQuote(payload)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function escapeAppleScriptString(value: string): string {
|
|
137
|
+
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function shellQuote(value: string): string {
|
|
141
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
142
|
+
}
|
|
@@ -1,23 +1,32 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionCommandContext,
|
|
4
|
-
SessionManager,
|
|
5
|
-
} from "@mariozechner/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
6
2
|
import { buildSessionContext } from "@mariozechner/pi-coding-agent";
|
|
7
3
|
import { Key, matchesKey } from "@mariozechner/pi-tui";
|
|
8
4
|
import { generateHandoffDraft, type HandoffDraftResult } from "./session-handoff/extract.js";
|
|
9
5
|
import {
|
|
6
|
+
createHandoffBootstrap,
|
|
10
7
|
createHandoffSessionMetadata,
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
encodeHandoffBootstrap,
|
|
9
|
+
getHandoffMetadataFromEntries,
|
|
10
|
+
HANDOFF_BOOTSTRAP_ENV,
|
|
13
11
|
HANDOFF_METADATA_CUSTOM_TYPE,
|
|
14
|
-
|
|
12
|
+
HANDOFF_STALE_SESSION_MESSAGE,
|
|
13
|
+
hasUserMessages,
|
|
14
|
+
parseHandoffBootstrap,
|
|
15
15
|
} from "./session-handoff/metadata.js";
|
|
16
16
|
import { openSessionReferencePicker } from "./session-handoff/picker.js";
|
|
17
17
|
import { SESSION_TOKEN_PREFIX } from "./session-handoff/query.js";
|
|
18
18
|
import { renderStrongModal, reviewHandoffDraft } from "./session-handoff/review.js";
|
|
19
|
+
import {
|
|
20
|
+
buildPiResumeCommand,
|
|
21
|
+
createHandoffSession,
|
|
22
|
+
type HandoffSplitDirection,
|
|
23
|
+
launchSplitHandoffSession,
|
|
24
|
+
validateSplitHandoffPrerequisites,
|
|
25
|
+
} from "./session-handoff/spawn.js";
|
|
19
26
|
import { loadSettings } from "./shared/settings.js";
|
|
20
27
|
|
|
28
|
+
const HANDOFF_USAGE = "Usage: /handoff [--left|--right|--up|--down] <goal for new thread>";
|
|
29
|
+
|
|
21
30
|
export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
22
31
|
const settings = loadSettings();
|
|
23
32
|
|
|
@@ -34,9 +43,9 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
|
34
43
|
return;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
ctx.ui.notify(
|
|
46
|
+
const parsedArgs = parseHandoffCommandArgs(args);
|
|
47
|
+
if (parsedArgs.kind === "error") {
|
|
48
|
+
ctx.ui.notify(parsedArgs.message, "error");
|
|
40
49
|
return;
|
|
41
50
|
}
|
|
42
51
|
|
|
@@ -49,12 +58,20 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
|
49
58
|
return;
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
if (parsedArgs.splitDirection) {
|
|
62
|
+
const preflightError = await validateSplitHandoffPrerequisites(pi, ctx);
|
|
63
|
+
if (preflightError) {
|
|
64
|
+
ctx.ui.notify(preflightError, "error");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
let generatedDraft: HandoffDraftResult | undefined;
|
|
53
70
|
try {
|
|
54
71
|
generatedDraft = await runWithLoader(
|
|
55
72
|
ctx,
|
|
56
73
|
"Generating handoff draft...",
|
|
57
|
-
async (signal: AbortSignal) => generateHandoffDraft(ctx, goal, signal),
|
|
74
|
+
async (signal: AbortSignal) => generateHandoffDraft(ctx, parsedArgs.goal, signal),
|
|
58
75
|
);
|
|
59
76
|
} catch (error) {
|
|
60
77
|
ctx.ui.notify(formatHandoffError(error), "error");
|
|
@@ -72,22 +89,59 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
|
72
89
|
return;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
|
-
const
|
|
92
|
+
const parentSessionFile = ctx.sessionManager.getSessionFile();
|
|
93
|
+
if (!parentSessionFile) {
|
|
94
|
+
ctx.ui.notify("Handoff requires a persisted current session.", "error");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
76
97
|
|
|
77
98
|
const handoffMetadata = createHandoffSessionMetadata(
|
|
78
|
-
goal,
|
|
99
|
+
parsedArgs.goal,
|
|
79
100
|
generatedDraft.context.nextTask,
|
|
80
101
|
approvedDraft,
|
|
81
102
|
);
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
103
|
+
const createdSession = createHandoffSession({
|
|
104
|
+
cwd: ctx.cwd,
|
|
105
|
+
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
106
|
+
parentSessionFile,
|
|
107
|
+
});
|
|
108
|
+
const bootstrapValue = encodeHandoffBootstrap(
|
|
109
|
+
createHandoffBootstrap(createdSession.sessionId, handoffMetadata),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (parsedArgs.splitDirection) {
|
|
113
|
+
const launchResult = await launchSplitHandoffSession(pi, {
|
|
114
|
+
cwd: ctx.cwd,
|
|
115
|
+
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
116
|
+
direction: parsedArgs.splitDirection,
|
|
117
|
+
sessionId: createdSession.sessionId,
|
|
118
|
+
bootstrapValue,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!launchResult.success) {
|
|
122
|
+
ctx.ui.notify(
|
|
123
|
+
`${launchResult.error} Created handoff session ${createdSession.sessionId}; start it manually with: ${buildPiResumeCommand(ctx.sessionManager.getSessionDir(), createdSession.sessionId, bootstrapValue)}`,
|
|
124
|
+
"error",
|
|
125
|
+
);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
ctx.ui.notify(`Handoff started in a new pane (${parsedArgs.splitDirection}).`, "info");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
88
132
|
|
|
89
|
-
|
|
90
|
-
|
|
133
|
+
const previousBootstrapValue = process.env[HANDOFF_BOOTSTRAP_ENV];
|
|
134
|
+
process.env[HANDOFF_BOOTSTRAP_ENV] = bootstrapValue;
|
|
135
|
+
|
|
136
|
+
let switchResult: { cancelled: boolean };
|
|
137
|
+
try {
|
|
138
|
+
switchResult = await ctx.switchSession(createdSession.sessionFile);
|
|
139
|
+
} finally {
|
|
140
|
+
restoreProcessEnv(HANDOFF_BOOTSTRAP_ENV, previousBootstrapValue);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (switchResult.cancelled) {
|
|
144
|
+
ctx.ui.notify("Session switch cancelled", "info");
|
|
91
145
|
return;
|
|
92
146
|
}
|
|
93
147
|
|
|
@@ -116,24 +170,48 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
|
116
170
|
});
|
|
117
171
|
|
|
118
172
|
pi.on("session_start", async (_event, ctx) => {
|
|
119
|
-
const
|
|
120
|
-
|
|
173
|
+
const encodedBootstrap = process.env[HANDOFF_BOOTSTRAP_ENV];
|
|
174
|
+
if (!encodedBootstrap) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const bootstrap = parseHandoffBootstrap(encodedBootstrap);
|
|
179
|
+
if (!bootstrap) {
|
|
180
|
+
delete process.env[HANDOFF_BOOTSTRAP_ENV];
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
121
183
|
|
|
122
|
-
if (
|
|
184
|
+
if (bootstrap.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
123
185
|
return;
|
|
124
186
|
}
|
|
125
187
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
188
|
+
try {
|
|
189
|
+
const entries = ctx.sessionManager.getEntries();
|
|
190
|
+
if (hasUserMessages(entries)) {
|
|
191
|
+
if (ctx.hasUI) {
|
|
192
|
+
ctx.ui.notify(HANDOFF_STALE_SESSION_MESSAGE, "error");
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!getHandoffMetadataFromEntries(entries)) {
|
|
198
|
+
pi.appendEntry(
|
|
199
|
+
HANDOFF_METADATA_CUSTOM_TYPE,
|
|
200
|
+
createHandoffSessionMetadata(bootstrap.goal, bootstrap.nextTask, bootstrap.initialPrompt),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
pi.sendUserMessage(bootstrap.initialPrompt);
|
|
205
|
+
} finally {
|
|
206
|
+
delete process.env[HANDOFF_BOOTSTRAP_ENV];
|
|
207
|
+
}
|
|
131
208
|
});
|
|
132
209
|
|
|
133
|
-
pi.on("before_agent_start", async () => {
|
|
210
|
+
pi.on("before_agent_start", async (event) => {
|
|
134
211
|
return {
|
|
135
212
|
systemPrompt:
|
|
136
|
-
|
|
213
|
+
event.systemPrompt +
|
|
214
|
+
"\n\nWhen 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
215
|
};
|
|
138
216
|
});
|
|
139
217
|
}
|
|
@@ -201,3 +279,65 @@ function formatHandoffError(error: unknown): string {
|
|
|
201
279
|
|
|
202
280
|
return "Handoff generation failed.";
|
|
203
281
|
}
|
|
282
|
+
|
|
283
|
+
function parseHandoffCommandArgs(
|
|
284
|
+
args: string,
|
|
285
|
+
):
|
|
286
|
+
| { kind: "ok"; goal: string; splitDirection?: HandoffSplitDirection | undefined }
|
|
287
|
+
| { kind: "error"; message: string } {
|
|
288
|
+
const tokens = args
|
|
289
|
+
.trim()
|
|
290
|
+
.split(/\s+/)
|
|
291
|
+
.filter((token) => token.length > 0);
|
|
292
|
+
|
|
293
|
+
if (tokens.length === 0) {
|
|
294
|
+
return { kind: "error", message: HANDOFF_USAGE };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const directionFlags = new Map<string, HandoffSplitDirection>([
|
|
298
|
+
["--left", "left"],
|
|
299
|
+
["--right", "right"],
|
|
300
|
+
["--up", "up"],
|
|
301
|
+
["--down", "down"],
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
let splitDirection: HandoffSplitDirection | undefined;
|
|
305
|
+
const goalTokens: string[] = [];
|
|
306
|
+
|
|
307
|
+
for (const token of tokens) {
|
|
308
|
+
const direction = directionFlags.get(token);
|
|
309
|
+
if (!direction) {
|
|
310
|
+
goalTokens.push(token);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (splitDirection) {
|
|
315
|
+
return {
|
|
316
|
+
kind: "error",
|
|
317
|
+
message: "Use only one split flag: --left, --right, --up, or --down.",
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
splitDirection = direction;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const goal = goalTokens.join(" ").trim();
|
|
325
|
+
if (!goal) {
|
|
326
|
+
return { kind: "error", message: HANDOFF_USAGE };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
kind: "ok",
|
|
331
|
+
goal,
|
|
332
|
+
splitDirection,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function restoreProcessEnv(key: string, previousValue: string | undefined): void {
|
|
337
|
+
if (previousValue === undefined) {
|
|
338
|
+
delete process.env[key];
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
process.env[key] = previousValue;
|
|
343
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { HANDOFF_BOOTSTRAP_ENV, parseHandoffBootstrap } from "./session-handoff/metadata.js";
|
|
2
3
|
import { createSessionHookController } from "./session-search/hooks.js";
|
|
3
4
|
import { loadSettings } from "./shared/settings.js";
|
|
4
5
|
|
|
@@ -18,7 +19,12 @@ export default function sessionHooksExtension(pi: ExtensionAPI): void {
|
|
|
18
19
|
switch (reason) {
|
|
19
20
|
case "new":
|
|
20
21
|
case "resume":
|
|
21
|
-
await controller.handleSessionSwitch(
|
|
22
|
+
await controller.handleSessionSwitch(
|
|
23
|
+
previousSessionFile,
|
|
24
|
+
sessionFile,
|
|
25
|
+
ctx.cwd,
|
|
26
|
+
getSessionStartOrigin(ctx),
|
|
27
|
+
);
|
|
22
28
|
break;
|
|
23
29
|
case "fork":
|
|
24
30
|
await controller.handleSessionFork(previousSessionFile, sessionFile, ctx.cwd);
|
|
@@ -53,3 +59,17 @@ export default function sessionHooksExtension(pi: ExtensionAPI): void {
|
|
|
53
59
|
await controller.handleSessionShutdown(ctx.sessionManager.getSessionFile(), ctx.cwd);
|
|
54
60
|
});
|
|
55
61
|
}
|
|
62
|
+
|
|
63
|
+
function getSessionStartOrigin(ctx: ExtensionContext): "handoff" | undefined {
|
|
64
|
+
const encodedBootstrap = process.env[HANDOFF_BOOTSTRAP_ENV];
|
|
65
|
+
if (!encodedBootstrap) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bootstrap = parseHandoffBootstrap(encodedBootstrap);
|
|
70
|
+
if (!bootstrap) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return bootstrap.sessionId === ctx.sessionManager.getSessionId() ? "handoff" : undefined;
|
|
75
|
+
}
|
|
@@ -157,7 +157,7 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
|
|
|
157
157
|
}
|
|
158
158
|
case "custom": {
|
|
159
159
|
const nextHandoffMetadata = extractHandoffMetadata(entry, entryTs);
|
|
160
|
-
if (nextHandoffMetadata) {
|
|
160
|
+
if (nextHandoffMetadata && !durableHandoffMetadata) {
|
|
161
161
|
durableHandoffMetadata = nextHandoffMetadata;
|
|
162
162
|
}
|
|
163
163
|
continue;
|
|
@@ -282,7 +282,7 @@ function findDurableHandoffMetadata(
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
const parsed = extractHandoffMetadata(entry, getEntryTimestamp(entry, fallbackTs));
|
|
285
|
-
if (parsed) {
|
|
285
|
+
if (parsed && !durableHandoffMetadata) {
|
|
286
286
|
durableHandoffMetadata = parsed;
|
|
287
287
|
}
|
|
288
288
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi session search, ask, handoff, auto-titling, and indexing tools",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,27 +38,28 @@
|
|
|
38
38
|
"prepare": "husky"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@biomejs/biome": "^2.4.
|
|
42
|
-
"@mariozechner/pi-ai": "^0.
|
|
43
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
44
|
-
"@mariozechner/pi-tui": "^0.
|
|
41
|
+
"@biomejs/biome": "^2.4.12",
|
|
42
|
+
"@mariozechner/pi-ai": "^0.67.68",
|
|
43
|
+
"@mariozechner/pi-coding-agent": "^0.67.68",
|
|
44
|
+
"@mariozechner/pi-tui": "^0.67.68",
|
|
45
|
+
"@sinclair/typebox": "^0.34.49",
|
|
45
46
|
"@types/better-sqlite3": "^7.6.13",
|
|
46
47
|
"@types/node": "^25.6.0",
|
|
47
48
|
"husky": "^9.1.7",
|
|
48
49
|
"lint-staged": "^16.4.0",
|
|
49
|
-
"typescript": "^6.0.
|
|
50
|
+
"typescript": "^6.0.3",
|
|
50
51
|
"vitest": "^4.1.4"
|
|
51
52
|
},
|
|
52
|
-
"dependencies": {
|
|
53
|
-
"better-sqlite3": "^12.8.0"
|
|
54
|
-
},
|
|
55
53
|
"peerDependencies": {
|
|
56
|
-
"@mariozechner/pi-ai": ">=0.
|
|
57
|
-
"@mariozechner/pi-coding-agent": ">=0.
|
|
58
|
-
"@mariozechner/pi-tui": ">=0.
|
|
59
|
-
"@sinclair/typebox": ">=0.34.
|
|
54
|
+
"@mariozechner/pi-ai": ">=0.67.68",
|
|
55
|
+
"@mariozechner/pi-coding-agent": ">=0.67.68",
|
|
56
|
+
"@mariozechner/pi-tui": ">=0.67.68",
|
|
57
|
+
"@sinclair/typebox": ">=0.34.41"
|
|
60
58
|
},
|
|
61
59
|
"lint-staged": {
|
|
62
60
|
"*.{js,cjs,mjs,jsx,ts,tsx,json,jsonc}": "biome check --write --no-errors-on-unmatched"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"better-sqlite3": "^12.9.0"
|
|
63
64
|
}
|
|
64
65
|
}
|