pi-sessions 0.9.0 → 0.11.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 (42) hide show
  1. package/README.md +29 -20
  2. package/dist/session-messaging/broker/client-frame.js +62 -0
  3. package/dist/session-messaging/broker/process.js +234 -0
  4. package/dist/shared/session-broker/framing.js +59 -0
  5. package/dist/shared/session-broker/socket-path.js +19 -0
  6. package/extensions/pi-sessions.ts +14 -1
  7. package/extensions/session-auto-title/generate.ts +56 -66
  8. package/extensions/session-auto-title/install.ts +2 -0
  9. package/extensions/session-auto-title/runs.ts +50 -0
  10. package/extensions/session-handoff/board-view-model.ts +2 -4
  11. package/extensions/session-handoff/bootstrap.ts +14 -16
  12. package/extensions/session-handoff/extract.ts +34 -16
  13. package/extensions/session-handoff/install.ts +1 -1
  14. package/extensions/session-handoff/kickoff.ts +4 -3
  15. package/extensions/session-handoff/launch-target.ts +1 -1
  16. package/extensions/session-handoff/metadata.ts +72 -23
  17. package/extensions/session-messaging/broker/client-frame.ts +113 -0
  18. package/extensions/session-messaging/broker/process.ts +12 -21
  19. package/extensions/session-messaging/broker/spawn.ts +12 -7
  20. package/extensions/session-messaging/pi/reachable-contract.ts +59 -0
  21. package/extensions/session-messaging/pi/reachable-presenter.ts +49 -0
  22. package/extensions/session-messaging/pi/reachable-tool.ts +190 -0
  23. package/extensions/session-messaging/pi/reachable-view-model.ts +59 -0
  24. package/extensions/session-messaging/pi/renderer.ts +1 -1
  25. package/extensions/session-messaging/pi/tools.ts +8 -8
  26. package/extensions/session-search/hooks.ts +45 -52
  27. package/extensions/session-search/install.ts +3 -158
  28. package/extensions/session-search/reindex.ts +3 -3
  29. package/extensions/session-search/renderer.ts +1 -32
  30. package/extensions/session-search/tool-contract.ts +1 -21
  31. package/extensions/shared/model-runtime.ts +8 -1
  32. package/extensions/shared/session-broker/client.ts +3 -4
  33. package/extensions/shared/session-broker/framing.ts +6 -7
  34. package/extensions/shared/session-index/lineage.ts +6 -2
  35. package/extensions/shared/session-index/sqlite.ts +44 -17
  36. package/extensions/shared/settings.ts +25 -9
  37. package/extensions/shared/tmux.ts +22 -0
  38. package/extensions/subagents/classify.ts +6 -0
  39. package/extensions/subagents/install.ts +22 -137
  40. package/extensions/subagents/report-message-renderer.ts +2 -2
  41. package/extensions/subagents/settle.ts +182 -0
  42. package/package.json +18 -26
@@ -1,13 +1,10 @@
1
- import { appendFileSync, mkdirSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
2
  import type { Api, Model, TextContent, UserMessage } from "@earendil-works/pi-ai";
6
3
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
7
4
  import type { AutoTitleContext } from "./context.ts";
5
+ import { type AutoTitleRun, startAutoTitleRun } from "./runs.ts";
8
6
  import type { AutoTitleTrigger } from "./state.ts";
9
7
 
10
- const AUTO_TITLE_MAX_TOKENS = 64;
11
8
  const AUTO_TITLE_CHAR_MAX = 80;
12
9
 
13
10
  export interface AutoTitleFailure {
@@ -20,7 +17,9 @@ export interface AutoTitleFailure {
20
17
  export interface AutoTitleGeneration {
21
18
  systemPrompt: string;
22
19
  timeoutMs: number;
20
+ tokenBudget: number;
23
21
  thinkingLevel: ThinkingLevel | undefined;
22
+ persistRuns: boolean;
24
23
  }
25
24
 
26
25
  export type AutoTitleGenerationResult =
@@ -66,35 +65,47 @@ export async function generateAutoTitle(
66
65
  const abortController = new AbortController();
67
66
  const timeoutId = setTimeout(() => abortController.abort(), generation.timeoutMs);
68
67
  const thinkingLevel = generation.thinkingLevel;
68
+ const run = generation.persistRuns
69
+ ? startAutoTitleRun({
70
+ cwd: context.cwd ?? process.cwd(),
71
+ model,
72
+ trigger,
73
+ systemPrompt: resolvedSystemPrompt,
74
+ tokenBudget: generation.tokenBudget,
75
+ thinkingLevel,
76
+ message,
77
+ })
78
+ : undefined;
69
79
 
70
80
  try {
71
- const requestContext = {
72
- systemPrompt: resolvedSystemPrompt,
73
- messages: [message],
74
- };
75
- writeAutoTitleDebugRequest(model, trigger, requestContext);
76
- const response = await modelRuntime.completeSimple(model, requestContext, {
77
- maxTokens: AUTO_TITLE_MAX_TOKENS,
78
- ...(thinkingLevel && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
79
- signal: abortController.signal,
80
- });
81
- writeAutoTitleDebugResponse(model, trigger, response);
81
+ const response = await modelRuntime.completeSimple(
82
+ model,
83
+ {
84
+ systemPrompt: resolvedSystemPrompt,
85
+ messages: [message],
86
+ },
87
+ {
88
+ maxTokens: generation.tokenBudget,
89
+ ...(thinkingLevel && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
90
+ signal: abortController.signal,
91
+ },
92
+ );
93
+ run?.recordResponse(response);
82
94
 
83
95
  if (response.stopReason === "error" || response.stopReason === "aborted") {
84
96
  const fallbackMessage =
85
97
  response.stopReason === "aborted" ? "Request was aborted." : "Provider returned an error.";
86
- return {
87
- ok: false,
88
- failure: createAutoTitleFailure(trigger, model, response.errorMessage || fallbackMessage),
89
- };
98
+ return failGeneration(run, trigger, model, response.errorMessage || fallbackMessage);
90
99
  }
91
100
 
92
101
  const normalizedTitle = normalizeGeneratedAutoTitle(extractResponseText(response.content));
93
102
  if (!normalizedTitle) {
94
- return {
95
- ok: false,
96
- failure: createAutoTitleFailure(trigger, model, "Model returned an empty title."),
97
- };
103
+ return failGeneration(
104
+ run,
105
+ trigger,
106
+ model,
107
+ describeEmptyTitle(response.stopReason === "length", generation.tokenBudget),
108
+ );
98
109
  }
99
110
 
100
111
  return {
@@ -102,10 +113,7 @@ export async function generateAutoTitle(
102
113
  title: normalizedTitle,
103
114
  };
104
115
  } catch (error) {
105
- return {
106
- ok: false,
107
- failure: createAutoTitleFailure(trigger, model, extractErrorMessage(error)),
108
- };
116
+ return failGeneration(run, trigger, model, extractErrorMessage(error));
109
117
  } finally {
110
118
  clearTimeout(timeoutId);
111
119
  }
@@ -124,6 +132,27 @@ export function createAutoTitleFailure(
124
132
  };
125
133
  }
126
134
 
135
+ function failGeneration(
136
+ run: AutoTitleRun | undefined,
137
+ trigger: AutoTitleTrigger,
138
+ model: Model<Api>,
139
+ message: string,
140
+ ): AutoTitleGenerationResult {
141
+ run?.recordFailure(message);
142
+ return {
143
+ ok: false,
144
+ failure: createAutoTitleFailure(trigger, model, message),
145
+ };
146
+ }
147
+
148
+ function describeEmptyTitle(hitTokenBudget: boolean, tokenBudget: number): string {
149
+ if (!hitTokenBudget) {
150
+ return "Model returned an empty title.";
151
+ }
152
+
153
+ return `Model spent its ${tokenBudget}-token budget without producing a title. Raise sessions.autoTitle.tokenBudget if the model reasons before answering.`;
154
+ }
155
+
127
156
  function buildAutoTitleSystemPrompt(systemPrompt: string, shouldPreserveTitle: boolean): string {
128
157
  if (!shouldPreserveTitle) {
129
158
  return systemPrompt;
@@ -170,45 +199,6 @@ function normalizeGeneratedAutoTitle(value: string): string | undefined {
170
199
  return truncated || undefined;
171
200
  }
172
201
 
173
- function writeAutoTitleDebugRequest(
174
- model: Model<Api>,
175
- trigger: AutoTitleTrigger,
176
- request: unknown,
177
- ): void {
178
- writeAutoTitleDebugEntry({
179
- phase: "request",
180
- trigger,
181
- model: formatModelLabel(model),
182
- request,
183
- });
184
- }
185
-
186
- function writeAutoTitleDebugResponse(
187
- model: Model<Api>,
188
- trigger: AutoTitleTrigger,
189
- response: unknown,
190
- ): void {
191
- writeAutoTitleDebugEntry({
192
- phase: "response",
193
- trigger,
194
- model: formatModelLabel(model),
195
- response,
196
- });
197
- }
198
-
199
- function writeAutoTitleDebugEntry(entry: Record<string, unknown>): void {
200
- try {
201
- const dir = join(homedir(), ".pi", "agent", "pi-sessions");
202
- mkdirSync(dir, { recursive: true });
203
- appendFileSync(
204
- join(dir, "auto-title-debug.jsonl"),
205
- `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`,
206
- );
207
- } catch {
208
- // Debug logging must not affect title generation.
209
- }
210
- }
211
-
212
202
  function extractResponseText(content: unknown[]): string {
213
203
  return content
214
204
  .filter(
@@ -53,7 +53,9 @@ export function installAutoTitle(
53
53
  ): AutoTitleGeneration => ({
54
54
  systemPrompt: settings.autoTitle.prompt,
55
55
  timeoutMs: settings.autoTitle.timeoutMs,
56
+ tokenBudget: settings.autoTitle.tokenBudget,
56
57
  thinkingLevel: settings.autoTitle.thinkingLevel ?? resolution?.thinkingLevel,
58
+ persistRuns: settings.autoTitle.persistRuns,
57
59
  });
58
60
 
59
61
  pi.registerCommand("title", {
@@ -0,0 +1,50 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { Api, AssistantMessage, Model, UserMessage } from "@earendil-works/pi-ai";
3
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import { getDefaultAutoTitleRunsDir } from "../shared/settings.ts";
5
+ import type { AutoTitleTrigger } from "./state.ts";
6
+
7
+ export const AUTO_TITLE_RUN_REQUEST_CUSTOM_TYPE = "pi-sessions.auto-title-request";
8
+ export const AUTO_TITLE_RUN_FAILURE_CUSTOM_TYPE = "pi-sessions.auto-title-failure";
9
+
10
+ export interface AutoTitleRunRequest {
11
+ cwd: string;
12
+ model: Model<Api>;
13
+ trigger: AutoTitleTrigger;
14
+ systemPrompt: string;
15
+ tokenBudget: number;
16
+ thinkingLevel: ThinkingLevel | undefined;
17
+ message: UserMessage;
18
+ }
19
+
20
+ export interface AutoTitleRun {
21
+ sessionPath: string | undefined;
22
+ recordResponse(response: AssistantMessage): void;
23
+ recordFailure(message: string): void;
24
+ }
25
+
26
+ /** Records one auto-title request as a standalone Pi session so the exact prompt can be replayed. */
27
+ export function startAutoTitleRun(request: AutoTitleRunRequest): AutoTitleRun {
28
+ const sessionManager = SessionManager.create(request.cwd, getDefaultAutoTitleRunsDir());
29
+ sessionManager.appendSessionInfo(`auto-title (${request.trigger})`);
30
+ sessionManager.appendModelChange(request.model.provider, request.model.id);
31
+ if (request.thinkingLevel) {
32
+ sessionManager.appendThinkingLevelChange(request.thinkingLevel);
33
+ }
34
+ sessionManager.appendCustomEntry(AUTO_TITLE_RUN_REQUEST_CUSTOM_TYPE, {
35
+ trigger: request.trigger,
36
+ systemPrompt: request.systemPrompt,
37
+ tokenBudget: request.tokenBudget,
38
+ });
39
+ sessionManager.appendMessage(request.message);
40
+
41
+ return {
42
+ sessionPath: sessionManager.getSessionFile(),
43
+ recordResponse(response) {
44
+ sessionManager.appendMessage(response);
45
+ },
46
+ recordFailure(message) {
47
+ sessionManager.appendCustomEntry(AUTO_TITLE_RUN_FAILURE_CUSTOM_TYPE, { message });
48
+ },
49
+ };
50
+ }
@@ -1,11 +1,9 @@
1
1
  import { tmuxSessionName } from "../shared/tmux.ts";
2
- import type { SubagentState } from "../subagents/classify.ts";
2
+ import { isRunningSubagentState, type SubagentState } from "../subagents/classify.ts";
3
3
  import type { SubagentRosterEntry } from "../subagents/roster.ts";
4
4
  import { shellQuote } from "./launch/shell.ts";
5
5
  import type { HandoffLaunchReceipt } from "./receipt.ts";
6
6
 
7
- const RUNNING_SUBAGENT_STATES: ReadonlySet<SubagentState> = new Set(["starting", "busy", "active"]);
8
-
9
7
  export type HandoffBoardTab = "subagents" | "user-sessions";
10
8
  export type UserSessionStatus = "live" | "ready" | "starting" | "closed" | "unknown";
11
9
 
@@ -169,7 +167,7 @@ function buildUserSessionAction(
169
167
  function buildSubagentAction(entry: SubagentRosterEntry, insideTmux: boolean): HandoffBoardAction {
170
168
  return {
171
169
  subagent: entry,
172
- canStop: RUNNING_SUBAGENT_STATES.has(entry.state),
170
+ canStop: isRunningSubagentState(entry.state),
173
171
  ...(entry.tmuxWindowId ? { observeCommand: buildObserveCommand(entry, insideTmux) } : {}),
174
172
  ...(!entry.managedLive ? { resumeCommand: entry.resumeCommand } : {}),
175
173
  };
@@ -2,9 +2,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { SessionManager } from "@earendil-works/pi-coding-agent";
4
4
  import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
5
+ import type { HandoffSettings } from "../shared/settings.ts";
5
6
  import { generateHandoffDraftFromSessionManager } from "./extract.ts";
6
7
  import { buildHandoffKickoffMessage, buildHandoffKickoffSource } from "./kickoff.ts";
7
- import { SUBAGENT_LAUNCH } from "./launch-target.ts";
8
8
  import {
9
9
  type ChildGeneratedHandoffBootstrap,
10
10
  createHandoffSessionMetadata,
@@ -23,8 +23,8 @@ export async function consumePendingHandoffBootstrap(
23
23
  pi: ExtensionAPI,
24
24
  ctx: ExtensionContext,
25
25
  getModelRuntime: ModelRuntimeProvider,
26
- thinkingLevel: ThinkingLevel | undefined,
27
- persistRuns: boolean,
26
+ destinationThinkingLevel: ThinkingLevel | undefined,
27
+ handoffSettings: HandoffSettings,
28
28
  ): Promise<void> {
29
29
  const scan = findPendingHandoffBootstrap(ctx.sessionManager.getBranch());
30
30
  if (!scan) {
@@ -56,8 +56,8 @@ export async function consumePendingHandoffBootstrap(
56
56
  scan.entryId,
57
57
  consumeBootstrap,
58
58
  getModelRuntime,
59
- thinkingLevel,
60
- persistRuns,
59
+ destinationThinkingLevel,
60
+ handoffSettings,
61
61
  );
62
62
  }
63
63
 
@@ -68,8 +68,8 @@ async function startChildGeneratedHandoff(
68
68
  bootstrapEntryId: string,
69
69
  consumeBootstrap: (reason: HandoffBootstrapConsumedReason) => void,
70
70
  getModelRuntime: ModelRuntimeProvider,
71
- thinkingLevel: ThinkingLevel | undefined,
72
- persistRuns: boolean,
71
+ destinationThinkingLevel: ThinkingLevel | undefined,
72
+ handoffSettings: HandoffSettings,
73
73
  ): Promise<void> {
74
74
  const entries = ctx.sessionManager.getEntries();
75
75
  if (hasStartedConversation(entries)) {
@@ -96,17 +96,17 @@ async function startChildGeneratedHandoff(
96
96
  ctx,
97
97
  "Generating handoff draft...",
98
98
  async (signal: AbortSignal) =>
99
- generateHandoffDraftFromSessionManager(
99
+ generateHandoffDraftFromSessionManager({
100
100
  ctx,
101
101
  modelRuntime,
102
102
  sourceSessionManager,
103
- bootstrap.sourceLeafId,
104
- bootstrap.goal,
105
- thinkingLevel,
106
- persistRuns,
103
+ sourceLeafId: bootstrap.sourceLeafId,
104
+ goal: bootstrap.goal,
105
+ settings: handoffSettings,
106
+ destinationThinkingLevel,
107
107
  signal,
108
- bootstrap.requestResponse,
109
- ),
108
+ requestResponse: bootstrap.requestResponse,
109
+ }),
110
110
  );
111
111
  if (!generatedDraft) {
112
112
  consumeBootstrap("cancelled");
@@ -140,13 +140,11 @@ async function startChildGeneratedHandoff(
140
140
 
141
141
  // The tool-provided bootstrap title is authoritative; extraction does not
142
142
  // replace it with a second generated title.
143
- const subagent = bootstrap.launch === SUBAGENT_LAUNCH ? bootstrap.subagent : undefined;
144
143
  const metadata = createHandoffSessionMetadata(
145
144
  bootstrap.goal,
146
145
  prompt,
147
146
  bootstrap.title,
148
147
  bootstrap.launch,
149
- subagent,
150
148
  );
151
149
  if (!getHandoffMetadataFromEntries(ctx.sessionManager.getEntries())) {
152
150
  pi.appendEntry(HANDOFF_METADATA_CUSTOM_TYPE, metadata);
@@ -24,8 +24,9 @@ import {
24
24
  } from "@earendil-works/pi-coding-agent";
25
25
  import { type Static, Type } from "typebox";
26
26
  import { freshenModel } from "../shared/model-runtime.ts";
27
- import { getDefaultHandoffRunsDir } from "../shared/settings.ts";
27
+ import { getDefaultHandoffRunsDir, type HandoffSettings } from "../shared/settings.ts";
28
28
  import { parseTypeBoxValue } from "../shared/typebox.ts";
29
+ import { resolveModelOverride } from "./model.ts";
29
30
 
30
31
  const MAX_HANDOFF_EXTRACTION_ATTEMPTS = 3;
31
32
  const HANDOFF_EXTRACTION_RETRY_PROMPT =
@@ -88,22 +89,39 @@ export function resolveHandoffSource(
88
89
  return sessionContext;
89
90
  }
90
91
 
91
- export async function generateHandoffDraftFromSessionManager(
92
- ctx: ExtensionContext,
93
- modelRuntime: ModelRuntime,
94
- sourceSessionManager: ExtensionContext["sessionManager"],
95
- sourceLeafId: string,
96
- goal: string,
97
- thinkingLevel: ThinkingLevel | undefined,
98
- persistRuns: boolean,
99
- signal?: AbortSignal,
92
+ export async function generateHandoffDraftFromSessionManager({
93
+ ctx,
94
+ modelRuntime,
95
+ sourceSessionManager,
96
+ sourceLeafId,
97
+ goal,
98
+ settings,
99
+ destinationThinkingLevel,
100
+ signal,
100
101
  requestResponse = false,
101
- ): Promise<HandoffDraftResult | undefined> {
102
- if (!ctx.model) {
103
- throw new Error("No model is available for handoff.");
102
+ }: {
103
+ ctx: ExtensionContext;
104
+ modelRuntime: ModelRuntime;
105
+ sourceSessionManager: ExtensionContext["sessionManager"];
106
+ sourceLeafId: string;
107
+ goal: string;
108
+ settings: HandoffSettings;
109
+ destinationThinkingLevel: ThinkingLevel | undefined;
110
+ signal?: AbortSignal | undefined;
111
+ requestResponse?: boolean | undefined;
112
+ }): Promise<HandoffDraftResult | undefined> {
113
+ const modelOverride = settings.model
114
+ ? resolveModelOverride(modelRuntime, settings.model, settings.thinkingLevel)
115
+ : undefined;
116
+ let model = modelOverride?.model;
117
+ if (!model) {
118
+ if (!ctx.model) {
119
+ throw new Error("No model is available for handoff.");
120
+ }
121
+ model = freshenModel(modelRuntime, ctx.model);
104
122
  }
105
-
106
- const model = freshenModel(modelRuntime, ctx.model);
123
+ const thinkingLevel =
124
+ modelOverride?.thinkingLevel ?? settings.thinkingLevel ?? destinationThinkingLevel;
107
125
  const sessionContext = resolveHandoffSource(sourceSessionManager, sourceLeafId);
108
126
 
109
127
  const conversationText = serializeConversation(convertToLlm(sessionContext.messages));
@@ -114,7 +132,7 @@ export async function generateHandoffDraftFromSessionManager(
114
132
  conversationText,
115
133
  goal,
116
134
  thinkingLevel,
117
- persistRuns,
135
+ settings.persistRuns,
118
136
  signal,
119
137
  );
120
138
  if (!handoffContext) {
@@ -214,7 +214,7 @@ export function installHandoff(
214
214
  ctx,
215
215
  deps.getModelRuntime,
216
216
  pi.getThinkingLevel(),
217
- settings.handoff.persistRuns,
217
+ settings.handoff,
218
218
  );
219
219
  },
220
220
  };
@@ -55,22 +55,23 @@ export function buildHandoffKickoffSource(source: HandoffKickoffSource): Handoff
55
55
  };
56
56
  }
57
57
 
58
- export const renderHandoffKickoffMessage: MessageRenderer = (message, _options, theme) => {
58
+ export const renderHandoffKickoffMessage: MessageRenderer = (message, options, theme) => {
59
59
  const details = safeParseTypeBoxValue(HANDOFF_KICKOFF_DETAILS_SCHEMA, message.details);
60
60
  if (!details) {
61
61
  return undefined;
62
62
  }
63
63
 
64
64
  const prompt = typeof message.content === "string" ? message.content : "";
65
- return createHandoffKickoffComponent(details, prompt, theme);
65
+ return createHandoffKickoffComponent(details, prompt, options.outputPad, theme);
66
66
  };
67
67
 
68
68
  export function createHandoffKickoffComponent(
69
69
  details: HandoffKickoffDetails,
70
70
  prompt: string,
71
+ outputPad: number,
71
72
  theme: RenderTheme,
72
73
  ): Component {
73
- const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text));
74
+ const box = new Box(outputPad, 1, (text: string) => theme.bg("customMessageBg", text));
74
75
  box.addChild(new Text(renderHandoffKickoffView(details, prompt, theme), 0, 0));
75
76
  return box;
76
77
  }
@@ -59,7 +59,7 @@ export interface HandoffLaunchTarget {
59
59
  description?: string | undefined;
60
60
  requestResponseDefault: boolean;
61
61
  bootstrapMode: "review" | "automatic";
62
- /** Subagent launches stamp their child's identity into the durable handoff record. */
62
+ /** Subagent launches stamp their child's identity into the child bootstrap. */
63
63
  describeSubagentChild?(input: {
64
64
  childSessionId: string;
65
65
  ownerSessionId: string;
@@ -3,6 +3,7 @@ import { type Static, Type } from "typebox";
3
3
  import { safeParseTypeBoxValue } from "../shared/typebox.ts";
4
4
  import { HANDOFF_KICKOFF_CUSTOM_TYPE, HANDOFF_KICKOFF_DETAILS_SCHEMA } from "./kickoff.ts";
5
5
  import {
6
+ HANDOFF_LAUNCH_VALUE_SCHEMA,
6
7
  HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA,
7
8
  type HandoffLaunchValue,
8
9
  SUBAGENT_LAUNCH,
@@ -16,7 +17,7 @@ export const HANDOFF_STALE_SESSION_MESSAGE =
16
17
  export const SESSION_STARTING_MESSAGE =
17
18
  "Target session is still starting. wait for it to show up in session_search, then resend.";
18
19
 
19
- // A subagent's self-knowledge, stamped into the durable handoff record so the
20
+ // A subagent's self-knowledge, stamped into its child-local bootstrap so the
20
21
  // child never opens the parent transcript to learn who it is.
21
22
  export const HANDOFF_SUBAGENT_SCHEMA = Type.Object({
22
23
  childSessionId: Type.String(),
@@ -32,18 +33,10 @@ const HANDOFF_METADATA_BASE = {
32
33
  initial_prompt: Type.String(),
33
34
  };
34
35
 
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
- ]);
36
+ export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Object({
37
+ ...HANDOFF_METADATA_BASE,
38
+ launch: HANDOFF_LAUNCH_VALUE_SCHEMA,
39
+ });
47
40
 
48
41
  const HANDOFF_BOOTSTRAP_BASE = {
49
42
  mode: Type.Literal("generate"),
@@ -91,21 +84,14 @@ export function createHandoffSessionMetadata(
91
84
  initialPrompt: string,
92
85
  title: string,
93
86
  launch: HandoffLaunchValue,
94
- subagent: HandoffSubagent | undefined,
95
87
  ): HandoffSessionMetadata {
96
- const base = {
97
- origin: "handoff" as const,
88
+ return {
89
+ origin: "handoff",
98
90
  goal: goal.trim(),
99
91
  title,
100
92
  initial_prompt: initialPrompt.trim(),
93
+ launch,
101
94
  };
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 };
109
95
  }
110
96
 
111
97
  export function createChildGeneratedHandoffBootstrap(options: {
@@ -195,6 +181,69 @@ export function isSessionStarting(branch: readonly SessionEntry[]): boolean {
195
181
  return findPendingHandoffBootstrap(branch)?.kind === "pending";
196
182
  }
197
183
 
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) {
236
+ return undefined;
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;
245
+ }
246
+
198
247
  export function parseHandoffSessionMetadata(value: unknown): HandoffSessionMetadata | undefined {
199
248
  return safeParseTypeBoxValue(HANDOFF_SESSION_METADATA_SCHEMA, value);
200
249
  }