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.
- package/README.md +71 -37
- package/extensions/pi-sessions.ts +125 -0
- package/extensions/session-ask/agent.ts +34 -14
- package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
- package/extensions/session-ask/navigate.ts +3 -1
- package/extensions/session-ask/presenter.ts +23 -0
- package/extensions/session-ask/renderer.ts +63 -0
- package/extensions/session-ask/tool-contract.ts +27 -0
- package/extensions/session-ask/view-model.ts +18 -0
- package/extensions/session-auto-title/generate.ts +18 -18
- package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +67 -46
- package/extensions/session-auto-title/model.ts +25 -17
- package/extensions/session-auto-title/retitle.ts +26 -12
- package/extensions/session-auto-title/wizard.ts +12 -6
- package/extensions/session-handoff/board-view-model.ts +181 -0
- package/extensions/session-handoff/board.ts +663 -0
- package/extensions/session-handoff/bootstrap.ts +174 -0
- package/extensions/session-handoff/extract.ts +101 -122
- package/extensions/session-handoff/install.ts +225 -0
- package/extensions/session-handoff/kickoff.ts +92 -0
- package/extensions/session-handoff/launch/backend.ts +16 -0
- package/extensions/session-handoff/launch/deferred.ts +20 -0
- package/extensions/session-handoff/launch/ghostty.ts +99 -0
- package/extensions/session-handoff/launch/resolution.ts +57 -0
- package/extensions/session-handoff/launch/shell.ts +7 -0
- package/extensions/session-handoff/launch/tmux.ts +31 -0
- package/extensions/session-handoff/launch-options.ts +61 -0
- package/extensions/session-handoff/launch-target.ts +91 -0
- package/extensions/session-handoff/metadata.ts +152 -63
- package/extensions/session-handoff/model.ts +58 -0
- package/extensions/session-handoff/query.ts +0 -1
- package/extensions/session-handoff/receipt.ts +96 -0
- package/extensions/session-handoff/review.ts +2 -28
- package/extensions/session-handoff/spawn.ts +76 -181
- package/extensions/session-handoff/strong-modal.ts +13 -0
- package/extensions/session-handoff/tool-contract.ts +12 -0
- package/extensions/session-handoff/tool-presenter.ts +35 -0
- package/extensions/session-handoff/tool-renderer.ts +66 -0
- package/extensions/session-handoff/tool-schema.ts +54 -0
- package/extensions/session-handoff/tool-view-model.ts +49 -0
- package/extensions/session-handoff/tool.ts +206 -0
- package/extensions/session-handoff/ui.ts +66 -0
- package/extensions/session-hooks/install.ts +75 -0
- package/extensions/{session-index.ts → session-index/install.ts} +9 -7
- package/extensions/session-messaging/broker/process.ts +21 -28
- package/extensions/session-messaging/install.ts +78 -0
- package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
- package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
- package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
- package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
- package/extensions/session-messaging/pi/message-contracts.ts +32 -0
- package/extensions/session-messaging/pi/renderer.ts +19 -12
- package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
- package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
- package/extensions/session-messaging/pi/service.ts +262 -81
- package/extensions/session-messaging/pi/tools.ts +184 -40
- package/extensions/session-search/extract.ts +60 -14
- package/extensions/session-search/hooks.ts +10 -13
- package/extensions/session-search/install.ts +373 -0
- package/extensions/session-search/renderer.ts +195 -0
- package/extensions/session-search/tool-contract.ts +51 -0
- package/extensions/shared/composition.ts +20 -0
- package/extensions/shared/errors.ts +3 -0
- package/extensions/shared/model-resolution.ts +73 -0
- package/extensions/shared/model-runtime.ts +33 -0
- package/extensions/shared/model.ts +7 -10
- package/extensions/shared/rendering/collapsible-text.ts +89 -0
- package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
- package/extensions/shared/rendering/theme.ts +5 -0
- package/extensions/shared/session-broker/client.ts +30 -29
- package/extensions/shared/session-broker/protocol.ts +128 -10
- package/extensions/shared/session-index/common.ts +5 -5
- package/extensions/shared/session-index/lineage.ts +0 -3
- package/extensions/shared/session-index/schema.ts +0 -1
- package/extensions/shared/session-index/scoring.ts +1 -2
- package/extensions/shared/session-index/search.ts +11 -4
- package/extensions/shared/session-index/store.ts +4 -9
- package/extensions/shared/settings.ts +80 -38
- package/extensions/shared/text.ts +5 -0
- package/extensions/shared/thinking-levels.ts +22 -0
- package/extensions/shared/tmux.ts +205 -0
- package/extensions/subagents/cancel.ts +111 -0
- package/extensions/subagents/classify.ts +52 -0
- package/extensions/subagents/install.ts +344 -0
- package/extensions/subagents/launch-target.ts +75 -0
- package/extensions/subagents/ledger.ts +217 -0
- package/extensions/subagents/reconcile.ts +386 -0
- package/extensions/subagents/report-message-presenter.ts +36 -0
- package/extensions/subagents/report-message-renderer.ts +21 -0
- package/extensions/subagents/report-message-view-model.ts +29 -0
- package/extensions/subagents/report.ts +159 -0
- package/extensions/subagents/roster.ts +262 -0
- package/extensions/subagents/wake.ts +173 -0
- package/images/handoff-board-subagents.png +0 -0
- package/images/handoff-board-user-sessions.png +0 -0
- package/images/handoff-subagent-report.png +0 -0
- package/images/session-handoff-tool.png +0 -0
- package/package.json +10 -16
- package/extensions/session-handoff.ts +0 -741
- package/extensions/session-hooks.ts +0 -75
- package/extensions/session-messaging/pi/message-view.ts +0 -131
- package/extensions/session-messaging.ts +0 -30
- package/extensions/session-search.ts +0 -419
- 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 "
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
|
16
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|