pi-sessions 0.8.0 → 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 +70 -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} +60 -53
  12. package/extensions/session-auto-title/model.ts +25 -17
  13. package/extensions/session-auto-title/retitle.ts +17 -18
  14. package/extensions/session-auto-title/wizard.ts +12 -11
  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 +76 -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,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 durable handoff record. */
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,79 +1,111 @@
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_NON_SUBAGENT_LAUNCH_SCHEMA,
7
+ type HandoffLaunchValue,
8
+ SUBAGENT_LAUNCH,
9
+ } from "./launch-target.ts";
5
10
 
6
11
  export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
7
- export const HANDOFF_BOOTSTRAP_ENV = "PI_SESSIONS_HANDOFF_BOOTSTRAP";
12
+ export const HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap";
13
+ export const HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-consumed";
8
14
  export const HANDOFF_STALE_SESSION_MESSAGE =
9
15
  "Session handoff failed: target session already has user input.";
16
+ export const SESSION_STARTING_MESSAGE =
17
+ "Target session is still starting. wait for it to show up in session_search, then resend.";
18
+
19
+ // A subagent's self-knowledge, stamped into the durable handoff record so the
20
+ // child never opens the parent transcript to learn who it is.
21
+ export const HANDOFF_SUBAGENT_SCHEMA = Type.Object({
22
+ childSessionId: Type.String(),
23
+ ownerSessionId: Type.String(),
24
+ depth: Type.Integer({ minimum: 1 }),
25
+ requestResponse: Type.Boolean(),
26
+ });
10
27
 
11
- export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Object({
28
+ const HANDOFF_METADATA_BASE = {
12
29
  origin: Type.Literal("handoff"),
13
30
  goal: Type.String(),
14
- nextTask: Type.String(),
15
31
  title: Type.String(),
16
32
  initial_prompt: Type.String(),
17
- });
18
-
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(),
25
- });
33
+ };
34
+
35
+ export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Union([
36
+ Type.Object({
37
+ ...HANDOFF_METADATA_BASE,
38
+ launch: Type.Literal(SUBAGENT_LAUNCH),
39
+ subagent: HANDOFF_SUBAGENT_SCHEMA,
40
+ }),
41
+ Type.Object({
42
+ ...HANDOFF_METADATA_BASE,
43
+ launch: HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA,
44
+ subagent: Type.Optional(Type.Never()),
45
+ }),
46
+ ]);
26
47
 
27
- export const CHILD_GENERATED_HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
48
+ const HANDOFF_BOOTSTRAP_BASE = {
28
49
  mode: Type.Literal("generate"),
29
50
  sessionId: Type.String(),
30
51
  goal: Type.String(),
31
52
  title: Type.String(),
32
53
  parentSessionFile: Type.String(),
33
- requestResponse: Type.Optional(Type.Boolean()),
34
- });
54
+ sourceLeafId: Type.String(),
55
+ requestResponse: Type.Boolean(),
56
+ bootstrapMode: Type.Union([Type.Literal("review"), Type.Literal("automatic")]),
57
+ };
35
58
 
36
59
  export const HANDOFF_BOOTSTRAP_SCHEMA = Type.Union([
37
- IMMEDIATE_HANDOFF_BOOTSTRAP_SCHEMA,
38
- CHILD_GENERATED_HANDOFF_BOOTSTRAP_SCHEMA,
60
+ Type.Object({
61
+ ...HANDOFF_BOOTSTRAP_BASE,
62
+ launch: Type.Literal(SUBAGENT_LAUNCH),
63
+ subagent: HANDOFF_SUBAGENT_SCHEMA,
64
+ }),
65
+ Type.Object({
66
+ ...HANDOFF_BOOTSTRAP_BASE,
67
+ launch: HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA,
68
+ subagent: Type.Optional(Type.Never()),
69
+ }),
39
70
  ]);
40
71
 
72
+ export const HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA = Type.Object({
73
+ bootstrapEntryId: Type.String(),
74
+ reason: Type.Union([
75
+ Type.Literal("cancelled"),
76
+ Type.Literal("prefilled"),
77
+ Type.Literal("stale"),
78
+ Type.Literal("invalid"),
79
+ ]),
80
+ });
81
+
82
+ export type HandoffSubagent = Static<typeof HANDOFF_SUBAGENT_SCHEMA>;
41
83
  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
84
  export type HandoffBootstrap = Static<typeof HANDOFF_BOOTSTRAP_SCHEMA>;
85
+ export type ChildGeneratedHandoffBootstrap = HandoffBootstrap;
86
+ export type HandoffBootstrapConsumed = Static<typeof HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA>;
87
+ export type HandoffBootstrapConsumedReason = HandoffBootstrapConsumed["reason"];
47
88
 
48
89
  export function createHandoffSessionMetadata(
49
90
  goal: string,
50
- nextTask: string,
51
91
  initialPrompt: string,
52
92
  title: string,
93
+ launch: HandoffLaunchValue,
94
+ subagent: HandoffSubagent | undefined,
53
95
  ): HandoffSessionMetadata {
54
- const normalizedGoal = goal.trim();
55
- const normalizedNextTask = nextTask.trim() || normalizedGoal;
56
-
57
- return {
58
- origin: "handoff",
59
- goal: normalizedGoal,
60
- nextTask: normalizedNextTask,
96
+ const base = {
97
+ origin: "handoff" as const,
98
+ goal: goal.trim(),
61
99
  title,
62
100
  initial_prompt: initialPrompt.trim(),
63
101
  };
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,
76
- };
102
+ if (launch === SUBAGENT_LAUNCH) {
103
+ if (!subagent) {
104
+ throw new Error("A subagent handoff requires a subagent identity block.");
105
+ }
106
+ return { ...base, launch, subagent };
107
+ }
108
+ return { ...base, launch };
77
109
  }
78
110
 
79
111
  export function createChildGeneratedHandoffBootstrap(options: {
@@ -81,35 +113,86 @@ export function createChildGeneratedHandoffBootstrap(options: {
81
113
  goal: string;
82
114
  title: string;
83
115
  parentSessionFile: string;
84
- requestResponse?: boolean | undefined;
116
+ sourceLeafId: string;
117
+ requestResponse: boolean;
118
+ bootstrapMode: "review" | "automatic";
119
+ launch: HandoffLaunchValue;
120
+ subagent?: HandoffSubagent | undefined;
85
121
  }): ChildGeneratedHandoffBootstrap {
86
- return {
87
- mode: "generate",
122
+ const base = {
123
+ mode: "generate" as const,
88
124
  sessionId: options.sessionId,
89
125
  goal: options.goal.trim(),
90
126
  title: options.title.trim(),
91
127
  parentSessionFile: options.parentSessionFile,
92
- ...(options.requestResponse === undefined ? {} : { requestResponse: options.requestResponse }),
128
+ sourceLeafId: options.sourceLeafId,
129
+ requestResponse: options.requestResponse,
130
+ bootstrapMode: options.bootstrapMode,
93
131
  };
132
+ if (options.launch === SUBAGENT_LAUNCH) {
133
+ if (!options.subagent) {
134
+ throw new Error("A subagent handoff requires a subagent identity block.");
135
+ }
136
+ return { ...base, launch: options.launch, subagent: options.subagent };
137
+ }
138
+ return { ...base, launch: options.launch };
94
139
  }
95
140
 
96
- export function isChildGeneratedHandoffBootstrap(
97
- bootstrap: HandoffBootstrap,
98
- ): bootstrap is ChildGeneratedHandoffBootstrap {
99
- return "mode" in bootstrap && bootstrap.mode === "generate";
100
- }
141
+ export type PendingHandoffBootstrapScan =
142
+ | { kind: "pending"; entryId: string; bootstrap: HandoffBootstrap }
143
+ | { kind: "invalid"; entryId: string };
144
+
145
+ /**
146
+ * Finds the newest unconsumed pending bootstrap on a branch. Consumption is
147
+ * append-only: a delivered kickoff or consumed marker must reference the
148
+ * bootstrap entry it consumed.
149
+ */
150
+ export function findPendingHandoffBootstrap(
151
+ branch: readonly SessionEntry[],
152
+ ): PendingHandoffBootstrapScan | undefined {
153
+ const consumedEntryIds = new Set<string>();
154
+ for (const entry of branch) {
155
+ if (entry.type === "custom_message" && entry.customType === HANDOFF_KICKOFF_CUSTOM_TYPE) {
156
+ const details = safeParseTypeBoxValue(HANDOFF_KICKOFF_DETAILS_SCHEMA, entry.details);
157
+ if (details?.bootstrapEntryId) {
158
+ consumedEntryIds.add(details.bootstrapEntryId);
159
+ }
160
+ }
161
+ if (entry.type === "custom" && entry.customType === HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE) {
162
+ const consumed = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA, entry.data);
163
+ if (consumed) {
164
+ consumedEntryIds.add(consumed.bootstrapEntryId);
165
+ }
166
+ }
167
+ }
101
168
 
102
- export function encodeHandoffBootstrap(bootstrap: HandoffBootstrap): string {
103
- return Buffer.from(JSON.stringify(bootstrap), "utf8").toString("base64");
104
- }
169
+ for (let i = branch.length - 1; i >= 0; i -= 1) {
170
+ const entry = branch[i];
171
+ if (
172
+ entry?.type !== "custom" ||
173
+ entry.customType !== HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE ||
174
+ consumedEntryIds.has(entry.id)
175
+ ) {
176
+ continue;
177
+ }
105
178
 
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 {
111
- return undefined;
179
+ const bootstrap = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_SCHEMA, entry.data);
180
+ return bootstrap
181
+ ? { kind: "pending", entryId: entry.id, bootstrap }
182
+ : { kind: "invalid", entryId: entry.id };
112
183
  }
184
+
185
+ return undefined;
186
+ }
187
+
188
+ /**
189
+ * A session is *starting* when its active branch still carries an unconsumed,
190
+ * well-formed handoff bootstrap: it has been prepared but has not received its
191
+ * kickoff. An invalid bootstrap does not count — the child consumes it and
192
+ * proceeds as an ordinary session rather than kicking off a handoff.
193
+ */
194
+ export function isSessionStarting(branch: readonly SessionEntry[]): boolean {
195
+ return findPendingHandoffBootstrap(branch)?.kind === "pending";
113
196
  }
114
197
 
115
198
  export function parseHandoffSessionMetadata(value: unknown): HandoffSessionMetadata | undefined {
@@ -133,8 +216,14 @@ export function getHandoffMetadataFromEntries(
133
216
  return undefined;
134
217
  }
135
218
 
136
- export function hasUserMessages(entries: readonly SessionEntry[]): boolean {
137
- return entries.some((entry) => entry.type === "message" && entry.message.role === "user");
219
+ // Bootstrap freshness: an existing kickoff counts as a started conversation
220
+ // even though no native user message exists.
221
+ export function hasStartedConversation(entries: readonly SessionEntry[]): boolean {
222
+ return entries.some(
223
+ (entry) =>
224
+ (entry.type === "message" && entry.message.role === "user") ||
225
+ (entry.type === "custom_message" && entry.customType === HANDOFF_KICKOFF_CUSTOM_TYPE),
226
+ );
138
227
  }
139
228
 
140
229
  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,