pi-sessions 0.8.0 → 0.10.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 (111) hide show
  1. package/README.md +74 -37
  2. package/dist/session-messaging/broker/process.js +234 -0
  3. package/dist/shared/session-broker/framing.js +58 -0
  4. package/dist/shared/session-broker/protocol.js +163 -0
  5. package/dist/shared/session-broker/socket-path.js +19 -0
  6. package/dist/shared/typebox.js +36 -0
  7. package/extensions/pi-sessions.ts +125 -0
  8. package/extensions/session-ask/agent.ts +34 -14
  9. package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
  10. package/extensions/session-ask/navigate.ts +3 -1
  11. package/extensions/session-ask/presenter.ts +23 -0
  12. package/extensions/session-ask/renderer.ts +63 -0
  13. package/extensions/session-ask/tool-contract.ts +27 -0
  14. package/extensions/session-ask/view-model.ts +18 -0
  15. package/extensions/session-auto-title/generate.ts +18 -18
  16. package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +60 -53
  17. package/extensions/session-auto-title/model.ts +25 -17
  18. package/extensions/session-auto-title/retitle.ts +17 -18
  19. package/extensions/session-auto-title/wizard.ts +12 -11
  20. package/extensions/session-handoff/board-view-model.ts +179 -0
  21. package/extensions/session-handoff/board.ts +663 -0
  22. package/extensions/session-handoff/bootstrap.ts +172 -0
  23. package/extensions/session-handoff/extract.ts +129 -132
  24. package/extensions/session-handoff/install.ts +225 -0
  25. package/extensions/session-handoff/kickoff.ts +92 -0
  26. package/extensions/session-handoff/launch/backend.ts +16 -0
  27. package/extensions/session-handoff/launch/deferred.ts +20 -0
  28. package/extensions/session-handoff/launch/ghostty.ts +99 -0
  29. package/extensions/session-handoff/launch/resolution.ts +57 -0
  30. package/extensions/session-handoff/launch/shell.ts +7 -0
  31. package/extensions/session-handoff/launch/tmux.ts +31 -0
  32. package/extensions/session-handoff/launch-options.ts +61 -0
  33. package/extensions/session-handoff/launch-target.ts +91 -0
  34. package/extensions/session-handoff/metadata.ts +194 -56
  35. package/extensions/session-handoff/model.ts +58 -0
  36. package/extensions/session-handoff/query.ts +0 -1
  37. package/extensions/session-handoff/receipt.ts +96 -0
  38. package/extensions/session-handoff/review.ts +2 -28
  39. package/extensions/session-handoff/spawn.ts +76 -181
  40. package/extensions/session-handoff/strong-modal.ts +13 -0
  41. package/extensions/session-handoff/tool-contract.ts +12 -0
  42. package/extensions/session-handoff/tool-presenter.ts +35 -0
  43. package/extensions/session-handoff/tool-renderer.ts +66 -0
  44. package/extensions/session-handoff/tool-schema.ts +54 -0
  45. package/extensions/session-handoff/tool-view-model.ts +49 -0
  46. package/extensions/session-handoff/tool.ts +206 -0
  47. package/extensions/session-handoff/ui.ts +66 -0
  48. package/extensions/session-hooks/install.ts +75 -0
  49. package/extensions/{session-index.ts → session-index/install.ts} +9 -7
  50. package/extensions/session-messaging/broker/process.ts +21 -28
  51. package/extensions/session-messaging/broker/spawn.ts +12 -7
  52. package/extensions/session-messaging/install.ts +78 -0
  53. package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
  54. package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
  55. package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
  56. package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
  57. package/extensions/session-messaging/pi/message-contracts.ts +32 -0
  58. package/extensions/session-messaging/pi/renderer.ts +19 -12
  59. package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
  60. package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
  61. package/extensions/session-messaging/pi/service.ts +262 -81
  62. package/extensions/session-messaging/pi/tools.ts +184 -40
  63. package/extensions/session-search/extract.ts +60 -14
  64. package/extensions/session-search/hooks.ts +10 -13
  65. package/extensions/session-search/install.ts +373 -0
  66. package/extensions/session-search/renderer.ts +195 -0
  67. package/extensions/session-search/tool-contract.ts +51 -0
  68. package/extensions/shared/composition.ts +20 -0
  69. package/extensions/shared/errors.ts +3 -0
  70. package/extensions/shared/model-resolution.ts +73 -0
  71. package/extensions/shared/model-runtime.ts +33 -0
  72. package/extensions/shared/model.ts +7 -10
  73. package/extensions/shared/rendering/collapsible-text.ts +89 -0
  74. package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
  75. package/extensions/shared/rendering/theme.ts +5 -0
  76. package/extensions/shared/session-broker/client.ts +30 -29
  77. package/extensions/shared/session-broker/protocol.ts +128 -10
  78. package/extensions/shared/session-index/common.ts +5 -5
  79. package/extensions/shared/session-index/lineage.ts +0 -3
  80. package/extensions/shared/session-index/schema.ts +0 -1
  81. package/extensions/shared/session-index/scoring.ts +1 -2
  82. package/extensions/shared/session-index/search.ts +11 -4
  83. package/extensions/shared/session-index/store.ts +4 -9
  84. package/extensions/shared/settings.ts +83 -40
  85. package/extensions/shared/text.ts +5 -0
  86. package/extensions/shared/thinking-levels.ts +22 -0
  87. package/extensions/shared/tmux.ts +205 -0
  88. package/extensions/subagents/cancel.ts +111 -0
  89. package/extensions/subagents/classify.ts +58 -0
  90. package/extensions/subagents/install.ts +229 -0
  91. package/extensions/subagents/launch-target.ts +75 -0
  92. package/extensions/subagents/ledger.ts +217 -0
  93. package/extensions/subagents/reconcile.ts +386 -0
  94. package/extensions/subagents/report-message-presenter.ts +36 -0
  95. package/extensions/subagents/report-message-renderer.ts +21 -0
  96. package/extensions/subagents/report-message-view-model.ts +29 -0
  97. package/extensions/subagents/report.ts +159 -0
  98. package/extensions/subagents/roster.ts +262 -0
  99. package/extensions/subagents/settle.ts +182 -0
  100. package/extensions/subagents/wake.ts +173 -0
  101. package/images/handoff-board-subagents.png +0 -0
  102. package/images/handoff-board-user-sessions.png +0 -0
  103. package/images/handoff-subagent-report.png +0 -0
  104. package/images/session-handoff-tool.png +0 -0
  105. package/package.json +16 -18
  106. package/extensions/session-handoff.ts +0 -741
  107. package/extensions/session-hooks.ts +0 -75
  108. package/extensions/session-messaging/pi/message-view.ts +0 -131
  109. package/extensions/session-messaging.ts +0 -30
  110. package/extensions/session-search.ts +0 -419
  111. package/extensions/shared/session-broker/active.ts +0 -26
@@ -0,0 +1,91 @@
1
+ import type { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { type Static, Type } from "typebox";
3
+ import type { ClipboardStatus, HandoffSplitDirection, LaunchBackend } from "./launch/backend.ts";
4
+ import type { HandoffSubagent } from "./metadata.ts";
5
+ import type { PreparedHandoff } from "./spawn.ts";
6
+
7
+ export const DEFERRED_LAUNCH = "deferred" as const;
8
+ export const SUBAGENT_LAUNCH = "subagent" as const;
9
+
10
+ export const LAUNCH_DIRECTIONS = ["left", "right", "up", "down"] as const;
11
+ export const HANDOFF_DIRECTION_LAUNCH_SCHEMA = Type.Union([
12
+ Type.Literal("left"),
13
+ Type.Literal("right"),
14
+ Type.Literal("up"),
15
+ Type.Literal("down"),
16
+ ]);
17
+
18
+ export const HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA = Type.Union([
19
+ HANDOFF_DIRECTION_LAUNCH_SCHEMA,
20
+ Type.Literal(DEFERRED_LAUNCH),
21
+ ]);
22
+
23
+ export const HANDOFF_LAUNCH_VALUE_SCHEMA = Type.Union([
24
+ HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA,
25
+ Type.Literal(SUBAGENT_LAUNCH),
26
+ ]);
27
+
28
+ export type HandoffLaunchValue = Static<typeof HANDOFF_LAUNCH_VALUE_SCHEMA>;
29
+
30
+ export interface HandoffLaunchTargetPreparation {
31
+ manager: SessionManager;
32
+ childSessionId: string;
33
+ parentSessionId: string;
34
+ parentSessionFile: string;
35
+ requestResponse: boolean;
36
+ }
37
+
38
+ export interface HandoffLaunchTargetInput {
39
+ prepared: PreparedHandoff;
40
+ parentSessionId: string;
41
+ title: string;
42
+ goal: string;
43
+ requestResponse: boolean;
44
+ model: string;
45
+ cwd: string;
46
+ }
47
+
48
+ export type HandoffLaunchTargetOutcome =
49
+ | {
50
+ success: true;
51
+ backend: string;
52
+ clipboardStatus?: ClipboardStatus | undefined;
53
+ }
54
+ | { success: false; error: string };
55
+
56
+ /** A backend plus the handoff policy required to prepare and launch one child. */
57
+ export interface HandoffLaunchTarget {
58
+ value: HandoffLaunchValue;
59
+ description?: string | undefined;
60
+ requestResponseDefault: boolean;
61
+ bootstrapMode: "review" | "automatic";
62
+ /** Subagent launches stamp their child's identity into the child bootstrap. */
63
+ describeSubagentChild?(input: {
64
+ childSessionId: string;
65
+ ownerSessionId: string;
66
+ requestResponse: boolean;
67
+ }): HandoffSubagent;
68
+ prepareChild(input: HandoffLaunchTargetPreparation): void;
69
+ launch(input: HandoffLaunchTargetInput): Promise<HandoffLaunchTargetOutcome>;
70
+ }
71
+
72
+ export function createBackendLaunchTarget(
73
+ value: HandoffSplitDirection | "deferred",
74
+ backend: LaunchBackend,
75
+ description?: string | undefined,
76
+ ): HandoffLaunchTarget {
77
+ return {
78
+ value,
79
+ ...(description ? { description } : {}),
80
+ requestResponseDefault: false,
81
+ bootstrapMode: "review",
82
+ prepareChild() {},
83
+ launch(input) {
84
+ return backend.launch({
85
+ cwd: input.cwd,
86
+ title: input.title,
87
+ resumeCommand: input.prepared.resumeCommand,
88
+ });
89
+ },
90
+ };
91
+ }
@@ -1,78 +1,96 @@
1
- import { Buffer } from "node:buffer";
2
1
  import type { CustomEntry, SessionEntry } from "@earendil-works/pi-coding-agent";
3
2
  import { type Static, Type } from "typebox";
4
3
  import { safeParseTypeBoxValue } from "../shared/typebox.ts";
4
+ import { HANDOFF_KICKOFF_CUSTOM_TYPE, HANDOFF_KICKOFF_DETAILS_SCHEMA } from "./kickoff.ts";
5
+ import {
6
+ HANDOFF_LAUNCH_VALUE_SCHEMA,
7
+ HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA,
8
+ type HandoffLaunchValue,
9
+ SUBAGENT_LAUNCH,
10
+ } from "./launch-target.ts";
5
11
 
6
12
  export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
7
- export const HANDOFF_BOOTSTRAP_ENV = "PI_SESSIONS_HANDOFF_BOOTSTRAP";
13
+ export const HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap";
14
+ export const HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-consumed";
8
15
  export const HANDOFF_STALE_SESSION_MESSAGE =
9
16
  "Session handoff failed: target session already has user input.";
17
+ export const SESSION_STARTING_MESSAGE =
18
+ "Target session is still starting. wait for it to show up in session_search, then resend.";
10
19
 
11
- export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Object({
20
+ // A subagent's self-knowledge, stamped into its child-local bootstrap so the
21
+ // child never opens the parent transcript to learn who it is.
22
+ export const HANDOFF_SUBAGENT_SCHEMA = Type.Object({
23
+ childSessionId: Type.String(),
24
+ ownerSessionId: Type.String(),
25
+ depth: Type.Integer({ minimum: 1 }),
26
+ requestResponse: Type.Boolean(),
27
+ });
28
+
29
+ const HANDOFF_METADATA_BASE = {
12
30
  origin: Type.Literal("handoff"),
13
31
  goal: Type.String(),
14
- nextTask: Type.String(),
15
32
  title: Type.String(),
16
33
  initial_prompt: Type.String(),
17
- });
34
+ };
18
35
 
19
- export const IMMEDIATE_HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
20
- sessionId: Type.String(),
21
- goal: Type.String(),
22
- nextTask: Type.String(),
23
- title: Type.String(),
24
- initialPrompt: Type.String(),
36
+ export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Object({
37
+ ...HANDOFF_METADATA_BASE,
38
+ launch: HANDOFF_LAUNCH_VALUE_SCHEMA,
25
39
  });
26
40
 
27
- export const CHILD_GENERATED_HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
41
+ const HANDOFF_BOOTSTRAP_BASE = {
28
42
  mode: Type.Literal("generate"),
29
43
  sessionId: Type.String(),
30
44
  goal: Type.String(),
31
45
  title: Type.String(),
32
46
  parentSessionFile: Type.String(),
33
- requestResponse: Type.Optional(Type.Boolean()),
34
- });
47
+ sourceLeafId: Type.String(),
48
+ requestResponse: Type.Boolean(),
49
+ bootstrapMode: Type.Union([Type.Literal("review"), Type.Literal("automatic")]),
50
+ };
35
51
 
36
52
  export const HANDOFF_BOOTSTRAP_SCHEMA = Type.Union([
37
- IMMEDIATE_HANDOFF_BOOTSTRAP_SCHEMA,
38
- CHILD_GENERATED_HANDOFF_BOOTSTRAP_SCHEMA,
53
+ Type.Object({
54
+ ...HANDOFF_BOOTSTRAP_BASE,
55
+ launch: Type.Literal(SUBAGENT_LAUNCH),
56
+ subagent: HANDOFF_SUBAGENT_SCHEMA,
57
+ }),
58
+ Type.Object({
59
+ ...HANDOFF_BOOTSTRAP_BASE,
60
+ launch: HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA,
61
+ subagent: Type.Optional(Type.Never()),
62
+ }),
39
63
  ]);
40
64
 
65
+ export const HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA = Type.Object({
66
+ bootstrapEntryId: Type.String(),
67
+ reason: Type.Union([
68
+ Type.Literal("cancelled"),
69
+ Type.Literal("prefilled"),
70
+ Type.Literal("stale"),
71
+ Type.Literal("invalid"),
72
+ ]),
73
+ });
74
+
75
+ export type HandoffSubagent = Static<typeof HANDOFF_SUBAGENT_SCHEMA>;
41
76
  export type HandoffSessionMetadata = Static<typeof HANDOFF_SESSION_METADATA_SCHEMA>;
42
- export type ImmediateHandoffBootstrap = Static<typeof IMMEDIATE_HANDOFF_BOOTSTRAP_SCHEMA>;
43
- export type ChildGeneratedHandoffBootstrap = Static<
44
- typeof CHILD_GENERATED_HANDOFF_BOOTSTRAP_SCHEMA
45
- >;
46
77
  export type HandoffBootstrap = Static<typeof HANDOFF_BOOTSTRAP_SCHEMA>;
78
+ export type ChildGeneratedHandoffBootstrap = HandoffBootstrap;
79
+ export type HandoffBootstrapConsumed = Static<typeof HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA>;
80
+ export type HandoffBootstrapConsumedReason = HandoffBootstrapConsumed["reason"];
47
81
 
48
82
  export function createHandoffSessionMetadata(
49
83
  goal: string,
50
- nextTask: string,
51
84
  initialPrompt: string,
52
85
  title: string,
86
+ launch: HandoffLaunchValue,
53
87
  ): HandoffSessionMetadata {
54
- const normalizedGoal = goal.trim();
55
- const normalizedNextTask = nextTask.trim() || normalizedGoal;
56
-
57
88
  return {
58
89
  origin: "handoff",
59
- goal: normalizedGoal,
60
- nextTask: normalizedNextTask,
90
+ goal: goal.trim(),
61
91
  title,
62
92
  initial_prompt: initialPrompt.trim(),
63
- };
64
- }
65
-
66
- export function createHandoffBootstrap(
67
- sessionId: string,
68
- metadata: HandoffSessionMetadata,
69
- ): ImmediateHandoffBootstrap {
70
- return {
71
- sessionId,
72
- goal: metadata.goal,
73
- nextTask: metadata.nextTask,
74
- title: metadata.title,
75
- initialPrompt: metadata.initial_prompt,
93
+ launch,
76
94
  };
77
95
  }
78
96
 
@@ -81,35 +99,149 @@ export function createChildGeneratedHandoffBootstrap(options: {
81
99
  goal: string;
82
100
  title: string;
83
101
  parentSessionFile: string;
84
- requestResponse?: boolean | undefined;
102
+ sourceLeafId: string;
103
+ requestResponse: boolean;
104
+ bootstrapMode: "review" | "automatic";
105
+ launch: HandoffLaunchValue;
106
+ subagent?: HandoffSubagent | undefined;
85
107
  }): ChildGeneratedHandoffBootstrap {
86
- return {
87
- mode: "generate",
108
+ const base = {
109
+ mode: "generate" as const,
88
110
  sessionId: options.sessionId,
89
111
  goal: options.goal.trim(),
90
112
  title: options.title.trim(),
91
113
  parentSessionFile: options.parentSessionFile,
92
- ...(options.requestResponse === undefined ? {} : { requestResponse: options.requestResponse }),
114
+ sourceLeafId: options.sourceLeafId,
115
+ requestResponse: options.requestResponse,
116
+ bootstrapMode: options.bootstrapMode,
93
117
  };
118
+ if (options.launch === SUBAGENT_LAUNCH) {
119
+ if (!options.subagent) {
120
+ throw new Error("A subagent handoff requires a subagent identity block.");
121
+ }
122
+ return { ...base, launch: options.launch, subagent: options.subagent };
123
+ }
124
+ return { ...base, launch: options.launch };
94
125
  }
95
126
 
96
- export function isChildGeneratedHandoffBootstrap(
97
- bootstrap: HandoffBootstrap,
98
- ): bootstrap is ChildGeneratedHandoffBootstrap {
99
- return "mode" in bootstrap && bootstrap.mode === "generate";
127
+ export type PendingHandoffBootstrapScan =
128
+ | { kind: "pending"; entryId: string; bootstrap: HandoffBootstrap }
129
+ | { kind: "invalid"; entryId: string };
130
+
131
+ /**
132
+ * Finds the newest unconsumed pending bootstrap on a branch. Consumption is
133
+ * append-only: a delivered kickoff or consumed marker must reference the
134
+ * bootstrap entry it consumed.
135
+ */
136
+ export function findPendingHandoffBootstrap(
137
+ branch: readonly SessionEntry[],
138
+ ): PendingHandoffBootstrapScan | undefined {
139
+ const consumedEntryIds = new Set<string>();
140
+ for (const entry of branch) {
141
+ if (entry.type === "custom_message" && entry.customType === HANDOFF_KICKOFF_CUSTOM_TYPE) {
142
+ const details = safeParseTypeBoxValue(HANDOFF_KICKOFF_DETAILS_SCHEMA, entry.details);
143
+ if (details?.bootstrapEntryId) {
144
+ consumedEntryIds.add(details.bootstrapEntryId);
145
+ }
146
+ }
147
+ if (entry.type === "custom" && entry.customType === HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE) {
148
+ const consumed = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA, entry.data);
149
+ if (consumed) {
150
+ consumedEntryIds.add(consumed.bootstrapEntryId);
151
+ }
152
+ }
153
+ }
154
+
155
+ for (let i = branch.length - 1; i >= 0; i -= 1) {
156
+ const entry = branch[i];
157
+ if (
158
+ entry?.type !== "custom" ||
159
+ entry.customType !== HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE ||
160
+ consumedEntryIds.has(entry.id)
161
+ ) {
162
+ continue;
163
+ }
164
+
165
+ const bootstrap = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_SCHEMA, entry.data);
166
+ return bootstrap
167
+ ? { kind: "pending", entryId: entry.id, bootstrap }
168
+ : { kind: "invalid", entryId: entry.id };
169
+ }
170
+
171
+ return undefined;
100
172
  }
101
173
 
102
- export function encodeHandoffBootstrap(bootstrap: HandoffBootstrap): string {
103
- return Buffer.from(JSON.stringify(bootstrap), "utf8").toString("base64");
174
+ /**
175
+ * A session is *starting* when its active branch still carries an unconsumed,
176
+ * well-formed handoff bootstrap: it has been prepared but has not received its
177
+ * kickoff. An invalid bootstrap does not count — the child consumes it and
178
+ * proceeds as an ordinary session rather than kicking off a handoff.
179
+ */
180
+ export function isSessionStarting(branch: readonly SessionEntry[]): boolean {
181
+ return findPendingHandoffBootstrap(branch)?.kind === "pending";
104
182
  }
105
183
 
106
- export function parseHandoffBootstrap(value: string): HandoffBootstrap | undefined {
107
- try {
108
- const decoded = Buffer.from(value, "base64").toString("utf8");
109
- return safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_SCHEMA, JSON.parse(decoded));
110
- } catch {
184
+ /**
185
+ * Folds the bootstrap lifecycle in one transcript pass. The bootstrap owns its
186
+ * data; kickoff and consumed entries only establish whether it started or ended.
187
+ */
188
+ export function findCurrentHandoffBootstrap(
189
+ branch: readonly SessionEntry[],
190
+ ): HandoffBootstrap | undefined {
191
+ const bootstrapsById = new Map<string, HandoffBootstrap>();
192
+ const bootstrapIds: string[] = [];
193
+ const startedBootstrapIds: string[] = [];
194
+ const endedBootstrapIds = new Set<string>();
195
+ let conversationStarted = false;
196
+
197
+ for (const entry of branch) {
198
+ if (entry.type === "message" && entry.message.role === "user") {
199
+ conversationStarted = true;
200
+ continue;
201
+ }
202
+ if (entry.type === "custom_message" && entry.customType === HANDOFF_KICKOFF_CUSTOM_TYPE) {
203
+ conversationStarted = true;
204
+ const details = safeParseTypeBoxValue(HANDOFF_KICKOFF_DETAILS_SCHEMA, entry.details);
205
+ if (details?.bootstrapEntryId) {
206
+ startedBootstrapIds.push(details.bootstrapEntryId);
207
+ }
208
+ continue;
209
+ }
210
+ if (entry.type !== "custom") {
211
+ continue;
212
+ }
213
+ if (entry.customType === HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE) {
214
+ const bootstrap = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_SCHEMA, entry.data);
215
+ if (bootstrap) {
216
+ bootstrapsById.set(entry.id, bootstrap);
217
+ bootstrapIds.push(entry.id);
218
+ }
219
+ continue;
220
+ }
221
+ if (entry.customType === HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE) {
222
+ const consumed = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA, entry.data);
223
+ if (consumed) {
224
+ endedBootstrapIds.add(consumed.bootstrapEntryId);
225
+ }
226
+ }
227
+ }
228
+
229
+ for (let index = startedBootstrapIds.length - 1; index >= 0; index -= 1) {
230
+ const bootstrap = bootstrapsById.get(startedBootstrapIds[index] ?? "");
231
+ if (bootstrap) {
232
+ return bootstrap;
233
+ }
234
+ }
235
+ if (conversationStarted) {
111
236
  return undefined;
112
237
  }
238
+ for (let index = bootstrapIds.length - 1; index >= 0; index -= 1) {
239
+ const bootstrapId = bootstrapIds[index];
240
+ if (bootstrapId && !endedBootstrapIds.has(bootstrapId)) {
241
+ return bootstrapsById.get(bootstrapId);
242
+ }
243
+ }
244
+ return undefined;
113
245
  }
114
246
 
115
247
  export function parseHandoffSessionMetadata(value: unknown): HandoffSessionMetadata | undefined {
@@ -133,8 +265,14 @@ export function getHandoffMetadataFromEntries(
133
265
  return undefined;
134
266
  }
135
267
 
136
- export function hasUserMessages(entries: readonly SessionEntry[]): boolean {
137
- return entries.some((entry) => entry.type === "message" && entry.message.role === "user");
268
+ // Bootstrap freshness: an existing kickoff counts as a started conversation
269
+ // even though no native user message exists.
270
+ export function hasStartedConversation(entries: readonly SessionEntry[]): boolean {
271
+ return entries.some(
272
+ (entry) =>
273
+ (entry.type === "message" && entry.message.role === "user") ||
274
+ (entry.type === "custom_message" && entry.customType === HANDOFF_KICKOFF_CUSTOM_TYPE),
275
+ );
138
276
  }
139
277
 
140
278
  function parseCustomHandoffMetadata(entry: CustomEntry): HandoffSessionMetadata | undefined {
@@ -0,0 +1,58 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
4
+ import { formatAvailableModelList, resolveAuthenticatedModel } from "../shared/model-resolution.ts";
5
+ import { isThinkingLevel } from "../shared/thinking-levels.ts";
6
+
7
+ export function formatModelArgument(
8
+ model: Model<Api> | undefined,
9
+ thinkingLevel: ThinkingLevel | undefined,
10
+ ): string | undefined {
11
+ if (!model) {
12
+ return undefined;
13
+ }
14
+
15
+ const base = `${model.provider}/${model.id}`;
16
+ return thinkingLevel ? `${base}:${thinkingLevel}` : base;
17
+ }
18
+
19
+ export function parseModelArgument(value: string): {
20
+ model: string;
21
+ thinkingLevel?: ThinkingLevel | undefined;
22
+ } {
23
+ const separator = value.lastIndexOf(":");
24
+ const suffix = separator >= 0 ? value.slice(separator + 1) : undefined;
25
+ if (!isThinkingLevel(suffix)) {
26
+ return { model: value };
27
+ }
28
+ return {
29
+ model: value.slice(0, separator),
30
+ thinkingLevel: suffix,
31
+ };
32
+ }
33
+
34
+ export interface HandoffModelOverride {
35
+ model: Model<Api>;
36
+ thinkingLevel: ThinkingLevel | undefined;
37
+ }
38
+
39
+ export function resolveModelOverride(
40
+ modelRuntime: ModelRuntime,
41
+ modelPattern: string,
42
+ thinkingLevel?: ThinkingLevel | undefined,
43
+ ): HandoffModelOverride {
44
+ const resolution = resolveAuthenticatedModel({ modelRuntime, modelPattern, thinkingLevel });
45
+ if (!resolution.ok) {
46
+ const warning = resolution.warning ? ` ${resolution.warning}` : "";
47
+ throw new Error(
48
+ `${resolution.error}${warning} Available models: ${formatAvailableModelList(
49
+ modelRuntime.getAvailableSnapshot(),
50
+ )}.`,
51
+ );
52
+ }
53
+
54
+ return {
55
+ model: resolution.model,
56
+ thinkingLevel: resolution.thinkingLevel,
57
+ };
58
+ }
@@ -264,7 +264,6 @@ function getBestTextSnippet(result: SearchSessionResult): string | undefined {
264
264
  function getSessionTitle(result: SearchSessionResult): string {
265
265
  return (
266
266
  normalizeDisplayText(result.sessionName) ??
267
- normalizeDisplayText(result.handoffNextTask) ??
268
267
  normalizeDisplayText(result.handoffGoal) ??
269
268
  normalizeDisplayText(result.firstUserPrompt) ??
270
269
  normalizeDisplayText(stripSearchSnippetMarkers(getBestTextSnippet(result))) ??
@@ -0,0 +1,96 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import { Box, type Component, Container, Text } from "@earendil-works/pi-tui";
4
+ import { type Static, type TLiteral, Type } from "typebox";
5
+ import type { RenderTheme } from "../shared/rendering/theme.ts";
6
+ import { THINKING_LEVELS } from "../shared/thinking-levels.ts";
7
+ import { safeParseTypeBoxValue } from "../shared/typebox.ts";
8
+ import type { ClipboardStatus } from "./launch/backend.ts";
9
+ import { HANDOFF_LAUNCH_VALUE_SCHEMA, type HandoffLaunchValue } from "./launch-target.ts";
10
+
11
+ const THINKING_LEVEL_SCHEMAS = THINKING_LEVELS.map((level) => Type.Literal(level)) as [
12
+ TLiteral<ThinkingLevel>,
13
+ ...TLiteral<ThinkingLevel>[],
14
+ ];
15
+
16
+ export const HANDOFF_LAUNCH_RECEIPT_SCHEMA = Type.Object({
17
+ sessionId: Type.String(),
18
+ childSessionFile: Type.String(),
19
+ title: Type.String(),
20
+ launch: HANDOFF_LAUNCH_VALUE_SCHEMA,
21
+ resumeCommand: Type.String(),
22
+ backend: Type.Optional(Type.String()),
23
+ cwd: Type.Optional(Type.String()),
24
+ model: Type.String(),
25
+ provider: Type.Optional(Type.String()),
26
+ modelName: Type.Optional(Type.String()),
27
+ thinkingLevel: Type.Optional(Type.Union(THINKING_LEVEL_SCHEMAS)),
28
+ });
29
+
30
+ export type HandoffLaunchReceipt = Static<typeof HANDOFF_LAUNCH_RECEIPT_SCHEMA>;
31
+
32
+ export function parseHandoffLaunchReceiptEntry(
33
+ entry: SessionEntry,
34
+ ): HandoffLaunchReceipt | undefined {
35
+ if (
36
+ entry.type === "message" &&
37
+ entry.message.role === "toolResult" &&
38
+ entry.message.toolName === "session_handoff"
39
+ ) {
40
+ return safeParseTypeBoxValue(HANDOFF_LAUNCH_RECEIPT_SCHEMA, entry.message.details);
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ export function buildLaunchReceipt(options: {
46
+ sessionId: string;
47
+ childSessionFile: string;
48
+ title: string;
49
+ launch: HandoffLaunchValue;
50
+ resumeCommand: string;
51
+ backend?: string | undefined;
52
+ targetCwd: string;
53
+ parentCwd: string;
54
+ childModel: string;
55
+ childProvider: string;
56
+ childModelName: string;
57
+ thinkingLevel?: ThinkingLevel | undefined;
58
+ }): HandoffLaunchReceipt {
59
+ return {
60
+ sessionId: options.sessionId,
61
+ childSessionFile: options.childSessionFile,
62
+ title: options.title,
63
+ launch: options.launch,
64
+ resumeCommand: options.resumeCommand,
65
+ ...(options.backend ? { backend: options.backend } : {}),
66
+ ...(options.targetCwd !== options.parentCwd ? { cwd: options.targetCwd } : {}),
67
+ model: options.childModel,
68
+ provider: options.childProvider,
69
+ modelName: options.childModelName,
70
+ ...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}),
71
+ };
72
+ }
73
+
74
+ export function createLaunchCommandComponent(
75
+ command: string,
76
+ label: string,
77
+ theme: RenderTheme,
78
+ ): Component {
79
+ const container = new Container();
80
+ container.addChild(new Text(theme.fg("muted", label), 0, 0));
81
+ const commandBox = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
82
+ commandBox.addChild(new Text(theme.fg("toolOutput", command), 0, 0));
83
+ container.addChild(commandBox);
84
+ return container;
85
+ }
86
+
87
+ export function formatDeferredCommandLabel(clipboardStatus?: ClipboardStatus | undefined): string {
88
+ switch (clipboardStatus) {
89
+ case "copied":
90
+ return "resume command · copied to clipboard";
91
+ case "failed":
92
+ return "resume command · clipboard copy failed";
93
+ default:
94
+ return "resume command";
95
+ }
96
+ }
@@ -1,8 +1,4 @@
1
- import type {
2
- ExtensionCommandContext,
3
- ExtensionUIContext,
4
- Theme,
5
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
6
2
  import type { Component } from "@earendil-works/pi-tui";
7
3
  import {
8
4
  Key,
@@ -11,6 +7,7 @@ import {
11
7
  visibleWidth,
12
8
  wrapTextWithAnsi,
13
9
  } from "@earendil-works/pi-tui";
10
+ import { renderStrongModal } from "./strong-modal.ts";
14
11
 
15
12
  const PREVIEW_TIMEOUT_MS = 8_000;
16
13
  const PREVIEW_BODY_LINE_LIMIT = 16;
@@ -205,17 +202,6 @@ export class HandoffPreviewComponent implements Component {
205
202
  }
206
203
  }
207
204
 
208
- export function renderStrongModal(lines: string[], width: number, theme: Theme): string[] {
209
- const innerWidth = Math.max(20, width - 4);
210
- const fillLine = (text: string) => {
211
- const truncated = truncateToWidth(text, innerWidth, "…", true);
212
- const padding = Math.max(0, innerWidth - visibleWidth(truncated));
213
- return theme.bg("customMessageBg", ` ${truncated}${" ".repeat(padding)} `);
214
- };
215
-
216
- return ["", ...lines, ""].map(fillLine);
217
- }
218
-
219
205
  function formatKeymapLine(left: string, right: string): string {
220
206
  return `${left.padEnd(27, " ")}${right}`;
221
207
  }
@@ -235,18 +221,6 @@ function renderPromptSection(lines: string[], width: number, _theme: Theme): str
235
221
  ];
236
222
  }
237
223
 
238
- export async function reviewHandoffDraft(
239
- ctx: ExtensionCommandContext,
240
- draft: string,
241
- ): Promise<string | undefined> {
242
- const result = await reviewHandoffDraftForSend(ctx.ui, draft);
243
- if (result.action !== "send") {
244
- return undefined;
245
- }
246
-
247
- return result.prompt;
248
- }
249
-
250
224
  export async function reviewHandoffDraftForSend(
251
225
  ui: ExtensionUIContext,
252
226
  draft: string,