pi-sessions 0.18.1 → 0.20.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 (39) hide show
  1. package/README.md +64 -39
  2. package/extensions/hosts/contract.ts +10 -46
  3. package/extensions/hosts/registry.ts +4 -78
  4. package/extensions/pi-sessions.ts +27 -53
  5. package/extensions/session-handoff/agent-profiles.ts +110 -0
  6. package/extensions/session-handoff/board-loading.ts +4 -1
  7. package/extensions/session-handoff/board-view-model.ts +1 -0
  8. package/extensions/session-handoff/board.ts +4 -1
  9. package/extensions/session-handoff/install.ts +78 -34
  10. package/extensions/session-handoff/launch/backend.ts +16 -4
  11. package/extensions/session-handoff/launch-options.ts +2 -17
  12. package/extensions/session-handoff/launch-target.ts +8 -10
  13. package/extensions/session-handoff/metadata.ts +10 -0
  14. package/extensions/session-handoff/model.ts +22 -0
  15. package/extensions/session-handoff/receipt.ts +1 -1
  16. package/extensions/session-handoff/spawn.ts +6 -0
  17. package/extensions/session-handoff/tool-presenter.ts +1 -1
  18. package/extensions/session-handoff/tool-schema.ts +14 -9
  19. package/extensions/session-handoff/tool-view-model.ts +6 -2
  20. package/extensions/session-handoff/tool.ts +20 -6
  21. package/extensions/session-messaging/directory-list.ts +115 -0
  22. package/extensions/session-messaging/directory.ts +114 -0
  23. package/extensions/session-messaging/message-router.ts +3 -8
  24. package/extensions/session-messaging/pi/directory-tools.ts +41 -0
  25. package/extensions/session-messaging/pi/install.ts +102 -0
  26. package/extensions/session-messaging/pi/message-contracts.ts +4 -6
  27. package/extensions/session-messaging/pi/reachable-contract.ts +3 -4
  28. package/extensions/session-messaging/pi/reachable-tool.ts +25 -175
  29. package/extensions/session-messaging/pi/send-message-presenter.ts +2 -1
  30. package/extensions/session-messaging/pi/tools.ts +49 -91
  31. package/extensions/session-search/extract.ts +12 -2
  32. package/extensions/shared/lineage.ts +8 -0
  33. package/extensions/shared/session-index/sync.ts +3 -0
  34. package/extensions/shared/settings.ts +21 -5
  35. package/extensions/subagents/install.ts +11 -31
  36. package/extensions/subagents/launch-target.ts +2 -1
  37. package/extensions/subagents/reconcile.ts +10 -2
  38. package/extensions/subagents/settle.ts +2 -0
  39. package/package.json +5 -5
@@ -5,6 +5,7 @@ import type { SendMessageViewModel } from "./send-message-view-model.ts";
5
5
  export function buildSendMessagePresentation(
6
6
  model: SendMessageViewModel,
7
7
  theme: RenderTheme,
8
+ toolName = "session_send_message",
8
9
  ): ExpandableContentPresentation {
9
10
  const target =
10
11
  model.status === "delivered"
@@ -18,7 +19,7 @@ export function buildSendMessagePresentation(
18
19
  const action = model.status === "delivered" ? "delivered to" : "to";
19
20
 
20
21
  return {
21
- header: `${theme.fg("toolTitle", theme.bold("session_send_message"))} ${theme.fg(
22
+ header: `${theme.fg("toolTitle", theme.bold(toolName))} ${theme.fg(
22
23
  "muted",
23
24
  `${action} ${target}${metadataHint}`,
24
25
  )}`,
@@ -1,4 +1,8 @@
1
- import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ defineTool,
3
+ type ExtensionContext,
4
+ type ToolDefinition,
5
+ } from "@earendil-works/pi-coding-agent";
2
6
  import { Text } from "@earendil-works/pi-tui";
3
7
  import { formatError } from "../../shared/errors.ts";
4
8
  import { ExpandableContentLayout } from "../../shared/rendering/expandable-content-layout.ts";
@@ -26,10 +30,11 @@ import { buildDeliveredMessageView, buildSendingMessageView } from "./send-messa
26
30
  import type { CancelSessionResult, SendMessageRequest, SendMessageResult } from "./service.ts";
27
31
 
28
32
  interface SendMessageService {
29
- sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
33
+ sendMessage(request: SendMessageRequest, ctx: ExtensionContext): Promise<SendMessageResult>;
30
34
  }
31
35
 
32
- interface ManagedCancelSessionResult {
36
+ /** A subagent's cancel goes through its parent's ledger, which reports the state it reconciled to. */
37
+ export interface ManagedCancelSessionResult {
33
38
  kind: "managed";
34
39
  accepted: true;
35
40
  target: {
@@ -43,17 +48,15 @@ interface ManagedCancelSessionResult {
43
48
  }
44
49
 
45
50
  interface CancelSessionService {
46
- cancelSession(sessionId: string): Promise<CancelSessionResult | ManagedCancelSessionResult>;
51
+ cancelSession(
52
+ sessionId: string,
53
+ ctx: ExtensionContext,
54
+ ): Promise<CancelSessionResult | ManagedCancelSessionResult>;
47
55
  }
48
56
 
49
- export type SessionSendMessageRole =
50
- | { kind: "plain" }
51
- | { kind: "wakeCapable" }
52
- | { kind: "subagent" };
53
-
54
57
  export interface SessionSendMessageToolOptions {
55
- role: SessionSendMessageRole;
56
- hostedSessions?: boolean;
58
+ name: string;
59
+ wording: SendMessageToolWording;
57
60
  getCachedRelationTo(sessionId: string | undefined): string | undefined;
58
61
  /**
59
62
  * Resolved on every call: subagent identity depends on the active branch, so a rewind can
@@ -62,10 +65,9 @@ export interface SessionSendMessageToolOptions {
62
65
  getParentSessionId?: (() => string | undefined) | undefined;
63
66
  }
64
67
 
65
- export type SessionCancelRole = { kind: "plain" } | { kind: "subagent" };
66
-
67
68
  export interface SessionCancelToolOptions {
68
- role: SessionCancelRole;
69
+ name: string;
70
+ wording: SendMessageToolWording;
69
71
  /**
70
72
  * Resolved on every call: subagent identity depends on the active branch, so a rewind can
71
73
  * turn a plain session into a subagent without re-registering this tool.
@@ -73,38 +75,12 @@ export interface SessionCancelToolOptions {
73
75
  getParentSessionId?: (() => string | undefined) | undefined;
74
76
  }
75
77
 
76
- interface SendMessageToolWording {
78
+ export interface SendMessageToolWording {
77
79
  description: string;
78
80
  promptSnippet: string;
79
81
  promptGuidelines: string[];
80
82
  }
81
83
 
82
- function getSendMessageWording(role: SessionSendMessageRole): SendMessageToolWording {
83
- switch (role.kind) {
84
- case "plain":
85
- return {
86
- description: "Send a message to another live pi session.",
87
- promptSnippet: "Send a message to another live pi session",
88
- promptGuidelines: ["Use session_reachable to list live sessions and find the target."],
89
- };
90
- case "wakeCapable":
91
- return {
92
- description: "Send a message to another live pi session or a subagent.",
93
- promptSnippet: "Send a message to another pi session or a subagent",
94
- promptGuidelines: ["Use session_reachable to discover live sessions and owned subagents."],
95
- };
96
- case "subagent":
97
- return {
98
- description: "Send a message to another subagent.",
99
- promptSnippet: "Send a message to another subagent",
100
- promptGuidelines: [
101
- "Use session_reachable to discover reachable subagents.",
102
- "Report to your parent with submit_task_report; it cannot be reached with session_send_message.",
103
- ],
104
- };
105
- }
106
- }
107
-
108
84
  interface SendMessageRendererState {
109
85
  callComponent?: ExpandableContentLayout | undefined;
110
86
  }
@@ -113,21 +89,14 @@ export function createSessionSendMessageTool(
113
89
  service: SendMessageService,
114
90
  options: SessionSendMessageToolOptions,
115
91
  ): ToolDefinition {
116
- const { description, promptSnippet, promptGuidelines } = getSendMessageWording(options.role);
92
+ const { description, promptSnippet, promptGuidelines } = options.wording;
117
93
 
118
94
  return defineTool({
119
- name: "session_send_message",
95
+ name: options.name,
120
96
  label: "Send Message to Session",
121
97
  description,
122
98
  promptSnippet,
123
- promptGuidelines: [
124
- ...promptGuidelines,
125
- ...(options.hostedSessions
126
- ? [
127
- "session_reachable also lists dormant hosted sessions; sending a message wakes them automatically.",
128
- ]
129
- : []),
130
- ],
99
+ promptGuidelines,
131
100
  parameters: SEND_MESSAGE_PARAMS,
132
101
  renderCall(args, theme, context) {
133
102
  const state = context.state as SendMessageRendererState;
@@ -137,12 +106,13 @@ export function createSessionSendMessageTool(
137
106
  buildSendMessagePresentation(
138
107
  buildSendingMessageView(args, options.getCachedRelationTo(args?.session)),
139
108
  theme,
109
+ options.name,
140
110
  ),
141
111
  context.expanded,
142
112
  );
143
113
  return component;
144
114
  },
145
- renderResult(result, options, theme, context) {
115
+ renderResult(result, renderOptions, theme, context) {
146
116
  if (context.isError) {
147
117
  const output = getFirstText(result);
148
118
  return new Text(output ? `\n${theme.fg("error", output)}` : "", 0, 0);
@@ -155,29 +125,36 @@ export function createSessionSendMessageTool(
155
125
  }
156
126
 
157
127
  state.callComponent.update(
158
- buildSendMessagePresentation(buildDeliveredMessageView(context.args, details), theme),
159
- options.expanded,
128
+ buildSendMessagePresentation(
129
+ buildDeliveredMessageView(context.args, details),
130
+ theme,
131
+ options.name,
132
+ ),
133
+ renderOptions.expanded,
160
134
  );
161
135
  return new Text("", 0, 0);
162
136
  },
163
- async execute(toolCallId, params: SendMessageParams, _signal, _onUpdate, _ctx) {
164
- const target = parseSessionTarget(params.session, "session_send_message");
137
+ async execute(toolCallId, params: SendMessageParams, _signal, _onUpdate, ctx) {
138
+ const target = parseSessionTarget(params.session, options.name);
165
139
  if (target === options.getParentSessionId?.()) {
166
140
  throw new Error("The parent session cannot be messaged. Use submit_task_report.");
167
141
  }
168
142
 
169
143
  const body = params.message.trim();
170
144
  if (!body) {
171
- throw new Error("session_send_message requires a non-empty message.");
145
+ throw new Error(`${options.name} requires a non-empty message.`);
172
146
  }
173
147
 
174
148
  try {
175
- const result = await service.sendMessage({
176
- target,
177
- body,
178
- requestResponse: params.requestResponse,
179
- sourceToolCallId: toolCallId,
180
- });
149
+ const result = await service.sendMessage(
150
+ {
151
+ target,
152
+ body,
153
+ requestResponse: params.requestResponse,
154
+ sourceToolCallId: toolCallId,
155
+ },
156
+ ctx,
157
+ );
181
158
  if (!result.delivered) {
182
159
  throw new Error(result.error ?? "Message was not delivered.");
183
160
  }
@@ -203,28 +180,14 @@ export function createSessionSendMessageTool(
203
180
  });
204
181
  }
205
182
 
206
- function getCancelPromptGuidelines(role: SessionCancelRole): string[] {
207
- switch (role.kind) {
208
- case "plain":
209
- return ["Only cancel a user session when the user directs it."];
210
- case "subagent":
211
- return [
212
- "Only cancel a user session when the user directs it.",
213
- "Never cancel your parent session; it is waiting on your report.",
214
- ];
215
- }
216
- }
217
-
218
183
  export function createSessionCancelTool(
219
184
  service: CancelSessionService,
220
185
  options: SessionCancelToolOptions,
221
186
  ): ToolDefinition {
222
187
  return defineTool<typeof CANCEL_SESSION_PARAMS, CancelSessionToolDetails>({
223
- name: "session_cancel",
188
+ name: options.name,
224
189
  label: "Cancel a running session",
225
- description: "Cancel another running pi session",
226
- promptSnippet: "Cancel another running pi session",
227
- promptGuidelines: getCancelPromptGuidelines(options.role),
190
+ ...options.wording,
228
191
  parameters: CANCEL_SESSION_PARAMS,
229
192
  renderResult(result, options, theme, context) {
230
193
  const output = getFirstText(result);
@@ -244,14 +207,17 @@ export function createSessionCancelTool(
244
207
  }
245
208
  return new Text(text, 0, 0);
246
209
  },
247
- async execute(_toolCallId, params: CancelSessionParams) {
248
- const target = parseSessionTarget(params.session, "session_cancel");
210
+ async execute(_toolCallId, params: CancelSessionParams, _signal, _onUpdate, ctx) {
211
+ const target = parseSessionTarget(params.session, options.name);
249
212
  if (target === options.getParentSessionId?.()) {
250
213
  throw new Error("The parent session cannot be cancelled. It is waiting on your report.");
251
214
  }
252
215
 
253
- const result = await service.cancelSession(target);
216
+ const result = await service.cancelSession(target, ctx);
254
217
  if (result.kind === "managed") {
218
+ if (!isConfirmedManagedCancellation(result.state)) {
219
+ throw new Error(buildUnknownCancellationError(result.target));
220
+ }
255
221
  const details: CancelSessionToolDetails = {
256
222
  kind: "managed",
257
223
  accepted: true,
@@ -260,16 +226,8 @@ export function createSessionCancelTool(
260
226
  ...(result.cancelId === undefined ? {} : { cancelId: result.cancelId }),
261
227
  ...(result.relation === undefined ? {} : { relation: result.relation }),
262
228
  };
263
- if (!isConfirmedManagedCancellation(result.state)) {
264
- throw new Error(buildUnknownCancellationError(result.target));
265
- }
266
229
  return {
267
- content: [
268
- {
269
- type: "text" as const,
270
- text: buildCancelSessionModelText(details),
271
- },
272
- ],
230
+ content: [{ type: "text" as const, text: buildCancelSessionModelText(details) }],
273
231
  details,
274
232
  };
275
233
  }
@@ -23,6 +23,7 @@ import {
23
23
  type HandoffSessionMetadata,
24
24
  parseHandoffSessionMetadata,
25
25
  } from "../session-handoff/metadata.ts";
26
+ import { LINEAGE_CUSTOM_TYPE, LINEAGE_SCHEMA, type SessionLineage } from "../shared/lineage.ts";
26
27
  import type { SessionOrigin } from "../shared/session-index/index.ts";
27
28
  import { contentToText, isRecord } from "../shared/text.ts";
28
29
  import { safeParseTypeBoxValue } from "../shared/typebox.ts";
@@ -97,6 +98,7 @@ export interface SessionEntryScan {
97
98
  sessionNameEntryId?: string | undefined;
98
99
  sessionNameTs?: string | undefined;
99
100
  handoffMetadata?: DurableHandoffMetadataRecord | undefined;
101
+ lineage?: SessionLineage | undefined;
100
102
  }
101
103
 
102
104
  export interface SessionTailBaseline {
@@ -194,7 +196,10 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
194
196
  chunks.push(...scan.chunks);
195
197
  appendDurableHandoffMetadataChunks(chunks, scan.handoffMetadata);
196
198
 
197
- const parentSessionPath = normalizeParentSessionPath(header.parentSession);
199
+ const lineage = header.parentSession ? undefined : scan.lineage;
200
+ const parentSessionPath = normalizeParentSessionPath(
201
+ header.parentSession ?? lineage?.parentSessionFile,
202
+ );
198
203
  const parentSession = parentSessionPath
199
204
  ? inspectParentSession(parentSessionPath, header.id, sessionPath)
200
205
  : undefined;
@@ -211,7 +216,7 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
211
216
  messageCount: scan.messageCount,
212
217
  entryCount: scan.entryCount,
213
218
  parentSessionPath,
214
- parentSessionId: parentSession?.sessionId,
219
+ parentSessionId: lineage?.parentSessionId ?? parentSession?.sessionId,
215
220
  sessionOrigin: deriveSessionOrigin(
216
221
  parentSessionPath,
217
222
  parentSession?.launchedSubagent ?? false,
@@ -306,6 +311,7 @@ export function scanSessionEntries(
306
311
  let sessionNameEntryId: string | undefined;
307
312
  let sessionNameTs: string | undefined;
308
313
  let handoffMetadata: DurableHandoffMetadataRecord | undefined;
314
+ let lineage: SessionLineage | undefined;
309
315
 
310
316
  for (const entry of entries) {
311
317
  if (!isSessionEntry(entry)) continue;
@@ -336,6 +342,9 @@ export function scanSessionEntries(
336
342
  continue;
337
343
  }
338
344
  case "custom": {
345
+ if (entry.customType === LINEAGE_CUSTOM_TYPE && !lineage) {
346
+ lineage = safeParseTypeBoxValue(LINEAGE_SCHEMA, entry.data);
347
+ }
339
348
  const nextHandoffMetadata = extractHandoffMetadata(entry, entryTs);
340
349
  if (nextHandoffMetadata && !handoffMetadata) {
341
350
  handoffMetadata = nextHandoffMetadata;
@@ -393,6 +402,7 @@ export function scanSessionEntries(
393
402
  sessionNameEntryId,
394
403
  sessionNameTs,
395
404
  handoffMetadata,
405
+ lineage,
396
406
  };
397
407
  }
398
408
 
@@ -0,0 +1,8 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ export const LINEAGE_CUSTOM_TYPE = "pi-sessions.lineage";
4
+ export const LINEAGE_SCHEMA = Type.Object({
5
+ parentSessionId: Type.String({ minLength: 1 }),
6
+ parentSessionFile: Type.Optional(Type.String({ minLength: 1 })),
7
+ });
8
+ export type SessionLineage = Static<typeof LINEAGE_SCHEMA>;
@@ -231,6 +231,9 @@ function applyTailSync(
231
231
  sessionOrigin?: SessionOrigin,
232
232
  ): boolean {
233
233
  return db.transaction((): boolean => {
234
+ // A lineage entry can arrive after the initial header sync. Re-read the header to
235
+ // preserve its precedence and rebuild lineage through the normal full-sync path.
236
+ if (tail.scan.lineage) return false;
234
237
  // Another process may have advanced the index between our baseline read
235
238
  // and this transaction; the tail deltas would then double-count.
236
239
  const current = getSessionRowByPath(db, baseline.sessionPath);
@@ -23,6 +23,7 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
23
23
  enable: Type.Optional(Type.Boolean()),
24
24
  maxDepth: Type.Optional(Type.Integer({ minimum: 0 })),
25
25
  contextLimit: Type.Optional(Type.Integer({ minimum: 1 })),
26
+ agentsDir: Type.Optional(Type.String()),
26
27
  }),
27
28
  ),
28
29
  handoff: Type.Optional(
@@ -125,6 +126,8 @@ export interface SessionSettings {
125
126
  maxDepth: number;
126
127
  /** Effective context window for a subagent, when smaller than its model's. */
127
128
  contextLimit?: number;
129
+ /** Where agent profiles for subagent launches live. */
130
+ agentsDir: string;
128
131
  };
129
132
  handoff: HandoffSettings;
130
133
  index: {
@@ -140,6 +143,10 @@ export function getDefaultIndexDir(): string {
140
143
  return path.join(getAgentDir(), "pi-sessions");
141
144
  }
142
145
 
146
+ export function getDefaultAgentsDir(): string {
147
+ return path.join(getAgentDir(), "agents");
148
+ }
149
+
143
150
  export function getDefaultIndexPath(): string {
144
151
  return path.join(getDefaultIndexDir(), "index.sqlite");
145
152
  }
@@ -168,19 +175,19 @@ function expandHome(rawPath: string): string {
168
175
  return rawPath;
169
176
  }
170
177
 
171
- function normalizeIndexDir(value: string | undefined): string {
178
+ function normalizeDir(value: string | undefined, defaultDir: string, key: string): string {
172
179
  if (value === undefined) {
173
- return getDefaultIndexDir();
180
+ return defaultDir;
174
181
  }
175
182
 
176
183
  const trimmed = value.trim();
177
184
  if (trimmed.length === 0) {
178
- return getDefaultIndexDir();
185
+ return defaultDir;
179
186
  }
180
187
 
181
188
  const expanded = expandHome(trimmed);
182
189
  if (!path.isAbsolute(expanded)) {
183
- throw new Error('sessions.index.dir must be an absolute path or start with "~/".');
190
+ throw new Error(`${key} must be an absolute path or start with "~/".`);
184
191
  }
185
192
 
186
193
  return path.normalize(expanded);
@@ -254,7 +261,11 @@ function resolveFeatureToggles(value: SessionFileSettings): FeatureToggles {
254
261
  }
255
262
 
256
263
  function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSettings {
257
- const indexDir = normalizeIndexDir(fileSettings.index?.dir);
264
+ const indexDir = normalizeDir(
265
+ fileSettings.index?.dir,
266
+ getDefaultIndexDir(),
267
+ "sessions.index.dir",
268
+ );
258
269
 
259
270
  return {
260
271
  features: resolveFeatureToggles(fileSettings),
@@ -263,6 +274,11 @@ function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSetti
263
274
  ...(fileSettings.subagents?.contextLimit === undefined
264
275
  ? {}
265
276
  : { contextLimit: fileSettings.subagents.contextLimit }),
277
+ agentsDir: normalizeDir(
278
+ fileSettings.subagents?.agentsDir,
279
+ getDefaultAgentsDir(),
280
+ "sessions.subagents.agentsDir",
281
+ ),
266
282
  },
267
283
  handoff: {
268
284
  ...resolveAgentModelSettings(fileSettings.handoff),
@@ -10,10 +10,6 @@ import type {
10
10
  SendMessageResult,
11
11
  } from "../session-messaging/install.ts";
12
12
  import type { SessionWaker } from "../session-messaging/message-router.ts";
13
- import {
14
- createSessionCancelTool,
15
- createSessionSendMessageTool,
16
- } from "../session-messaging/pi/tools.ts";
17
13
  import type { SessionLifecycle } from "../shared/composition.ts";
18
14
  import type { CompactionThresholdSettings, SessionSettings } from "../shared/settings.ts";
19
15
  import { isTmuxInstalled } from "../shared/tmux.ts";
@@ -82,7 +78,6 @@ export function installSubagents(
82
78
  messaging: MessagingHandle;
83
79
  readCompactionSettings: (cwd: string) => CompactionThresholdSettings;
84
80
  sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
85
- hasHostedSessions?: () => boolean;
86
81
  },
87
82
  ): SubagentsHandle {
88
83
  pi.registerMessageRenderer(SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE, renderSubagentReportMessage);
@@ -154,7 +149,10 @@ export function installSubagents(
154
149
 
155
150
  pi.on("before_agent_start", (event) => {
156
151
  if (current?.child) {
157
- event.systemPromptOptions.sections[SUBAGENT_PROMPT_SECTION] = SUBAGENT_PROMPT;
152
+ const agent = current.child.identity.agent;
153
+ event.systemPromptOptions.sections[SUBAGENT_PROMPT_SECTION] = agent
154
+ ? `${SUBAGENT_PROMPT}\n\n${agent.instructions}`
155
+ : SUBAGENT_PROMPT;
158
156
  }
159
157
  });
160
158
 
@@ -171,12 +169,12 @@ export function installSubagents(
171
169
 
172
170
  pi.on("agent_start", (_event, ctx) => {
173
171
  settledChildLifecycle.cancel();
174
- if (current?.child) {
172
+ if (current?.child && !current.child.paused) {
175
173
  current.child.reportsAtTurnStart = countSubagentReports(ctx.sessionManager.getBranch());
176
174
  }
177
175
  });
178
176
 
179
- pi.on("agent_settled", async (_event, ctx) => {
177
+ pi.on("agent_settled", async (event, ctx) => {
180
178
  const session = current;
181
179
  if (!session || session.parent.sessionId !== ctx.sessionManager.getSessionId()) {
182
180
  return;
@@ -192,7 +190,11 @@ export function installSubagents(
192
190
  }
193
191
  if (session.child) {
194
192
  await contextLimit.compactIfOverLimit(ctx);
195
- await settledChildLifecycle.settle(session.parent, session.child, reconciliation);
193
+ // Escape in the child's pane pauses the delegated turn; it still owes its parent a report.
194
+ session.child.paused = event.aborted;
195
+ if (!event.aborted) {
196
+ await settledChildLifecycle.settle(session.parent, session.child, reconciliation);
197
+ }
196
198
  }
197
199
  });
198
200
 
@@ -225,28 +227,9 @@ export function installSubagents(
225
227
  }
226
228
  };
227
229
 
228
- const registerMessagingTools = (): void => {
229
- const isSubagent = handle.getParentSessionId() !== undefined;
230
- pi.registerTool(
231
- createSessionSendMessageTool(handle, {
232
- role: isSubagent ? { kind: "subagent" } : { kind: "wakeCapable" },
233
- hostedSessions: deps.hasHostedSessions?.() ?? false,
234
- getCachedRelationTo: deps.messaging.getCachedRelationTo,
235
- getParentSessionId: () => handle.getParentSessionId(),
236
- }),
237
- );
238
- pi.registerTool(
239
- createSessionCancelTool(handle, {
240
- role: isSubagent ? { kind: "subagent" } : { kind: "plain" },
241
- getParentSessionId: () => handle.getParentSessionId(),
242
- }),
243
- );
244
- };
245
-
246
230
  pi.on("session_tree", async (_event, ctx) => {
247
231
  settledChildLifecycle.cancel();
248
232
  refreshChildSession();
249
- registerMessagingTools();
250
233
  try {
251
234
  await reconciler.reconcileAndRestoreSuspended();
252
235
  } catch (error) {
@@ -303,9 +286,6 @@ export function installSubagents(
303
286
  reconciler.beginSession();
304
287
  current = { parent };
305
288
  refreshChildSession();
306
- if (identity) {
307
- registerMessagingTools();
308
- }
309
289
  try {
310
290
  if (_event.reason === "reload") {
311
291
  await reconciler.reconcile();
@@ -19,15 +19,16 @@ export function createSubagentLaunchTarget(
19
19
  value: SUBAGENT_LAUNCH,
20
20
  description:
21
21
  "'subagent' delegates one task to a detached tmux worker and requests a report by default.",
22
- requestResponseDefault: true,
23
22
  bootstrapMode: "automatic",
24
23
  approveProjectTrust: true,
24
+ requiredTools: ["submit_task_report"],
25
25
  describeSubagentChild(input) {
26
26
  return {
27
27
  childSessionId: input.childSessionId,
28
28
  ownerSessionId: input.ownerSessionId,
29
29
  depth: state.depth + 1,
30
30
  requestResponse: input.requestResponse,
31
+ ...(input.agent ? { agent: input.agent } : {}),
31
32
  };
32
33
  },
33
34
  prepareChild(input) {
@@ -8,7 +8,12 @@ import {
8
8
  type TmuxExecutor,
9
9
  tmuxSessionName,
10
10
  } from "../shared/tmux.ts";
11
- import { classifySubagent, type SubagentEvidence, type SubagentState } from "./classify.ts";
11
+ import {
12
+ classifySubagent,
13
+ isRunningSubagentState,
14
+ type SubagentEvidence,
15
+ type SubagentState,
16
+ } from "./classify.ts";
12
17
  import {
13
18
  type ChildSubagentLifecycle,
14
19
  collectParentLedger,
@@ -220,8 +225,11 @@ export class SubagentReconciler {
220
225
  continue;
221
226
  }
222
227
 
228
+ // A running child writes its report to its own transcript before sending it live; recovering it in
229
+ // between would deliver it as recovered. Once the child stops, recovery is the report's only path.
230
+ const reports = isRunningSubagentState(child.state) ? [] : child.reports;
223
231
  let reportLedger: ParentSubagentLedger | undefined;
224
- for (const report of child.reports) {
232
+ for (const report of reports) {
225
233
  if (this.hasSentReport(report.reportId)) {
226
234
  continue;
227
235
  }
@@ -17,6 +17,7 @@ export interface SubagentChildSessionState {
17
17
  identity: HandoffSubagent;
18
18
  requestResponse: boolean;
19
19
  reportsAtTurnStart: number;
20
+ paused: boolean;
20
21
  }
21
22
 
22
23
  export interface SettledChildParentSession {
@@ -159,6 +160,7 @@ export function refreshSubagentChildState(
159
160
  identity,
160
161
  requestResponse: identity.requestResponse,
161
162
  reportsAtTurnStart: reports,
163
+ paused: false,
162
164
  };
163
165
  }
164
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
6
6
  "license": "MIT",
@@ -38,10 +38,10 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@biomejs/biome": "^2.5.10",
41
- "@earendil-works/pi-agent-core": "^1.1.0",
42
- "@earendil-works/pi-ai": "^1.1.0",
43
- "@earendil-works/pi-coding-agent": "^1.1.0",
44
- "@earendil-works/pi-tui": "^1.1.0",
41
+ "@earendil-works/pi-agent-core": "^1.3.0",
42
+ "@earendil-works/pi-ai": "^1.3.0",
43
+ "@earendil-works/pi-coding-agent": "^1.3.0",
44
+ "@earendil-works/pi-tui": "^1.3.0",
45
45
  "@types/node": "^24.13.3",
46
46
  "strip-ansi": "^7.2.0",
47
47
  "typebox": "1.3.27",