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,225 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
6
+ import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
7
+ import { isTuiMode } from "../shared/pi-mode.ts";
8
+ import type { SessionSettings } from "../shared/settings.ts";
9
+ import { THINKING_LEVELS } from "../shared/thinking-levels.ts";
10
+ import { safeParseTypeBoxValue } from "../shared/typebox.ts";
11
+ import { type HandoffBoardServices, openHandoffBoard } from "./board.ts";
12
+ import { consumePendingHandoffBootstrap } from "./bootstrap.ts";
13
+ import { HANDOFF_KICKOFF_CUSTOM_TYPE, renderHandoffKickoffMessage } from "./kickoff.ts";
14
+ import type { ClipboardStatus } from "./launch/backend.ts";
15
+ import { resolveSplitLaunchBackend } from "./launch/resolution.ts";
16
+ import { createHandoffLaunchTargets } from "./launch-options.ts";
17
+ import type { HandoffLaunchTarget } from "./launch-target.ts";
18
+ import { openSessionReferencePicker } from "./picker.ts";
19
+ import { SESSION_TOKEN_PREFIX } from "./query.ts";
20
+ import {
21
+ executeSessionHandoffTool,
22
+ type HandoffToolParams,
23
+ MAX_HANDOFF_TITLE_LENGTH,
24
+ } from "./tool.ts";
25
+ import { HANDOFF_TOOL_DETAILS_SCHEMA } from "./tool-contract.ts";
26
+ import { HandoffToolComponent } from "./tool-renderer.ts";
27
+ import { buildHandoffLaunchSchema, buildHandoffPromptGuidelines } from "./tool-schema.ts";
28
+ import { buildHandoffToolView } from "./tool-view-model.ts";
29
+ import { formatHandoffError } from "./ui.ts";
30
+
31
+ interface HandoffToolRendererState {
32
+ callComponent?: HandoffToolComponent | undefined;
33
+ }
34
+
35
+ export function installHandoff(
36
+ pi: ExtensionAPI,
37
+ deps: {
38
+ settings: SessionSettings;
39
+ index: IndexHandle;
40
+ getModelRuntime: ModelRuntimeProvider;
41
+ getLaunchTargets?: (() => readonly HandoffLaunchTarget[]) | undefined;
42
+ board: HandoffBoardServices;
43
+ },
44
+ ): SessionLifecycle {
45
+ const { settings } = deps;
46
+ const indexPath = deps.index.path;
47
+ let identifiedGhosttyTerminalId: string | undefined;
48
+ const clipboardStatusBySessionId = new Map<string, ClipboardStatus>();
49
+ const splitBackend = resolveSplitLaunchBackend(pi, {
50
+ getTerminalId: () => identifiedGhosttyTerminalId,
51
+ });
52
+
53
+ pi.registerMessageRenderer(HANDOFF_KICKOFF_CUSTOM_TYPE, renderHandoffKickoffMessage);
54
+ function registerHandoffTool(
55
+ models: readonly Model<Api>[],
56
+ launchTargets: readonly HandoffLaunchTarget[],
57
+ ): void {
58
+ pi.registerTool({
59
+ name: "session_handoff",
60
+ label: "Session Handoff",
61
+ description: "Start a new Pi session with a self-contained task.",
62
+ promptSnippet:
63
+ "Delegate bounded work to a background subagent or hand off context to another Pi session",
64
+ promptGuidelines: buildHandoffPromptGuidelines(launchTargets, models),
65
+ parameters: Type.Object({
66
+ goal: Type.String({
67
+ description:
68
+ "Self-contained briefing that explains the objective, relevant context, constraints, and expected result.",
69
+ }),
70
+ title: Type.String({
71
+ maxLength: MAX_HANDOFF_TITLE_LENGTH,
72
+ description:
73
+ "Short display title for the child session, 64 characters or less. Summarize the mission; do not repeat the full goal.",
74
+ }),
75
+ launch: buildHandoffLaunchSchema(launchTargets),
76
+ cwd: Type.Optional(
77
+ Type.String({
78
+ description: "Optional target working directory (defaults to current).",
79
+ }),
80
+ ),
81
+ requestResponse: Type.Optional(
82
+ Type.Boolean({
83
+ description:
84
+ "Whether the child session should report completion/results of its task back to this session. Defaults to true for subagent launches and false otherwise.",
85
+ }),
86
+ ),
87
+ provider: Type.Optional(
88
+ Type.String({
89
+ description: "Model provider for the child session.",
90
+ }),
91
+ ),
92
+ model: Type.Optional(
93
+ Type.String({
94
+ description: "Model id for the child session.",
95
+ }),
96
+ ),
97
+ thinkingLevel: Type.Optional(
98
+ Type.Union(
99
+ THINKING_LEVELS.map((level) => Type.Literal(level)),
100
+ {
101
+ description:
102
+ "Thinking level for the child session. For directional or deferred, override only when the user requests it.",
103
+ },
104
+ ),
105
+ ),
106
+ }),
107
+ renderCall(args, theme, context) {
108
+ const state = context.state as HandoffToolRendererState;
109
+ const component = state.callComponent ?? new HandoffToolComponent(theme);
110
+ state.callComponent = component;
111
+ component.update(buildHandoffToolView(args), context.expanded);
112
+ return component;
113
+ },
114
+ renderResult(result, options, theme, context) {
115
+ const text = getFirstText(result);
116
+ if (context.isError) {
117
+ return new Text(theme.fg("error", text), 0, 0);
118
+ }
119
+
120
+ const details = safeParseTypeBoxValue(HANDOFF_TOOL_DETAILS_SCHEMA, result.details);
121
+ const state = context.state as HandoffToolRendererState;
122
+ if (!details || !state.callComponent) {
123
+ return new Text(text, 0, 0);
124
+ }
125
+
126
+ state.callComponent.update(
127
+ buildHandoffToolView(context.args, details),
128
+ options.expanded,
129
+ clipboardStatusBySessionId.get(details.sessionId),
130
+ );
131
+ return new Text("", 0, 0);
132
+ },
133
+ async execute(_toolCallId, params: HandoffToolParams, _signal, _onUpdate, ctx) {
134
+ const modelRuntime = await deps.getModelRuntime(ctx.modelRegistry);
135
+ return executeSessionHandoffTool(
136
+ pi,
137
+ params,
138
+ ctx,
139
+ modelRuntime,
140
+ launchTargets,
141
+ (sessionId, status) => clipboardStatusBySessionId.set(sessionId, status),
142
+ );
143
+ },
144
+ });
145
+ }
146
+
147
+ pi.registerCommand("handoff", {
148
+ description: "Open the handoff board",
149
+ handler: async (args, ctx): Promise<void> => {
150
+ if (!isTuiMode(ctx)) {
151
+ ctx.ui.notify("handoff requires interactive mode", "error");
152
+ return;
153
+ }
154
+ if (args.trim()) {
155
+ ctx.ui.notify("/handoff does not accept arguments.", "error");
156
+ return;
157
+ }
158
+
159
+ try {
160
+ await openHandoffBoard(ctx, deps.board);
161
+ } catch (error) {
162
+ ctx.ui.notify(formatHandoffError(error), "error");
163
+ }
164
+ },
165
+ });
166
+
167
+ pi.registerShortcut(settings.handoff.pickerShortcut, {
168
+ description: "Open the session reference picker",
169
+ handler: async (ctx) => {
170
+ if (!isTuiMode(ctx)) {
171
+ return;
172
+ }
173
+
174
+ const result = await openSessionReferencePicker(
175
+ ctx,
176
+ indexPath,
177
+ settings.handoff.pickerShortcut,
178
+ );
179
+ if (result.kind !== "insert-session-token") {
180
+ return;
181
+ }
182
+
183
+ ctx.ui.pasteToEditor(`${SESSION_TOKEN_PREFIX}${result.sessionId}`);
184
+ },
185
+ });
186
+
187
+ pi.on("before_agent_start", async (event, ctx) => {
188
+ if (splitBackend?.identifyTerminalId && ctx) {
189
+ identifiedGhosttyTerminalId =
190
+ (await splitBackend.identifyTerminalId(ctx.cwd)) ?? identifiedGhosttyTerminalId;
191
+ }
192
+
193
+ return {
194
+ systemPrompt:
195
+ event.systemPrompt +
196
+ "\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.",
197
+ };
198
+ });
199
+
200
+ return {
201
+ async onSessionStart(_event, ctx) {
202
+ registerHandoffTool(
203
+ ctx.modelRegistry.getAvailable(),
204
+ createHandoffLaunchTargets({
205
+ pi,
206
+ splitBackend,
207
+ copyDeferredToClipboard: settings.handoff.deferred.copyToClipboard,
208
+ additionalTargets: deps.getLaunchTargets?.() ?? [],
209
+ }),
210
+ );
211
+
212
+ await consumePendingHandoffBootstrap(
213
+ pi,
214
+ ctx,
215
+ deps.getModelRuntime,
216
+ pi.getThinkingLevel(),
217
+ settings.handoff.persistRuns,
218
+ );
219
+ },
220
+ };
221
+ }
222
+
223
+ function getFirstText(result: { content: Array<{ type: string; text?: string }> }): string {
224
+ return result.content.find((item) => item.type === "text")?.text ?? "";
225
+ }
@@ -0,0 +1,92 @@
1
+ import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
2
+ import { Box, type Component, Text } from "@earendil-works/pi-tui";
3
+ import { type Static, Type } from "typebox";
4
+ import type { RenderTheme } from "../shared/rendering/theme.ts";
5
+ import { safeParseTypeBoxValue } from "../shared/typebox.ts";
6
+
7
+ export const HANDOFF_KICKOFF_CUSTOM_TYPE = "pi-sessions.handoff-kickoff";
8
+
9
+ export const HANDOFF_KICKOFF_SOURCE_SCHEMA = Type.Object({
10
+ sessionId: Type.String(),
11
+ sessionName: Type.Optional(Type.String()),
12
+ });
13
+
14
+ export const HANDOFF_KICKOFF_DETAILS_SCHEMA = Type.Object({
15
+ source: HANDOFF_KICKOFF_SOURCE_SCHEMA,
16
+ title: Type.String(),
17
+ bootstrapEntryId: Type.Optional(Type.String()),
18
+ });
19
+
20
+ export type HandoffKickoffSource = Static<typeof HANDOFF_KICKOFF_SOURCE_SCHEMA>;
21
+ export type HandoffKickoffDetails = Static<typeof HANDOFF_KICKOFF_DETAILS_SCHEMA>;
22
+
23
+ export interface HandoffKickoffMessage {
24
+ customType: typeof HANDOFF_KICKOFF_CUSTOM_TYPE;
25
+ content: string;
26
+ display: true;
27
+ details: HandoffKickoffDetails;
28
+ }
29
+
30
+ // The kickoff is the model-visible delivery of the approved handoff prompt:
31
+ // content is exactly the prompt (provider context unchanged), details carry
32
+ // renderer metadata only.
33
+ export function buildHandoffKickoffMessage(options: {
34
+ prompt: string;
35
+ title: string;
36
+ source: HandoffKickoffSource;
37
+ bootstrapEntryId?: string | undefined;
38
+ }): HandoffKickoffMessage {
39
+ return {
40
+ customType: HANDOFF_KICKOFF_CUSTOM_TYPE,
41
+ content: options.prompt,
42
+ display: true,
43
+ details: {
44
+ source: buildHandoffKickoffSource(options.source),
45
+ title: options.title,
46
+ ...(options.bootstrapEntryId ? { bootstrapEntryId: options.bootstrapEntryId } : {}),
47
+ },
48
+ };
49
+ }
50
+
51
+ export function buildHandoffKickoffSource(source: HandoffKickoffSource): HandoffKickoffSource {
52
+ return {
53
+ sessionId: source.sessionId,
54
+ ...(source.sessionName?.trim() ? { sessionName: source.sessionName.trim() } : {}),
55
+ };
56
+ }
57
+
58
+ export const renderHandoffKickoffMessage: MessageRenderer = (message, _options, theme) => {
59
+ const details = safeParseTypeBoxValue(HANDOFF_KICKOFF_DETAILS_SCHEMA, message.details);
60
+ if (!details) {
61
+ return undefined;
62
+ }
63
+
64
+ const prompt = typeof message.content === "string" ? message.content : "";
65
+ return createHandoffKickoffComponent(details, prompt, theme);
66
+ };
67
+
68
+ export function createHandoffKickoffComponent(
69
+ details: HandoffKickoffDetails,
70
+ prompt: string,
71
+ theme: RenderTheme,
72
+ ): Component {
73
+ const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text));
74
+ box.addChild(new Text(renderHandoffKickoffView(details, prompt, theme), 0, 0));
75
+ return box;
76
+ }
77
+
78
+ export function renderHandoffKickoffView(
79
+ details: HandoffKickoffDetails,
80
+ prompt: string,
81
+ theme: RenderTheme,
82
+ ): string {
83
+ const label = theme.fg("toolTitle", theme.bold("handoff"));
84
+ const header = `${label} ${theme.bold(details.title)}`;
85
+ const from = theme.fg("muted", `from ${formatKickoffSource(details.source)}`);
86
+ return [`${header} ${from}`, "", theme.fg("toolOutput", prompt)].join("\n");
87
+ }
88
+
89
+ function formatKickoffSource(source: HandoffKickoffDetails["source"]): string {
90
+ const title = source.sessionName?.trim();
91
+ return title ? `${title} (${source.sessionId})` : source.sessionId;
92
+ }
@@ -0,0 +1,16 @@
1
+ import type { HandoffLaunchTargetOutcome } from "../launch-target.ts";
2
+
3
+ export type HandoffSplitDirection = "left" | "right" | "up" | "down";
4
+
5
+ export interface LaunchInput {
6
+ cwd: string;
7
+ title: string;
8
+ resumeCommand: string;
9
+ }
10
+
11
+ export type ClipboardStatus = "copied" | "failed";
12
+
13
+ export interface LaunchBackend {
14
+ name: string;
15
+ launch(input: LaunchInput): Promise<HandoffLaunchTargetOutcome>;
16
+ }
@@ -0,0 +1,20 @@
1
+ import { copyToClipboard } from "@earendil-works/pi-coding-agent";
2
+ import type { LaunchBackend, LaunchInput } from "./backend.ts";
3
+
4
+ export function createDeferredLaunchBackend(options: { copyToClipboard: boolean }): LaunchBackend {
5
+ return {
6
+ name: "deferred",
7
+ async launch(input: LaunchInput) {
8
+ if (!options.copyToClipboard) {
9
+ return { success: true, backend: "deferred" as const };
10
+ }
11
+
12
+ try {
13
+ await copyToClipboard(input.resumeCommand);
14
+ return { success: true, backend: "deferred" as const, clipboardStatus: "copied" as const };
15
+ } catch {
16
+ return { success: true, backend: "deferred" as const, clipboardStatus: "failed" as const };
17
+ }
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,99 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { HandoffLaunchTargetOutcome } from "../launch-target.ts";
3
+ import type { HandoffSplitDirection, LaunchBackend, LaunchInput } from "./backend.ts";
4
+ import { escapeAppleScriptString, shellQuote } from "./shell.ts";
5
+
6
+ const GHOSTTY_MACOS_ONLY_MESSAGE = "Split handoff currently supports Ghostty on macOS only.";
7
+ const GHOSTTY_SPLIT_TIMEOUT_MS = 15_000;
8
+ const OSASCRIPT_PATH = "/usr/bin/osascript";
9
+
10
+ export interface GhosttyLaunchConfig {
11
+ direction: HandoffSplitDirection;
12
+ terminalId?: string | undefined;
13
+ }
14
+
15
+ export function isGhosttyHandoffAvailable(
16
+ env: NodeJS.ProcessEnv = process.env,
17
+ platform: NodeJS.Platform = process.platform,
18
+ ): boolean {
19
+ return platform === "darwin" && env.TERM_PROGRAM === "ghostty";
20
+ }
21
+
22
+ export async function getFocusedGhosttyTerminalId(
23
+ pi: ExtensionAPI,
24
+ cwd: string,
25
+ ): Promise<string | undefined> {
26
+ const appleScript = [
27
+ 'tell application "Ghostty"',
28
+ " get id of focused terminal of selected tab of front window",
29
+ "end tell",
30
+ ].join("\n");
31
+ const result = await pi.exec(OSASCRIPT_PATH, ["-e", appleScript], {
32
+ cwd,
33
+ timeout: GHOSTTY_SPLIT_TIMEOUT_MS,
34
+ });
35
+
36
+ if (result.code !== 0) {
37
+ return undefined;
38
+ }
39
+
40
+ return result.stdout.trim() || undefined;
41
+ }
42
+
43
+ export function createGhosttyLaunchBackend(
44
+ pi: ExtensionAPI,
45
+ config: GhosttyLaunchConfig,
46
+ ): LaunchBackend {
47
+ return {
48
+ name: "Ghostty",
49
+ async launch(input: LaunchInput): Promise<HandoffLaunchTargetOutcome> {
50
+ const first = await runGhosttySplit(pi, config, config.terminalId, input);
51
+ if (first.success || !config.terminalId) {
52
+ return first;
53
+ }
54
+
55
+ return runGhosttySplit(pi, config, undefined, input);
56
+ },
57
+ };
58
+ }
59
+
60
+ async function runGhosttySplit(
61
+ pi: ExtensionAPI,
62
+ config: GhosttyLaunchConfig,
63
+ terminalId: string | undefined,
64
+ input: LaunchInput,
65
+ ): Promise<HandoffLaunchTargetOutcome> {
66
+ const escapedCwd = escapeAppleScriptString(input.cwd);
67
+ const escapedCommand = escapeAppleScriptString(buildGhosttyShellCommand(input.resumeCommand));
68
+ const targetTerminalLine = terminalId
69
+ ? ` set targetTerminal to item 1 of (every terminal whose id is "${escapeAppleScriptString(terminalId)}")`
70
+ : " set targetTerminal to focused terminal of selected tab of front window";
71
+ const appleScript = [
72
+ 'tell application "Ghostty"',
73
+ targetTerminalLine,
74
+ " set cfg to new surface configuration",
75
+ ` set initial working directory of cfg to "${escapedCwd}"`,
76
+ ` set command of cfg to "${escapedCommand}"`,
77
+ ` set newTerminal to split targetTerminal direction ${config.direction} with configuration cfg`,
78
+ "end tell",
79
+ ].join("\n");
80
+ const result = await pi.exec(OSASCRIPT_PATH, ["-e", appleScript], {
81
+ cwd: input.cwd,
82
+ timeout: GHOSTTY_SPLIT_TIMEOUT_MS,
83
+ });
84
+
85
+ if (result.code === 0) {
86
+ return { success: true, backend: "Ghostty" };
87
+ }
88
+
89
+ const details = `${result.stderr || result.stdout}`.trim() || `exit code ${result.code}`;
90
+ return {
91
+ success: false,
92
+ error: `Failed to launch Ghostty split: ${details}. ${GHOSTTY_MACOS_ONLY_MESSAGE}`,
93
+ };
94
+ }
95
+
96
+ function buildGhosttyShellCommand(resumeCommand: string): string {
97
+ const payload = `${resumeCommand}; exec /bin/zsh -il`;
98
+ return `/bin/zsh -ilc ${shellQuote(payload)}`;
99
+ }
@@ -0,0 +1,57 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { isInsideTmux } from "../../shared/tmux.ts";
3
+ import type { HandoffSplitDirection, LaunchBackend } from "./backend.ts";
4
+ import {
5
+ createGhosttyLaunchBackend,
6
+ getFocusedGhosttyTerminalId,
7
+ isGhosttyHandoffAvailable,
8
+ } from "./ghostty.ts";
9
+ import { createTmuxSplitLaunchBackend } from "./tmux.ts";
10
+
11
+ export interface SplitLaunchBackend {
12
+ name: string;
13
+ create(direction: HandoffSplitDirection): LaunchBackend;
14
+ identifyTerminalId?(cwd: string): Promise<string | undefined>;
15
+ }
16
+
17
+ export function resolveSplitLaunchBackend(
18
+ pi: ExtensionAPI,
19
+ options: {
20
+ getTerminalId: () => string | undefined;
21
+ env?: NodeJS.ProcessEnv | undefined;
22
+ },
23
+ ): SplitLaunchBackend | undefined {
24
+ const env = options.env ?? process.env;
25
+ if (isInsideTmux(env)) {
26
+ return {
27
+ name: "tmux",
28
+ create: (direction) => createTmuxSplitLaunchBackend(pi, direction),
29
+ };
30
+ }
31
+ if (!isGhosttyHandoffAvailable(env)) {
32
+ return undefined;
33
+ }
34
+ return {
35
+ name: "Ghostty",
36
+ identifyTerminalId: (cwd) => getFocusedGhosttyTerminalId(pi, cwd),
37
+ create(direction) {
38
+ return createGhosttyLaunchBackend(pi, {
39
+ direction,
40
+ terminalId: options.getTerminalId(),
41
+ });
42
+ },
43
+ };
44
+ }
45
+
46
+ export function validateSplitHandoffPrerequisites(
47
+ ctx: ExtensionContext,
48
+ backend: SplitLaunchBackend | undefined,
49
+ ): string | undefined {
50
+ if (!ctx.sessionManager.getSessionFile()) {
51
+ return "Split handoff requires a persisted current session.";
52
+ }
53
+ if (!backend) {
54
+ return "Split handoff requires running inside tmux or Ghostty on macOS.";
55
+ }
56
+ return undefined;
57
+ }
@@ -0,0 +1,7 @@
1
+ export function shellQuote(value: string): string {
2
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
3
+ }
4
+
5
+ export function escapeAppleScriptString(value: string): string {
6
+ return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
7
+ }
@@ -0,0 +1,31 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { HandoffSplitDirection, LaunchBackend, LaunchInput } from "./backend.ts";
3
+
4
+ const TMUX_SPLIT_TIMEOUT_MS = 15_000;
5
+
6
+ export function createTmuxSplitLaunchBackend(
7
+ pi: ExtensionAPI,
8
+ direction: HandoffSplitDirection,
9
+ ): LaunchBackend {
10
+ return {
11
+ name: "tmux",
12
+ async launch(input: LaunchInput) {
13
+ const result = await pi.exec("tmux", splitArgs(direction, input), {
14
+ cwd: input.cwd,
15
+ timeout: TMUX_SPLIT_TIMEOUT_MS,
16
+ });
17
+ if (result.code === 0) {
18
+ return { success: true, backend: "tmux" as const };
19
+ }
20
+
21
+ const details = `${result.stderr || result.stdout}`.trim() || `exit code ${result.code}`;
22
+ return { success: false, error: `Failed to launch tmux split: ${details}.` };
23
+ },
24
+ };
25
+ }
26
+
27
+ function splitArgs(direction: HandoffSplitDirection, input: LaunchInput): string[] {
28
+ const orientation = direction === "left" || direction === "right" ? "-h" : "-v";
29
+ const before = direction === "left" || direction === "up" ? ["-b"] : [];
30
+ return ["split-window", orientation, ...before, "-d", "-c", input.cwd, input.resumeCommand];
31
+ }
@@ -0,0 +1,61 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createDeferredLaunchBackend } from "./launch/deferred.ts";
3
+ import type { SplitLaunchBackend } from "./launch/resolution.ts";
4
+ import {
5
+ createBackendLaunchTarget,
6
+ DEFERRED_LAUNCH,
7
+ type HandoffLaunchTarget,
8
+ type HandoffLaunchValue,
9
+ LAUNCH_DIRECTIONS,
10
+ } from "./launch-target.ts";
11
+
12
+ export interface ResolvedHandoffLaunchTarget {
13
+ target: HandoffLaunchTarget;
14
+ degradedFrom?: (typeof LAUNCH_DIRECTIONS)[number] | undefined;
15
+ }
16
+
17
+ export function createHandoffLaunchTargets(options: {
18
+ pi: ExtensionAPI;
19
+ splitBackend: SplitLaunchBackend | undefined;
20
+ copyDeferredToClipboard: boolean;
21
+ additionalTargets: readonly HandoffLaunchTarget[];
22
+ }): HandoffLaunchTarget[] {
23
+ const splitBackend = options.splitBackend;
24
+ const splitTargets = splitBackend
25
+ ? LAUNCH_DIRECTIONS.map((direction, index) =>
26
+ createBackendLaunchTarget(
27
+ direction,
28
+ splitBackend.create(direction),
29
+ index === 0
30
+ ? `direction values open a ${splitBackend.name} split. If the user does not make the launch target clear, ask for clarification.`
31
+ : undefined,
32
+ ),
33
+ )
34
+ : [];
35
+ const deferred = createBackendLaunchTarget(
36
+ DEFERRED_LAUNCH,
37
+ createDeferredLaunchBackend({ copyToClipboard: options.copyDeferredToClipboard }),
38
+ "'deferred' creates the session and returns its resume command without opening anything.",
39
+ );
40
+ return [...splitTargets, deferred, ...options.additionalTargets];
41
+ }
42
+
43
+ export function resolveHandoffLaunchTarget(
44
+ requested: HandoffLaunchValue,
45
+ targets: readonly HandoffLaunchTarget[],
46
+ ): ResolvedHandoffLaunchTarget {
47
+ const exact = targets.find((target) => target.value === requested);
48
+ if (exact) {
49
+ return { target: exact };
50
+ }
51
+
52
+ if (LAUNCH_DIRECTIONS.some((direction) => direction === requested)) {
53
+ const deferred = targets.find((target) => target.value === DEFERRED_LAUNCH);
54
+ if (!deferred) {
55
+ throw new Error("Deferred handoff launch is unavailable.");
56
+ }
57
+ return { target: deferred, degradedFrom: requested as (typeof LAUNCH_DIRECTIONS)[number] };
58
+ }
59
+
60
+ throw new Error(`Handoff launch target is unavailable: ${requested}`);
61
+ }