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,206 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
+ import type { ExtensionAPI, ExtensionContext, ModelRuntime } from "@earendil-works/pi-coding-agent";
6
+ import { resolveHandoffSource } from "./extract.ts";
7
+ import type { ClipboardStatus } from "./launch/backend.ts";
8
+ import { resolveHandoffLaunchTarget } from "./launch-options.ts";
9
+ import {
10
+ DEFERRED_LAUNCH,
11
+ type HandoffLaunchTarget,
12
+ type HandoffLaunchValue,
13
+ } from "./launch-target.ts";
14
+ import { createChildGeneratedHandoffBootstrap } from "./metadata.ts";
15
+ import { formatModelArgument, resolveModelOverride } from "./model.ts";
16
+ import { buildLaunchReceipt } from "./receipt.ts";
17
+ import { formatHandoffLaunchFailure, prepareHandoffLaunch } from "./spawn.ts";
18
+ import type { HandoffToolDetails } from "./tool-contract.ts";
19
+
20
+ export type { HandoffLaunchValue } from "./launch-target.ts";
21
+ export { DEFERRED_LAUNCH, LAUNCH_DIRECTIONS, SUBAGENT_LAUNCH } from "./launch-target.ts";
22
+
23
+ export const MAX_HANDOFF_TITLE_LENGTH = 64;
24
+
25
+ export interface HandoffToolParams {
26
+ goal: string;
27
+ title: string;
28
+ launch: HandoffLaunchValue;
29
+ cwd?: string | undefined;
30
+ requestResponse?: boolean | undefined;
31
+ provider?: string | undefined;
32
+ model?: string | undefined;
33
+ thinkingLevel?: ThinkingLevel | undefined;
34
+ }
35
+
36
+ export async function executeSessionHandoffTool(
37
+ pi: ExtensionAPI,
38
+ params: HandoffToolParams,
39
+ ctx: ExtensionContext,
40
+ modelRuntime: ModelRuntime,
41
+ launchTargets: readonly HandoffLaunchTarget[],
42
+ recordClipboardStatus: (sessionId: string, status: ClipboardStatus) => void,
43
+ ) {
44
+ const goal = params.goal.trim();
45
+ if (!goal) {
46
+ throw new Error("session_handoff requires a goal.");
47
+ }
48
+
49
+ const title = params.title.trim();
50
+ if (!title) {
51
+ throw new Error("session_handoff requires a title.");
52
+ }
53
+ if ([...title].length > MAX_HANDOFF_TITLE_LENGTH) {
54
+ throw new Error("session_handoff title must be 64 characters or less.");
55
+ }
56
+
57
+ if (!ctx.model) {
58
+ throw new Error("No model selected.");
59
+ }
60
+
61
+ const { target, degradedFrom } = resolveHandoffLaunchTarget(params.launch, launchTargets);
62
+
63
+ const targetCwd = resolveHandoffCwd(ctx.cwd, params.cwd);
64
+ if (targetCwd.error) {
65
+ throw new Error(targetCwd.message);
66
+ }
67
+
68
+ const parentSessionFile = ctx.sessionManager.getSessionFile();
69
+ if (!parentSessionFile) {
70
+ throw new Error("Handoff requires a persisted current session.");
71
+ }
72
+
73
+ // Tools execute in parallel, so the leaf is still the invoking assistant turn; its parent is the
74
+ // last settled entry. Anchoring there keeps the launch receipt this handoff produces out of the snapshot.
75
+ const invocationLeafId = ctx.sessionManager.getLeafId();
76
+ const sourceLeafId = invocationLeafId
77
+ ? ctx.sessionManager.getEntry(invocationLeafId)?.parentId
78
+ : undefined;
79
+ if (!sourceLeafId) {
80
+ throw new Error("No conversation to hand off.");
81
+ }
82
+
83
+ resolveHandoffSource(ctx.sessionManager, sourceLeafId);
84
+
85
+ const requestResponse = params.requestResponse ?? target.requestResponseDefault;
86
+ const provider = params.provider?.trim();
87
+ const modelId = params.model?.trim();
88
+ if (Boolean(provider) !== Boolean(modelId)) {
89
+ throw new Error("session_handoff requires provider and model together, or neither.");
90
+ }
91
+ const override =
92
+ provider && modelId
93
+ ? resolveModelOverride(modelRuntime, `${provider}/${modelId}`, params.thinkingLevel)
94
+ : undefined;
95
+ const childModel = override?.model ?? ctx.model;
96
+ const thinkingLevel = override?.thinkingLevel ?? params.thinkingLevel ?? pi.getThinkingLevel();
97
+ const model = formatModelArgument(childModel, thinkingLevel);
98
+ if (!model) {
99
+ throw new Error("No active model is available for the handoff.");
100
+ }
101
+ const prepared = prepareHandoffLaunch({
102
+ targetCwd: targetCwd.path,
103
+ parentCwd: ctx.cwd,
104
+ parentSessionDir: ctx.sessionManager.getSessionDir(),
105
+ parentSessionFile,
106
+ title,
107
+ model,
108
+ buildBootstrap: (sessionId) =>
109
+ createChildGeneratedHandoffBootstrap({
110
+ sessionId,
111
+ goal,
112
+ title,
113
+ parentSessionFile,
114
+ sourceLeafId,
115
+ requestResponse,
116
+ bootstrapMode: target.bootstrapMode,
117
+ launch: target.value,
118
+ subagent: target.describeSubagentChild?.({
119
+ childSessionId: sessionId,
120
+ ownerSessionId: ctx.sessionManager.getSessionId(),
121
+ requestResponse,
122
+ }),
123
+ }),
124
+ prepareChild: (manager, childSessionId) =>
125
+ target.prepareChild({
126
+ manager,
127
+ childSessionId,
128
+ parentSessionId: ctx.sessionManager.getSessionId(),
129
+ parentSessionFile,
130
+ requestResponse,
131
+ }),
132
+ });
133
+ const outcome = await target.launch({
134
+ prepared,
135
+ parentSessionId: ctx.sessionManager.getSessionId(),
136
+ title,
137
+ goal,
138
+ requestResponse,
139
+ model,
140
+ cwd: targetCwd.path,
141
+ });
142
+
143
+ if (!outcome.success) {
144
+ throw new Error(formatHandoffLaunchFailure(outcome.error, prepared));
145
+ }
146
+ if (outcome.clipboardStatus) {
147
+ recordClipboardStatus(prepared.sessionId, outcome.clipboardStatus);
148
+ }
149
+
150
+ const details: HandoffToolDetails = {
151
+ ...buildLaunchReceipt({
152
+ sessionId: prepared.sessionId,
153
+ childSessionFile: prepared.sessionFile,
154
+ title,
155
+ launch: target.value,
156
+ resumeCommand: prepared.resumeCommand,
157
+ backend: target.value === DEFERRED_LAUNCH ? undefined : outcome.backend,
158
+ targetCwd: targetCwd.path,
159
+ parentCwd: ctx.cwd,
160
+ childModel: model,
161
+ childProvider: childModel.provider,
162
+ childModelName: childModel.name || childModel.id,
163
+ thinkingLevel,
164
+ }),
165
+ ...(degradedFrom ? { degradedFrom } : {}),
166
+ };
167
+
168
+ return {
169
+ content: [
170
+ {
171
+ type: "text" as const,
172
+ text: JSON.stringify({ ...details, requestResponse }, null, 2),
173
+ },
174
+ ],
175
+ details,
176
+ };
177
+ }
178
+
179
+ function resolveHandoffCwd(
180
+ currentCwd: string,
181
+ requestedCwd: string | undefined,
182
+ ): { path: string; error?: undefined } | { error: true; message: string } {
183
+ const rawPath = requestedCwd?.trim();
184
+ const resolvedPath = rawPath ? resolveRequestedPath(currentCwd, rawPath) : currentCwd;
185
+
186
+ if (!existsSync(resolvedPath)) {
187
+ return { error: true, message: `Handoff cwd does not exist: ${resolvedPath}` };
188
+ }
189
+ if (!statSync(resolvedPath).isDirectory()) {
190
+ return { error: true, message: `Handoff cwd is not a directory: ${resolvedPath}` };
191
+ }
192
+ return { path: resolvedPath };
193
+ }
194
+
195
+ function resolveRequestedPath(currentCwd: string, requestedPath: string): string {
196
+ if (requestedPath === "~") {
197
+ return homedir();
198
+ }
199
+ if (requestedPath.startsWith("~/")) {
200
+ return resolve(homedir(), requestedPath.slice(2));
201
+ }
202
+ if (isAbsolute(requestedPath)) {
203
+ return requestedPath;
204
+ }
205
+ return resolve(currentCwd, requestedPath);
206
+ }
@@ -0,0 +1,66 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
3
+ import { renderStrongModal } from "./strong-modal.ts";
4
+
5
+ export async function runHandoffTaskWithLoader<T>(
6
+ ctx: { ui: ExtensionUIContext },
7
+ label: string,
8
+ task: (signal: AbortSignal) => Promise<T>,
9
+ ): Promise<T | undefined> {
10
+ let taskError: unknown;
11
+
12
+ const result = await ctx.ui.custom<T | undefined>(
13
+ (tui, theme, _keybindings, done) => {
14
+ const abortController = new AbortController();
15
+
16
+ task(abortController.signal)
17
+ .then(done)
18
+ .catch((error: unknown) => {
19
+ if (!abortController.signal.aborted) {
20
+ taskError = error;
21
+ }
22
+ done(undefined);
23
+ });
24
+
25
+ return {
26
+ render(width: number): string[] {
27
+ return renderStrongModal(
28
+ [theme.fg("accent", theme.bold(label)), "", theme.fg("muted", "Press Esc to cancel.")],
29
+ width,
30
+ theme,
31
+ );
32
+ },
33
+ invalidate(): void {},
34
+ handleInput(data: string): void {
35
+ if (matchesKey(data, Key.escape)) {
36
+ abortController.abort();
37
+ done(undefined);
38
+ tui.requestRender();
39
+ }
40
+ },
41
+ };
42
+ },
43
+ {
44
+ overlay: true,
45
+ overlayOptions: {
46
+ anchor: "center",
47
+ width: "70%",
48
+ maxHeight: "40%",
49
+ margin: 2,
50
+ },
51
+ },
52
+ );
53
+
54
+ if (taskError) {
55
+ throw taskError;
56
+ }
57
+
58
+ return result;
59
+ }
60
+
61
+ export function formatHandoffError(error: unknown): string {
62
+ if (error instanceof Error && error.message.trim()) {
63
+ return error.message;
64
+ }
65
+ return "Handoff generation failed.";
66
+ }
@@ -0,0 +1,75 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ findPendingHandoffBootstrap,
4
+ getHandoffMetadataFromEntries,
5
+ } from "../session-handoff/metadata.ts";
6
+ import { createSessionHookController } from "../session-search/hooks.ts";
7
+ import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
8
+ import type { SessionSettings } from "../shared/settings.ts";
9
+
10
+ export function installHooks(
11
+ pi: ExtensionAPI,
12
+ deps: { settings: SessionSettings; index: IndexHandle },
13
+ ): SessionLifecycle {
14
+ const controller = createSessionHookController({ indexPath: deps.index.path });
15
+
16
+ pi.on("tool_call", async (event, ctx) => {
17
+ controller.handleToolCall(event, ctx.sessionManager.getSessionFile(), ctx.cwd);
18
+ });
19
+
20
+ pi.on("tool_result", async (event) => {
21
+ controller.handleToolResult(event);
22
+ });
23
+
24
+ pi.on("turn_end", async (_event, ctx) => {
25
+ await controller.handleTurnEnd(ctx.sessionManager.getSessionFile(), ctx.cwd);
26
+ });
27
+
28
+ pi.on("session_tree", async (_event, ctx) => {
29
+ await controller.handleSessionTree(ctx.sessionManager.getSessionFile(), ctx.cwd);
30
+ });
31
+
32
+ pi.on("session_compact", async (_event, ctx) => {
33
+ await controller.handleSessionCompact(ctx.sessionManager.getSessionFile(), ctx.cwd);
34
+ });
35
+
36
+ return {
37
+ async onSessionStart(event, ctx) {
38
+ const { reason, previousSessionFile } = event;
39
+ const sessionFile = ctx.sessionManager.getSessionFile();
40
+
41
+ switch (reason) {
42
+ case "new":
43
+ case "resume":
44
+ await controller.handleSessionSwitch(
45
+ previousSessionFile,
46
+ sessionFile,
47
+ ctx.cwd,
48
+ getSessionStartOrigin(ctx),
49
+ );
50
+ break;
51
+ case "fork":
52
+ await controller.handleSessionFork(previousSessionFile, sessionFile, ctx.cwd);
53
+ break;
54
+ default:
55
+ await controller.handleSessionStart(sessionFile, ctx.cwd);
56
+ break;
57
+ }
58
+ },
59
+ async onSessionShutdown(_event, ctx) {
60
+ await controller.handleSessionShutdown(ctx.sessionManager.getSessionFile(), ctx.cwd);
61
+ },
62
+ };
63
+ }
64
+
65
+ function getSessionStartOrigin(ctx: ExtensionContext): "handoff" | undefined {
66
+ const entries = ctx.sessionManager.getEntries();
67
+ if (getHandoffMetadataFromEntries(entries)) {
68
+ return "handoff";
69
+ }
70
+
71
+ const scan = findPendingHandoffBootstrap(ctx.sessionManager.getBranch());
72
+ return scan?.kind === "pending" && scan.bootstrap.sessionId === ctx.sessionManager.getSessionId()
73
+ ? "handoff"
74
+ : undefined;
75
+ }
@@ -5,15 +5,16 @@ import {
5
5
  type Theme,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { type Focusable, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
8
- import { type ReindexResult, rebuildSessionIndex } from "./session-search/reindex.ts";
9
- import { isTuiMode } from "./shared/pi-mode.ts";
10
- import { getIndexStatus, type SessionIndexStatus } from "./shared/session-index/index.ts";
11
- import { loadSettings } from "./shared/settings.ts";
8
+ import { type ReindexResult, rebuildSessionIndex } from "../session-search/reindex.ts";
9
+ import type { IndexHandle } from "../shared/composition.ts";
10
+ import { isTuiMode } from "../shared/pi-mode.ts";
11
+ import { getIndexStatus, type SessionIndexStatus } from "../shared/session-index/index.ts";
12
+ import type { SessionSettings } from "../shared/settings.ts";
12
13
 
13
14
  type SessionIndexAction = "reindex" | undefined;
14
15
 
15
- export default function sessionIndexExtension(pi: ExtensionAPI): void {
16
- const settings = loadSettings();
16
+ export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings }): IndexHandle {
17
+ const indexPath = deps.settings.index.path;
17
18
 
18
19
  pi.registerCommand("session-index", {
19
20
  description: "Open the session index control panel",
@@ -23,7 +24,6 @@ export default function sessionIndexExtension(pi: ExtensionAPI): void {
23
24
  return;
24
25
  }
25
26
 
26
- const indexPath = settings.index.path;
27
27
  const status = getIndexStatus(indexPath);
28
28
  const action = await ctx.ui.custom<SessionIndexAction>(
29
29
  (_tui, theme, _keybindings, done) => new SessionIndexPanel(theme, status, done),
@@ -62,6 +62,8 @@ export default function sessionIndexExtension(pi: ExtensionAPI): void {
62
62
  );
63
63
  },
64
64
  });
65
+
66
+ return { path: indexPath };
65
67
  }
66
68
 
67
69
  class SessionIndexPanel implements Focusable {
@@ -4,6 +4,8 @@ import { join } from "node:path";
4
4
  import { readFrames, writeFrame } from "../../shared/session-broker/framing.ts";
5
5
  import {
6
6
  CLIENT_FRAME_SCHEMA,
7
+ type OutboundSessionEnvelope,
8
+ type SessionEnvelope,
7
9
  type SessionMessagingClientFrame,
8
10
  type SessionMessagingIncomingAckClientFrame,
9
11
  type SessionMessagingSendClientFrame,
@@ -23,8 +25,8 @@ const pidPath = join(messagingDir, "broker.pid");
23
25
 
24
26
  interface PendingSend {
25
27
  source: Socket;
28
+ target: Socket;
26
29
  timeout: NodeJS.Timeout;
27
- messageId: string;
28
30
  targetSessionId: string;
29
31
  }
30
32
 
@@ -119,7 +121,7 @@ function handleFrame(
119
121
  break;
120
122
 
121
123
  case "incoming_ack":
122
- handleIncomingAck(message);
124
+ handleIncomingAck(socket, message);
123
125
  break;
124
126
  }
125
127
  }
@@ -130,13 +132,11 @@ function handleSend(
130
132
  message: SessionMessagingSendClientFrame,
131
133
  ): void {
132
134
  const source = currentSessionId ? sessions.get(currentSessionId) : undefined;
133
- const { messageId } = message;
134
135
 
135
136
  if (!currentSessionId || !source) {
136
137
  writeFrame(socket, {
137
138
  type: "send_result",
138
139
  requestId: message.requestId,
139
- messageId,
140
140
  delivered: false,
141
141
  error: "Sender session is not registered.",
142
142
  });
@@ -148,8 +148,8 @@ function handleSend(
148
148
  writeFrame(socket, {
149
149
  type: "send_result",
150
150
  requestId: message.requestId,
151
- messageId,
152
151
  delivered: false,
152
+ reason: "no_session",
153
153
  error: resolved.error,
154
154
  });
155
155
  return;
@@ -159,9 +159,8 @@ function handleSend(
159
159
  writeFrame(socket, {
160
160
  type: "send_result",
161
161
  requestId: message.requestId,
162
- messageId,
163
162
  delivered: false,
164
- error: "Cannot send a session message to the current session.",
163
+ error: "Cannot send a session envelope to the current session.",
165
164
  });
166
165
  return;
167
166
  }
@@ -171,34 +170,21 @@ function handleSend(
171
170
  writeFrame(socket, {
172
171
  type: "send_result",
173
172
  requestId: message.requestId,
174
- messageId,
175
173
  delivered: false,
176
- error: "Timed out waiting for target session to accept the message.",
174
+ error: "Timed out waiting for target session to accept the envelope.",
177
175
  });
178
176
  }, SEND_TIMEOUT_MS);
179
177
  pendingSends.set(message.requestId, {
180
178
  source: socket,
179
+ target: resolved.socket,
181
180
  timeout,
182
- messageId,
183
181
  targetSessionId: resolved.sessionId,
184
182
  });
185
183
 
186
184
  writeFrame(resolved.socket, {
187
185
  type: "incoming",
188
186
  requestId: message.requestId,
189
- message: {
190
- messageId,
191
- source: currentSessionId,
192
- target: resolved.sessionId,
193
- body: message.body,
194
- ...(message.requestResponse === undefined
195
- ? {}
196
- : { requestResponse: message.requestResponse }),
197
- sentAt: message.sentAt,
198
- ...(message.sourceToolCallId === undefined
199
- ? {}
200
- : { sourceToolCallId: message.sourceToolCallId }),
201
- },
187
+ envelope: stampEnvelope(message.envelope, currentSessionId, resolved.sessionId),
202
188
  });
203
189
  }
204
190
 
@@ -213,28 +199,35 @@ function failPendingSendsTo(targetSessionId: string): void {
213
199
  writeFrame(pending.source, {
214
200
  type: "send_result",
215
201
  requestId,
216
- messageId: pending.messageId,
217
202
  delivered: false,
218
- error: "Target disconnected before accepting the message.",
203
+ reason: "disconnected",
204
+ error: "Target disconnected before accepting the envelope.",
219
205
  });
220
206
  }
221
207
  }
222
208
 
223
- function handleIncomingAck(message: SessionMessagingIncomingAckClientFrame): void {
209
+ function handleIncomingAck(socket: Socket, message: SessionMessagingIncomingAckClientFrame): void {
224
210
  const pending = pendingSends.get(message.requestId);
225
- if (!pending) return;
211
+ if (!pending || pending.target !== socket) return;
226
212
 
227
213
  clearTimeout(pending.timeout);
228
214
  pendingSends.delete(message.requestId);
229
215
  writeFrame(pending.source, {
230
216
  type: "send_result",
231
217
  requestId: message.requestId,
232
- messageId: message.messageId,
233
218
  delivered: message.delivered,
234
219
  ...(message.error === undefined ? {} : { error: message.error }),
235
220
  });
236
221
  }
237
222
 
223
+ function stampEnvelope(
224
+ envelope: OutboundSessionEnvelope,
225
+ source: string,
226
+ target: string,
227
+ ): SessionEnvelope {
228
+ return { ...envelope, source, target };
229
+ }
230
+
238
231
  function handleConnection(socket: Socket): void {
239
232
  let sessionId: string | undefined;
240
233
  socket.setKeepAlive(true, KEEPALIVE_DELAY_MS);
@@ -34,7 +34,15 @@ export async function spawnSessionMessagingBrokerIfNeeded(): Promise<void> {
34
34
  return;
35
35
  }
36
36
 
37
- const brokerPath = join(dirname(fileURLToPath(import.meta.url)), "process.ts");
37
+ const brokerPath = join(
38
+ dirname(fileURLToPath(import.meta.url)),
39
+ "../../../dist/session-messaging/broker/process.js",
40
+ );
41
+ if (!existsSync(brokerPath)) {
42
+ throw new Error(
43
+ `Session messaging broker build not found: ${brokerPath}. Run npm install in the pi-sessions package directory.`,
44
+ );
45
+ }
38
46
  const child = spawn(process.execPath, [brokerPath], {
39
47
  detached: true,
40
48
  stdio: "ignore",
@@ -164,15 +172,12 @@ function releaseSpawnLock(): void {
164
172
  }
165
173
 
166
174
  function brokerSpawnEnv(): NodeJS.ProcessEnv {
167
- // NODE_NO_WARNINGS silences Node's experimental-TypeScript warning when the
168
- // broker is launched as `node process.ts`. Redundant while stdio is ignored,
169
- // but cheap insurance if that changes or an older Node revives the warning.
170
- const env: NodeJS.ProcessEnv = { ...process.env, NODE_NO_WARNINGS: "1" };
175
+ const env: NodeJS.ProcessEnv = { ...process.env };
171
176
  // process.execPath is the Pi executable. As a Bun-compiled binary it treats a
172
177
  // positional path as a file argument to the agent, not a script to run, which
173
178
  // would relaunch a full agent session and recurse. BUN_BE_BUN makes it behave
174
- // as the Bun CLI and execute process.ts directly. It is set whenever Bun is the
175
- // runtime (compiled binary or `bun run`) and ignored by a plain Node launch.
179
+ // as the Bun CLI and execute the broker. It is set whenever Bun is the runtime
180
+ // (compiled binary or `bun run`) and ignored by a plain Node launch.
176
181
  if (process.versions.bun) {
177
182
  env.BUN_BE_BUN = "1";
178
183
  }
@@ -0,0 +1,78 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
3
+ import type { SessionSettings } from "../shared/settings.ts";
4
+ import {
5
+ IncomingSessionMessageRuntime,
6
+ SESSION_MESSAGE_CUSTOM_TYPE,
7
+ } from "./pi/incoming-runtime.ts";
8
+ import { renderIncomingSessionMessage } from "./pi/renderer.ts";
9
+ import {
10
+ type CancelSessionResult,
11
+ type IncomingCancelHandler,
12
+ type IncomingMessageHandler,
13
+ type IncomingSubagentReportHandler,
14
+ type SendMessageRequest,
15
+ type SendMessageResult,
16
+ type SendSubagentReportRequest,
17
+ type SendSubagentReportResult,
18
+ SessionMessagingService,
19
+ } from "./pi/service.ts";
20
+
21
+ export type {
22
+ CancelSessionResult,
23
+ SendMessageRequest,
24
+ SendMessageResult,
25
+ } from "./pi/service.ts";
26
+
27
+ /** The live surface the messaging feature exposes to other features (search, subagents). */
28
+ export interface MessagingHandle {
29
+ sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
30
+ sendSubagentReport(request: SendSubagentReportRequest): Promise<SendSubagentReportResult>;
31
+ cancelSession(sessionId: string): Promise<CancelSessionResult>;
32
+ listSessions(): Promise<string[]>;
33
+ waitForSession(sessionId: string, timeoutMs: number): Promise<boolean>;
34
+ getCachedRelationTo(sessionId: string | undefined): string | undefined;
35
+ onIncomingMessage(handler: IncomingMessageHandler): void;
36
+ onIncomingCancel(handler: IncomingCancelHandler): void;
37
+ onIncomingSubagentReport(handler: IncomingSubagentReportHandler): void;
38
+ }
39
+
40
+ export function installMessaging(
41
+ pi: ExtensionAPI,
42
+ deps: { settings: SessionSettings; index: IndexHandle },
43
+ ): MessagingHandle & SessionLifecycle {
44
+ const incomingRuntime = new IncomingSessionMessageRuntime(pi);
45
+ const service = new SessionMessagingService(deps.index.path);
46
+
47
+ service.onIncomingMessage((envelope) => {
48
+ incomingRuntime.deliver(service.buildReceivedMessage(envelope));
49
+ });
50
+ service.onIncomingCancel(() => {
51
+ incomingRuntime.cancel();
52
+ });
53
+
54
+ pi.registerMessageRenderer(SESSION_MESSAGE_CUSTOM_TYPE, renderIncomingSessionMessage);
55
+
56
+ return {
57
+ sendMessage: (request) => service.sendMessage(request),
58
+ sendSubagentReport: (request) => service.sendSubagentReport(request),
59
+ cancelSession: (sessionId) => service.cancelSession(sessionId),
60
+ listSessions: () => service.listSessions(),
61
+ waitForSession: (sessionId, timeoutMs) => service.waitForSession(sessionId, timeoutMs),
62
+ getCachedRelationTo: (sessionId) => service.getCachedRelationTo(sessionId),
63
+ onIncomingMessage: (handler) => service.onIncomingMessage(handler),
64
+ onIncomingCancel: (handler) => service.onIncomingCancel(handler),
65
+ onIncomingSubagentReport: (handler) => service.onIncomingSubagentReport(handler),
66
+ async onSessionStart(_event, ctx) {
67
+ incomingRuntime.bindContext(ctx);
68
+ incomingRuntime.replayPending(ctx);
69
+ try {
70
+ await service.start(ctx);
71
+ } catch {}
72
+ },
73
+ onSessionShutdown() {
74
+ incomingRuntime.clearContext();
75
+ service.stop();
76
+ },
77
+ };
78
+ }