pi-sessions 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +71 -37
  2. package/extensions/pi-sessions.ts +125 -0
  3. package/extensions/session-ask/agent.ts +34 -14
  4. package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
  5. package/extensions/session-ask/navigate.ts +3 -1
  6. package/extensions/session-ask/presenter.ts +23 -0
  7. package/extensions/session-ask/renderer.ts +63 -0
  8. package/extensions/session-ask/tool-contract.ts +27 -0
  9. package/extensions/session-ask/view-model.ts +18 -0
  10. package/extensions/session-auto-title/generate.ts +18 -18
  11. package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +67 -46
  12. package/extensions/session-auto-title/model.ts +25 -17
  13. package/extensions/session-auto-title/retitle.ts +26 -12
  14. package/extensions/session-auto-title/wizard.ts +12 -6
  15. package/extensions/session-handoff/board-view-model.ts +181 -0
  16. package/extensions/session-handoff/board.ts +663 -0
  17. package/extensions/session-handoff/bootstrap.ts +174 -0
  18. package/extensions/session-handoff/extract.ts +101 -122
  19. package/extensions/session-handoff/install.ts +225 -0
  20. package/extensions/session-handoff/kickoff.ts +92 -0
  21. package/extensions/session-handoff/launch/backend.ts +16 -0
  22. package/extensions/session-handoff/launch/deferred.ts +20 -0
  23. package/extensions/session-handoff/launch/ghostty.ts +99 -0
  24. package/extensions/session-handoff/launch/resolution.ts +57 -0
  25. package/extensions/session-handoff/launch/shell.ts +7 -0
  26. package/extensions/session-handoff/launch/tmux.ts +31 -0
  27. package/extensions/session-handoff/launch-options.ts +61 -0
  28. package/extensions/session-handoff/launch-target.ts +91 -0
  29. package/extensions/session-handoff/metadata.ts +152 -63
  30. package/extensions/session-handoff/model.ts +58 -0
  31. package/extensions/session-handoff/query.ts +0 -1
  32. package/extensions/session-handoff/receipt.ts +96 -0
  33. package/extensions/session-handoff/review.ts +2 -28
  34. package/extensions/session-handoff/spawn.ts +76 -181
  35. package/extensions/session-handoff/strong-modal.ts +13 -0
  36. package/extensions/session-handoff/tool-contract.ts +12 -0
  37. package/extensions/session-handoff/tool-presenter.ts +35 -0
  38. package/extensions/session-handoff/tool-renderer.ts +66 -0
  39. package/extensions/session-handoff/tool-schema.ts +54 -0
  40. package/extensions/session-handoff/tool-view-model.ts +49 -0
  41. package/extensions/session-handoff/tool.ts +206 -0
  42. package/extensions/session-handoff/ui.ts +66 -0
  43. package/extensions/session-hooks/install.ts +75 -0
  44. package/extensions/{session-index.ts → session-index/install.ts} +9 -7
  45. package/extensions/session-messaging/broker/process.ts +21 -28
  46. package/extensions/session-messaging/install.ts +78 -0
  47. package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
  48. package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
  49. package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
  50. package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
  51. package/extensions/session-messaging/pi/message-contracts.ts +32 -0
  52. package/extensions/session-messaging/pi/renderer.ts +19 -12
  53. package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
  54. package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
  55. package/extensions/session-messaging/pi/service.ts +262 -81
  56. package/extensions/session-messaging/pi/tools.ts +184 -40
  57. package/extensions/session-search/extract.ts +60 -14
  58. package/extensions/session-search/hooks.ts +10 -13
  59. package/extensions/session-search/install.ts +373 -0
  60. package/extensions/session-search/renderer.ts +195 -0
  61. package/extensions/session-search/tool-contract.ts +51 -0
  62. package/extensions/shared/composition.ts +20 -0
  63. package/extensions/shared/errors.ts +3 -0
  64. package/extensions/shared/model-resolution.ts +73 -0
  65. package/extensions/shared/model-runtime.ts +33 -0
  66. package/extensions/shared/model.ts +7 -10
  67. package/extensions/shared/rendering/collapsible-text.ts +89 -0
  68. package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
  69. package/extensions/shared/rendering/theme.ts +5 -0
  70. package/extensions/shared/session-broker/client.ts +30 -29
  71. package/extensions/shared/session-broker/protocol.ts +128 -10
  72. package/extensions/shared/session-index/common.ts +5 -5
  73. package/extensions/shared/session-index/lineage.ts +0 -3
  74. package/extensions/shared/session-index/schema.ts +0 -1
  75. package/extensions/shared/session-index/scoring.ts +1 -2
  76. package/extensions/shared/session-index/search.ts +11 -4
  77. package/extensions/shared/session-index/store.ts +4 -9
  78. package/extensions/shared/settings.ts +80 -38
  79. package/extensions/shared/text.ts +5 -0
  80. package/extensions/shared/thinking-levels.ts +22 -0
  81. package/extensions/shared/tmux.ts +205 -0
  82. package/extensions/subagents/cancel.ts +111 -0
  83. package/extensions/subagents/classify.ts +52 -0
  84. package/extensions/subagents/install.ts +344 -0
  85. package/extensions/subagents/launch-target.ts +75 -0
  86. package/extensions/subagents/ledger.ts +217 -0
  87. package/extensions/subagents/reconcile.ts +386 -0
  88. package/extensions/subagents/report-message-presenter.ts +36 -0
  89. package/extensions/subagents/report-message-renderer.ts +21 -0
  90. package/extensions/subagents/report-message-view-model.ts +29 -0
  91. package/extensions/subagents/report.ts +159 -0
  92. package/extensions/subagents/roster.ts +262 -0
  93. package/extensions/subagents/wake.ts +173 -0
  94. package/images/handoff-board-subagents.png +0 -0
  95. package/images/handoff-board-user-sessions.png +0 -0
  96. package/images/handoff-subagent-report.png +0 -0
  97. package/images/session-handoff-tool.png +0 -0
  98. package/package.json +10 -16
  99. package/extensions/session-handoff.ts +0 -741
  100. package/extensions/session-hooks.ts +0 -75
  101. package/extensions/session-messaging/pi/message-view.ts +0 -131
  102. package/extensions/session-messaging.ts +0 -30
  103. package/extensions/session-search.ts +0 -419
  104. package/extensions/shared/session-broker/active.ts +0 -26
package/README.md CHANGED
@@ -8,7 +8,21 @@
8
8
 
9
9
  ![session picker](images/session_picker.png)
10
10
 
11
- ### Handoffs
11
+ ### Handoff board
12
+
13
+ ![handoff board showing subagents](images/handoff-board-subagents.png)
14
+
15
+ ![handoff board showing user sessions](images/handoff-board-user-sessions.png)
16
+
17
+ ### Subagent report
18
+
19
+ ![subagent report card in the parent session](images/handoff-subagent-report.png)
20
+
21
+ ### Session handoff
22
+
23
+ ![session handoff tool call](images/session-handoff-tool.png)
24
+
25
+ ### Handoff prompt review
12
26
 
13
27
  ![handoff preview](images/handoff.png)
14
28
 
@@ -20,7 +34,7 @@
20
34
 
21
35
  ## Install
22
36
 
23
- Requires Pi `0.80.2` or newer and Node `>=24 <26`.
37
+ Requires Pi `0.80.10` or newer and Node `>=24 <26`.
24
38
 
25
39
  **From npm** (recommended):
26
40
 
@@ -46,20 +60,36 @@ What session did I implement the db layer?
46
60
  ```
47
61
 
48
62
  ```text
49
- /handoff i want to implement the frontend component now
63
+ Open the frontend implementation task in a session to the right.
50
64
  ```
51
65
 
52
66
  ## Features
53
67
 
54
- | Extension | Surface | What it does |
55
- | ------------------ | ------------------------------------- | ------------------------------------------------------- |
56
- | Session Search | `session_search` pi tool | Search through old sessions |
57
- | Session Ask | `session_ask` pi tool | Ask questions about old sessions |
58
- | Session Handoff | `/handoff`, `session_handoff` pi tool | Start a focused new session; alternative to compaction |
59
- | Session Messaging | `session_send_message` pi tool | Send messages between running Pi sessions |
60
- | Session Picker | `Alt+O` | Reference old sessions in your prompt |
61
- | Session Index | `/session-index` slash command | Shows index status and rebuilds the local session index |
62
- | Session Auto Title | in background, `/title` slash command | Give sessions titles |
68
+ | Extension | Surface | What it does |
69
+ | ------------------ | ------------------------------------------------- | ------------------------------------------------------- |
70
+ | Session Search | `session_search` pi tool | Search through old sessions |
71
+ | Session Ask | `session_ask` pi tool | Ask questions about old sessions |
72
+ | Session Handoff | `session_handoff` pi tool, `/handoff` board | Start and manage focused child sessions |
73
+ | Session Messaging | `session_send_message`, `session_cancel` pi tools | Coordinate between live Pi sessions and own subagents |
74
+ | Session Picker | `Alt+O` | Reference old sessions in your prompt |
75
+ | Session Index | `/session-index` slash command | Shows index status and rebuilds the local session index |
76
+ | Session Auto Title | in background, `/title` slash command | Give sessions titles |
77
+
78
+ Every feature is on by default. Turn one off with `enable: false` under its own settings namespace, which unregisters its tools and hooks.
79
+
80
+ ```json
81
+ {
82
+ "sessions": {
83
+ "messaging": { "enable": true },
84
+ "subagents": { "enable": true },
85
+ "handoff": { "enable": true },
86
+ "search": { "enable": true },
87
+ "ask": { "enable": true },
88
+ "autoTitle": { "enable": true },
89
+ "hooks": { "enable": true }
90
+ }
91
+ }
92
+ ```
63
93
 
64
94
  ## Session Search
65
95
 
@@ -72,40 +102,25 @@ File filters distinguish read-or-write evidence from write-only evidence:
72
102
  - `files.touched`: sessions that read or changed a path
73
103
  - `files.changed`: sessions that changed a path
74
104
 
75
- ## Session Handoff
76
-
77
- `/handoff <goal>` starts a focused new session. Give pi a goal, and it will generate a prompt for you to review before kicking it off.
78
-
79
- You can either start a new session directly in your current one, or if you have Ghostty on macOS, you can spawn a new one in a split-pane and continue in your current:
80
-
81
- - `/handoff --left <goal>`
82
- - `/handoff --right <goal>`
83
- - `/handoff --up <goal>`
84
- - `/handoff --down <goal>`
85
-
86
- The flag indicates the Ghostty split direction.
105
+ Use `kind: "user"` or `kind: "subagent"` to filter by session type across the whole index. Use `relationScope: "branch"` to search subagents launched from the current conversation branch, or `relationScope: "tree"` to include subagents launched from abandoned branches as well.
87
106
 
88
- Flow:
89
-
90
- - run `/handoff [--<direction>] <goal>`
91
- - review the generated prompt preview
92
- - optionally edit the prompt
93
- - start the new session
107
+ ## Session Handoff
94
108
 
95
- If you do nothing, the preview autostarts after a short countdown.
109
+ The `session_handoff` tool lets the agent create a child session with a self-contained task. Ask for a direction when you want a visible split, ask for a deferred handoff when you only want the prepared session, or let the agent delegate suitable independent work to a background subagent.
96
110
 
97
- When running in Ghostty on macOS, pi-sessions also exposes a `session_handoff` tool. This lets the agent start a background handoff after you choose a split direction. The current session keeps running, while the child session opens in the requested split, gathers context, and shows the same review countdown before starting.
111
+ Directional launches use tmux when the current terminal is inside tmux, or Ghostty on macOS. Deferred launches create the child without starting it and copy its resume command to the clipboard. A background subagent runs in a detached tmux window and reports back when finished.
98
112
 
99
- If background handoffs ever target the wrong pane, run `/handoff --identify` from the intended source pane to refresh the in-memory Ghostty terminal binding.
113
+ Run `/handoff` to open the **Handoffs** board. The Subagents and User sessions tabs show status, age, launch details, and the actions currently available: stop, copy an observation command, or copy a resume command.
100
114
 
101
115
  ## Session Messaging
102
116
 
103
- Agents can coordinate with other currently running Pi sessions:
117
+ Agents can coordinate with live Pi sessions and their own subagents:
104
118
 
105
119
  - `session_search` with `live: true` lists live sessions
106
- - `session_send_message` sends a message to another live session
120
+ - `session_send_message` sends a message to a live session or own subagent
121
+ - `session_cancel` aborts another live session's current turn
107
122
 
108
- Incoming messages start the recipient agent when idle and steer it when already running. Inactive sessions cannot receive messages, but you can still use `session_search` and `session_ask` with them.
123
+ Incoming messages start the recipient agent when idle and steer it when already running. Messaging a dormant owned subagent resumes it automatically; other inactive sessions cannot receive messages, but you can still use `session_search` and `session_ask` with them.
109
124
 
110
125
  ## Session picker
111
126
 
@@ -124,7 +139,24 @@ If you want to override the shortcut, put this in your `~/.pi/agent/settings.jso
124
139
  {
125
140
  "sessions": {
126
141
  "handoff": {
127
- "pickerShortcut": "alt+p"
142
+ "pickerShortcut": "alt+p",
143
+ "deferred": {
144
+ "copyToClipboard": true
145
+ }
146
+ }
147
+ }
148
+ }
149
+ ```
150
+
151
+ `deferred.copyToClipboard` (default `true`) controls whether deferred handoffs copy the resume command to the clipboard. When off, the resume command is only shown in the tool call.
152
+
153
+ Subagents require the handoff and messaging features. Limit recursive delegation depth with `sessions.subagents.maxDepth` (default `2`):
154
+
155
+ ```json
156
+ {
157
+ "sessions": {
158
+ "subagents": {
159
+ "maxDepth": 2
128
160
  }
129
161
  }
130
162
  }
@@ -190,7 +222,9 @@ To change auto-titling settings, edit `~/.pi/agent/settings.json`:
190
222
  "sessions": {
191
223
  "autoTitle": {
192
224
  "refreshTurns": 4,
225
+ "timeoutSecs": 15,
193
226
  "model": "anthropic/claude-haiku-4-5",
227
+ "thinkingLevel": "off",
194
228
  "prompt": "Custom prompt that overrides the default."
195
229
  }
196
230
  }
@@ -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 ?? "");