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
@@ -1,741 +0,0 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { isAbsolute, resolve } from "node:path";
4
- import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
- import type {
6
- ExtensionAPI,
7
- ExtensionCommandContext,
8
- ExtensionContext,
9
- ExtensionUIContext,
10
- } from "@earendil-works/pi-coding-agent";
11
- import { buildSessionContext, SessionManager } from "@earendil-works/pi-coding-agent";
12
- import { Key, matchesKey, Text } from "@earendil-works/pi-tui";
13
- import { Type } from "typebox";
14
- import {
15
- generateHandoffDraft,
16
- generateHandoffDraftFromSessionManager,
17
- type HandoffDraftResult,
18
- } from "./session-handoff/extract.ts";
19
- import {
20
- type ChildGeneratedHandoffBootstrap,
21
- createChildGeneratedHandoffBootstrap,
22
- createHandoffBootstrap,
23
- createHandoffSessionMetadata,
24
- encodeHandoffBootstrap,
25
- getHandoffMetadataFromEntries,
26
- HANDOFF_BOOTSTRAP_ENV,
27
- HANDOFF_METADATA_CUSTOM_TYPE,
28
- HANDOFF_STALE_SESSION_MESSAGE,
29
- hasUserMessages,
30
- isChildGeneratedHandoffBootstrap,
31
- parseHandoffBootstrap,
32
- } from "./session-handoff/metadata.ts";
33
- import { openSessionReferencePicker } from "./session-handoff/picker.ts";
34
- import { SESSION_TOKEN_PREFIX } from "./session-handoff/query.ts";
35
- import {
36
- renderStrongModal,
37
- reviewHandoffDraft,
38
- reviewHandoffDraftForSend,
39
- } from "./session-handoff/review.ts";
40
- import {
41
- buildPiResumeCommand,
42
- createHandoffSession,
43
- getFocusedGhosttyTerminalId,
44
- type HandoffSplitDirection,
45
- isGhosttyHandoffAvailable,
46
- launchSplitHandoffSession,
47
- validateSplitHandoffPrerequisites,
48
- } from "./session-handoff/spawn.ts";
49
- import { isTuiMode } from "./shared/pi-mode.ts";
50
- import { loadSettings } from "./shared/settings.ts";
51
-
52
- const HANDOFF_USAGE = "Usage: /handoff [--left|--right|--up|--down] <goal for new thread>";
53
- const TOOL_HANDOFF_PROVISIONAL_TITLE = "Session handoff";
54
- const NO_IDENTIFIED_TERMINAL_MESSAGE =
55
- "No Ghostty source terminal identified. Run /handoff --identify from the intended source pane.";
56
-
57
- interface HandoffToolParams {
58
- goal: string;
59
- splitDirection: HandoffSplitDirection;
60
- cwd?: string | undefined;
61
- requestResponse?: boolean | undefined;
62
- }
63
-
64
- interface HandoffToolDetails {
65
- sessionId?: string | undefined;
66
- title?: string | undefined;
67
- splitDirection?: HandoffSplitDirection | undefined;
68
- cwd?: string | undefined;
69
- }
70
-
71
- interface HandoffPromptContext {
72
- ui: ExtensionUIContext;
73
- sendUserMessage(content: string): Promise<void>;
74
- }
75
-
76
- export default function sessionHandoffExtension(pi: ExtensionAPI): void {
77
- const settings = loadSettings();
78
- let identifiedGhosttyTerminalId: string | undefined;
79
-
80
- if (isGhosttyHandoffAvailable()) {
81
- pi.registerTool({
82
- name: "session_handoff",
83
- label: "Session Handoff",
84
- description:
85
- "Start a new background Pi session with directed instructions based on current work. The current session continues after launch.",
86
- promptSnippet:
87
- "Start a background pi session in a terminal split based on the current session",
88
- promptGuidelines: [
89
- "Use session_handoff only when it is clear the work should be forked to a new context.",
90
- "Reach for session_handoff by direction of the user, not as an unsolicited default.",
91
- "session_handoff should only request a response when there is a specific ask-and-response expectation: the user asked for a report back, or this session needs the child result to continue. Leave it off by default and for independent background work.",
92
- "session_handoff can only fork a background session; to replace the current session, tell the user to run /handoff instead.",
93
- ],
94
- executionMode: "sequential",
95
- parameters: Type.Object({
96
- goal: Type.String({
97
- description:
98
- "Goal for the new session. Capture enough detail to encompass the ask and any directions the next session should consider.",
99
- }),
100
- splitDirection: Type.Union(
101
- [Type.Literal("left"), Type.Literal("right"), Type.Literal("up"), Type.Literal("down")],
102
- {
103
- description:
104
- "Direction to split the Ghostty terminal. Must be explicitly provided by the user.",
105
- },
106
- ),
107
- cwd: Type.Optional(
108
- Type.String({
109
- description:
110
- "Optional target working directory. Relative paths resolve from the current session cwd.",
111
- }),
112
- ),
113
- requestResponse: Type.Optional(
114
- Type.Boolean({
115
- description:
116
- "Whether the child session should report completion/results of its task back to this session.",
117
- }),
118
- ),
119
- }),
120
- renderResult(result, _options, theme, context) {
121
- const text = getFirstText(result);
122
- if (context.isError) {
123
- return new Text(theme.fg("error", text), 0, 0);
124
- }
125
-
126
- const details = result.details as HandoffToolDetails | undefined;
127
- if (!details?.sessionId || !details.splitDirection || !details.cwd) {
128
- return new Text(text, 0, 0);
129
- }
130
-
131
- return new Text(
132
- `Started handoff session ${theme.bold(details.sessionId)} (${details.splitDirection}) in ${theme.fg("dim", details.cwd)}.`,
133
- 0,
134
- 0,
135
- );
136
- },
137
- async execute(_toolCallId, params: HandoffToolParams, _signal, _onUpdate, ctx) {
138
- return executeSessionHandoffTool(pi, params, ctx, identifiedGhosttyTerminalId);
139
- },
140
- });
141
- }
142
-
143
- pi.registerCommand("handoff", {
144
- description: "Transfer context to a new focused session",
145
- handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
146
- if (!isTuiMode(ctx)) {
147
- ctx.ui.notify("handoff requires interactive mode", "error");
148
- return;
149
- }
150
-
151
- const parsedArgs = parseHandoffCommandArgs(args);
152
- if (parsedArgs.kind === "error") {
153
- ctx.ui.notify(parsedArgs.message, "error");
154
- return;
155
- }
156
-
157
- if (parsedArgs.kind === "identify") {
158
- const terminalId = await getFocusedGhosttyTerminalId(pi, ctx.cwd);
159
- if (!terminalId) {
160
- ctx.ui.notify("Unable to identify the focused Ghostty terminal.", "error");
161
- return;
162
- }
163
-
164
- identifiedGhosttyTerminalId = terminalId;
165
- ctx.ui.notify(`Identified Ghostty terminal ${terminalId}.`, "info");
166
- return;
167
- }
168
-
169
- if (!ctx.model) {
170
- ctx.ui.notify("No model selected", "error");
171
- return;
172
- }
173
-
174
- const sessionContext = buildSessionContext(
175
- ctx.sessionManager.getEntries(),
176
- ctx.sessionManager.getLeafId(),
177
- );
178
- if (sessionContext.messages.length === 0) {
179
- ctx.ui.notify("No conversation to hand off", "error");
180
- return;
181
- }
182
-
183
- if (parsedArgs.splitDirection) {
184
- const preflightError = await validateSplitHandoffPrerequisites(pi, ctx);
185
- if (preflightError) {
186
- ctx.ui.notify(preflightError, "error");
187
- return;
188
- }
189
- }
190
-
191
- let generatedDraft: HandoffDraftResult | undefined;
192
- try {
193
- generatedDraft = await runWithLoader(
194
- ctx,
195
- "Generating handoff draft...",
196
- async (signal: AbortSignal) =>
197
- generateHandoffDraft(ctx, parsedArgs.goal, pi.getThinkingLevel(), signal),
198
- );
199
- } catch (error) {
200
- ctx.ui.notify(formatHandoffError(error), "error");
201
- return;
202
- }
203
-
204
- if (!generatedDraft) {
205
- ctx.ui.notify("Cancelled", "info");
206
- return;
207
- }
208
-
209
- const approvedDraft = await reviewHandoffDraft(ctx, generatedDraft.draft);
210
- if (!approvedDraft) {
211
- ctx.ui.notify("Cancelled", "info");
212
- return;
213
- }
214
-
215
- const parentSessionFile = ctx.sessionManager.getSessionFile();
216
- if (!parentSessionFile) {
217
- ctx.ui.notify("Handoff requires a persisted current session.", "error");
218
- return;
219
- }
220
-
221
- const handoffMetadata = createHandoffSessionMetadata(
222
- parsedArgs.goal,
223
- generatedDraft.context.nextTask,
224
- approvedDraft,
225
- generatedDraft.context.title,
226
- );
227
- if (parsedArgs.splitDirection) {
228
- const createdSession = createHandoffSession({
229
- cwd: ctx.cwd,
230
- sessionDir: ctx.sessionManager.getSessionDir(),
231
- parentSessionFile,
232
- title: handoffMetadata.title,
233
- });
234
- const bootstrapValue = encodeHandoffBootstrap(
235
- createHandoffBootstrap(createdSession.sessionId, handoffMetadata),
236
- );
237
- let launchResult = await launchSplitHandoffSession(pi, {
238
- cwd: ctx.cwd,
239
- sessionDir: ctx.sessionManager.getSessionDir(),
240
- direction: parsedArgs.splitDirection,
241
- sessionId: createdSession.sessionId,
242
- bootstrapValue,
243
- title: handoffMetadata.title,
244
- terminalId: identifiedGhosttyTerminalId,
245
- });
246
- if (!launchResult.success && identifiedGhosttyTerminalId) {
247
- launchResult = await launchSplitHandoffSession(pi, {
248
- cwd: ctx.cwd,
249
- sessionDir: ctx.sessionManager.getSessionDir(),
250
- direction: parsedArgs.splitDirection,
251
- sessionId: createdSession.sessionId,
252
- bootstrapValue,
253
- title: handoffMetadata.title,
254
- });
255
- }
256
-
257
- if (!launchResult.success) {
258
- ctx.ui.notify(
259
- `${launchResult.error} Created handoff session ${createdSession.sessionId}; start it manually with: ${buildPiResumeCommand(ctx.sessionManager.getSessionDir(), createdSession.sessionId, bootstrapValue, handoffMetadata.title)}`,
260
- "error",
261
- );
262
- return;
263
- }
264
-
265
- ctx.ui.notify(`Handoff started in a new pane (${parsedArgs.splitDirection}).`, "info");
266
- return;
267
- }
268
-
269
- const switchResult = await ctx.newSession({
270
- parentSession: parentSessionFile,
271
- setup: async (sessionManager) => {
272
- sessionManager.appendSessionInfo(handoffMetadata.title);
273
- sessionManager.appendCustomEntry(HANDOFF_METADATA_CUSTOM_TYPE, handoffMetadata);
274
- },
275
- withSession: async (nextCtx) => {
276
- startHandoffPromptAfterSessionRender(nextCtx, approvedDraft);
277
- },
278
- });
279
-
280
- if (switchResult.cancelled) {
281
- ctx.ui.notify("Session switch cancelled", "info");
282
- }
283
- },
284
- });
285
-
286
- pi.registerShortcut(settings.handoff.pickerShortcut, {
287
- description: "Open the session reference picker",
288
- handler: async (ctx) => {
289
- if (!isTuiMode(ctx)) {
290
- return;
291
- }
292
-
293
- const result = await openSessionReferencePicker(
294
- ctx,
295
- settings.index.path,
296
- settings.handoff.pickerShortcut,
297
- );
298
- if (result.kind !== "insert-session-token") {
299
- return;
300
- }
301
-
302
- ctx.ui.pasteToEditor(`${SESSION_TOKEN_PREFIX}${result.sessionId}`);
303
- },
304
- });
305
-
306
- pi.on("session_start", async (_event, ctx) => {
307
- const encodedBootstrap = process.env[HANDOFF_BOOTSTRAP_ENV];
308
- if (!encodedBootstrap) {
309
- return;
310
- }
311
-
312
- const bootstrap = parseHandoffBootstrap(encodedBootstrap);
313
- if (!bootstrap) {
314
- delete process.env[HANDOFF_BOOTSTRAP_ENV];
315
- return;
316
- }
317
-
318
- if (bootstrap.sessionId !== ctx.sessionManager.getSessionId()) {
319
- return;
320
- }
321
-
322
- try {
323
- if (isChildGeneratedHandoffBootstrap(bootstrap)) {
324
- await startChildGeneratedHandoff(pi, ctx, bootstrap, pi.getThinkingLevel());
325
- return;
326
- }
327
-
328
- const entries = ctx.sessionManager.getEntries();
329
- if (hasUserMessages(entries)) {
330
- if (ctx.hasUI) {
331
- ctx.ui.notify(HANDOFF_STALE_SESSION_MESSAGE, "error");
332
- }
333
- return;
334
- }
335
-
336
- if (!getHandoffMetadataFromEntries(entries)) {
337
- pi.appendEntry(
338
- HANDOFF_METADATA_CUSTOM_TYPE,
339
- createHandoffSessionMetadata(
340
- bootstrap.goal,
341
- bootstrap.nextTask,
342
- bootstrap.initialPrompt,
343
- bootstrap.title,
344
- ),
345
- );
346
- }
347
-
348
- if (!ctx.sessionManager.getSessionName()) {
349
- pi.setSessionName(bootstrap.title);
350
- }
351
-
352
- pi.sendUserMessage(bootstrap.initialPrompt);
353
- } finally {
354
- delete process.env[HANDOFF_BOOTSTRAP_ENV];
355
- }
356
- });
357
-
358
- pi.on("before_agent_start", async (event, ctx) => {
359
- if (isGhosttyHandoffAvailable() && ctx) {
360
- identifiedGhosttyTerminalId =
361
- (await getFocusedGhosttyTerminalId(pi, ctx.cwd)) ?? identifiedGhosttyTerminalId;
362
- }
363
-
364
- return {
365
- systemPrompt:
366
- event.systemPrompt +
367
- "\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.",
368
- };
369
- });
370
- }
371
-
372
- async function executeSessionHandoffTool(
373
- pi: ExtensionAPI,
374
- params: HandoffToolParams,
375
- ctx: ExtensionContext,
376
- terminalId: string | undefined,
377
- ) {
378
- const goal = params.goal.trim();
379
- if (!goal) {
380
- throw new Error("session_handoff requires a goal.");
381
- }
382
-
383
- if (!ctx.model) {
384
- throw new Error("No model selected.");
385
- }
386
-
387
- const preflightError = await validateSplitHandoffPrerequisites(pi, ctx);
388
- if (preflightError) {
389
- throw new Error(preflightError);
390
- }
391
-
392
- if (!terminalId) {
393
- throw new Error(NO_IDENTIFIED_TERMINAL_MESSAGE);
394
- }
395
-
396
- const targetCwd = resolveHandoffCwd(ctx.cwd, params.cwd);
397
- if (targetCwd.error) {
398
- throw new Error(targetCwd.message);
399
- }
400
-
401
- const parentSessionFile = ctx.sessionManager.getSessionFile();
402
- if (!parentSessionFile) {
403
- throw new Error("Handoff requires a persisted current session.");
404
- }
405
-
406
- const sessionContext = buildSessionContext(
407
- ctx.sessionManager.getEntries(),
408
- ctx.sessionManager.getLeafId(),
409
- );
410
- if (sessionContext.messages.length === 0) {
411
- throw new Error("No conversation to hand off.");
412
- }
413
-
414
- const requestResponse = params.requestResponse ?? false;
415
- const createdSession = createHandoffSession({
416
- cwd: targetCwd.path,
417
- sessionDir: ctx.sessionManager.getSessionDir(),
418
- parentSessionFile,
419
- title: TOOL_HANDOFF_PROVISIONAL_TITLE,
420
- });
421
- const bootstrapValue = encodeHandoffBootstrap(
422
- createChildGeneratedHandoffBootstrap({
423
- sessionId: createdSession.sessionId,
424
- goal,
425
- title: TOOL_HANDOFF_PROVISIONAL_TITLE,
426
- parentSessionFile,
427
- requestResponse,
428
- }),
429
- );
430
- const model = formatModelArgument(ctx.model, pi.getThinkingLevel());
431
- const launchResult = await launchSplitHandoffSession(pi, {
432
- cwd: targetCwd.path,
433
- sessionDir: ctx.sessionManager.getSessionDir(),
434
- direction: params.splitDirection,
435
- sessionId: createdSession.sessionId,
436
- bootstrapValue,
437
- title: TOOL_HANDOFF_PROVISIONAL_TITLE,
438
- terminalId,
439
- restoreFocus: true,
440
- model,
441
- });
442
-
443
- if (!launchResult.success) {
444
- throw new Error(
445
- `${launchResult.error} Created handoff session ${createdSession.sessionId}; start it manually with: ${buildPiResumeCommand(
446
- ctx.sessionManager.getSessionDir(),
447
- createdSession.sessionId,
448
- bootstrapValue,
449
- TOOL_HANDOFF_PROVISIONAL_TITLE,
450
- model,
451
- )}`,
452
- );
453
- }
454
-
455
- const details: HandoffToolDetails = {
456
- sessionId: createdSession.sessionId,
457
- title: TOOL_HANDOFF_PROVISIONAL_TITLE,
458
- splitDirection: params.splitDirection,
459
- cwd: targetCwd.path,
460
- };
461
-
462
- return {
463
- content: [
464
- {
465
- type: "text" as const,
466
- text: formatHandoffToolResultForModel(details, requestResponse),
467
- },
468
- ],
469
- details,
470
- };
471
- }
472
-
473
- async function startChildGeneratedHandoff(
474
- pi: ExtensionAPI,
475
- ctx: ExtensionContext,
476
- bootstrap: ChildGeneratedHandoffBootstrap,
477
- thinkingLevel: ThinkingLevel | undefined,
478
- ): Promise<void> {
479
- const entries = ctx.sessionManager.getEntries();
480
- if (hasUserMessages(entries)) {
481
- if (ctx.hasUI) {
482
- ctx.ui.notify(HANDOFF_STALE_SESSION_MESSAGE, "error");
483
- }
484
- return;
485
- }
486
-
487
- if (!ctx.hasUI) {
488
- return;
489
- }
490
-
491
- try {
492
- const sourceSessionManager = SessionManager.open(bootstrap.parentSessionFile);
493
- const generatedDraft = await runWithLoader(
494
- ctx,
495
- "Generating handoff draft...",
496
- async (signal: AbortSignal) =>
497
- generateHandoffDraftFromSessionManager(
498
- ctx,
499
- sourceSessionManager,
500
- bootstrap.goal,
501
- thinkingLevel,
502
- signal,
503
- bootstrap.requestResponse ?? false,
504
- ),
505
- );
506
- if (!generatedDraft) {
507
- ctx.ui.notify("Cancelled", "info");
508
- return;
509
- }
510
-
511
- const review = await reviewHandoffDraftForSend(ctx.ui, generatedDraft.draft);
512
- if (review.action === "prefill") {
513
- ctx.ui.setEditorText(review.prompt);
514
- ctx.ui.notify("Handoff prompt ready in editor.", "info");
515
- return;
516
- }
517
-
518
- if (review.action === "cancel") {
519
- ctx.ui.notify("Cancelled", "info");
520
- return;
521
- }
522
-
523
- const metadata = createHandoffSessionMetadata(
524
- bootstrap.goal,
525
- generatedDraft.context.nextTask,
526
- review.prompt,
527
- generatedDraft.context.title,
528
- );
529
- if (!getHandoffMetadataFromEntries(ctx.sessionManager.getEntries())) {
530
- pi.appendEntry(HANDOFF_METADATA_CUSTOM_TYPE, metadata);
531
- }
532
- pi.setSessionName(metadata.title);
533
- pi.sendUserMessage(review.prompt);
534
- } catch (error) {
535
- ctx.ui.notify(formatHandoffError(error), "error");
536
- }
537
- }
538
-
539
- function formatHandoffToolResultForModel(
540
- details: HandoffToolDetails,
541
- requestResponse: boolean,
542
- ): string {
543
- return JSON.stringify({ ...details, requestResponse }, null, 2);
544
- }
545
-
546
- function getFirstText(result: { content: Array<{ type: string; text?: string }> }): string {
547
- return result.content.find((item) => item.type === "text")?.text ?? "";
548
- }
549
-
550
- function resolveHandoffCwd(
551
- currentCwd: string,
552
- requestedCwd: string | undefined,
553
- ): { path: string; error?: undefined } | { error: true; message: string } {
554
- const rawPath = requestedCwd?.trim();
555
- const resolvedPath = rawPath ? resolveRequestedPath(currentCwd, rawPath) : currentCwd;
556
-
557
- if (!existsSync(resolvedPath)) {
558
- return {
559
- error: true,
560
- message: `Handoff cwd does not exist: ${resolvedPath}`,
561
- };
562
- }
563
-
564
- if (!statSync(resolvedPath).isDirectory()) {
565
- return {
566
- error: true,
567
- message: `Handoff cwd is not a directory: ${resolvedPath}`,
568
- };
569
- }
570
-
571
- return { path: resolvedPath };
572
- }
573
-
574
- function resolveRequestedPath(currentCwd: string, requestedPath: string): string {
575
- if (requestedPath === "~") {
576
- return homedir();
577
- }
578
-
579
- if (requestedPath.startsWith("~/")) {
580
- return resolve(homedir(), requestedPath.slice(2));
581
- }
582
-
583
- if (isAbsolute(requestedPath)) {
584
- return requestedPath;
585
- }
586
-
587
- return resolve(currentCwd, requestedPath);
588
- }
589
-
590
- function formatModelArgument(
591
- model: ExtensionContext["model"],
592
- thinkingLevel: ThinkingLevel | undefined,
593
- ): string | undefined {
594
- if (!model) {
595
- return undefined;
596
- }
597
-
598
- const base = `${model.provider}/${model.id}`;
599
- return thinkingLevel ? `${base}:${thinkingLevel}` : base;
600
- }
601
-
602
- async function runWithLoader<T>(
603
- ctx: { ui: ExtensionUIContext },
604
- label: string,
605
- task: (signal: AbortSignal) => Promise<T>,
606
- ): Promise<T | undefined> {
607
- let taskError: unknown;
608
-
609
- const result = await ctx.ui.custom<T | undefined>(
610
- (tui, theme, _keybindings, done) => {
611
- const abortController = new AbortController();
612
-
613
- task(abortController.signal)
614
- .then(done)
615
- .catch((error: unknown) => {
616
- if (!abortController.signal.aborted) {
617
- taskError = error;
618
- }
619
- done(undefined);
620
- });
621
-
622
- return {
623
- render(width: number): string[] {
624
- return renderStrongModal(
625
- [theme.fg("accent", theme.bold(label)), "", theme.fg("muted", "Press Esc to cancel.")],
626
- width,
627
- theme,
628
- );
629
- },
630
- invalidate(): void {},
631
- handleInput(data: string): void {
632
- if (matchesKey(data, Key.escape)) {
633
- abortController.abort();
634
- done(undefined);
635
- tui.requestRender();
636
- }
637
- },
638
- };
639
- },
640
- {
641
- overlay: true,
642
- overlayOptions: {
643
- anchor: "center",
644
- width: "70%",
645
- maxHeight: "40%",
646
- margin: 2,
647
- },
648
- },
649
- );
650
-
651
- if (taskError) {
652
- throw taskError;
653
- }
654
-
655
- return result;
656
- }
657
-
658
- function startHandoffPromptAfterSessionRender(
659
- ctx: HandoffPromptContext,
660
- approvedDraft: string,
661
- ): void {
662
- // ctx.newSession() renders the replacement session only after withSession returns.
663
- setImmediate(() => {
664
- void (async () => {
665
- try {
666
- await ctx.sendUserMessage(approvedDraft);
667
- ctx.ui.notify("Handoff started in a new session.", "info");
668
- } catch (error) {
669
- ctx.ui.notify(formatHandoffError(error), "error");
670
- }
671
- })();
672
- });
673
- }
674
-
675
- function formatHandoffError(error: unknown): string {
676
- if (error instanceof Error && error.message.trim()) {
677
- return error.message;
678
- }
679
-
680
- return "Handoff generation failed.";
681
- }
682
-
683
- function parseHandoffCommandArgs(args: string):
684
- | { kind: "identify" }
685
- | {
686
- kind: "ok";
687
- goal: string;
688
- splitDirection?: HandoffSplitDirection | undefined;
689
- }
690
- | { kind: "error"; message: string } {
691
- const tokens = args
692
- .trim()
693
- .split(/\s+/)
694
- .filter((token) => token.length > 0);
695
-
696
- if (tokens.includes("--identify")) {
697
- return { kind: "identify" };
698
- }
699
-
700
- if (tokens.length === 0) {
701
- return { kind: "error", message: HANDOFF_USAGE };
702
- }
703
-
704
- const directionFlags = new Map<string, HandoffSplitDirection>([
705
- ["--left", "left"],
706
- ["--right", "right"],
707
- ["--up", "up"],
708
- ["--down", "down"],
709
- ]);
710
-
711
- let splitDirection: HandoffSplitDirection | undefined;
712
- const goalTokens: string[] = [];
713
-
714
- for (const token of tokens) {
715
- const direction = directionFlags.get(token);
716
- if (!direction) {
717
- goalTokens.push(token);
718
- continue;
719
- }
720
-
721
- if (splitDirection) {
722
- return {
723
- kind: "error",
724
- message: "Use only one split flag: --left, --right, --up, or --down.",
725
- };
726
- }
727
-
728
- splitDirection = direction;
729
- }
730
-
731
- const goal = goalTokens.join(" ").trim();
732
- if (!goal) {
733
- return { kind: "error", message: HANDOFF_USAGE };
734
- }
735
-
736
- return {
737
- kind: "ok",
738
- goal,
739
- splitDirection,
740
- };
741
- }