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,125 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ModelRuntime,
4
+ SessionManager,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { installAsk } from "./session-ask/install.ts";
7
+ import { installAutoTitle } from "./session-auto-title/install.ts";
8
+ import { installHandoff } from "./session-handoff/install.ts";
9
+ import { installHooks } from "./session-hooks/install.ts";
10
+ import { installIndex } from "./session-index/install.ts";
11
+ import { installMessaging } from "./session-messaging/install.ts";
12
+ import {
13
+ createSessionCancelTool,
14
+ createSessionSendMessageTool,
15
+ } from "./session-messaging/pi/tools.ts";
16
+ import { installSearch } from "./session-search/install.ts";
17
+ import type { SessionLifecycle } from "./shared/composition.ts";
18
+ import { createSessionModelRuntime, type ModelRuntimeProvider } from "./shared/model-runtime.ts";
19
+ import { loadSettings } from "./shared/settings.ts";
20
+ import { installSubagents } from "./subagents/install.ts";
21
+
22
+ /**
23
+ * The single advertised entrypoint. It loads settings once, constructs each feature in
24
+ * dependency order, and wires them by constructor parameters.
25
+ * It also owns the single `session_start`/`session_shutdown` subscription so lifecycle order
26
+ * is deterministic: broker registration (messaging) resolves before any other feature hook.
27
+ */
28
+ export default function piSessions(pi: ExtensionAPI): void {
29
+ const settings = loadSettings();
30
+ let sessionEpoch = 0;
31
+
32
+ // The mirrored ModelRuntime is expensive to build and only changes across session
33
+ // boundaries, so cache it per epoch (the root's own invalidation signal) instead of
34
+ // rebuilding it on every auto-title, /title, session_ask, and handoff draft.
35
+ let cachedModelRuntime: { epoch: number; runtime: Promise<ModelRuntime> } | undefined;
36
+ const getModelRuntime: ModelRuntimeProvider = (modelRegistry) => {
37
+ if (cachedModelRuntime?.epoch !== sessionEpoch) {
38
+ cachedModelRuntime = {
39
+ epoch: sessionEpoch,
40
+ runtime: createSessionModelRuntime(modelRegistry),
41
+ };
42
+ }
43
+ return cachedModelRuntime.runtime;
44
+ };
45
+
46
+ const index = installIndex(pi, { settings });
47
+ const messaging = settings.features.messaging
48
+ ? installMessaging(pi, { settings, index })
49
+ : undefined;
50
+
51
+ const lifecycles: SessionLifecycle[] = [];
52
+ if (messaging) {
53
+ lifecycles.push(messaging);
54
+ }
55
+ const subagents =
56
+ settings.features.subagents && messaging
57
+ ? installSubagents(pi, { settings, messaging })
58
+ : undefined;
59
+ if (subagents) {
60
+ lifecycles.push(subagents);
61
+ }
62
+ if (messaging) {
63
+ pi.registerTool(
64
+ createSessionSendMessageTool(subagents ?? messaging, {
65
+ wakeCapable: Boolean(subagents),
66
+ getCachedRelationTo: messaging.getCachedRelationTo,
67
+ }),
68
+ );
69
+ pi.registerTool(createSessionCancelTool(subagents ?? messaging));
70
+ }
71
+ if (settings.features.handoff) {
72
+ const board = {
73
+ roster: subagents?.roster,
74
+ cancelSubagent: subagents
75
+ ? (sessionId: string) => subagents.cancelSession(sessionId)
76
+ : undefined,
77
+ listLiveSessions: messaging ? () => messaging.listSessions() : undefined,
78
+ readSessionEntries: (sessionFile: string) => SessionManager.open(sessionFile).getEntries(),
79
+ };
80
+ lifecycles.push(
81
+ installHandoff(pi, {
82
+ settings,
83
+ index,
84
+ getModelRuntime,
85
+ ...(subagents ? { getLaunchTargets: () => subagents.getLaunchTargets() } : {}),
86
+ board,
87
+ }),
88
+ );
89
+ }
90
+ if (settings.features.search) {
91
+ installSearch(pi, { settings, index, messaging, roster: subagents?.roster });
92
+ }
93
+ if (settings.features.ask) {
94
+ installAsk(pi, { settings, index, getModelRuntime });
95
+ }
96
+ if (settings.features.autoTitle) {
97
+ lifecycles.push(
98
+ installAutoTitle(pi, {
99
+ settings,
100
+ getModelRuntime,
101
+ getSessionEpoch: () => sessionEpoch,
102
+ }),
103
+ );
104
+ }
105
+ if (settings.features.hooks) {
106
+ lifecycles.push(installHooks(pi, { settings, index }));
107
+ }
108
+ // Messaging leads every session_start so the broker connection is registered before any
109
+ // other feature hook runs. A side effect is that messaging's first relation snapshot can
110
+ // predate hooks' index sync for this session; getCachedRelationTo self-heals on miss, so
111
+ // this ordering is intentional — do not reorder to "fix" it.
112
+ pi.on("session_start", async (event, ctx) => {
113
+ sessionEpoch += 1;
114
+ for (const lifecycle of lifecycles) {
115
+ await lifecycle.onSessionStart?.(event, ctx);
116
+ }
117
+ });
118
+
119
+ pi.on("session_shutdown", async (event, ctx) => {
120
+ sessionEpoch += 1;
121
+ for (let index = lifecycles.length - 1; index >= 0; index -= 1) {
122
+ await lifecycles[index]?.onSessionShutdown?.(event, ctx);
123
+ }
124
+ });
125
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionContext, ModelRuntime } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
4
  createAgentSession,
5
5
  DefaultResourceLoader,
@@ -8,7 +8,8 @@ import {
8
8
  SessionManager,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import { type Static, Type } from "typebox";
11
- import { resolveConfiguredModel } from "../shared/model.ts";
11
+ import { resolveAuthenticatedModel } from "../shared/model-resolution.ts";
12
+ import { freshenModel } from "../shared/model-runtime.ts";
12
13
  import {
13
14
  type SessionLineageRow,
14
15
  searchSessionChunks,
@@ -122,6 +123,7 @@ export interface SessionAskAgentResult {
122
123
 
123
124
  export async function runSessionAskAgent(params: {
124
125
  ctx: ExtensionContext;
126
+ modelRuntime: ModelRuntime;
125
127
  target: SessionLineageRow;
126
128
  question: string;
127
129
  indexPath: string;
@@ -129,13 +131,22 @@ export async function runSessionAskAgent(params: {
129
131
  thinkingLevel: ThinkingLevel | undefined;
130
132
  signal?: AbortSignal | undefined;
131
133
  }): Promise<SessionAskAgentResult | undefined> {
134
+ params.signal?.throwIfAborted();
132
135
  const navigationData = loadSessionNavigationData(params.target.sessionPath);
133
- const model =
134
- resolveConfiguredModel(params.ctx.modelRegistry.getAvailable(), params.askSettings?.model) ??
135
- params.ctx.model;
136
+ const configured = params.askSettings?.model
137
+ ? resolveAuthenticatedModel({
138
+ modelRuntime: params.modelRuntime,
139
+ modelPattern: params.askSettings.model,
140
+ })
141
+ : undefined;
142
+ const currentModel = params.ctx.model
143
+ ? freshenModel(params.modelRuntime, params.ctx.model)
144
+ : undefined;
145
+ const model = (configured?.ok ? configured.model : undefined) ?? currentModel;
136
146
  if (!model) {
137
147
  throw new Error("No active model is available for session_ask.");
138
148
  }
149
+ const configuredThinkingLevel = configured?.ok ? configured.thinkingLevel : undefined;
139
150
 
140
151
  let capturedArguments: ProvideResultsArgs | undefined;
141
152
  const searchSessionTool = defineTool({
@@ -234,7 +245,7 @@ export async function runSessionAskAgent(params: {
234
245
  description: "Return the final answer for session_ask.",
235
246
  promptSnippet: "Provide session ask result",
236
247
  promptGuidelines: [
237
- "Call provide_results exactly once when you are ready to answer. It is the final tool call and ends the session_ask sub-agent turn.",
248
+ "Call provide_results exactly once when you are ready to answer. It is the final tool call and ends the session_ask subagent turn.",
238
249
  ],
239
250
  parameters: PROVIDE_RESULTS_PARAMETERS,
240
251
  execute: async (_toolCallId, toolParams: ProvideResultsArgs) => {
@@ -261,9 +272,12 @@ export async function runSessionAskAgent(params: {
261
272
  noSkills: true,
262
273
  appendSystemPromptOverride: (base) => [...base, SESSION_ASK_NAVIGATION_SYSTEM_PROMPT],
263
274
  });
275
+ params.signal?.throwIfAborted();
264
276
  await resourceLoader.reload();
277
+ params.signal?.throwIfAborted();
265
278
 
266
- const thinkingLevel = params.askSettings?.thinkingLevel ?? params.thinkingLevel;
279
+ const thinkingLevel =
280
+ params.askSettings?.thinkingLevel ?? configuredThinkingLevel ?? params.thinkingLevel;
267
281
  const sessionManager = params.askSettings?.persistRuns
268
282
  ? SessionManager.create(cwd, getDefaultSessionAskRunsDir())
269
283
  : SessionManager.inMemory(cwd);
@@ -273,7 +287,7 @@ export async function runSessionAskAgent(params: {
273
287
  const { session } = await createAgentSession({
274
288
  cwd,
275
289
  model,
276
- modelRegistry: params.ctx.modelRegistry,
290
+ modelRuntime: params.modelRuntime,
277
291
  ...(thinkingLevel ? { thinkingLevel } : {}),
278
292
  tools: SESSION_ASK_AGENT_TOOLS,
279
293
  customTools: [searchSessionTool, sessionReadTool, provideResultsTool],
@@ -281,29 +295,35 @@ export async function runSessionAskAgent(params: {
281
295
  sessionManager,
282
296
  });
283
297
 
284
- const abortHandler = (): void => {
285
- void session.abort();
298
+ // Exactly one session.abort() runs no matter how many abort paths fire.
299
+ let nestedAbort: Promise<void> | undefined;
300
+ const startNestedAbort = (): void => {
301
+ nestedAbort ??= Promise.resolve(session.abort()).catch(() => {});
286
302
  };
287
303
 
288
304
  try {
289
- params.signal?.addEventListener("abort", abortHandler, { once: true });
305
+ params.signal?.addEventListener("abort", startNestedAbort, { once: true });
290
306
  if (params.signal?.aborted) {
291
- await session.abort();
292
- return undefined;
307
+ startNestedAbort();
293
308
  }
294
309
 
295
310
  for (let attempt = 1; attempt <= MAX_SESSION_ASK_ATTEMPTS; attempt += 1) {
311
+ params.signal?.throwIfAborted();
296
312
  const prompt =
297
313
  attempt === 1
298
314
  ? buildNavigationPrompt(navigationData, params.question)
299
315
  : `You did not call ${PROVIDE_RESULTS_TOOL_NAME}. Continue from your prior work and call ${PROVIDE_RESULTS_TOOL_NAME} exactly once with the final markdown answer.`;
300
316
  await session.prompt(prompt);
317
+ params.signal?.throwIfAborted();
301
318
  if (capturedArguments) {
302
319
  break;
303
320
  }
304
321
  }
305
322
  } finally {
306
- params.signal?.removeEventListener("abort", abortHandler);
323
+ params.signal?.removeEventListener("abort", startNestedAbort);
324
+ if (nestedAbort) {
325
+ await nestedAbort;
326
+ }
307
327
  session.dispose();
308
328
  }
309
329
 
@@ -1,39 +1,38 @@
1
- import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
- import { getKeybindings, type Keybinding, Text } from "@earendil-works/pi-tui";
1
+ import { type ExtensionAPI, SessionManager } from "@earendil-works/pi-coding-agent";
3
2
  import { Type } from "typebox";
4
- import { runSessionAskAgent } from "./session-ask/agent.ts";
3
+ import { isSessionStarting } from "../session-handoff/metadata.ts";
4
+ import type { IndexHandle } from "../shared/composition.ts";
5
+ import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
5
6
  import {
6
7
  getSessionById,
7
8
  type SessionLineageRow,
8
9
  withSessionIndex,
9
- } from "./shared/session-index/index.ts";
10
- import { formatSessionTitleOrShortId, isExactSessionId } from "./shared/session-ui.ts";
11
- import { loadSettings } from "./shared/settings.ts";
12
-
13
- const COLLAPSED_ANSWER_PREVIEW_ROWS = 6;
10
+ } from "../shared/session-index/index.ts";
11
+ import { isExactSessionId } from "../shared/session-ui.ts";
12
+ import type { SessionSettings } from "../shared/settings.ts";
13
+ import { runSessionAskAgent } from "./agent.ts";
14
+ import { renderSessionAskResult } from "./renderer.ts";
15
+ import type {
16
+ SessionAskProgressDetails,
17
+ SessionAskRelevantFile,
18
+ SessionAskResultDetails,
19
+ } from "./tool-contract.ts";
14
20
 
15
21
  interface SessionAskToolParams {
16
22
  session: string;
17
23
  question: string;
18
24
  }
19
25
 
20
- interface SessionAskRelevantFile {
21
- path: string;
22
- reason: string;
23
- }
24
-
25
- interface SessionAskToolDetails {
26
- answer?: string | undefined;
27
- debugSessionPath?: string | undefined;
28
- question?: string | undefined;
29
- relevantFiles?: SessionAskRelevantFile[] | undefined;
30
- sessionId?: string | undefined;
31
- sessionName?: string | undefined;
32
- sessionPath?: string | undefined;
33
- }
34
-
35
- export default function sessionAskExtension(pi: ExtensionAPI): void {
36
- const settings = loadSettings();
26
+ export function installAsk(
27
+ pi: ExtensionAPI,
28
+ deps: {
29
+ settings: SessionSettings;
30
+ index: IndexHandle;
31
+ getModelRuntime: ModelRuntimeProvider;
32
+ },
33
+ ): void {
34
+ const { settings } = deps;
35
+ const indexPath = deps.index.path;
37
36
 
38
37
  pi.registerTool({
39
38
  name: "session_ask",
@@ -60,12 +59,40 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
60
59
  throw new Error("session_ask requires a question.");
61
60
  }
62
61
 
63
- const resolvedTarget = resolveSessionAskTarget(sessionId, settings.index.path);
62
+ const resolvedTarget = resolveSessionAskTarget(sessionId, indexPath);
64
63
  if (!resolvedTarget.resolved) {
65
64
  throw new Error(resolvedTarget.error ?? "Unable to resolve session id.");
66
65
  }
67
66
 
68
- const progressDetails: SessionAskToolDetails = {
67
+ const startingAnswer = getStartingSessionAnswer(resolvedTarget.resolved.sessionPath);
68
+ if (startingAnswer) {
69
+ const details: SessionAskResultDetails = {
70
+ answer: startingAnswer,
71
+ relevantFiles: [],
72
+ sessionId: resolvedTarget.resolved.sessionId,
73
+ sessionName: resolvedTarget.resolved.sessionName,
74
+ sessionPath: resolvedTarget.resolved.sessionPath,
75
+ question,
76
+ };
77
+ return {
78
+ content: [
79
+ {
80
+ type: "text",
81
+ text: [
82
+ formatSessionAskHeader(
83
+ resolvedTarget.resolved.sessionId,
84
+ resolvedTarget.resolved.sessionName,
85
+ question,
86
+ ),
87
+ startingAnswer,
88
+ ].join("\n\n"),
89
+ },
90
+ ],
91
+ details,
92
+ };
93
+ }
94
+
95
+ const progressDetails: SessionAskProgressDetails = {
69
96
  question,
70
97
  sessionId: resolvedTarget.resolved.sessionId,
71
98
  sessionName: resolvedTarget.resolved.sessionName,
@@ -90,26 +117,26 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
90
117
  details: progressDetails,
91
118
  });
92
119
 
120
+ const modelRuntime = await deps.getModelRuntime(ctx.modelRegistry);
93
121
  const agentResult = await runSessionAskAgent({
94
122
  ctx,
123
+ modelRuntime,
95
124
  target: resolvedTarget.resolved,
96
125
  question,
97
- indexPath: settings.index.path,
126
+ indexPath,
98
127
  askSettings: settings.ask,
99
128
  thinkingLevel: pi.getThinkingLevel(),
100
129
  signal,
101
130
  });
102
131
 
103
- if (signal?.aborted) {
104
- throw new Error("Session ask was cancelled.");
105
- }
106
-
107
132
  const answer = agentResult?.answer || "Could not determine an answer from the session.";
108
133
  const relevantFiles = agentResult?.relevantFiles ?? [];
109
134
 
110
- const details: SessionAskToolDetails = {
135
+ const details: SessionAskResultDetails = {
111
136
  answer,
112
- debugSessionPath: agentResult?.debugSessionPath,
137
+ ...(agentResult?.debugSessionPath
138
+ ? { debugSessionPath: agentResult.debugSessionPath }
139
+ : {}),
113
140
  relevantFiles,
114
141
  sessionId: resolvedTarget.resolved.sessionId,
115
142
  sessionName: resolvedTarget.resolved.sessionName,
@@ -133,42 +160,21 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
133
160
  details,
134
161
  };
135
162
  },
136
- renderResult(result, { expanded, isPartial }, theme, context) {
137
- const details = result.details as SessionAskToolDetails | undefined;
138
- const content = result.content[0];
139
- if (content?.type !== "text") {
140
- return new Text(theme.fg("error", "No session output"), 0, 0);
141
- }
142
-
143
- if (context.isError) {
144
- return new Text(theme.fg("error", content.text), 0, 0);
145
- }
146
-
147
- if (isPartial) {
148
- const lines = [theme.bold(theme.fg("warning", "Reading session..."))];
149
- if (details?.sessionId || details?.sessionName) {
150
- const identity = formatSessionTitleOrShortId(details.sessionName, details.sessionId);
151
- lines.push(`title: ${theme.fg("accent", identity)}`);
152
- }
153
- if (details?.question) {
154
- lines.push(theme.fg("muted", `prompt: ${details.question}`));
155
- }
156
- return new Text(lines.join("\n"), 0, 0);
157
- }
158
-
159
- const answer = (details?.answer ?? "").trim() || "No answer generated.";
160
- const identity = formatSessionTitleOrShortId(details?.sessionName, details?.sessionId);
161
- const lines = [`title: ${theme.bold(identity)}`];
162
- if (details?.question) {
163
- lines.push(theme.fg("muted", `prompt: ${details.question}`));
164
- lines.push("");
165
- }
166
- lines.push(...formatSessionAskAnswerPreview(answer, expanded, theme));
167
- return new Text(lines.join("\n"), 0, 0);
168
- },
163
+ renderResult: renderSessionAskResult,
169
164
  });
170
165
  }
171
166
 
167
+ function getStartingSessionAnswer(sessionPath: string): string | undefined {
168
+ try {
169
+ if (isSessionStarting(SessionManager.open(sessionPath).getBranch())) {
170
+ return "This session is still starting: a handoff is in progress and it has not received its kickoff. Retry once the session is no longer starting.";
171
+ }
172
+ } catch {
173
+ // The navigation agent preserves the existing failure behavior for unreadable sessions.
174
+ }
175
+ return undefined;
176
+ }
177
+
172
178
  function resolveSessionAskTarget(
173
179
  sessionId: string,
174
180
  indexPath: string,
@@ -220,26 +226,3 @@ function formatSessionAskAnswer(
220
226
  }
221
227
  return lines.join("\n");
222
228
  }
223
-
224
- function formatSessionAskAnswerPreview(answer: string, expanded: boolean, theme: Theme): string[] {
225
- const lines = answer.split(/\r?\n/);
226
- if (expanded || lines.length <= COLLAPSED_ANSWER_PREVIEW_ROWS) {
227
- return lines;
228
- }
229
-
230
- return [
231
- ...lines.slice(0, COLLAPSED_ANSWER_PREVIEW_ROWS),
232
- formatOverflowHint(lines.length - COLLAPSED_ANSWER_PREVIEW_ROWS, lines.length, theme),
233
- ];
234
- }
235
-
236
- function formatOverflowHint(remaining: number, total: number, theme: Theme): string {
237
- return `${theme.fg("muted", `... (${remaining} more lines, ${total} total,`)} ${theme.fg(
238
- "dim",
239
- formatKeyHint("app.tools.expand"),
240
- )}${theme.fg("muted", " to expand)")}`;
241
- }
242
-
243
- function formatKeyHint(keybinding: Keybinding): string {
244
- return getKeybindings().getKeys(keybinding).join("/");
245
- }
@@ -7,6 +7,7 @@ import {
7
7
  SessionManager,
8
8
  type SessionMessageEntry,
9
9
  } from "@earendil-works/pi-coding-agent";
10
+ import { HANDOFF_KICKOFF_CUSTOM_TYPE } from "../session-handoff/kickoff.ts";
10
11
  import { contentToText, isRecord, truncateBlock, truncateInline } from "../shared/text.ts";
11
12
 
12
13
  const MAX_TOOL_RESULT_PREVIEW_CHARS = 300;
@@ -726,7 +727,8 @@ function renderEntryContent(entry: SessionEntry, body: SessionReadBodyMode = "fu
726
727
  return renderMessageEntry(entry, body);
727
728
  }
728
729
  if (entry.type === "custom_message") {
729
- return `[Custom]: ${String(entry.content ?? "")}`;
730
+ const label = entry.customType === HANDOFF_KICKOFF_CUSTOM_TYPE ? "Handoff kickoff" : "Custom";
731
+ return `[${label}]: ${String(entry.content ?? "")}`;
730
732
  }
731
733
  if (entry.type === "compaction" || entry.type === "branch_summary") {
732
734
  return String(entry.summary ?? "");
@@ -0,0 +1,23 @@
1
+ import type { ExpandableContentPresentation } from "../shared/rendering/expandable-content-layout.ts";
2
+ import type { RenderTheme } from "../shared/rendering/theme.ts";
3
+ import { formatSessionTitleOrShortId } from "../shared/session-ui.ts";
4
+ import type { SessionAskViewModel } from "./view-model.ts";
5
+
6
+ export function buildSessionAskPresentation(
7
+ model: SessionAskViewModel,
8
+ theme: RenderTheme,
9
+ ): ExpandableContentPresentation {
10
+ const identity = formatSessionTitleOrShortId(model.sessionName, model.sessionId);
11
+ return {
12
+ header: `title: ${theme.bold(identity)}`,
13
+ ...(model.question
14
+ ? {
15
+ metadata: [theme.fg("muted", `prompt: ${model.question}`)],
16
+ }
17
+ : {}),
18
+ body: {
19
+ text: model.answer,
20
+ collapsedRows: 6,
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,63 @@
1
+ import type {
2
+ AgentToolResult,
3
+ Theme,
4
+ ToolRenderResultOptions,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { type Component, Text } from "@earendil-works/pi-tui";
7
+ import { ExpandableContentLayout } from "../shared/rendering/expandable-content-layout.ts";
8
+ import { formatSessionTitleOrShortId } from "../shared/session-ui.ts";
9
+ import { safeParseTypeBoxValue } from "../shared/typebox.ts";
10
+ import { buildSessionAskPresentation } from "./presenter.ts";
11
+ import {
12
+ SESSION_ASK_PROGRESS_DETAILS_SCHEMA,
13
+ SESSION_ASK_RESULT_DETAILS_SCHEMA,
14
+ } from "./tool-contract.ts";
15
+ import { buildSessionAskView } from "./view-model.ts";
16
+
17
+ interface SessionAskToolRendererState {
18
+ layout?: ExpandableContentLayout | undefined;
19
+ }
20
+
21
+ interface SessionAskToolRenderContext {
22
+ state: SessionAskToolRendererState;
23
+ isError: boolean;
24
+ }
25
+
26
+ export function renderSessionAskResult(
27
+ result: AgentToolResult<unknown>,
28
+ options: ToolRenderResultOptions,
29
+ theme: Theme,
30
+ context: SessionAskToolRenderContext,
31
+ ): Component {
32
+ const content = result.content[0];
33
+ if (content?.type !== "text") {
34
+ return new Text(theme.fg("error", "No session output"), 0, 0);
35
+ }
36
+
37
+ if (context.isError) {
38
+ return new Text(theme.fg("error", content.text), 0, 0);
39
+ }
40
+
41
+ if (options.isPartial) {
42
+ const details = safeParseTypeBoxValue(SESSION_ASK_PROGRESS_DETAILS_SCHEMA, result.details);
43
+ const lines = [theme.bold(theme.fg("warning", "Reading session..."))];
44
+ if (details) {
45
+ const identity = formatSessionTitleOrShortId(details.sessionName, details.sessionId);
46
+ lines.push(`title: ${theme.fg("accent", identity)}`);
47
+ }
48
+ if (details?.question) {
49
+ lines.push(theme.fg("muted", `prompt: ${details.question}`));
50
+ }
51
+ return new Text(lines.join("\n"), 0, 0);
52
+ }
53
+
54
+ const details = safeParseTypeBoxValue(SESSION_ASK_RESULT_DETAILS_SCHEMA, result.details);
55
+ if (!details) {
56
+ return new Text(content.text, 0, 0);
57
+ }
58
+
59
+ const layout = context.state.layout ?? new ExpandableContentLayout(theme);
60
+ context.state.layout = layout;
61
+ layout.update(buildSessionAskPresentation(buildSessionAskView(details), theme), options.expanded);
62
+ return layout;
63
+ }
@@ -0,0 +1,27 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ export const SESSION_ASK_RELEVANT_FILE_SCHEMA = Type.Object({
4
+ path: Type.String(),
5
+ reason: Type.String(),
6
+ });
7
+
8
+ export const SESSION_ASK_PROGRESS_DETAILS_SCHEMA = Type.Object({
9
+ question: Type.String({ minLength: 1 }),
10
+ sessionId: Type.String({ minLength: 1 }),
11
+ sessionName: Type.String(),
12
+ sessionPath: Type.String({ minLength: 1 }),
13
+ });
14
+
15
+ export const SESSION_ASK_RESULT_DETAILS_SCHEMA = Type.Object({
16
+ answer: Type.String({ minLength: 1 }),
17
+ debugSessionPath: Type.Optional(Type.String({ minLength: 1 })),
18
+ question: Type.String({ minLength: 1 }),
19
+ relevantFiles: Type.Array(SESSION_ASK_RELEVANT_FILE_SCHEMA),
20
+ sessionId: Type.String({ minLength: 1 }),
21
+ sessionName: Type.String(),
22
+ sessionPath: Type.String({ minLength: 1 }),
23
+ });
24
+
25
+ export type SessionAskRelevantFile = Static<typeof SESSION_ASK_RELEVANT_FILE_SCHEMA>;
26
+ export type SessionAskProgressDetails = Static<typeof SESSION_ASK_PROGRESS_DETAILS_SCHEMA>;
27
+ export type SessionAskResultDetails = Static<typeof SESSION_ASK_RESULT_DETAILS_SCHEMA>;
@@ -0,0 +1,18 @@
1
+ import { normalizeOptionalText } from "../shared/text.ts";
2
+ import type { SessionAskResultDetails } from "./tool-contract.ts";
3
+
4
+ export interface SessionAskViewModel {
5
+ sessionId: string;
6
+ sessionName?: string | undefined;
7
+ question: string;
8
+ answer: string;
9
+ }
10
+
11
+ export function buildSessionAskView(details: SessionAskResultDetails): SessionAskViewModel {
12
+ return {
13
+ sessionId: details.sessionId.trim(),
14
+ sessionName: normalizeOptionalText(details.sessionName),
15
+ question: details.question.trim(),
16
+ answer: details.answer.trim(),
17
+ };
18
+ }