pi-sessions 0.18.0 → 0.19.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 +44 -40
- package/extensions/hosts/contract.ts +10 -46
- package/extensions/hosts/registry.ts +4 -78
- package/extensions/pi-sessions.ts +27 -53
- package/extensions/session-handoff/board-loading.ts +4 -1
- package/extensions/session-handoff/board-view-model.ts +1 -0
- package/extensions/session-handoff/board.ts +4 -1
- package/extensions/session-handoff/install.ts +47 -33
- package/extensions/session-handoff/launch/backend.ts +16 -4
- package/extensions/session-handoff/launch-options.ts +2 -17
- package/extensions/session-handoff/launch-target.ts +4 -9
- package/extensions/session-handoff/receipt.ts +1 -1
- package/extensions/session-handoff/tool-presenter.ts +1 -1
- package/extensions/session-handoff/tool-schema.ts +1 -9
- package/extensions/session-handoff/tool-view-model.ts +6 -2
- package/extensions/session-handoff/tool.ts +2 -2
- package/extensions/session-messaging/directory-list.ts +115 -0
- package/extensions/session-messaging/directory.ts +114 -0
- package/extensions/session-messaging/message-router.ts +3 -8
- package/extensions/session-messaging/pi/directory-tools.ts +41 -0
- package/extensions/session-messaging/pi/install.ts +102 -0
- package/extensions/session-messaging/pi/message-contracts.ts +4 -6
- package/extensions/session-messaging/pi/reachable-contract.ts +3 -4
- package/extensions/session-messaging/pi/reachable-tool.ts +25 -175
- package/extensions/session-messaging/pi/send-message-presenter.ts +2 -1
- package/extensions/session-messaging/pi/tools.ts +49 -91
- package/extensions/session-search/extract.ts +12 -2
- package/extensions/shared/lineage.ts +8 -0
- package/extensions/shared/session-index/sync.ts +3 -0
- package/extensions/shared/settings.ts +1 -1
- package/extensions/subagents/install.ts +0 -27
- package/extensions/subagents/launch-target.ts +0 -1
- package/extensions/subagents/reconcile.ts +10 -2
- package/package.json +5 -5
|
@@ -2,7 +2,6 @@ import type { Api, Model } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
|
-
import type { Host } from "../hosts/contract.ts";
|
|
6
5
|
import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
|
|
7
6
|
import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
|
|
8
7
|
import { isTuiMode } from "../shared/pi-mode.ts";
|
|
@@ -21,11 +20,7 @@ import { HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE } from "./metadata.ts";
|
|
|
21
20
|
import { openSessionReferencePicker } from "./picker.ts";
|
|
22
21
|
import { SESSION_TOKEN_PREFIX } from "./query.ts";
|
|
23
22
|
import { type HandoffRoster, resolveHandoffRoster } from "./roster.ts";
|
|
24
|
-
import {
|
|
25
|
-
executeSessionHandoffTool,
|
|
26
|
-
type HandoffToolParams,
|
|
27
|
-
MAX_HANDOFF_TITLE_LENGTH,
|
|
28
|
-
} from "./tool.ts";
|
|
23
|
+
import { executeSessionHandoffTool, MAX_HANDOFF_TITLE_LENGTH } from "./tool.ts";
|
|
29
24
|
import { HANDOFF_TOOL_DETAILS_SCHEMA } from "./tool-contract.ts";
|
|
30
25
|
import { HandoffToolComponent } from "./tool-renderer.ts";
|
|
31
26
|
import {
|
|
@@ -51,7 +46,7 @@ export function installHandoff(
|
|
|
51
46
|
index: IndexHandle;
|
|
52
47
|
getModelRuntime: ModelRuntimeProvider;
|
|
53
48
|
getLaunchTargets?: (() => readonly HandoffLaunchTarget[]) | undefined;
|
|
54
|
-
|
|
49
|
+
claimsPeers: () => boolean;
|
|
55
50
|
board: HandoffBoardServices;
|
|
56
51
|
},
|
|
57
52
|
): SessionLifecycle {
|
|
@@ -69,16 +64,17 @@ export function installHandoff(
|
|
|
69
64
|
models: readonly Model<Api>[],
|
|
70
65
|
launchTargets: readonly HandoffLaunchTarget[],
|
|
71
66
|
roster: HandoffRoster | undefined,
|
|
67
|
+
name: "session_handoff" | "subagent_launch",
|
|
72
68
|
): void {
|
|
73
69
|
const offersSubagent = offersSubagentLaunch(launchTargets);
|
|
74
70
|
pi.registerTool({
|
|
75
|
-
name
|
|
71
|
+
name,
|
|
76
72
|
label: "Session Handoff",
|
|
77
73
|
description: "Start a new Pi session with a self-contained task.",
|
|
78
74
|
// Nested calls write no toolResult entry, so the board would never see the launch receipt.
|
|
79
75
|
exposure: "model-only",
|
|
80
76
|
promptSnippet: offersSubagent
|
|
81
|
-
? "Delegate bounded work to a background subagent
|
|
77
|
+
? "Delegate bounded work to a background subagent"
|
|
82
78
|
: "Hand off context to another Pi session",
|
|
83
79
|
promptGuidelines: buildHandoffPromptGuidelines(launchTargets, models, roster),
|
|
84
80
|
parameters: Type.Object({
|
|
@@ -91,17 +87,16 @@ export function installHandoff(
|
|
|
91
87
|
description:
|
|
92
88
|
"Short display title for the child session, 64 characters or less. Summarize the mission; do not repeat the full goal.",
|
|
93
89
|
}),
|
|
94
|
-
launch: buildHandoffLaunchSchema(launchTargets),
|
|
90
|
+
...(name === "session_handoff" ? { launch: buildHandoffLaunchSchema(launchTargets) } : {}),
|
|
95
91
|
cwd: Type.Optional(
|
|
96
92
|
Type.String({
|
|
97
93
|
description: "Optional target working directory (defaults to current).",
|
|
98
94
|
}),
|
|
99
95
|
),
|
|
100
|
-
requestResponse: Type.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
),
|
|
96
|
+
requestResponse: Type.Boolean({
|
|
97
|
+
description:
|
|
98
|
+
"Whether the child session should report completion/results of its task back to this session.",
|
|
99
|
+
}),
|
|
105
100
|
provider: Type.Optional(
|
|
106
101
|
Type.String({
|
|
107
102
|
description: "Model provider for the child session.",
|
|
@@ -127,7 +122,7 @@ export function installHandoff(
|
|
|
127
122
|
const state = context.state as HandoffToolRendererState;
|
|
128
123
|
const component = state.callComponent ?? new HandoffToolComponent(theme);
|
|
129
124
|
state.callComponent = component;
|
|
130
|
-
component.update(buildHandoffToolView(args), context.expanded);
|
|
125
|
+
component.update(buildHandoffToolView(args, undefined, name), context.expanded);
|
|
131
126
|
return component;
|
|
132
127
|
},
|
|
133
128
|
renderResult(result, options, theme, context) {
|
|
@@ -143,17 +138,23 @@ export function installHandoff(
|
|
|
143
138
|
}
|
|
144
139
|
|
|
145
140
|
state.callComponent.update(
|
|
146
|
-
buildHandoffToolView(context.args, details),
|
|
141
|
+
buildHandoffToolView(context.args, details, name),
|
|
147
142
|
options.expanded,
|
|
148
143
|
clipboardStatusBySessionId.get(details.sessionId),
|
|
149
144
|
);
|
|
150
145
|
return new Text("", 0, 0);
|
|
151
146
|
},
|
|
152
|
-
async execute(_toolCallId, params
|
|
147
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
148
|
+
if (name === "session_handoff" && deps.claimsPeers())
|
|
149
|
+
throw new Error("Peer tools belong to the current host.");
|
|
150
|
+
const launch = name === "subagent_launch" ? "subagent" : params.launch;
|
|
151
|
+
if (typeof launch !== "string") throw new Error("session_handoff requires launch.");
|
|
152
|
+
if (name === "subagent_launch" && !deps.getLaunchTargets?.().length)
|
|
153
|
+
throw new Error("Subagent launch is unavailable.");
|
|
153
154
|
const modelRuntime = await deps.getModelRuntime(ctx.modelRegistry);
|
|
154
155
|
return executeSessionHandoffTool(
|
|
155
156
|
pi,
|
|
156
|
-
params,
|
|
157
|
+
{ ...params, launch },
|
|
157
158
|
ctx,
|
|
158
159
|
modelRuntime,
|
|
159
160
|
launchTargets,
|
|
@@ -205,7 +206,7 @@ export function installHandoff(
|
|
|
205
206
|
});
|
|
206
207
|
|
|
207
208
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
208
|
-
if (!deps.
|
|
209
|
+
if (!deps.claimsPeers() && splitBackend?.identifyTerminalId && ctx) {
|
|
209
210
|
identifiedGhosttyTerminalId =
|
|
210
211
|
(await splitBackend.identifyTerminalId(ctx.cwd)) ?? identifiedGhosttyTerminalId;
|
|
211
212
|
}
|
|
@@ -216,21 +217,34 @@ export function installHandoff(
|
|
|
216
217
|
return {
|
|
217
218
|
async onSessionStart(_event, ctx) {
|
|
218
219
|
const models = ctx.modelRegistry.getAvailable();
|
|
219
|
-
|
|
220
|
+
const roster = resolveHandoffRoster({
|
|
220
221
|
models,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
hosts: deps.getHosts?.() ?? [],
|
|
227
|
-
}),
|
|
228
|
-
resolveHandoffRoster({
|
|
222
|
+
patterns: settings.handoff.roster,
|
|
223
|
+
scopedModels: ctx.scopedModels,
|
|
224
|
+
});
|
|
225
|
+
if (!deps.claimsPeers?.()) {
|
|
226
|
+
registerHandoffTool(
|
|
229
227
|
models,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
228
|
+
createHandoffLaunchTargets({
|
|
229
|
+
pi,
|
|
230
|
+
splitBackend,
|
|
231
|
+
deferred: settings.handoff.deferred,
|
|
232
|
+
additionalTargets: [],
|
|
233
|
+
}),
|
|
234
|
+
roster,
|
|
235
|
+
"session_handoff",
|
|
236
|
+
);
|
|
237
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), "session_handoff"])]);
|
|
238
|
+
} else {
|
|
239
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "session_handoff"));
|
|
240
|
+
}
|
|
241
|
+
const subagentTargets = deps.getLaunchTargets?.() ?? [];
|
|
242
|
+
if (subagentTargets.length) {
|
|
243
|
+
registerHandoffTool(models, subagentTargets, roster, "subagent_launch");
|
|
244
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), "subagent_launch"])]);
|
|
245
|
+
} else {
|
|
246
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "subagent_launch"));
|
|
247
|
+
}
|
|
234
248
|
|
|
235
249
|
await consumePendingHandoffBootstrap(
|
|
236
250
|
pi,
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import type { Host, HostLaunchInput } from "../../hosts/contract.ts";
|
|
2
|
-
|
|
3
1
|
export type HandoffSplitDirection = "left" | "right" | "up" | "down";
|
|
4
2
|
|
|
5
|
-
export
|
|
3
|
+
export interface LaunchInput {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
sessionFile: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
title: string;
|
|
8
|
+
model: string;
|
|
9
|
+
resumeCommand: string;
|
|
10
|
+
}
|
|
6
11
|
|
|
7
12
|
export type ClipboardStatus = "copied" | "failed";
|
|
8
13
|
|
|
9
|
-
export
|
|
14
|
+
export interface LaunchBackend {
|
|
15
|
+
name: string;
|
|
16
|
+
launch(
|
|
17
|
+
input: LaunchInput,
|
|
18
|
+
): Promise<
|
|
19
|
+
{ success: true; clipboardStatus?: ClipboardStatus } | { success: false; error: string }
|
|
20
|
+
>;
|
|
21
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { Host } from "../hosts/contract.ts";
|
|
3
2
|
import type { HandoffSettings } from "../shared/settings.ts";
|
|
4
3
|
import { createDeferredLaunchBackend } from "./launch/deferred.ts";
|
|
5
4
|
import type { SplitLaunchBackend } from "./launch/resolution.ts";
|
|
@@ -21,10 +20,8 @@ export function createHandoffLaunchTargets(options: {
|
|
|
21
20
|
splitBackend: SplitLaunchBackend | undefined;
|
|
22
21
|
deferred: HandoffSettings["deferred"];
|
|
23
22
|
additionalTargets: readonly HandoffLaunchTarget[];
|
|
24
|
-
hosts?: readonly Host[];
|
|
25
23
|
}): HandoffLaunchTarget[] {
|
|
26
|
-
const
|
|
27
|
-
const splitBackend = hosts.length ? undefined : options.splitBackend;
|
|
24
|
+
const splitBackend = options.splitBackend;
|
|
28
25
|
const splitTargets = splitBackend
|
|
29
26
|
? LAUNCH_DIRECTIONS.map((direction, index) =>
|
|
30
27
|
createBackendLaunchTarget(
|
|
@@ -45,19 +42,7 @@ export function createHandoffLaunchTargets(options: {
|
|
|
45
42
|
),
|
|
46
43
|
]
|
|
47
44
|
: [];
|
|
48
|
-
return [
|
|
49
|
-
...hosts.map((host) =>
|
|
50
|
-
createBackendLaunchTarget(
|
|
51
|
-
host.name,
|
|
52
|
-
host,
|
|
53
|
-
`'${host.name}' opens a session in ${host.name}.`,
|
|
54
|
-
true,
|
|
55
|
-
),
|
|
56
|
-
),
|
|
57
|
-
...splitTargets,
|
|
58
|
-
...deferredTargets,
|
|
59
|
-
...options.additionalTargets,
|
|
60
|
-
];
|
|
45
|
+
return [...splitTargets, ...deferredTargets, ...options.additionalTargets];
|
|
61
46
|
}
|
|
62
47
|
|
|
63
48
|
export function resolveHandoffLaunchTarget(
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type Static, Type } from "typebox";
|
|
3
|
-
import type { Host } from "../hosts/contract.ts";
|
|
4
3
|
import { formatError } from "../shared/errors.ts";
|
|
5
|
-
import type { ClipboardStatus } from "./launch/backend.ts";
|
|
4
|
+
import type { ClipboardStatus, LaunchBackend } from "./launch/backend.ts";
|
|
6
5
|
import type { HandoffSubagent } from "./metadata.ts";
|
|
7
6
|
import type { PreparedHandoff } from "./spawn.ts";
|
|
8
7
|
|
|
@@ -58,7 +57,6 @@ export type HandoffLaunchTargetOutcome =
|
|
|
58
57
|
export interface HandoffLaunchTarget {
|
|
59
58
|
value: HandoffLaunchValue;
|
|
60
59
|
description?: string | undefined;
|
|
61
|
-
requestResponseDefault: boolean;
|
|
62
60
|
bootstrapMode: "review" | "automatic";
|
|
63
61
|
/** Unattended children skip the startup trust prompt; watched ones let the user answer it. */
|
|
64
62
|
approveProjectTrust: boolean;
|
|
@@ -75,17 +73,14 @@ export interface HandoffLaunchTarget {
|
|
|
75
73
|
|
|
76
74
|
export function createBackendLaunchTarget(
|
|
77
75
|
value: string,
|
|
78
|
-
host:
|
|
76
|
+
host: LaunchBackend,
|
|
79
77
|
description?: string | undefined,
|
|
80
|
-
external = false,
|
|
81
78
|
): HandoffLaunchTarget {
|
|
82
79
|
return {
|
|
83
80
|
value,
|
|
84
81
|
...(description ? { description } : {}),
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
approveProjectTrust: external,
|
|
88
|
-
useDefaultSessionDir: external,
|
|
82
|
+
bootstrapMode: "review",
|
|
83
|
+
approveProjectTrust: false,
|
|
89
84
|
prepareChild() {},
|
|
90
85
|
async launch(input) {
|
|
91
86
|
try {
|
|
@@ -35,7 +35,7 @@ export function parseHandoffLaunchReceiptEntry(
|
|
|
35
35
|
if (
|
|
36
36
|
entry.type === "message" &&
|
|
37
37
|
entry.message.role === "toolResult" &&
|
|
38
|
-
entry.message.toolName === "session_handoff"
|
|
38
|
+
(entry.message.toolName === "session_handoff" || entry.message.toolName === "subagent_launch")
|
|
39
39
|
) {
|
|
40
40
|
return safeParseTypeBoxValue(HANDOFF_LAUNCH_RECEIPT_SCHEMA, entry.message.details);
|
|
41
41
|
}
|
|
@@ -18,7 +18,7 @@ export function buildHandoffToolPresentation(
|
|
|
18
18
|
|
|
19
19
|
return {
|
|
20
20
|
header: [
|
|
21
|
-
theme.fg("accent", "session_handoff"),
|
|
21
|
+
theme.fg("accent", model.toolName ?? "session_handoff"),
|
|
22
22
|
theme.fg("dim", `[${model.launch ?? PENDING}]`),
|
|
23
23
|
theme.bold(model.title ?? PENDING),
|
|
24
24
|
].join(" "),
|
|
@@ -21,7 +21,7 @@ export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const SUBAGENT_GUIDELINES = [
|
|
24
|
-
|
|
24
|
+
"Use subagent_launch for a concrete, bounded task that can proceed independently while useful work continues in the current session.",
|
|
25
25
|
"Do not launch a subagent for work that is trivial, duplicates work already underway, requires frequent coordination, or is tightly coupled to the current session's immediate next step.",
|
|
26
26
|
"Tell every subagent explicitly whether it may modify files or should only investigate and report.",
|
|
27
27
|
"Give concurrent subagents disjoint responsibilities and non-overlapping write scopes.",
|
|
@@ -73,14 +73,6 @@ function describeUserFacingLaunchKinds(targets: readonly HandoffLaunchTarget[]):
|
|
|
73
73
|
const isDirection = (value: string) => LAUNCH_DIRECTIONS.some((direction) => direction === value);
|
|
74
74
|
const kinds = [
|
|
75
75
|
targets.some((target) => isDirection(target.value)) ? "directional" : undefined,
|
|
76
|
-
targets.some(
|
|
77
|
-
(target) =>
|
|
78
|
-
target.value !== SUBAGENT_LAUNCH &&
|
|
79
|
-
target.value !== DEFERRED_LAUNCH &&
|
|
80
|
-
!isDirection(target.value),
|
|
81
|
-
)
|
|
82
|
-
? "host"
|
|
83
|
-
: undefined,
|
|
84
76
|
targets.some((target) => target.value === DEFERRED_LAUNCH) ? "deferred" : undefined,
|
|
85
77
|
];
|
|
86
78
|
return kinds.filter((kind) => kind !== undefined).join(" or ");
|
|
@@ -4,6 +4,7 @@ import { isThinkingLevel } from "../shared/thinking-levels.ts";
|
|
|
4
4
|
import type { HandoffToolDetails } from "./tool-contract.ts";
|
|
5
5
|
|
|
6
6
|
export interface HandoffToolViewModel {
|
|
7
|
+
toolName: string;
|
|
7
8
|
launch?: string | undefined;
|
|
8
9
|
title?: string | undefined;
|
|
9
10
|
goal?: string | undefined;
|
|
@@ -15,14 +16,17 @@ export interface HandoffToolViewModel {
|
|
|
15
16
|
|
|
16
17
|
export function buildHandoffToolView(
|
|
17
18
|
args: unknown,
|
|
18
|
-
result
|
|
19
|
+
result: HandoffToolDetails | undefined,
|
|
20
|
+
toolName: string,
|
|
19
21
|
): HandoffToolViewModel {
|
|
20
22
|
const record = isRecord(args) ? args : {};
|
|
21
23
|
const provider = normalizeOptionalText(result?.provider) ?? readString(record.provider);
|
|
22
24
|
const model = normalizeOptionalText(result?.modelName) ?? readString(record.model);
|
|
23
25
|
const thinkingLevel = result?.thinkingLevel ?? readThinkingLevel(record.thinkingLevel);
|
|
24
26
|
return {
|
|
25
|
-
|
|
27
|
+
toolName,
|
|
28
|
+
launch:
|
|
29
|
+
toolName === "subagent_launch" ? "subagent" : (readString(record.launch) ?? result?.launch),
|
|
26
30
|
title: readString(record.title) ?? result?.title,
|
|
27
31
|
goal: readString(record.goal),
|
|
28
32
|
...(provider ? { provider } : {}),
|
|
@@ -28,7 +28,7 @@ export interface HandoffToolParams {
|
|
|
28
28
|
title: string;
|
|
29
29
|
launch: HandoffLaunchValue;
|
|
30
30
|
cwd?: string | undefined;
|
|
31
|
-
requestResponse
|
|
31
|
+
requestResponse: boolean;
|
|
32
32
|
provider?: string | undefined;
|
|
33
33
|
model?: string | undefined;
|
|
34
34
|
thinkingLevel?: ThinkingLevel | undefined;
|
|
@@ -84,7 +84,7 @@ export async function executeSessionHandoffTool(
|
|
|
84
84
|
|
|
85
85
|
resolveHandoffSource(ctx.sessionManager, sourceLeafId);
|
|
86
86
|
|
|
87
|
-
const requestResponse = params.requestResponse
|
|
87
|
+
const requestResponse = params.requestResponse;
|
|
88
88
|
const provider = params.provider?.trim();
|
|
89
89
|
const modelId = params.model?.trim();
|
|
90
90
|
if (Boolean(provider) !== Boolean(modelId)) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { type ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isSessionStarting } from "../session-handoff/metadata.ts";
|
|
3
|
+
import { formatError } from "../shared/errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
getSessionById,
|
|
6
|
+
type SessionLineageRow,
|
|
7
|
+
withSessionIndex,
|
|
8
|
+
} from "../shared/session-index/index.ts";
|
|
9
|
+
import type {
|
|
10
|
+
ReachableSubagent,
|
|
11
|
+
ReachableUserSession,
|
|
12
|
+
SessionReachableScope,
|
|
13
|
+
} from "./pi/reachable-contract.ts";
|
|
14
|
+
|
|
15
|
+
/** The subagent facts the directory needs; the roster satisfies it structurally. */
|
|
16
|
+
export interface ReachableSubagentEntry {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
title: string;
|
|
19
|
+
goal: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
state: string;
|
|
22
|
+
depth: number;
|
|
23
|
+
onActiveBranch: boolean;
|
|
24
|
+
launchedAt: string;
|
|
25
|
+
model?: string | undefined;
|
|
26
|
+
ownerSessionId: string;
|
|
27
|
+
ownerTitle: string;
|
|
28
|
+
ownerIsCurrentSession: boolean;
|
|
29
|
+
resumeCommand: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SessionReachableDeps {
|
|
33
|
+
indexPath: string;
|
|
34
|
+
listSessions(): Promise<string[]>;
|
|
35
|
+
getRelationTo(sessionId: string): string | undefined;
|
|
36
|
+
listSubagents?:
|
|
37
|
+
| ((
|
|
38
|
+
scope: Exclude<SessionReachableScope, "user">,
|
|
39
|
+
) => Promise<readonly ReachableSubagentEntry[]>)
|
|
40
|
+
| undefined;
|
|
41
|
+
getParentSessionId?: (() => string | undefined) | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function listReachableUserSessions(
|
|
45
|
+
deps: SessionReachableDeps,
|
|
46
|
+
ctx: ExtensionContext,
|
|
47
|
+
): Promise<ReachableUserSession[]> {
|
|
48
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
49
|
+
let liveSessionIds: string[];
|
|
50
|
+
try {
|
|
51
|
+
liveSessionIds = await deps.listSessions();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
|
|
54
|
+
}
|
|
55
|
+
const parentSessionId = deps.getParentSessionId?.();
|
|
56
|
+
const targetIds = [...new Set(liveSessionIds)].filter(
|
|
57
|
+
(sessionId) => sessionId !== currentSessionId && sessionId !== parentSessionId,
|
|
58
|
+
);
|
|
59
|
+
return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
|
|
60
|
+
const sessions: ReachableUserSession[] = [];
|
|
61
|
+
for (const sessionId of targetIds) {
|
|
62
|
+
const row = getSessionById(db, sessionId);
|
|
63
|
+
if (row?.sessionOrigin === "subagent") continue;
|
|
64
|
+
const relation = deps.getRelationTo(sessionId);
|
|
65
|
+
sessions.push({
|
|
66
|
+
kind: "user",
|
|
67
|
+
sessionId,
|
|
68
|
+
state: row && isStartingSession(row) ? "starting" : "live",
|
|
69
|
+
...(row?.sessionName ? { title: row.sessionName } : {}),
|
|
70
|
+
...(row?.cwd ? { cwd: row.cwd } : {}),
|
|
71
|
+
...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
|
|
72
|
+
...(relation === undefined ? {} : { relation }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return sessions.sort((left, right) =>
|
|
76
|
+
(right.modifiedAt ?? "").localeCompare(left.modifiedAt ?? ""),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function listReachableSubagents(
|
|
82
|
+
deps: SessionReachableDeps,
|
|
83
|
+
scope: Exclude<SessionReachableScope, "user">,
|
|
84
|
+
): Promise<ReachableSubagent[]> {
|
|
85
|
+
if (!deps.listSubagents) throw new Error("Subagent roster is unavailable.");
|
|
86
|
+
try {
|
|
87
|
+
const entries = await deps.listSubagents(scope);
|
|
88
|
+
return entries.map((entry) => ({
|
|
89
|
+
kind: "subagent",
|
|
90
|
+
sessionId: entry.sessionId,
|
|
91
|
+
title: entry.title,
|
|
92
|
+
goal: entry.goal,
|
|
93
|
+
cwd: entry.cwd,
|
|
94
|
+
state: entry.state,
|
|
95
|
+
depth: entry.depth,
|
|
96
|
+
onActiveBranch: entry.onActiveBranch,
|
|
97
|
+
launchedAt: entry.launchedAt,
|
|
98
|
+
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
99
|
+
ownerSessionId: entry.ownerSessionId,
|
|
100
|
+
ownerTitle: entry.ownerTitle,
|
|
101
|
+
ownerIsCurrentSession: entry.ownerIsCurrentSession,
|
|
102
|
+
resumeCommand: entry.resumeCommand,
|
|
103
|
+
}));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(`Subagent roster is unavailable: ${formatError(error)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isStartingSession(row: SessionLineageRow): boolean {
|
|
110
|
+
try {
|
|
111
|
+
return isSessionStarting(SessionManager.open(row.sessionPath).getBranch());
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getSessionById, withSessionIndex } from "../shared/session-index/index.ts";
|
|
3
|
+
import type { SubagentsHandle } from "../subagents/install.ts";
|
|
4
|
+
import {
|
|
5
|
+
listReachableSubagents,
|
|
6
|
+
listReachableUserSessions,
|
|
7
|
+
type SessionReachableDeps,
|
|
8
|
+
} from "./directory-list.ts";
|
|
9
|
+
import type { MessagingHandle } from "./install.ts";
|
|
10
|
+
import type { MessageRouter } from "./message-router.ts";
|
|
11
|
+
import type { ReachableSession, SessionReachableScope } from "./pi/reachable-contract.ts";
|
|
12
|
+
import type { CancelSessionResult, SendMessageRequest, SendMessageResult } from "./pi/service.ts";
|
|
13
|
+
import type { ManagedCancelSessionResult } from "./pi/tools.ts";
|
|
14
|
+
|
|
15
|
+
/** The addressable set, not a transport. Resolution is enforced before every mutation. */
|
|
16
|
+
export interface SessionDirectory {
|
|
17
|
+
list(scope: SessionReachableScope, ctx: ExtensionContext): Promise<ReachableSession[]>;
|
|
18
|
+
resolve(sessionId: string, ctx: ExtensionContext): Promise<{ sessionId: string }>;
|
|
19
|
+
send(request: SendMessageRequest, ctx: ExtensionContext): Promise<SendMessageResult>;
|
|
20
|
+
cancel(
|
|
21
|
+
sessionId: string,
|
|
22
|
+
ctx: ExtensionContext,
|
|
23
|
+
): Promise<CancelSessionResult | ManagedCancelSessionResult>;
|
|
24
|
+
wake(sessionId: string): Promise<void>;
|
|
25
|
+
getRelationTo(sessionId: string | undefined): string | undefined;
|
|
26
|
+
getParentSessionId(): string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createPeerDirectory(
|
|
30
|
+
messaging: MessagingHandle,
|
|
31
|
+
deps: SessionReachableDeps,
|
|
32
|
+
): SessionDirectory {
|
|
33
|
+
const directory: SessionDirectory = {
|
|
34
|
+
list: (scope, ctx) => {
|
|
35
|
+
if (scope !== "user") throw new Error("Peer discovery has no subagent scope.");
|
|
36
|
+
return listReachableUserSessions(deps, ctx);
|
|
37
|
+
},
|
|
38
|
+
async resolve(sessionId, ctx) {
|
|
39
|
+
const target = (await directory.list("user", ctx)).find(
|
|
40
|
+
(entry) => entry.sessionId === sessionId,
|
|
41
|
+
);
|
|
42
|
+
if (!target) throw new Error(`No reachable user session: ${sessionId}`);
|
|
43
|
+
return target;
|
|
44
|
+
},
|
|
45
|
+
async send(request, ctx) {
|
|
46
|
+
await directory.resolve(request.target, ctx);
|
|
47
|
+
return messaging.sendMessage(request);
|
|
48
|
+
},
|
|
49
|
+
async cancel(sessionId, ctx) {
|
|
50
|
+
await directory.resolve(sessionId, ctx);
|
|
51
|
+
return messaging.cancelSession(sessionId);
|
|
52
|
+
},
|
|
53
|
+
async wake() {
|
|
54
|
+
throw new Error("Peer sessions must already be live.");
|
|
55
|
+
},
|
|
56
|
+
getRelationTo: messaging.getCachedRelationTo,
|
|
57
|
+
getParentSessionId: () => deps.getParentSessionId?.(),
|
|
58
|
+
};
|
|
59
|
+
return directory;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createSubagentDirectory(
|
|
63
|
+
subagents: SubagentsHandle,
|
|
64
|
+
router: MessageRouter,
|
|
65
|
+
messaging: MessagingHandle,
|
|
66
|
+
indexPath: string,
|
|
67
|
+
): SessionDirectory {
|
|
68
|
+
const deps: SessionReachableDeps = {
|
|
69
|
+
indexPath,
|
|
70
|
+
listSessions: () => messaging.listSessions(),
|
|
71
|
+
getRelationTo: messaging.getCachedRelationTo,
|
|
72
|
+
listSubagents: async (scope) => (await subagents.roster.resolve(scope)).entries,
|
|
73
|
+
};
|
|
74
|
+
const directory: SessionDirectory = {
|
|
75
|
+
list: (scope) => {
|
|
76
|
+
if (scope === "user") throw new Error("Subagent discovery requires branch or tree scope.");
|
|
77
|
+
return listReachableSubagents(deps, scope);
|
|
78
|
+
},
|
|
79
|
+
async resolve(sessionId, ctx) {
|
|
80
|
+
if (sessionId === subagents.getParentSessionId())
|
|
81
|
+
throw new Error("The parent session is not addressable. Use submit_task_report.");
|
|
82
|
+
if (sessionId === ctx.sessionManager.getSessionId())
|
|
83
|
+
throw new Error("The current session is not a subagent target.");
|
|
84
|
+
const target = (await directory.list("tree", ctx)).find(
|
|
85
|
+
(entry) => entry.sessionId === sessionId,
|
|
86
|
+
);
|
|
87
|
+
if (target) return target;
|
|
88
|
+
// Known live workers (including siblings) remain addressable without transferring
|
|
89
|
+
// ownership. Only the caller's own ledger can authorize dormant wake or teardown.
|
|
90
|
+
const live = (await messaging.listSessions()).includes(sessionId);
|
|
91
|
+
const worker =
|
|
92
|
+
live &&
|
|
93
|
+
withSessionIndex(
|
|
94
|
+
indexPath,
|
|
95
|
+
{ mode: "read", required: true },
|
|
96
|
+
({ db }) => getSessionById(db, sessionId)?.sessionOrigin === "subagent",
|
|
97
|
+
);
|
|
98
|
+
if (worker) return { sessionId };
|
|
99
|
+
throw new Error(`No reachable subagent: ${sessionId}`);
|
|
100
|
+
},
|
|
101
|
+
async send(request, ctx) {
|
|
102
|
+
await directory.resolve(request.target, ctx);
|
|
103
|
+
return router.sendMessage(request);
|
|
104
|
+
},
|
|
105
|
+
async cancel(sessionId, ctx) {
|
|
106
|
+
await directory.resolve(sessionId, ctx);
|
|
107
|
+
return subagents.cancelSession(sessionId);
|
|
108
|
+
},
|
|
109
|
+
wake: (sessionId) => subagents.waker.wake(sessionId),
|
|
110
|
+
getRelationTo: messaging.getCachedRelationTo,
|
|
111
|
+
getParentSessionId: () => subagents.getParentSessionId(),
|
|
112
|
+
};
|
|
113
|
+
return directory;
|
|
114
|
+
}
|
|
@@ -18,7 +18,7 @@ export class MessageRouter {
|
|
|
18
18
|
|
|
19
19
|
constructor(
|
|
20
20
|
private readonly messaging: WakeMessaging,
|
|
21
|
-
private readonly
|
|
21
|
+
private readonly getWaker: () => SessionWaker | undefined,
|
|
22
22
|
private readonly getEpoch: () => number,
|
|
23
23
|
private readonly readyTimeoutMs = 30_000,
|
|
24
24
|
) {}
|
|
@@ -32,13 +32,8 @@ export class MessageRouter {
|
|
|
32
32
|
if (!isTargetDeparture(result)) return result;
|
|
33
33
|
failedLiveSend = result;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (await waker.owns(request.target)) {
|
|
38
|
-
owner = waker;
|
|
39
|
-
break;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
35
|
+
const waker = this.getWaker();
|
|
36
|
+
const owner = waker && (await waker.owns(request.target)) ? waker : undefined;
|
|
42
37
|
this.requireCurrent(epoch);
|
|
43
38
|
if (!owner) return failedLiveSend ?? this.messaging.sendMessage(request);
|
|
44
39
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SessionDirectory } from "../directory.ts";
|
|
3
|
+
import { createSessionReachableTool, type ReachableToolOptions } from "./reachable-tool.ts";
|
|
4
|
+
import {
|
|
5
|
+
createSessionCancelTool,
|
|
6
|
+
createSessionSendMessageTool,
|
|
7
|
+
type SendMessageToolWording,
|
|
8
|
+
} from "./tools.ts";
|
|
9
|
+
|
|
10
|
+
export interface DirectoryToolWording {
|
|
11
|
+
list: ReachableToolOptions;
|
|
12
|
+
send: SendMessageToolWording & { name: string };
|
|
13
|
+
cancel: SendMessageToolWording & { name: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Both public tool sets share execution, validation and native rendering. */
|
|
17
|
+
export function createDirectoryTools(
|
|
18
|
+
directory: SessionDirectory,
|
|
19
|
+
wording: DirectoryToolWording,
|
|
20
|
+
): ToolDefinition[] {
|
|
21
|
+
return [
|
|
22
|
+
createSessionReachableTool(directory, wording.list),
|
|
23
|
+
createSessionSendMessageTool(
|
|
24
|
+
{ sendMessage: directory.send },
|
|
25
|
+
{
|
|
26
|
+
name: wording.send.name,
|
|
27
|
+
wording: wording.send,
|
|
28
|
+
getCachedRelationTo: directory.getRelationTo,
|
|
29
|
+
getParentSessionId: directory.getParentSessionId,
|
|
30
|
+
},
|
|
31
|
+
),
|
|
32
|
+
createSessionCancelTool(
|
|
33
|
+
{ cancelSession: directory.cancel },
|
|
34
|
+
{
|
|
35
|
+
name: wording.cancel.name,
|
|
36
|
+
wording: wording.cancel,
|
|
37
|
+
getParentSessionId: directory.getParentSessionId,
|
|
38
|
+
},
|
|
39
|
+
),
|
|
40
|
+
];
|
|
41
|
+
}
|