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,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
+ }
@@ -0,0 +1,262 @@
1
+ import {
2
+ type SessionEntry,
3
+ SessionManager,
4
+ type SessionTreeNode,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { isSessionStarting } from "../session-handoff/metadata.ts";
7
+ import { listTmuxWindows, type TmuxExecutor, tmuxSessionName } from "../shared/tmux.ts";
8
+ import { classifySubagent, type SubagentState } from "./classify.ts";
9
+ import {
10
+ type ChildSubagentLifecycle,
11
+ collectParentLedger,
12
+ getChildSubagentLifecycle,
13
+ type ParentSubagentLaunch,
14
+ type ParentSubagentLedger,
15
+ } from "./ledger.ts";
16
+ import type { ReconcileResult } from "./reconcile.ts";
17
+
18
+ export type SubagentRelationScope = "branch" | "tree";
19
+
20
+ export interface SubagentRosterEntry {
21
+ sessionId: string;
22
+ sessionFile: string;
23
+ ownerSessionId: string;
24
+ ownerTitle: string;
25
+ ownerIsCurrentSession: boolean;
26
+ title: string;
27
+ goal: string;
28
+ model?: string | undefined;
29
+ cwd: string;
30
+ resumeCommand: string;
31
+ launchedAt: string;
32
+ depth: number;
33
+ state: SubagentState;
34
+ onActiveBranch: boolean;
35
+ managedLive: boolean;
36
+ tmuxWindowId?: string | undefined;
37
+ }
38
+
39
+ export interface SubagentRosterResult {
40
+ entries: readonly SubagentRosterEntry[];
41
+ total: number;
42
+ }
43
+
44
+ export interface SubagentRoster {
45
+ resolve(scope: SubagentRelationScope): Promise<SubagentRosterResult>;
46
+ }
47
+
48
+ interface RosterSession {
49
+ sessionId: string;
50
+ getSessionName(): string | undefined;
51
+ getBranch(fromId?: string): readonly SessionEntry[];
52
+ getTree(): readonly SessionTreeNode[];
53
+ }
54
+
55
+ interface RosterParentSession extends RosterSession {
56
+ epoch: number;
57
+ }
58
+
59
+ interface RosterDependencies {
60
+ executor: TmuxExecutor;
61
+ messaging: { listSessions(): Promise<string[]> };
62
+ getParent(): RosterParentSession | undefined;
63
+ reconcile(): Promise<ReconcileResult>;
64
+ openSession(path: string): RosterSession;
65
+ }
66
+
67
+ interface TraversedSubagent {
68
+ launch: ParentSubagentLaunch;
69
+ ownerSessionId: string;
70
+ ownerTitle: string;
71
+ ownerIsCurrentSession: boolean;
72
+ ownerActiveLedger: ParentSubagentLedger;
73
+ onActiveBranch: boolean;
74
+ lifecycle: ChildSubagentLifecycle | undefined;
75
+ awaitingKickoff: boolean;
76
+ }
77
+
78
+ export class TranscriptSubagentRoster implements SubagentRoster {
79
+ constructor(private readonly deps: RosterDependencies) {}
80
+
81
+ async resolve(scope: SubagentRelationScope): Promise<SubagentRosterResult> {
82
+ const parent = this.deps.getParent();
83
+ if (!parent) {
84
+ throw new Error("Subagent roster is unavailable before session start.");
85
+ }
86
+
87
+ const reconciliation = await this.deps.reconcile();
88
+ const traversed = this.traverse(parent, scope);
89
+ const brokerLive = new Set(await this.deps.messaging.listSessions());
90
+
91
+ const ownerSessionIds = [...new Set(traversed.map((child) => child.ownerSessionId))];
92
+ const windowsByOwner = new Map(
93
+ await Promise.all(
94
+ ownerSessionIds.map(async (ownerSessionId) => {
95
+ const windows = await listTmuxWindows(
96
+ this.deps.executor,
97
+ tmuxSessionName(ownerSessionId),
98
+ );
99
+ return [
100
+ ownerSessionId,
101
+ new Map(windows.map((window) => [window.piSessionId, window])),
102
+ ] as const;
103
+ }),
104
+ ),
105
+ );
106
+
107
+ const entries = traversed.map((child): SubagentRosterEntry => {
108
+ const childSessionId = child.launch.childSessionId;
109
+ const window = windowsByOwner.get(child.ownerSessionId)?.get(childSessionId);
110
+ const hasWindow = window !== undefined;
111
+ const isBrokerLive = brokerLive.has(childSessionId);
112
+ const activeLedger = child.ownerActiveLedger;
113
+ const lifecycle = child.lifecycle;
114
+ const state =
115
+ reconciliation.states.get(childSessionId) ??
116
+ classifySubagent({
117
+ hasWindow,
118
+ brokerLive: isBrokerLive,
119
+ hasRegistered: reconciliation.registered.has(childSessionId),
120
+ awaitingKickoff: child.awaitingKickoff,
121
+ cancelled: activeLedger.cancelledChildIds.has(childSessionId),
122
+ suspended: activeLedger.suspendedChildIds.has(childSessionId),
123
+ hasReportOrClosure: Boolean(lifecycle?.reports.length || lifecycle?.closed),
124
+ childReadable: lifecycle !== undefined,
125
+ });
126
+
127
+ return {
128
+ sessionId: childSessionId,
129
+ sessionFile: child.launch.childSessionFile,
130
+ ownerSessionId: child.ownerSessionId,
131
+ ownerTitle: child.ownerTitle,
132
+ ownerIsCurrentSession: child.ownerIsCurrentSession,
133
+ title: child.launch.title,
134
+ goal: child.launch.goal,
135
+ ...(child.launch.model ? { model: child.launch.model } : {}),
136
+ cwd: child.launch.cwd,
137
+ resumeCommand: child.launch.resumeCommand,
138
+ launchedAt: child.launch.launchedAt,
139
+ depth: child.launch.depth,
140
+ state,
141
+ onActiveBranch: child.onActiveBranch,
142
+ managedLive: hasWindow || isBrokerLive,
143
+ ...(window ? { tmuxWindowId: window.windowId } : {}),
144
+ };
145
+ });
146
+
147
+ return { entries, total: entries.length };
148
+ }
149
+
150
+ private traverse(root: RosterSession, scope: SubagentRelationScope): TraversedSubagent[] {
151
+ const results = new Map<string, TraversedSubagent>();
152
+ const openedChildren = new Map<string, RosterSession | undefined>();
153
+ const visitedOwners = new Set<string>();
154
+ const queue: RosterSession[] = [root];
155
+
156
+ while (queue.length > 0) {
157
+ const owner = queue.shift();
158
+ if (!owner || visitedOwners.has(owner.sessionId)) {
159
+ continue;
160
+ }
161
+ visitedOwners.add(owner.sessionId);
162
+
163
+ const activeBranch = owner.getBranch();
164
+ const activeLedger = collectParentLedger(activeBranch, owner.sessionId);
165
+ const activeChildIds = new Set(activeLedger.launches.map((launch) => launch.childSessionId));
166
+ const launches =
167
+ scope === "branch" ? activeLedger.launches : collectTreeLaunches(owner, owner.sessionId);
168
+
169
+ for (const launch of launches) {
170
+ let child: RosterSession | undefined;
171
+ let lifecycle: ChildSubagentLifecycle | undefined;
172
+ let awaitingKickoff = false;
173
+ try {
174
+ if (openedChildren.has(launch.childSessionId)) {
175
+ child = openedChildren.get(launch.childSessionId);
176
+ } else {
177
+ child = this.deps.openSession(launch.childSessionFile);
178
+ if (child.sessionId !== launch.childSessionId) {
179
+ child = undefined;
180
+ }
181
+ openedChildren.set(launch.childSessionId, child);
182
+ }
183
+
184
+ const childBranch = child?.getBranch();
185
+ if (!childBranch) {
186
+ child = undefined;
187
+ } else {
188
+ lifecycle = getChildSubagentLifecycle(childBranch);
189
+ awaitingKickoff = isSessionStarting(childBranch);
190
+ }
191
+ } catch {
192
+ child = undefined;
193
+ openedChildren.set(launch.childSessionId, undefined);
194
+ }
195
+
196
+ const existing = results.get(launch.childSessionId);
197
+ const candidate: TraversedSubagent = {
198
+ launch,
199
+ ownerSessionId: owner.sessionId,
200
+ ownerTitle: owner.getSessionName()?.trim() || "Untitled session",
201
+ ownerIsCurrentSession: owner.sessionId === root.sessionId,
202
+ ownerActiveLedger: activeLedger,
203
+ onActiveBranch: activeChildIds.has(launch.childSessionId),
204
+ lifecycle,
205
+ awaitingKickoff,
206
+ };
207
+ if (!existing || candidate.launch.depth < existing.launch.depth) {
208
+ results.set(launch.childSessionId, candidate);
209
+ }
210
+ if (child && !visitedOwners.has(child.sessionId)) {
211
+ queue.push(child);
212
+ }
213
+ }
214
+ }
215
+
216
+ return [...results.values()];
217
+ }
218
+ }
219
+
220
+ export function openRosterSession(path: string): RosterSession {
221
+ const manager = SessionManager.open(path);
222
+ return {
223
+ sessionId: manager.getSessionId(),
224
+ getSessionName: () => manager.getSessionName(),
225
+ getBranch: (fromId) => manager.getBranch(fromId),
226
+ getTree: () => manager.getTree(),
227
+ };
228
+ }
229
+
230
+ function collectTreeLaunches(
231
+ session: RosterSession,
232
+ ownerSessionId: string,
233
+ ): ParentSubagentLaunch[] {
234
+ const launches = new Map<string, ParentSubagentLaunch>();
235
+ for (const leafId of collectLeafIds(session.getTree())) {
236
+ const ledger = collectParentLedger(session.getBranch(leafId), ownerSessionId);
237
+ for (const launch of ledger.launches) {
238
+ const existing = launches.get(launch.childSessionId);
239
+ if (!existing || launch.launchedAt > existing.launchedAt) {
240
+ launches.set(launch.childSessionId, launch);
241
+ }
242
+ }
243
+ }
244
+ return [...launches.values()];
245
+ }
246
+
247
+ function collectLeafIds(roots: readonly SessionTreeNode[]): string[] {
248
+ const leafIds: string[] = [];
249
+ const stack = [...roots];
250
+ while (stack.length > 0) {
251
+ const node = stack.pop();
252
+ if (!node) {
253
+ continue;
254
+ }
255
+ if (node.children.length === 0) {
256
+ leafIds.push(node.entry.id);
257
+ } else {
258
+ stack.push(...node.children);
259
+ }
260
+ }
261
+ return leafIds;
262
+ }
@@ -0,0 +1,173 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import type { SendMessageRequest, SendMessageResult } from "../session-messaging/install.ts";
3
+ import {
4
+ createTmuxWindow,
5
+ killTmuxWindow,
6
+ listTmuxWindows,
7
+ type TmuxExecutor,
8
+ tmuxSessionName,
9
+ } from "../shared/tmux.ts";
10
+ import { findOwnedSubagentLaunch, type SubagentLaunched } from "./ledger.ts";
11
+
12
+ const SESSION_READY_TIMEOUT_MS = 30_000;
13
+
14
+ export interface WakeParentSession {
15
+ sessionId: string;
16
+ epoch: number;
17
+ getBranch(): readonly SessionEntry[];
18
+ }
19
+
20
+ interface WakeMessaging {
21
+ sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
22
+ listSessions(): Promise<string[]>;
23
+ waitForSession(sessionId: string, timeoutMs: number): Promise<boolean>;
24
+ }
25
+
26
+ export interface SubagentMessageRouterOptions {
27
+ readyTimeoutMs?: number;
28
+ onMaterialize?(launch: SubagentLaunched): void;
29
+ afterOwnedSend?(): Promise<void> | void;
30
+ }
31
+
32
+ export class SubagentMessageRouter {
33
+ private readonly wakeBySessionId = new Map<string, Promise<void>>();
34
+
35
+ constructor(
36
+ private readonly executor: TmuxExecutor,
37
+ private readonly messaging: WakeMessaging,
38
+ private readonly getParent: () => WakeParentSession | undefined,
39
+ private readonly isCurrent: (epoch: number) => boolean,
40
+ private readonly options: SubagentMessageRouterOptions = {},
41
+ ) {}
42
+
43
+ async sendMessage(request: SendMessageRequest): Promise<SendMessageResult> {
44
+ let failedLiveSend: SendMessageResult | undefined;
45
+ if (await this.isLive(request.target)) {
46
+ const result = await this.messaging.sendMessage(request);
47
+ if (result.delivered || !isTargetDeparture(result)) {
48
+ return result;
49
+ }
50
+ failedLiveSend = result;
51
+ }
52
+
53
+ const parent = this.getParent();
54
+ const launch = parent
55
+ ? findOwnedSubagentLaunch(parent.getBranch(), parent.sessionId, request.target)
56
+ : undefined;
57
+ if (!parent || !launch) {
58
+ return failedLiveSend ?? this.messaging.sendMessage(request);
59
+ }
60
+
61
+ try {
62
+ await this.wake(parent, launch);
63
+ const result = await this.sendOwned(parent, request);
64
+ if (result.delivered || !isTargetDeparture(result)) {
65
+ return result;
66
+ }
67
+
68
+ await this.wake(parent, launch);
69
+ return this.sendOwned(parent, request);
70
+ } finally {
71
+ await this.options.afterOwnedSend?.();
72
+ }
73
+ }
74
+
75
+ private async wake(parent: WakeParentSession, launch: SubagentLaunched): Promise<void> {
76
+ const existing = this.wakeBySessionId.get(launch.childSessionId);
77
+ if (existing) {
78
+ return existing;
79
+ }
80
+
81
+ const wake = this.ensureReady(parent, launch).finally(() => {
82
+ if (this.wakeBySessionId.get(launch.childSessionId) === wake) {
83
+ this.wakeBySessionId.delete(launch.childSessionId);
84
+ }
85
+ });
86
+ this.wakeBySessionId.set(launch.childSessionId, wake);
87
+ return wake;
88
+ }
89
+
90
+ private async ensureReady(parent: WakeParentSession, launch: SubagentLaunched): Promise<void> {
91
+ if (await this.isLive(launch.childSessionId)) {
92
+ return;
93
+ }
94
+
95
+ const tmuxSession = tmuxSessionName(parent.sessionId);
96
+ const hasWindow = (await listTmuxWindows(this.executor, tmuxSession)).some(
97
+ (window) => window.piSessionId === launch.childSessionId,
98
+ );
99
+
100
+ if (!hasWindow) {
101
+ await this.createWindow(parent, launch, tmuxSession);
102
+ }
103
+ if (
104
+ await this.messaging.waitForSession(
105
+ launch.childSessionId,
106
+ this.options.readyTimeoutMs ?? SESSION_READY_TIMEOUT_MS,
107
+ )
108
+ ) {
109
+ return;
110
+ }
111
+
112
+ this.requireCurrent(parent);
113
+ const killed = await killTmuxWindow(this.executor, tmuxSession, launch.childSessionId);
114
+ if (!killed) {
115
+ throw new Error(
116
+ `Subagent ${launch.childSessionId} did not register and its stale tmux window could not be stopped.`,
117
+ );
118
+ }
119
+
120
+ await this.createWindow(parent, launch, tmuxSession);
121
+ if (
122
+ !(await this.messaging.waitForSession(
123
+ launch.childSessionId,
124
+ this.options.readyTimeoutMs ?? SESSION_READY_TIMEOUT_MS,
125
+ ))
126
+ ) {
127
+ throw new Error(
128
+ `Subagent ${launch.childSessionId} was restarted but did not register for messaging.`,
129
+ );
130
+ }
131
+ }
132
+
133
+ private async createWindow(
134
+ parent: WakeParentSession,
135
+ launch: SubagentLaunched,
136
+ tmuxSession: string,
137
+ ): Promise<void> {
138
+ if (await this.isLive(launch.childSessionId)) {
139
+ return;
140
+ }
141
+ this.requireCurrent(parent);
142
+ this.options.onMaterialize?.(launch);
143
+ await createTmuxWindow(this.executor, {
144
+ tmuxSession,
145
+ name: launch.title,
146
+ cwd: launch.cwd,
147
+ command: launch.resumeCommand,
148
+ piSessionId: launch.childSessionId,
149
+ });
150
+ }
151
+
152
+ private sendOwned(
153
+ parent: WakeParentSession,
154
+ request: SendMessageRequest,
155
+ ): Promise<SendMessageResult> {
156
+ this.requireCurrent(parent);
157
+ return this.messaging.sendMessage(request);
158
+ }
159
+
160
+ private async isLive(sessionId: string): Promise<boolean> {
161
+ return (await this.messaging.listSessions()).includes(sessionId);
162
+ }
163
+
164
+ private requireCurrent(parent: WakeParentSession): void {
165
+ if (!this.isCurrent(parent.epoch)) {
166
+ throw new Error("The parent session changed while waking its subagent.");
167
+ }
168
+ }
169
+ }
170
+
171
+ function isTargetDeparture(result: SendMessageResult): boolean {
172
+ return !result.delivered && (result.reason === "no_session" || result.reason === "disconnected");
173
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.7.2",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
6
6
  "license": "MIT",
@@ -24,13 +24,7 @@
24
24
  ],
25
25
  "pi": {
26
26
  "extensions": [
27
- "./extensions/session-ask.ts",
28
- "./extensions/session-auto-title.ts",
29
- "./extensions/session-handoff.ts",
30
- "./extensions/session-hooks.ts",
31
- "./extensions/session-index.ts",
32
- "./extensions/session-messaging.ts",
33
- "./extensions/session-search.ts"
27
+ "./extensions/pi-sessions.ts"
34
28
  ]
35
29
  },
36
30
  "scripts": {
@@ -46,10 +40,10 @@
46
40
  },
47
41
  "devDependencies": {
48
42
  "@biomejs/biome": "^2.4.14",
49
- "@earendil-works/pi-agent-core": "^0.80.2",
50
- "@earendil-works/pi-ai": "^0.80.2",
51
- "@earendil-works/pi-coding-agent": "^0.80.2",
52
- "@earendil-works/pi-tui": "^0.80.2",
43
+ "@earendil-works/pi-agent-core": "^0.80.10",
44
+ "@earendil-works/pi-ai": "^0.80.10",
45
+ "@earendil-works/pi-coding-agent": "^0.80.10",
46
+ "@earendil-works/pi-tui": "^0.80.10",
53
47
  "@types/node": "^25.6.2",
54
48
  "husky": "^9.1.7",
55
49
  "lint-staged": "^17.0.3",
@@ -57,10 +51,10 @@
57
51
  "vitest": "^4.1.5"
58
52
  },
59
53
  "peerDependencies": {
60
- "@earendil-works/pi-agent-core": ">=0.80.2",
61
- "@earendil-works/pi-ai": ">=0.80.2",
62
- "@earendil-works/pi-coding-agent": ">=0.80.2",
63
- "@earendil-works/pi-tui": ">=0.80.2"
54
+ "@earendil-works/pi-agent-core": ">=0.80.10",
55
+ "@earendil-works/pi-ai": ">=0.80.10",
56
+ "@earendil-works/pi-coding-agent": ">=0.80.10",
57
+ "@earendil-works/pi-tui": ">=0.80.10"
64
58
  },
65
59
  "lint-staged": {
66
60
  "*.{js,cjs,mjs,jsx,ts,tsx,json,jsonc}": "biome check --write --no-errors-on-unmatched"