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,386 @@
1
+ import { type SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { isSessionStarting } from "../session-handoff/metadata.ts";
3
+ import {
4
+ createTmuxWindow,
5
+ killTmuxSession,
6
+ killTmuxWindow,
7
+ listTmuxWindows,
8
+ type TmuxExecutor,
9
+ tmuxSessionName,
10
+ } from "../shared/tmux.ts";
11
+ import { classifySubagent, type SubagentEvidence, type SubagentState } from "./classify.ts";
12
+ import {
13
+ type ChildSubagentLifecycle,
14
+ collectParentLedger,
15
+ getChildSubagentLifecycle,
16
+ type ParentSubagentLedger,
17
+ SUBAGENT_DISOWNED_MESSAGE_CUSTOM_TYPE,
18
+ SUBAGENT_LAUNCHED_CUSTOM_TYPE,
19
+ SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE,
20
+ SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE,
21
+ SUBAGENT_SUSPENDED_CUSTOM_TYPE,
22
+ type SubagentLaunched,
23
+ type SubagentReport,
24
+ type SubagentReportMessage,
25
+ } from "./ledger.ts";
26
+ import { formatReportForModel } from "./report.ts";
27
+
28
+ export interface ReconcileParentSession {
29
+ sessionId: string;
30
+ epoch: number;
31
+ getBranch(): readonly SessionEntry[];
32
+ isIdle(): boolean;
33
+ }
34
+
35
+ export interface ReconcileSessionManager {
36
+ getBranch(): readonly SessionEntry[];
37
+ }
38
+
39
+ export interface ReconcileMessaging {
40
+ listSessions(): Promise<string[]>;
41
+ }
42
+
43
+ export interface ReconcileActions extends TmuxExecutor {
44
+ appendEntry(customType: string, data: unknown): void;
45
+ sendMessage(
46
+ message: { customType: string; content: string; display: boolean; details: unknown },
47
+ options?: { triggerTurn: true } | { deliverAs: "steer" },
48
+ ): void;
49
+ }
50
+
51
+ export interface ReconcileDependencies {
52
+ executor: ReconcileActions;
53
+ messaging: ReconcileMessaging;
54
+ getParent(): ReconcileParentSession | undefined;
55
+ isCurrent(epoch: number): boolean;
56
+ openSession(path: string): ReconcileSessionManager;
57
+ }
58
+
59
+ interface ChildLifecycle extends ChildSubagentLifecycle {
60
+ awaitingKickoff: boolean;
61
+ readable: boolean;
62
+ }
63
+
64
+ interface ChildEvidence {
65
+ launch: SubagentLaunched;
66
+ classification: SubagentEvidence;
67
+ state: SubagentState;
68
+ reports: readonly SubagentReport[];
69
+ }
70
+
71
+ export interface ReconcileResult {
72
+ states: ReadonlyMap<string, SubagentState>;
73
+ registered: ReadonlySet<string>;
74
+ }
75
+
76
+ /**
77
+ * Converges the runtime view of a parent's owned subagents from their durable ledgers.
78
+ * It intentionally does not cache child files: every trigger observes current branch truth.
79
+ */
80
+ export class SubagentReconciler {
81
+ private inFlight: Promise<ReconcileResult> | undefined;
82
+ private dirty = false;
83
+ private restoreSuspended = false;
84
+ private shuttingDown = false;
85
+ private registered: { epoch: number; sessionIds: Set<string> } | undefined;
86
+ private latestResult: ReconcileResult = emptyReconcileResult();
87
+
88
+ constructor(private readonly deps: ReconcileDependencies) {}
89
+
90
+ beginSession(): void {
91
+ this.shuttingDown = false;
92
+ this.dirty = false;
93
+ this.restoreSuspended = false;
94
+ this.registered = undefined;
95
+ this.latestResult = emptyReconcileResult();
96
+ }
97
+
98
+ reconcile(): Promise<ReconcileResult> {
99
+ return this.requestReconciliation(false);
100
+ }
101
+
102
+ reconcileAndRestoreSuspended(): Promise<ReconcileResult> {
103
+ return this.requestReconciliation(true);
104
+ }
105
+
106
+ private requestReconciliation(restoreSuspended: boolean): Promise<ReconcileResult> {
107
+ if (this.shuttingDown) {
108
+ return this.inFlight ?? Promise.resolve(this.latestResult);
109
+ }
110
+ this.dirty = true;
111
+ this.restoreSuspended ||= restoreSuspended;
112
+ if (this.inFlight) {
113
+ return this.inFlight;
114
+ }
115
+
116
+ const run = this.run().finally(() => {
117
+ if (this.inFlight === run) {
118
+ this.inFlight = undefined;
119
+ }
120
+ });
121
+ this.inFlight = run;
122
+ return run;
123
+ }
124
+
125
+ async suspendForShutdown(): Promise<void> {
126
+ this.shuttingDown = true;
127
+ await this.inFlight;
128
+
129
+ const parent = this.deps.getParent();
130
+ if (!parent) {
131
+ return;
132
+ }
133
+
134
+ const tmuxSession = tmuxSessionName(parent.sessionId);
135
+ const ledger = collectParentLedger(parent.getBranch(), parent.sessionId);
136
+ const windows = await listTmuxWindows(this.deps.executor, tmuxSession);
137
+ const ownedChildIds = new Set(ledger.launches.map((launch) => launch.childSessionId));
138
+ const childSessionIds = windows
139
+ .map((window) => window.piSessionId)
140
+ .filter((childSessionId) => ownedChildIds.has(childSessionId));
141
+
142
+ if (childSessionIds.length > 0) {
143
+ this.append(parent, SUBAGENT_SUSPENDED_CUSTOM_TYPE, {
144
+ writerSessionId: parent.sessionId,
145
+ childSessionIds,
146
+ });
147
+ }
148
+ this.requireCurrent(parent);
149
+ await killTmuxSession(this.deps.executor, tmuxSession);
150
+ }
151
+
152
+ private async run(): Promise<ReconcileResult> {
153
+ while (this.dirty && !this.shuttingDown) {
154
+ this.dirty = false;
155
+ const restoreSuspended = this.restoreSuspended;
156
+ this.restoreSuspended = false;
157
+ this.latestResult = await this.reconcileOnce(restoreSuspended);
158
+ }
159
+ return this.latestResult;
160
+ }
161
+
162
+ private async reconcileOnce(restoreSuspended: boolean): Promise<ReconcileResult> {
163
+ const parent = this.deps.getParent();
164
+ if (!parent) {
165
+ return emptyReconcileResult();
166
+ }
167
+
168
+ const branch = parent.getBranch();
169
+ const ledger = collectParentLedger(branch, parent.sessionId);
170
+ const tmuxSession = tmuxSessionName(parent.sessionId);
171
+ const [windows, liveSessionIds] = await Promise.all([
172
+ listTmuxWindows(this.deps.executor, tmuxSession),
173
+ this.deps.messaging.listSessions(),
174
+ ]);
175
+ const liveSessions = new Set(liveSessionIds);
176
+ const registered = this.getRegistered(parent.epoch);
177
+ for (const sessionId of liveSessionIds) {
178
+ registered.add(sessionId);
179
+ }
180
+ const windowSessionIds = new Set(windows.map((window) => window.piSessionId));
181
+ const evidence = ledger.launches.map((launch) =>
182
+ this.readChildEvidence(
183
+ launch,
184
+ ledger,
185
+ windowSessionIds.has(launch.childSessionId),
186
+ liveSessions.has(launch.childSessionId),
187
+ registered.has(launch.childSessionId),
188
+ ),
189
+ );
190
+
191
+ let runtimeChanged = false;
192
+
193
+ if (ledger.hasForeignLaunch && !ledger.hasDisownedNotice) {
194
+ this.sendDisownedMessage(parent);
195
+ }
196
+
197
+ for (const child of evidence) {
198
+ if (child.state === "unknown") {
199
+ continue;
200
+ }
201
+
202
+ for (const report of child.reports) {
203
+ if (!ledger.receivedReportIds.has(report.reportId)) {
204
+ this.append(parent, SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE, {
205
+ writerSessionId: parent.sessionId,
206
+ childSessionId: child.launch.childSessionId,
207
+ reportId: report.reportId,
208
+ });
209
+ }
210
+ if (!ledger.deliveredReportIds.has(report.reportId)) {
211
+ this.deliverRecoveredReport(parent, child.launch, report);
212
+ }
213
+ }
214
+
215
+ if (child.state === "suspended" && restoreSuspended) {
216
+ this.append(parent, SUBAGENT_LAUNCHED_CUSTOM_TYPE, child.launch);
217
+ await createTmuxWindow(this.deps.executor, {
218
+ tmuxSession,
219
+ name: child.launch.title,
220
+ cwd: child.launch.cwd,
221
+ command: child.launch.resumeCommand,
222
+ piSessionId: child.launch.childSessionId,
223
+ });
224
+ child.classification = {
225
+ ...child.classification,
226
+ cancelled: false,
227
+ suspended: false,
228
+ };
229
+ runtimeChanged = true;
230
+ } else if (child.state === "stopping") {
231
+ this.requireCurrent(parent);
232
+ await killTmuxWindow(this.deps.executor, tmuxSession, child.launch.childSessionId);
233
+ runtimeChanged = true;
234
+ }
235
+ }
236
+
237
+ const ownedIds = new Set(ledger.launches.map((launch) => launch.childSessionId));
238
+ for (const window of windows) {
239
+ if (!ownedIds.has(window.piSessionId)) {
240
+ this.requireCurrent(parent);
241
+ await killTmuxWindow(this.deps.executor, tmuxSession, window.piSessionId);
242
+ runtimeChanged = true;
243
+ }
244
+ }
245
+
246
+ if (runtimeChanged) {
247
+ const [currentWindows, currentLiveSessionIds] = await Promise.all([
248
+ listTmuxWindows(this.deps.executor, tmuxSession),
249
+ this.deps.messaging.listSessions(),
250
+ ]);
251
+ const currentWindowSessionIds = new Set(currentWindows.map((window) => window.piSessionId));
252
+ const currentLiveSessions = new Set(currentLiveSessionIds);
253
+ for (const sessionId of currentLiveSessionIds) {
254
+ registered.add(sessionId);
255
+ }
256
+ for (const child of evidence) {
257
+ child.classification = {
258
+ ...child.classification,
259
+ hasWindow: currentWindowSessionIds.has(child.launch.childSessionId),
260
+ brokerLive: currentLiveSessions.has(child.launch.childSessionId),
261
+ hasRegistered: registered.has(child.launch.childSessionId),
262
+ awaitingKickoff: child.classification.awaitingKickoff,
263
+ };
264
+ child.state = classifySubagent(child.classification);
265
+ }
266
+ }
267
+
268
+ return {
269
+ states: new Map(evidence.map((child) => [child.launch.childSessionId, child.state])),
270
+ registered: new Set(registered),
271
+ };
272
+ }
273
+
274
+ private readChildEvidence(
275
+ launch: SubagentLaunched,
276
+ ledger: ParentSubagentLedger,
277
+ hasWindow: boolean,
278
+ brokerLive: boolean,
279
+ hasRegistered: boolean,
280
+ ): ChildEvidence {
281
+ const lifecycle = this.readChildLifecycle(launch.childSessionFile);
282
+ const classification: SubagentEvidence = {
283
+ hasWindow,
284
+ brokerLive,
285
+ hasRegistered,
286
+ awaitingKickoff: lifecycle.awaitingKickoff,
287
+ cancelled: ledger.cancelledChildIds.has(launch.childSessionId),
288
+ suspended: ledger.suspendedChildIds.has(launch.childSessionId),
289
+ hasReportOrClosure: lifecycle.reports.length > 0 || lifecycle.closed !== undefined,
290
+ childReadable: lifecycle.readable,
291
+ };
292
+ return {
293
+ launch,
294
+ classification,
295
+ reports: lifecycle.reports,
296
+ state: classifySubagent(classification),
297
+ };
298
+ }
299
+
300
+ private readChildLifecycle(path: string): ChildLifecycle {
301
+ try {
302
+ const branch = this.deps.openSession(path).getBranch();
303
+ return {
304
+ ...getChildSubagentLifecycle(branch),
305
+ awaitingKickoff: isSessionStarting(branch),
306
+ readable: true,
307
+ };
308
+ } catch {
309
+ return {
310
+ reports: [],
311
+ closed: undefined,
312
+ hasReminder: false,
313
+ awaitingKickoff: false,
314
+ readable: false,
315
+ };
316
+ }
317
+ }
318
+
319
+ private getRegistered(epoch: number): Set<string> {
320
+ if (this.registered?.epoch !== epoch) {
321
+ this.registered = { epoch, sessionIds: new Set() };
322
+ }
323
+ return this.registered.sessionIds;
324
+ }
325
+
326
+ private append(parent: ReconcileParentSession, customType: string, data: unknown): void {
327
+ this.requireCurrent(parent);
328
+ this.deps.executor.appendEntry(customType, data);
329
+ }
330
+
331
+ private deliverRecoveredReport(
332
+ parent: ReconcileParentSession,
333
+ launch: SubagentLaunched,
334
+ report: SubagentReport,
335
+ ): void {
336
+ const message: SubagentReportMessage = {
337
+ writerSessionId: parent.sessionId,
338
+ childSessionId: launch.childSessionId,
339
+ title: launch.title,
340
+ ...report,
341
+ provenance: "recovered",
342
+ };
343
+ this.sendMessage(parent, {
344
+ customType: SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE,
345
+ content: formatReportForModel(launch.title, message),
346
+ display: true,
347
+ details: message,
348
+ });
349
+ }
350
+
351
+ private sendDisownedMessage(parent: ReconcileParentSession): void {
352
+ this.requireCurrent(parent);
353
+ this.deps.executor.sendMessage({
354
+ customType: SUBAGENT_DISOWNED_MESSAGE_CUSTOM_TYPE,
355
+ content:
356
+ "[system] copied subagent records belong to the original session; this fork owns none.",
357
+ display: true,
358
+ details: { writerSessionId: parent.sessionId },
359
+ });
360
+ }
361
+
362
+ private sendMessage(
363
+ parent: ReconcileParentSession,
364
+ message: { customType: string; content: string; display: boolean; details: unknown },
365
+ ): void {
366
+ this.requireCurrent(parent);
367
+ const delivery = parent.isIdle()
368
+ ? { triggerTurn: true as const }
369
+ : { deliverAs: "steer" as const };
370
+ this.deps.executor.sendMessage(message, delivery);
371
+ }
372
+
373
+ private requireCurrent(parent: ReconcileParentSession): void {
374
+ if (!this.deps.isCurrent(parent.epoch)) {
375
+ throw new Error("The parent session changed during subagent reconciliation.");
376
+ }
377
+ }
378
+ }
379
+
380
+ export function openReconcileSession(path: string): ReconcileSessionManager {
381
+ return SessionManager.open(path);
382
+ }
383
+
384
+ function emptyReconcileResult(): ReconcileResult {
385
+ return { states: new Map(), registered: new Set() };
386
+ }
@@ -0,0 +1,36 @@
1
+ import type { RenderTheme } from "../shared/rendering/theme.ts";
2
+ import type { SubagentReportMessageViewModel } from "./report-message-view-model.ts";
3
+
4
+ export function presentSubagentReportMessage(
5
+ report: SubagentReportMessageViewModel,
6
+ theme: RenderTheme,
7
+ ): string {
8
+ const status = report.status === "done" ? "" : `[${report.status}] `;
9
+ const sections = [
10
+ theme.fg("toolTitle", theme.bold(`Report from subagent ${status}“${report.title}”`)),
11
+ presentSection("Summary", report.summary, theme),
12
+ ];
13
+
14
+ if (report.details) {
15
+ sections.push(presentSection("Details", report.details, theme));
16
+ }
17
+ if (report.references.length > 0) {
18
+ const references = report.references.map((reference) =>
19
+ reference.description
20
+ ? `- ${reference.reference} — ${reference.description}`
21
+ : `- ${reference.reference}`,
22
+ );
23
+ sections.push(presentSection("References", references.join("\n"), theme));
24
+ }
25
+ if (report.nextSteps.length > 0) {
26
+ sections.push(
27
+ presentSection("Next steps", report.nextSteps.map((step) => `- ${step}`).join("\n"), theme),
28
+ );
29
+ }
30
+
31
+ return sections.join("\n\n");
32
+ }
33
+
34
+ function presentSection(heading: string, body: string, theme: RenderTheme): string {
35
+ return `${theme.fg("toolTitle", theme.bold(heading))}\n${theme.fg("toolOutput", body)}`;
36
+ }
@@ -0,0 +1,21 @@
1
+ import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Text } from "@earendil-works/pi-tui";
3
+ import { safeParseTypeBoxValue } from "../shared/typebox.ts";
4
+ import { SUBAGENT_REPORT_MESSAGE_SCHEMA } from "./ledger.ts";
5
+ import { presentSubagentReportMessage } from "./report-message-presenter.ts";
6
+ import { buildSubagentReportMessageView } from "./report-message-view-model.ts";
7
+
8
+ export const renderSubagentReportMessage: MessageRenderer = (message, _options, theme) => {
9
+ const details = safeParseTypeBoxValue(SUBAGENT_REPORT_MESSAGE_SCHEMA, message.details);
10
+ if (!details) {
11
+ return undefined;
12
+ }
13
+ const report = buildSubagentReportMessageView(details);
14
+ if (!report) {
15
+ return undefined;
16
+ }
17
+
18
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
19
+ box.addChild(new Text(presentSubagentReportMessage(report, theme), 0, 0));
20
+ return box;
21
+ };
@@ -0,0 +1,29 @@
1
+ import type { TaskReportReference } from "../shared/session-broker/protocol.ts";
2
+ import type { SubagentReportMessage } from "./ledger.ts";
3
+
4
+ export interface SubagentReportMessageViewModel {
5
+ title: string;
6
+ status: SubagentReportMessage["status"];
7
+ summary: string;
8
+ details?: string | undefined;
9
+ references: readonly TaskReportReference[];
10
+ nextSteps: readonly string[];
11
+ }
12
+
13
+ export function buildSubagentReportMessageView(
14
+ report: SubagentReportMessage,
15
+ ): SubagentReportMessageViewModel | undefined {
16
+ const title = report.title?.trim();
17
+ if (!title) {
18
+ return undefined;
19
+ }
20
+
21
+ return {
22
+ title,
23
+ status: report.status,
24
+ summary: report.summary,
25
+ details: report.details,
26
+ references: report.references ?? [],
27
+ nextSteps: report.nextSteps ?? [],
28
+ };
29
+ }
@@ -0,0 +1,159 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { defineTool, type ExtensionAPI, type SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import type { HandoffSubagent } from "../session-handoff/metadata.ts";
4
+ import type { MessagingHandle } from "../session-messaging/install.ts";
5
+ import { formatError } from "../shared/errors.ts";
6
+ import {
7
+ type SessionSubagentReportEnvelope,
8
+ TASK_REPORT_SCHEMA,
9
+ } from "../shared/session-broker/protocol.ts";
10
+ import {
11
+ collectParentLedger,
12
+ SUBAGENT_REPORT_CUSTOM_TYPE,
13
+ type SubagentReport,
14
+ type SubagentReportMessage,
15
+ type SubagentReportReceived,
16
+ } from "./ledger.ts";
17
+
18
+ export interface SubagentParentSession {
19
+ sessionId: string;
20
+ getBranch(): readonly SessionEntry[];
21
+ isIdle(): boolean;
22
+ epoch: number;
23
+ }
24
+
25
+ export interface ReportingSubagentSession extends SubagentParentSession {
26
+ identity: HandoffSubagent;
27
+ }
28
+
29
+ export interface IncomingSubagentReport {
30
+ receipt: SubagentReportReceived | undefined;
31
+ message: SubagentReportMessage;
32
+ content: string;
33
+ delivery: { triggerTurn: true } | { deliverAs: "steer" };
34
+ }
35
+
36
+ export function createSubmitTaskReportTool(
37
+ pi: ExtensionAPI,
38
+ messaging: MessagingHandle,
39
+ getSession: () => ReportingSubagentSession | undefined,
40
+ ) {
41
+ return defineTool({
42
+ name: "submit_task_report",
43
+ label: "Submit task report",
44
+ description:
45
+ "Send a report to the parent session and end the current turn. Use it when the delegated task or requested follow-up is done, blocked, or cannot be completed.",
46
+ promptSnippet: "Submit the current task report to the parent session and end the turn",
47
+ promptGuidelines: [
48
+ "Call submit_task_report exactly once as the final tool call for every delegated task or follow-up that expects a response.",
49
+ "Do not send task results through session_send_message; submit_task_report is the only supported means to send final results to the parent.",
50
+ "If you receive steering instructions from the parent via session_send_message, you may optionally reply to the parent using the session_send_message tool if the parent's message calls for something that is out of band of what would be included in the report.",
51
+ ],
52
+ parameters: TASK_REPORT_SCHEMA,
53
+ async execute(_toolCallId, params) {
54
+ const session = getSession();
55
+ if (!session) {
56
+ throw new Error("submit_task_report is available only in its original subagent session.");
57
+ }
58
+ const summary = params.summary.trim();
59
+ if (!summary) {
60
+ throw new Error("submit_task_report requires a summary.");
61
+ }
62
+
63
+ const durableReport: SubagentReport = {
64
+ reportId: randomUUID(),
65
+ status: params.status,
66
+ summary,
67
+ ...(params.details ? { details: params.details } : {}),
68
+ ...(params.references ? { references: params.references } : {}),
69
+ ...(params.nextSteps ? { nextSteps: params.nextSteps } : {}),
70
+ };
71
+ pi.appendEntry(SUBAGENT_REPORT_CUSTOM_TYPE, durableReport);
72
+
73
+ let delivered = false;
74
+ let deliveryError: string | undefined;
75
+ try {
76
+ const result = await messaging.sendSubagentReport({
77
+ target: session.identity.ownerSessionId,
78
+ ...durableReport,
79
+ });
80
+ delivered = result.delivered;
81
+ deliveryError = result.delivered ? undefined : result.error;
82
+ } catch (error) {
83
+ deliveryError = formatError(error);
84
+ }
85
+
86
+ const delivery = delivered
87
+ ? "Report delivered to the parent."
88
+ : `Report saved for later recovery${deliveryError ? `: ${deliveryError}` : "."}`;
89
+ return {
90
+ content: [{ type: "text" as const, text: `${delivery} Stopping.` }],
91
+ details: { ...durableReport, delivered },
92
+ terminate: true,
93
+ };
94
+ },
95
+ });
96
+ }
97
+
98
+ export function buildIncomingSubagentReport(
99
+ session: SubagentParentSession,
100
+ envelope: SessionSubagentReportEnvelope,
101
+ ): IncomingSubagentReport | undefined {
102
+ const ownerSessionId = session.sessionId;
103
+ const ledger = collectParentLedger(session.getBranch(), ownerSessionId);
104
+ const launch = ledger.launches.find((candidate) => candidate.childSessionId === envelope.source);
105
+ if (!launch) {
106
+ throw new Error("Report source is not an owned subagent on the active branch.");
107
+ }
108
+ if (ledger.deliveredReportIds.has(envelope.reportId)) {
109
+ return undefined;
110
+ }
111
+
112
+ const receipt = ledger.receivedReportIds.has(envelope.reportId)
113
+ ? undefined
114
+ : {
115
+ writerSessionId: ownerSessionId,
116
+ childSessionId: envelope.source,
117
+ reportId: envelope.reportId,
118
+ };
119
+ const message: SubagentReportMessage = {
120
+ writerSessionId: ownerSessionId,
121
+ childSessionId: envelope.source,
122
+ reportId: envelope.reportId,
123
+ title: launch.title,
124
+ status: envelope.status,
125
+ summary: envelope.summary,
126
+ ...(envelope.details ? { details: envelope.details } : {}),
127
+ ...(envelope.references ? { references: envelope.references } : {}),
128
+ ...(envelope.nextSteps ? { nextSteps: envelope.nextSteps } : {}),
129
+ provenance: "live",
130
+ };
131
+ return {
132
+ receipt,
133
+ message,
134
+ content: formatReportForModel(launch.title, message),
135
+ delivery: session.isIdle() ? { triggerTurn: true } : { deliverAs: "steer" },
136
+ };
137
+ }
138
+
139
+ export function formatReportForModel(title: string, report: SubagentReportMessage): string {
140
+ const sections = [
141
+ `Subagent report from "${title}" (session: ${report.childSessionId}, status: ${report.status})`,
142
+ `Summary\n\n${report.summary}`,
143
+ ];
144
+ if (report.details) {
145
+ sections.push(`Details\n\n${report.details}`);
146
+ }
147
+ if (report.references?.length) {
148
+ const references = report.references.map((reference) =>
149
+ reference.description
150
+ ? `- ${reference.reference} — ${reference.description}`
151
+ : `- ${reference.reference}`,
152
+ );
153
+ sections.push(`References\n\n${references.join("\n")}`);
154
+ }
155
+ if (report.nextSteps?.length) {
156
+ sections.push(`Next steps\n\n${report.nextSteps.map((step) => `- ${step}`).join("\n")}`);
157
+ }
158
+ return sections.join("\n\n");
159
+ }