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,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);
@@ -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
+ }
@@ -0,0 +1,64 @@
1
+ import type { CancelSessionToolDetails, ReceivedMessageEndpoint } from "./message-contracts.ts";
2
+
3
+ const CONFIRMED_MANAGED_STATES = new Set(["active", "completed", "stopped", "stopping"]);
4
+
5
+ export function isConfirmedManagedCancellation(state: string): boolean {
6
+ return CONFIRMED_MANAGED_STATES.has(state);
7
+ }
8
+
9
+ export function buildCancelSessionModelText(details: CancelSessionToolDetails): string {
10
+ return buildCancelSessionText(details, modelTarget(details.target));
11
+ }
12
+
13
+ export function buildCancelSessionUserText(details: CancelSessionToolDetails): string {
14
+ return buildCancelSessionText(details, userTarget(details.target));
15
+ }
16
+
17
+ export function buildUnknownCancellationError(target: ReceivedMessageEndpoint): string {
18
+ return `Could not confirm cancellation of subagent ${userTarget(target)}.\nsession ${target.sessionId}`;
19
+ }
20
+
21
+ export function buildDeadSessionError(sessionId: string): string {
22
+ return `No running session found: ${sessionId}.\nThe target is not an owned subagent and has no broker-live process to cancel.`;
23
+ }
24
+
25
+ export function buildCancelSessionUserError(content: string, expanded: boolean): string {
26
+ if (
27
+ !content.startsWith("No running session found:") &&
28
+ !content.startsWith("Could not confirm cancellation of subagent")
29
+ ) {
30
+ return content;
31
+ }
32
+
33
+ const [summary = content, ...details] = content.split("\n");
34
+ const session = details.find((line) => line.startsWith("session "));
35
+ return expanded && session ? `${summary}\n${session}` : summary;
36
+ }
37
+
38
+ function buildCancelSessionText(details: CancelSessionToolDetails, target: string): string {
39
+ if (details.kind === "transport") {
40
+ return `Cancellation sent to session ${target}.`;
41
+ }
42
+
43
+ switch (details.state) {
44
+ case "stopped":
45
+ return `Stopped subagent ${target}.`;
46
+ case "active":
47
+ case "stopping":
48
+ return `Stop requested for subagent ${target}; it is still shutting down.`;
49
+ case "completed":
50
+ return `Subagent ${target} already completed.`;
51
+ default:
52
+ return `Could not confirm cancellation of subagent ${target}.`;
53
+ }
54
+ }
55
+
56
+ function userTarget(target: ReceivedMessageEndpoint): string {
57
+ return target.sessionName ? `"${target.sessionName}"` : target.sessionId;
58
+ }
59
+
60
+ function modelTarget(target: ReceivedMessageEndpoint): string {
61
+ return target.sessionName
62
+ ? `"${target.sessionName}" (session: ${target.sessionId})`
63
+ : target.sessionId;
64
+ }