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
@@ -0,0 +1,217 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { type Static, Type } from "typebox";
3
+ import { TASK_REPORT_SCHEMA } from "../shared/session-broker/protocol.ts";
4
+ import { safeParseTypeBoxValue } from "../shared/typebox.ts";
5
+
6
+ export const SUBAGENT_LAUNCHED_CUSTOM_TYPE = "pi-sessions.subagent_launched";
7
+ export const SUBAGENT_REPORT_CUSTOM_TYPE = "pi-sessions.subagent_report";
8
+ export const SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE = "pi-sessions.subagent_report_received";
9
+ export const SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE = "pi-sessions.subagent_report_message";
10
+ export const SUBAGENT_CLOSED_CUSTOM_TYPE = "pi-sessions.subagent_closed";
11
+ export const SUBAGENT_REPORT_REMINDER_MESSAGE_CUSTOM_TYPE = "pi-sessions.report_reminder_message";
12
+ export const SUBAGENT_DISOWNED_MESSAGE_CUSTOM_TYPE = "pi-sessions.subagent_disowned_message";
13
+ export const SUBAGENT_CANCELLED_CUSTOM_TYPE = "pi-sessions.subagent_cancelled";
14
+ export const SUBAGENT_SUSPENDED_CUSTOM_TYPE = "pi-sessions.subagent_suspended";
15
+
16
+ export const SUBAGENT_LAUNCHED_SCHEMA = Type.Object({
17
+ writerSessionId: Type.String(),
18
+ childSessionId: Type.String(),
19
+ childSessionFile: Type.String(),
20
+ title: Type.String(),
21
+ goal: Type.String(),
22
+ requestResponse: Type.Boolean(),
23
+ model: Type.Optional(Type.String()),
24
+ cwd: Type.String(),
25
+ resumeCommand: Type.String(),
26
+ depth: Type.Integer({ minimum: 1 }),
27
+ });
28
+
29
+ export const SUBAGENT_REPORT_SCHEMA = Type.Intersect([
30
+ Type.Object({ reportId: Type.String() }),
31
+ TASK_REPORT_SCHEMA,
32
+ ]);
33
+
34
+ export const SUBAGENT_REPORT_RECEIVED_SCHEMA = Type.Object({
35
+ writerSessionId: Type.String(),
36
+ childSessionId: Type.String(),
37
+ reportId: Type.String(),
38
+ });
39
+
40
+ export const SUBAGENT_REPORT_MESSAGE_SCHEMA = Type.Intersect([
41
+ Type.Object({
42
+ writerSessionId: Type.String(),
43
+ childSessionId: Type.String(),
44
+ reportId: Type.String(),
45
+ title: Type.Optional(Type.String()),
46
+ provenance: Type.Union([Type.Literal("live"), Type.Literal("recovered")]),
47
+ }),
48
+ TASK_REPORT_SCHEMA,
49
+ ]);
50
+
51
+ export const SUBAGENT_CLOSED_SCHEMA = Type.Object({
52
+ reason: Type.Union([
53
+ Type.Literal("no_response_expected"),
54
+ Type.Literal("no_report_after_reminder"),
55
+ ]),
56
+ });
57
+
58
+ export const SUBAGENT_CANCELLED_SCHEMA = Type.Object({
59
+ writerSessionId: Type.String(),
60
+ childSessionId: Type.String(),
61
+ });
62
+ export const SUBAGENT_SUSPENDED_SCHEMA = Type.Object({
63
+ writerSessionId: Type.String(),
64
+ childSessionIds: Type.Array(Type.String()),
65
+ });
66
+ export const SUBAGENT_DISOWNED_MESSAGE_SCHEMA = Type.Object({
67
+ writerSessionId: Type.String(),
68
+ });
69
+
70
+ export type SubagentLaunched = Static<typeof SUBAGENT_LAUNCHED_SCHEMA>;
71
+ export type SubagentReport = Static<typeof SUBAGENT_REPORT_SCHEMA>;
72
+ export type SubagentReportReceived = Static<typeof SUBAGENT_REPORT_RECEIVED_SCHEMA>;
73
+ export type SubagentReportMessage = Static<typeof SUBAGENT_REPORT_MESSAGE_SCHEMA>;
74
+ export type SubagentClosed = Static<typeof SUBAGENT_CLOSED_SCHEMA>;
75
+
76
+ export interface ChildSubagentLifecycle {
77
+ reports: readonly SubagentReport[];
78
+ closed: SubagentClosed | undefined;
79
+ hasReminder: boolean;
80
+ }
81
+
82
+ export interface ParentSubagentLaunch extends SubagentLaunched {
83
+ launchedAt: string;
84
+ }
85
+
86
+ export interface ParentSubagentLedger {
87
+ launches: readonly ParentSubagentLaunch[];
88
+ cancelledChildIds: ReadonlySet<string>;
89
+ suspendedChildIds: ReadonlySet<string>;
90
+ receivedReportIds: ReadonlySet<string>;
91
+ deliveredReportIds: ReadonlySet<string>;
92
+ hasForeignLaunch: boolean;
93
+ hasDisownedNotice: boolean;
94
+ }
95
+
96
+ export function hasSubagentLaunchEntries(branch: readonly SessionEntry[]): boolean {
97
+ return branch.some(
98
+ (entry) => entry.type === "custom" && entry.customType === SUBAGENT_LAUNCHED_CUSTOM_TYPE,
99
+ );
100
+ }
101
+
102
+ export function findOwnedSubagentLaunch(
103
+ branch: readonly SessionEntry[],
104
+ ownerSessionId: string,
105
+ childSessionId: string,
106
+ ): ParentSubagentLaunch | undefined {
107
+ return collectParentLedger(branch, ownerSessionId).launches.find(
108
+ (launch) => launch.childSessionId === childSessionId,
109
+ );
110
+ }
111
+
112
+ export function collectParentLedger(
113
+ branch: readonly SessionEntry[],
114
+ ownerSessionId: string,
115
+ ): ParentSubagentLedger {
116
+ const launches = new Map<string, ParentSubagentLaunch>();
117
+ const cancelledChildIds = new Set<string>();
118
+ const suspendedChildIds = new Set<string>();
119
+ const receivedReportIds = new Set<string>();
120
+ const deliveredReportIds = new Set<string>();
121
+ let hasForeignLaunch = false;
122
+ let hasDisownedNotice = false;
123
+
124
+ for (const entry of branch) {
125
+ if (entry.type === "custom_message") {
126
+ if (entry.customType === SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE) {
127
+ const report = safeParseTypeBoxValue(SUBAGENT_REPORT_MESSAGE_SCHEMA, entry.details);
128
+ if (report?.writerSessionId === ownerSessionId) {
129
+ deliveredReportIds.add(report.reportId);
130
+ }
131
+ } else if (entry.customType === SUBAGENT_DISOWNED_MESSAGE_CUSTOM_TYPE) {
132
+ const notice = safeParseTypeBoxValue(SUBAGENT_DISOWNED_MESSAGE_SCHEMA, entry.details);
133
+ hasDisownedNotice ||= notice?.writerSessionId === ownerSessionId;
134
+ }
135
+ continue;
136
+ }
137
+ if (entry.type !== "custom") {
138
+ continue;
139
+ }
140
+
141
+ if (entry.customType === SUBAGENT_LAUNCHED_CUSTOM_TYPE) {
142
+ const launch = safeParseTypeBoxValue(SUBAGENT_LAUNCHED_SCHEMA, entry.data);
143
+ if (!launch) {
144
+ continue;
145
+ }
146
+ if (launch.writerSessionId !== ownerSessionId) {
147
+ hasForeignLaunch = true;
148
+ continue;
149
+ }
150
+ launches.set(launch.childSessionId, { ...launch, launchedAt: entry.timestamp });
151
+ cancelledChildIds.delete(launch.childSessionId);
152
+ suspendedChildIds.delete(launch.childSessionId);
153
+ continue;
154
+ }
155
+
156
+ if (entry.customType === SUBAGENT_CANCELLED_CUSTOM_TYPE) {
157
+ const cancellation = safeParseTypeBoxValue(SUBAGENT_CANCELLED_SCHEMA, entry.data);
158
+ if (cancellation?.writerSessionId === ownerSessionId) {
159
+ cancelledChildIds.add(cancellation.childSessionId);
160
+ }
161
+ continue;
162
+ }
163
+
164
+ if (entry.customType === SUBAGENT_SUSPENDED_CUSTOM_TYPE) {
165
+ const suspension = safeParseTypeBoxValue(SUBAGENT_SUSPENDED_SCHEMA, entry.data);
166
+ if (suspension?.writerSessionId === ownerSessionId) {
167
+ for (const childSessionId of suspension.childSessionIds) {
168
+ suspendedChildIds.add(childSessionId);
169
+ }
170
+ }
171
+ continue;
172
+ }
173
+
174
+ if (entry.customType === SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE) {
175
+ const receipt = safeParseTypeBoxValue(SUBAGENT_REPORT_RECEIVED_SCHEMA, entry.data);
176
+ if (receipt?.writerSessionId === ownerSessionId) {
177
+ receivedReportIds.add(receipt.reportId);
178
+ }
179
+ }
180
+ }
181
+
182
+ return {
183
+ launches: [...launches.values()],
184
+ cancelledChildIds,
185
+ suspendedChildIds,
186
+ receivedReportIds,
187
+ deliveredReportIds,
188
+ hasForeignLaunch,
189
+ hasDisownedNotice,
190
+ };
191
+ }
192
+
193
+ export function getChildSubagentLifecycle(branch: readonly SessionEntry[]): ChildSubagentLifecycle {
194
+ const reports = new Map<string, SubagentReport>();
195
+ let closed: SubagentClosed | undefined;
196
+ let hasReminder = false;
197
+
198
+ for (const entry of branch) {
199
+ if (entry.type === "custom_message") {
200
+ hasReminder ||= entry.customType === SUBAGENT_REPORT_REMINDER_MESSAGE_CUSTOM_TYPE;
201
+ continue;
202
+ }
203
+ if (entry.type !== "custom") {
204
+ continue;
205
+ }
206
+ if (entry.customType === SUBAGENT_REPORT_CUSTOM_TYPE) {
207
+ const report = safeParseTypeBoxValue(SUBAGENT_REPORT_SCHEMA, entry.data);
208
+ if (report) {
209
+ reports.set(report.reportId, report);
210
+ }
211
+ } else if (entry.customType === SUBAGENT_CLOSED_CUSTOM_TYPE) {
212
+ closed = safeParseTypeBoxValue(SUBAGENT_CLOSED_SCHEMA, entry.data) ?? closed;
213
+ }
214
+ }
215
+
216
+ return { reports: [...reports.values()], closed, hasReminder };
217
+ }
@@ -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
+ }