pi-sessions 0.14.2 → 0.15.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 +57 -1
- package/extensions/hosts/contract.ts +51 -0
- package/extensions/hosts/registry.ts +129 -0
- package/extensions/pi-sessions.ts +40 -8
- package/extensions/session-handoff/install.ts +4 -1
- package/extensions/session-handoff/launch/backend.ts +3 -10
- package/extensions/session-handoff/launch-options.ts +17 -2
- package/extensions/session-handoff/launch-target.ts +28 -16
- package/extensions/session-handoff/spawn.ts +2 -1
- package/extensions/session-handoff/tool-schema.ts +10 -2
- package/extensions/session-handoff/tool.ts +1 -0
- package/extensions/session-messaging/install.ts +3 -1
- package/extensions/session-messaging/message-router.ts +91 -0
- package/extensions/session-messaging/pi/incoming-runtime.ts +14 -2
- package/extensions/session-messaging/pi/reachable-contract.ts +2 -1
- package/extensions/session-messaging/pi/reachable-tool.ts +24 -4
- package/extensions/session-messaging/pi/tools.ts +9 -1
- package/extensions/shared/composition.ts +2 -0
- package/extensions/subagents/install.ts +13 -13
- package/extensions/subagents/settle.ts +1 -1
- package/extensions/subagents/wake.ts +32 -116
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -110,6 +110,8 @@ The `session_handoff` tool lets the agent create a child session with a self-con
|
|
|
110
110
|
|
|
111
111
|
Directional launches use tmux when the current terminal is inside tmux, or Ghostty on macOS. Deferred launches create the child without starting it and copy its resume command to the clipboard. A background subagent runs in a detached tmux window and reports back when finished; note that subagents are started with `--approve`, meaning pi will always start in the directory as trusted.
|
|
112
112
|
|
|
113
|
+
When an external host registers, its name replaces all directional launch values, even inside tmux or Ghostty. Deferred and subagent launches remain available. External-host children start automatically without draft review; their resume commands also carry `--approve`.
|
|
114
|
+
|
|
113
115
|
Run `/handoff` to open the **Handoffs** board. The Subagents and User sessions tabs show status, age, launch details, and the actions currently available: stop, copy an observation command, or copy a resume command.
|
|
114
116
|
|
|
115
117
|
## Session Messaging
|
|
@@ -120,7 +122,61 @@ Agents can coordinate with live Pi sessions and their own subagents:
|
|
|
120
122
|
- `session_send_message` sends a message to a live session or own subagent
|
|
121
123
|
- `session_cancel` aborts another live session's current turn
|
|
122
124
|
|
|
123
|
-
Incoming messages start the recipient agent when idle and steer it when already running. Messaging a dormant owned subagent resumes it automatically
|
|
125
|
+
Incoming messages start the recipient agent when idle and steer it when already running. Messaging a dormant owned subagent or a dormant session listed by a wake-capable host resumes it automatically. Hosted sessions appear in `session_reachable` with a `host` field and a broker-derived `live`, `starting`, or `dormant` state. Other inactive sessions cannot receive messages, but you can still use `session_search` and `session_ask` with them.
|
|
126
|
+
|
|
127
|
+
## Host extension API (v1)
|
|
128
|
+
|
|
129
|
+
A host controls where user-facing handoff children run. Built-in hosts launch tmux splits, Ghostty splits, or deferred resume commands. External extensions register over `pi.events`; neither extension imports the other.
|
|
130
|
+
|
|
131
|
+
Install a listener during extension initialization, **not** in your `session_start` handler:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
pi.events.on("pi-sessions:hosts:v1", (request) => {
|
|
135
|
+
const { register } = request as HostRegistrationRequest;
|
|
136
|
+
register({ name: "swb", launch, listSessions, wake });
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The structural types below are the public contract; copy them into your extension or define compatible types locally:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
interface HostRegistrationRequest {
|
|
144
|
+
register(host: Host): void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface Host {
|
|
148
|
+
name: string;
|
|
149
|
+
launch(input: HostLaunchInput): Promise<HostLaunchResult>;
|
|
150
|
+
listSessions?(): Promise<HostSession[]>;
|
|
151
|
+
wake?(sessionId: string): Promise<void>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface HostLaunchInput {
|
|
155
|
+
sessionId: string;
|
|
156
|
+
sessionFile: string;
|
|
157
|
+
cwd: string;
|
|
158
|
+
title: string;
|
|
159
|
+
model: string;
|
|
160
|
+
resumeCommand: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type HostLaunchResult =
|
|
164
|
+
| { success: true; clipboardStatus?: "copied" | "failed" }
|
|
165
|
+
| { success: false; error: string };
|
|
166
|
+
|
|
167
|
+
interface HostSession {
|
|
168
|
+
sessionId: string;
|
|
169
|
+
cwd: string;
|
|
170
|
+
title: string;
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- **Registration:** pi-sessions emits `pi-sessions:hosts:v1` at each `session_start`, including reloads and session switches. Call `register` synchronously, before returning or awaiting anything. Registration freezes when `emit` returns; late calls throw. Names must match `[a-z][a-z0-9-]*`, be unique, and cannot be `left`, `right`, `up`, `down`, `deferred`, `subagent`, `tmux`, or `ghostty`. Registration and callback results are validated with TypeBox. Multiple external hosts are offered in registration order; any external host suppresses all split targets. Without one, precedence remains tmux, then Ghostty, then deferred. Subagent availability still depends on tmux installation and delegation depth.
|
|
175
|
+
- **Launch:** the child file, lineage, title, and automatic bootstrap are durable before `launch` runs. External-host children always use Pi's default session directory for `cwd`, even when the parent uses a custom directory. Start interactive Pi in that cwd with `--session-id`, `--approve`, and `--model` using the supplied `model` (including its thinking suffix, such as `provider/model:high`), or execute `resumeCommand`. Preserve the same Pi agent directory and load pi-sessions in the child. Do not inject a second initial prompt. Return `{ success: true }` after starting the process; return `{ success: false, error }` or throw on failure. The tool surfaces the surviving child's resume command on failure. `clipboardStatus` is used by the deferred host; external hosts normally omit it.
|
|
176
|
+
- **Discovery:** `listSessions()` returns all open user-facing sessions this host can wake, whether running or dormant. Exclude closed sessions and subagents. It is called on demand by `session_reachable` and dormant-message routing, not polled in the background. Return unique session IDs; competing host ownership is an error. Hosts without both `listSessions` and `wake` do not contribute dormant reachability. `wake` without `listSessions` is invalid.
|
|
177
|
+
- **Wake:** `wake(sessionId)` starts or resumes an owned session and resolves with no value; throw on failure. It must be idempotent across callers because different Pi processes can wake the same target concurrently. Within one sender, concurrent wakes are coalesced. After it resolves, pi-sessions waits **30 seconds** for broker registration before delivering. It retries wake/delivery once if the target disconnects before acceptance, but does not restart a host session on registration timeout. The broker alone supplies liveness; do not wait for broker readiness inside `wake`. Host callbacks have no imposed timeout: bound your own I/O. Built-in split launch commands use a 15-second timeout; subagent stale-window recovery retains two 30-second registration waits.
|
|
178
|
+
|
|
179
|
+
Subagents are not hosts. They retain parent-owned tmux windows, automatic approval, their own ledger, and their own wake/recovery policy. `/handoff` remains a receipt board; it accepts external-host launch receipts but does not provide a separate launch picker or wake action.
|
|
124
180
|
|
|
125
181
|
## Session picker
|
|
126
182
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const HOSTS_CHANNEL = "pi-sessions:hosts:v1";
|
|
4
|
+
|
|
5
|
+
export const HOST_LAUNCH_INPUT_SCHEMA = Type.Object({
|
|
6
|
+
sessionId: Type.String({ minLength: 1 }),
|
|
7
|
+
sessionFile: Type.String({ minLength: 1 }),
|
|
8
|
+
cwd: Type.String({ minLength: 1 }),
|
|
9
|
+
title: Type.String(),
|
|
10
|
+
model: Type.String({ minLength: 1 }),
|
|
11
|
+
resumeCommand: Type.String({ minLength: 1 }),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const HOST_LAUNCH_RESULT_SCHEMA = Type.Union([
|
|
15
|
+
Type.Object({
|
|
16
|
+
success: Type.Literal(true),
|
|
17
|
+
clipboardStatus: Type.Optional(Type.Union([Type.Literal("copied"), Type.Literal("failed")])),
|
|
18
|
+
}),
|
|
19
|
+
Type.Object({ success: Type.Literal(false), error: Type.String() }),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export const HOST_SESSION_SCHEMA = Type.Object({
|
|
23
|
+
sessionId: Type.String({ minLength: 1 }),
|
|
24
|
+
cwd: Type.String({ minLength: 1 }),
|
|
25
|
+
title: Type.String(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const HOST_SCHEMA = Type.Object({
|
|
29
|
+
name: Type.String({ pattern: "^[a-z][a-z0-9-]*$" }),
|
|
30
|
+
launch: Type.Function([HOST_LAUNCH_INPUT_SCHEMA], Type.Unknown()),
|
|
31
|
+
listSessions: Type.Optional(Type.Function([], Type.Unknown())),
|
|
32
|
+
wake: Type.Optional(Type.Function([Type.String()], Type.Unknown())),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type HostLaunchInput = Static<typeof HOST_LAUNCH_INPUT_SCHEMA>;
|
|
36
|
+
export type HostLaunchResult = Static<typeof HOST_LAUNCH_RESULT_SCHEMA>;
|
|
37
|
+
export type HostSession = Static<typeof HOST_SESSION_SCHEMA>;
|
|
38
|
+
export interface Host {
|
|
39
|
+
name: string;
|
|
40
|
+
launch(input: HostLaunchInput): Promise<HostLaunchResult>;
|
|
41
|
+
listSessions?(): Promise<HostSession[]>;
|
|
42
|
+
wake?(sessionId: string): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface HostedSession extends HostSession {
|
|
46
|
+
host: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HostRegistrationRequest {
|
|
50
|
+
register(host: Host): void;
|
|
51
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { parseTypeBoxValue } from "../shared/typebox.ts";
|
|
4
|
+
import {
|
|
5
|
+
HOST_LAUNCH_RESULT_SCHEMA,
|
|
6
|
+
HOST_SCHEMA,
|
|
7
|
+
HOST_SESSION_SCHEMA,
|
|
8
|
+
HOSTS_CHANNEL,
|
|
9
|
+
type Host,
|
|
10
|
+
type HostedSession,
|
|
11
|
+
type HostLaunchInput,
|
|
12
|
+
} from "./contract.ts";
|
|
13
|
+
|
|
14
|
+
const RESERVED_NAMES = new Set([
|
|
15
|
+
"left",
|
|
16
|
+
"right",
|
|
17
|
+
"up",
|
|
18
|
+
"down",
|
|
19
|
+
"deferred",
|
|
20
|
+
"subagent",
|
|
21
|
+
"tmux",
|
|
22
|
+
"ghostty",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export class HostRegistry {
|
|
26
|
+
private hosts: readonly Host[] = [];
|
|
27
|
+
|
|
28
|
+
discover(events: ExtensionAPI["events"]): void {
|
|
29
|
+
const hosts: Host[] = [];
|
|
30
|
+
let frozen = false;
|
|
31
|
+
let registrationError: unknown;
|
|
32
|
+
this.hosts = [];
|
|
33
|
+
try {
|
|
34
|
+
events.emit(HOSTS_CHANNEL, {
|
|
35
|
+
register(value: unknown) {
|
|
36
|
+
if (frozen) throw new Error("Host registration is closed.");
|
|
37
|
+
try {
|
|
38
|
+
const host = parseTypeBoxValue(HOST_SCHEMA, value, "Invalid host");
|
|
39
|
+
if (RESERVED_NAMES.has(host.name) || hosts.some((entry) => entry.name === host.name)) {
|
|
40
|
+
throw new Error(`Host name is reserved or already registered: ${host.name}`);
|
|
41
|
+
}
|
|
42
|
+
if (host.wake && !host.listSessions) {
|
|
43
|
+
throw new Error(`Host ${host.name}: wake requires listSessions.`);
|
|
44
|
+
}
|
|
45
|
+
const name = host.name;
|
|
46
|
+
const launch = host.launch.bind(host);
|
|
47
|
+
const listSessions = host.listSessions?.bind(host);
|
|
48
|
+
const wake = host.wake?.bind(host);
|
|
49
|
+
hosts.push(
|
|
50
|
+
Object.freeze({
|
|
51
|
+
name,
|
|
52
|
+
async launch(input: HostLaunchInput) {
|
|
53
|
+
return parseTypeBoxValue(
|
|
54
|
+
HOST_LAUNCH_RESULT_SCHEMA,
|
|
55
|
+
await launch(input),
|
|
56
|
+
`Host ${name} launch result`,
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
...(listSessions
|
|
60
|
+
? {
|
|
61
|
+
listSessions: async () =>
|
|
62
|
+
parseTypeBoxValue(
|
|
63
|
+
Type.Array(HOST_SESSION_SCHEMA),
|
|
64
|
+
await listSessions(),
|
|
65
|
+
`Host ${name} sessions`,
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
: {}),
|
|
69
|
+
...(wake
|
|
70
|
+
? {
|
|
71
|
+
async wake(target: string) {
|
|
72
|
+
parseTypeBoxValue(
|
|
73
|
+
Type.Void(),
|
|
74
|
+
await wake(target),
|
|
75
|
+
`Host ${name} wake result`,
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
registrationError = error;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
} finally {
|
|
89
|
+
frozen = true;
|
|
90
|
+
}
|
|
91
|
+
// Pi logs listener errors instead of propagating them through emit.
|
|
92
|
+
if (registrationError) throw registrationError;
|
|
93
|
+
this.hosts = Object.freeze(hosts);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getHosts(): readonly Host[] {
|
|
97
|
+
return this.hosts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async listSessions(): Promise<HostedSession[]> {
|
|
101
|
+
const sessions = await Promise.all(
|
|
102
|
+
this.hosts.map(async (host) => {
|
|
103
|
+
if (!host.wake || !host.listSessions) return [];
|
|
104
|
+
return (await host.listSessions()).map((session) => ({ ...session, host: host.name }));
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
const result = sessions.flat();
|
|
108
|
+
const ids = new Set<string>();
|
|
109
|
+
for (const session of result) {
|
|
110
|
+
if (ids.has(session.sessionId))
|
|
111
|
+
throw new Error(`Multiple hosts claim session ${session.sessionId}.`);
|
|
112
|
+
ids.add(session.sessionId);
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async owns(target: string): Promise<boolean> {
|
|
118
|
+
return (await this.listSessions()).some((session) => session.sessionId === target);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async wake(target: string): Promise<void> {
|
|
122
|
+
const hosts = this.hosts;
|
|
123
|
+
const session = (await this.listSessions()).find((session) => session.sessionId === target);
|
|
124
|
+
if (this.hosts !== hosts) throw new Error("The host registry changed while waking a session.");
|
|
125
|
+
const host = hosts.find((host) => host.name === session?.host);
|
|
126
|
+
if (!host?.wake) throw new Error(`No host can wake session ${target}.`);
|
|
127
|
+
await host.wake(target);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -3,12 +3,14 @@ import {
|
|
|
3
3
|
type ModelRuntime,
|
|
4
4
|
SessionManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { HostRegistry } from "./hosts/registry.ts";
|
|
6
7
|
import { installAsk } from "./session-ask/install.ts";
|
|
7
8
|
import { installAutoTitle } from "./session-auto-title/install.ts";
|
|
8
9
|
import { installHandoff } from "./session-handoff/install.ts";
|
|
9
10
|
import { installHooks } from "./session-hooks/install.ts";
|
|
10
11
|
import { installIndex } from "./session-index/install.ts";
|
|
11
12
|
import { installMessaging } from "./session-messaging/install.ts";
|
|
13
|
+
import { MessageRouter, type SessionWaker } from "./session-messaging/message-router.ts";
|
|
12
14
|
import { createSessionReachableTool } from "./session-messaging/pi/reachable-tool.ts";
|
|
13
15
|
import {
|
|
14
16
|
createSessionCancelTool,
|
|
@@ -24,11 +26,13 @@ import { installSubagents } from "./subagents/install.ts";
|
|
|
24
26
|
* The single advertised entrypoint. It loads settings once, constructs each feature in
|
|
25
27
|
* dependency order, and wires them by constructor parameters.
|
|
26
28
|
* It also owns the single `session_start`/`session_shutdown` subscription so lifecycle order
|
|
27
|
-
* is deterministic: broker registration (messaging) resolves before any other feature hook
|
|
29
|
+
* is deterministic: broker registration (messaging) resolves before any other feature hook,
|
|
30
|
+
* and incoming messages start turns only once every feature hook has resolved.
|
|
28
31
|
*/
|
|
29
32
|
export default function piSessions(pi: ExtensionAPI): void {
|
|
30
33
|
const settings = loadSettings();
|
|
31
34
|
let sessionEpoch = 0;
|
|
35
|
+
const hosts = new HostRegistry();
|
|
32
36
|
|
|
33
37
|
// The mirrored ModelRuntime is expensive to build and only changes across session
|
|
34
38
|
// boundaries, so cache it per epoch (the root's own invalidation signal) instead of
|
|
@@ -53,31 +57,52 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
53
57
|
if (messaging) {
|
|
54
58
|
lifecycles.push(messaging);
|
|
55
59
|
}
|
|
60
|
+
const wakers: SessionWaker[] = [hosts];
|
|
61
|
+
const messageRouter = messaging
|
|
62
|
+
? new MessageRouter(messaging, wakers, () => sessionEpoch)
|
|
63
|
+
: undefined;
|
|
56
64
|
const subagents =
|
|
57
|
-
settings.features.subagents && messaging
|
|
58
|
-
? installSubagents(pi, {
|
|
65
|
+
settings.features.subagents && messaging && messageRouter
|
|
66
|
+
? installSubagents(pi, {
|
|
67
|
+
settings,
|
|
68
|
+
messaging,
|
|
69
|
+
readCompactionSettings,
|
|
70
|
+
sendMessage: (request) => messageRouter.sendMessage(request),
|
|
71
|
+
hasHostedSessions: () => hosts.getHosts().some((host) => Boolean(host.wake)),
|
|
72
|
+
})
|
|
59
73
|
: undefined;
|
|
60
74
|
if (subagents) {
|
|
75
|
+
wakers.unshift(subagents.waker);
|
|
61
76
|
lifecycles.push(subagents);
|
|
62
77
|
}
|
|
63
|
-
|
|
78
|
+
const registerMessagingTools = (): void => {
|
|
79
|
+
if (!messaging || !messageRouter) return;
|
|
80
|
+
const isSubagent = subagents?.getParentSessionId() !== undefined;
|
|
81
|
+
const hostedSessions = hosts.getHosts().some((host) => Boolean(host.wake));
|
|
64
82
|
pi.registerTool(
|
|
65
|
-
createSessionSendMessageTool(
|
|
66
|
-
role:
|
|
83
|
+
createSessionSendMessageTool(messageRouter, {
|
|
84
|
+
role: isSubagent
|
|
85
|
+
? { kind: "subagent" }
|
|
86
|
+
: subagents
|
|
87
|
+
? { kind: "wakeCapable" }
|
|
88
|
+
: { kind: "plain" },
|
|
89
|
+
hostedSessions,
|
|
67
90
|
getCachedRelationTo: messaging.getCachedRelationTo,
|
|
68
91
|
...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
|
|
69
92
|
}),
|
|
70
93
|
);
|
|
71
94
|
pi.registerTool(
|
|
72
95
|
createSessionCancelTool(subagents ?? messaging, {
|
|
73
|
-
role: { kind: "plain" },
|
|
96
|
+
role: isSubagent ? { kind: "subagent" } : { kind: "plain" },
|
|
74
97
|
...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
|
|
75
98
|
}),
|
|
76
99
|
);
|
|
77
100
|
pi.registerTool(
|
|
78
101
|
createSessionReachableTool({
|
|
79
102
|
indexPath: index.path,
|
|
103
|
+
hostedSessions,
|
|
80
104
|
listSessions: () => messaging.listSessions(),
|
|
105
|
+
listHostedSessions: () => hosts.listSessions(),
|
|
81
106
|
getRelationTo: messaging.getCachedRelationTo,
|
|
82
107
|
...(subagents
|
|
83
108
|
? {
|
|
@@ -87,7 +112,8 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
87
112
|
: {}),
|
|
88
113
|
}),
|
|
89
114
|
);
|
|
90
|
-
}
|
|
115
|
+
};
|
|
116
|
+
registerMessagingTools();
|
|
91
117
|
if (settings.features.handoff) {
|
|
92
118
|
const board = {
|
|
93
119
|
roster: subagents?.roster,
|
|
@@ -102,6 +128,7 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
102
128
|
settings,
|
|
103
129
|
index,
|
|
104
130
|
getModelRuntime,
|
|
131
|
+
getHosts: () => hosts.getHosts(),
|
|
105
132
|
...(subagents ? { getLaunchTargets: () => subagents.getLaunchTargets() } : {}),
|
|
106
133
|
board,
|
|
107
134
|
}),
|
|
@@ -138,9 +165,14 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
138
165
|
// this ordering is intentional — do not reorder to "fix" it.
|
|
139
166
|
pi.on("session_start", async (event, ctx) => {
|
|
140
167
|
sessionEpoch += 1;
|
|
168
|
+
hosts.discover(pi.events);
|
|
141
169
|
for (const lifecycle of lifecycles) {
|
|
142
170
|
await lifecycle.onSessionStart?.(event, ctx);
|
|
143
171
|
}
|
|
172
|
+
registerMessagingTools();
|
|
173
|
+
for (const lifecycle of lifecycles) {
|
|
174
|
+
lifecycle.onSessionReady?.(ctx);
|
|
175
|
+
}
|
|
144
176
|
});
|
|
145
177
|
|
|
146
178
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
@@ -2,6 +2,7 @@ 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";
|
|
5
6
|
import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
|
|
6
7
|
import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
|
|
7
8
|
import { isTuiMode } from "../shared/pi-mode.ts";
|
|
@@ -46,6 +47,7 @@ export function installHandoff(
|
|
|
46
47
|
index: IndexHandle;
|
|
47
48
|
getModelRuntime: ModelRuntimeProvider;
|
|
48
49
|
getLaunchTargets?: (() => readonly HandoffLaunchTarget[]) | undefined;
|
|
50
|
+
getHosts?: () => readonly Host[];
|
|
49
51
|
board: HandoffBoardServices;
|
|
50
52
|
},
|
|
51
53
|
): SessionLifecycle {
|
|
@@ -197,7 +199,7 @@ export function installHandoff(
|
|
|
197
199
|
});
|
|
198
200
|
|
|
199
201
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
200
|
-
if (splitBackend?.identifyTerminalId && ctx) {
|
|
202
|
+
if (!deps.getHosts?.().length && splitBackend?.identifyTerminalId && ctx) {
|
|
201
203
|
identifiedGhosttyTerminalId =
|
|
202
204
|
(await splitBackend.identifyTerminalId(ctx.cwd)) ?? identifiedGhosttyTerminalId;
|
|
203
205
|
}
|
|
@@ -215,6 +217,7 @@ export function installHandoff(
|
|
|
215
217
|
splitBackend,
|
|
216
218
|
copyDeferredToClipboard: settings.handoff.deferred.copyToClipboard,
|
|
217
219
|
additionalTargets: deps.getLaunchTargets?.() ?? [],
|
|
220
|
+
hosts: deps.getHosts?.() ?? [],
|
|
218
221
|
}),
|
|
219
222
|
resolveHandoffRoster({
|
|
220
223
|
models,
|
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Host, HostLaunchInput } from "../../hosts/contract.ts";
|
|
2
2
|
|
|
3
3
|
export type HandoffSplitDirection = "left" | "right" | "up" | "down";
|
|
4
4
|
|
|
5
|
-
export
|
|
6
|
-
cwd: string;
|
|
7
|
-
title: string;
|
|
8
|
-
resumeCommand: string;
|
|
9
|
-
}
|
|
5
|
+
export type LaunchInput = HostLaunchInput;
|
|
10
6
|
|
|
11
7
|
export type ClipboardStatus = "copied" | "failed";
|
|
12
8
|
|
|
13
|
-
export
|
|
14
|
-
name: string;
|
|
15
|
-
launch(input: LaunchInput): Promise<HandoffLaunchTargetOutcome>;
|
|
16
|
-
}
|
|
9
|
+
export type LaunchBackend = Host;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Host } from "../hosts/contract.ts";
|
|
2
3
|
import { createDeferredLaunchBackend } from "./launch/deferred.ts";
|
|
3
4
|
import type { SplitLaunchBackend } from "./launch/resolution.ts";
|
|
4
5
|
import {
|
|
@@ -19,8 +20,10 @@ export function createHandoffLaunchTargets(options: {
|
|
|
19
20
|
splitBackend: SplitLaunchBackend | undefined;
|
|
20
21
|
copyDeferredToClipboard: boolean;
|
|
21
22
|
additionalTargets: readonly HandoffLaunchTarget[];
|
|
23
|
+
hosts?: readonly Host[];
|
|
22
24
|
}): HandoffLaunchTarget[] {
|
|
23
|
-
const
|
|
25
|
+
const hosts = options.hosts ?? [];
|
|
26
|
+
const splitBackend = hosts.length ? undefined : options.splitBackend;
|
|
24
27
|
const splitTargets = splitBackend
|
|
25
28
|
? LAUNCH_DIRECTIONS.map((direction, index) =>
|
|
26
29
|
createBackendLaunchTarget(
|
|
@@ -37,7 +40,19 @@ export function createHandoffLaunchTargets(options: {
|
|
|
37
40
|
createDeferredLaunchBackend({ copyToClipboard: options.copyDeferredToClipboard }),
|
|
38
41
|
"'deferred' creates the session and returns its resume command without opening anything.",
|
|
39
42
|
);
|
|
40
|
-
return [
|
|
43
|
+
return [
|
|
44
|
+
...hosts.map((host) =>
|
|
45
|
+
createBackendLaunchTarget(
|
|
46
|
+
host.name,
|
|
47
|
+
host,
|
|
48
|
+
`'${host.name}' opens a session in ${host.name}.`,
|
|
49
|
+
true,
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
...splitTargets,
|
|
53
|
+
deferred,
|
|
54
|
+
...options.additionalTargets,
|
|
55
|
+
];
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
export function resolveHandoffLaunchTarget(
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type Static, Type } from "typebox";
|
|
3
|
-
import type {
|
|
3
|
+
import type { Host } from "../hosts/contract.ts";
|
|
4
|
+
import { formatError } from "../shared/errors.ts";
|
|
5
|
+
import type { ClipboardStatus } from "./launch/backend.ts";
|
|
4
6
|
import type { HandoffSubagent } from "./metadata.ts";
|
|
5
7
|
import type { PreparedHandoff } from "./spawn.ts";
|
|
6
8
|
|
|
@@ -15,10 +17,9 @@ export const HANDOFF_DIRECTION_LAUNCH_SCHEMA = Type.Union([
|
|
|
15
17
|
Type.Literal("down"),
|
|
16
18
|
]);
|
|
17
19
|
|
|
18
|
-
export const HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA = Type.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
]);
|
|
20
|
+
export const HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA = Type.String({
|
|
21
|
+
pattern: "^(?!subagent$)[a-z][a-z0-9-]*$",
|
|
22
|
+
});
|
|
22
23
|
|
|
23
24
|
export const HANDOFF_LAUNCH_VALUE_SCHEMA = Type.Union([
|
|
24
25
|
HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA,
|
|
@@ -49,7 +50,7 @@ export type HandoffLaunchTargetOutcome =
|
|
|
49
50
|
| {
|
|
50
51
|
success: true;
|
|
51
52
|
backend: string;
|
|
52
|
-
clipboardStatus?: ClipboardStatus
|
|
53
|
+
clipboardStatus?: ClipboardStatus;
|
|
53
54
|
}
|
|
54
55
|
| { success: false; error: string };
|
|
55
56
|
|
|
@@ -61,6 +62,7 @@ export interface HandoffLaunchTarget {
|
|
|
61
62
|
bootstrapMode: "review" | "automatic";
|
|
62
63
|
/** Unattended children skip the startup trust prompt; watched ones let the user answer it. */
|
|
63
64
|
approveProjectTrust: boolean;
|
|
65
|
+
useDefaultSessionDir?: boolean;
|
|
64
66
|
/** Subagent launches stamp their child's identity into the child bootstrap. */
|
|
65
67
|
describeSubagentChild?(input: {
|
|
66
68
|
childSessionId: string;
|
|
@@ -72,23 +74,33 @@ export interface HandoffLaunchTarget {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export function createBackendLaunchTarget(
|
|
75
|
-
value:
|
|
76
|
-
|
|
77
|
+
value: string,
|
|
78
|
+
host: Host,
|
|
77
79
|
description?: string | undefined,
|
|
80
|
+
external = false,
|
|
78
81
|
): HandoffLaunchTarget {
|
|
79
82
|
return {
|
|
80
83
|
value,
|
|
81
84
|
...(description ? { description } : {}),
|
|
82
85
|
requestResponseDefault: false,
|
|
83
|
-
bootstrapMode: "review",
|
|
84
|
-
approveProjectTrust:
|
|
86
|
+
bootstrapMode: external ? "automatic" : "review",
|
|
87
|
+
approveProjectTrust: external,
|
|
88
|
+
useDefaultSessionDir: external,
|
|
85
89
|
prepareChild() {},
|
|
86
|
-
launch(input) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
async launch(input) {
|
|
91
|
+
try {
|
|
92
|
+
const result = await host.launch({
|
|
93
|
+
sessionId: input.prepared.sessionId,
|
|
94
|
+
sessionFile: input.prepared.sessionFile,
|
|
95
|
+
cwd: input.cwd,
|
|
96
|
+
title: input.title,
|
|
97
|
+
model: input.model,
|
|
98
|
+
resumeCommand: input.prepared.resumeCommand,
|
|
99
|
+
});
|
|
100
|
+
return result.success ? { ...result, backend: host.name } : result;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return { success: false, error: formatError(error) };
|
|
103
|
+
}
|
|
92
104
|
},
|
|
93
105
|
};
|
|
94
106
|
}
|
|
@@ -22,6 +22,7 @@ export function prepareHandoffLaunch(options: {
|
|
|
22
22
|
title: string;
|
|
23
23
|
model: string | undefined;
|
|
24
24
|
approveProjectTrust: boolean;
|
|
25
|
+
useDefaultSessionDir?: boolean;
|
|
25
26
|
buildBootstrap: (sessionId: string) => HandoffBootstrap;
|
|
26
27
|
prepareChild?: ((manager: SessionManager, sessionId: string) => void) | undefined;
|
|
27
28
|
}): PreparedHandoff {
|
|
@@ -31,7 +32,7 @@ export function prepareHandoffLaunch(options: {
|
|
|
31
32
|
const sameCwd = options.targetCwd === options.parentCwd;
|
|
32
33
|
const manager = SessionManager.create(
|
|
33
34
|
options.targetCwd,
|
|
34
|
-
sameCwd ? options.parentSessionDir : undefined,
|
|
35
|
+
sameCwd && !options.useDefaultSessionDir ? options.parentSessionDir : undefined,
|
|
35
36
|
{ parentSession: options.parentSessionFile },
|
|
36
37
|
);
|
|
37
38
|
manager.appendSessionInfo(options.title);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { type HandoffLaunchTarget, SUBAGENT_LAUNCH } from "./launch-target.ts";
|
|
3
|
+
import { type HandoffLaunchTarget, LAUNCH_DIRECTIONS, SUBAGENT_LAUNCH } from "./launch-target.ts";
|
|
4
4
|
import { formatRosterEntry, type HandoffRoster } from "./roster.ts";
|
|
5
5
|
|
|
6
6
|
export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]) {
|
|
@@ -46,11 +46,19 @@ export function buildHandoffPromptGuidelines(
|
|
|
46
46
|
roster: HandoffRoster | undefined,
|
|
47
47
|
): string[] {
|
|
48
48
|
const hasSubagent = targets.some((target) => target.value === SUBAGENT_LAUNCH);
|
|
49
|
+
const hasHost = targets.some(
|
|
50
|
+
(target) =>
|
|
51
|
+
target.value !== "subagent" &&
|
|
52
|
+
target.value !== "deferred" &&
|
|
53
|
+
!LAUNCH_DIRECTIONS.some((direction) => direction === target.value),
|
|
54
|
+
);
|
|
49
55
|
const selectable =
|
|
50
56
|
roster?.map(formatRosterEntry) ?? models.map((model) => `${model.provider}/${model.id}`);
|
|
51
57
|
return [
|
|
52
58
|
...(hasSubagent ? SUBAGENT_GUIDELINES : []),
|
|
53
|
-
|
|
59
|
+
hasHost
|
|
60
|
+
? "Use session_handoff host or deferred launches only when the user requests one."
|
|
61
|
+
: LAUNCH_TARGET_GUIDELINE,
|
|
54
62
|
...MODEL_INHERITANCE_GUIDELINES,
|
|
55
63
|
hasSubagent ? SUBAGENT_MODEL_OVERRIDE_GUIDELINE : DEFAULT_MODEL_OVERRIDE_GUIDELINE,
|
|
56
64
|
...(selectable.length > 0
|
|
@@ -108,6 +108,7 @@ export async function executeSessionHandoffTool(
|
|
|
108
108
|
title,
|
|
109
109
|
model,
|
|
110
110
|
approveProjectTrust: target.approveProjectTrust,
|
|
111
|
+
useDefaultSessionDir: target.useDefaultSessionDir ?? false,
|
|
111
112
|
buildBootstrap: (sessionId) =>
|
|
112
113
|
createChildGeneratedHandoffBootstrap({
|
|
113
114
|
sessionId,
|
|
@@ -65,11 +65,13 @@ export function installMessaging(
|
|
|
65
65
|
onIncomingSubagentReport: (handler) => service.onIncomingSubagentReport(handler),
|
|
66
66
|
async onSessionStart(_event, ctx) {
|
|
67
67
|
incomingRuntime.bindContext(ctx);
|
|
68
|
-
incomingRuntime.replayPending(ctx);
|
|
69
68
|
try {
|
|
70
69
|
await service.start(ctx);
|
|
71
70
|
} catch {}
|
|
72
71
|
},
|
|
72
|
+
onSessionReady() {
|
|
73
|
+
incomingRuntime.open();
|
|
74
|
+
},
|
|
73
75
|
onSessionShutdown() {
|
|
74
76
|
incomingRuntime.clearContext();
|
|
75
77
|
service.stop();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { SendMessageRequest, SendMessageResult } from "./install.ts";
|
|
2
|
+
|
|
3
|
+
export interface SessionWaker {
|
|
4
|
+
owns(target: string): boolean | Promise<boolean>;
|
|
5
|
+
wake(target: string): Promise<void>;
|
|
6
|
+
restart?(target: string): Promise<void>;
|
|
7
|
+
afterSend?(): Promise<void> | void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface WakeMessaging {
|
|
11
|
+
sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
|
|
12
|
+
listSessions(): Promise<string[]>;
|
|
13
|
+
waitForSession(sessionId: string, timeoutMs: number): Promise<boolean>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class MessageRouter {
|
|
17
|
+
private readonly waking = new Map<string, Promise<void>>();
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly messaging: WakeMessaging,
|
|
21
|
+
private readonly wakers: readonly SessionWaker[],
|
|
22
|
+
private readonly getEpoch: () => number,
|
|
23
|
+
private readonly readyTimeoutMs = 30_000,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async sendMessage(request: SendMessageRequest): Promise<SendMessageResult> {
|
|
27
|
+
const epoch = this.getEpoch();
|
|
28
|
+
let failedLiveSend: SendMessageResult | undefined;
|
|
29
|
+
if (await this.isLive(request.target)) {
|
|
30
|
+
this.requireCurrent(epoch);
|
|
31
|
+
const result = await this.messaging.sendMessage(request);
|
|
32
|
+
if (!isTargetDeparture(result)) return result;
|
|
33
|
+
failedLiveSend = result;
|
|
34
|
+
}
|
|
35
|
+
let owner: SessionWaker | undefined;
|
|
36
|
+
for (const waker of this.wakers) {
|
|
37
|
+
if (await waker.owns(request.target)) {
|
|
38
|
+
owner = waker;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
this.requireCurrent(epoch);
|
|
43
|
+
if (!owner) return failedLiveSend ?? this.messaging.sendMessage(request);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
47
|
+
await this.wake(request.target, owner, epoch);
|
|
48
|
+
this.requireCurrent(epoch);
|
|
49
|
+
const result = await this.messaging.sendMessage(request);
|
|
50
|
+
if (attempt === 1 || !isTargetDeparture(result)) return result;
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
if (epoch === this.getEpoch()) await owner.afterSend?.();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private wake(target: string, waker: SessionWaker, epoch: number): Promise<void> {
|
|
58
|
+
const key = `${epoch}:${target}`;
|
|
59
|
+
const existing = this.waking.get(key);
|
|
60
|
+
if (existing) return existing;
|
|
61
|
+
const wake = this.ensureReady(target, waker, epoch).finally(() => this.waking.delete(key));
|
|
62
|
+
this.waking.set(key, wake);
|
|
63
|
+
return wake;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async ensureReady(target: string, waker: SessionWaker, epoch: number): Promise<void> {
|
|
67
|
+
if (await this.isLive(target)) return;
|
|
68
|
+
this.requireCurrent(epoch);
|
|
69
|
+
await waker.wake(target);
|
|
70
|
+
if (await this.messaging.waitForSession(target, this.readyTimeoutMs)) return;
|
|
71
|
+
this.requireCurrent(epoch);
|
|
72
|
+
if (waker.restart) {
|
|
73
|
+
await waker.restart(target);
|
|
74
|
+
if (await this.messaging.waitForSession(target, this.readyTimeoutMs)) return;
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Session ${target} did not register for messaging.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async isLive(target: string): Promise<boolean> {
|
|
80
|
+
return (await this.messaging.listSessions()).includes(target);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private requireCurrent(epoch: number): void {
|
|
84
|
+
if (epoch !== this.getEpoch())
|
|
85
|
+
throw new Error("The current session changed while waking a session.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isTargetDeparture(result: SendMessageResult): boolean {
|
|
90
|
+
return !result.delivered && (result.reason === "no_session" || result.reason === "disconnected");
|
|
91
|
+
}
|
|
@@ -21,6 +21,7 @@ interface IncomingMessageActions {
|
|
|
21
21
|
|
|
22
22
|
export class IncomingSessionMessageRuntime {
|
|
23
23
|
private context: ExtensionContext | undefined;
|
|
24
|
+
private ready = false;
|
|
24
25
|
private readonly actions: IncomingMessageActions;
|
|
25
26
|
|
|
26
27
|
constructor(pi: ExtensionAPI) {
|
|
@@ -32,10 +33,17 @@ export class IncomingSessionMessageRuntime {
|
|
|
32
33
|
|
|
33
34
|
bindContext(ctx: ExtensionContext): void {
|
|
34
35
|
this.context = ctx;
|
|
36
|
+
this.ready = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
open(): void {
|
|
40
|
+
this.replayPending(this.requireContext());
|
|
41
|
+
this.ready = true;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
clearContext(): void {
|
|
38
45
|
this.context = undefined;
|
|
46
|
+
this.ready = false;
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
deliver(received: ReceivedMessageEntry): void {
|
|
@@ -47,14 +55,18 @@ export class IncomingSessionMessageRuntime {
|
|
|
47
55
|
throw new Error(SESSION_STARTING_MESSAGE);
|
|
48
56
|
}
|
|
49
57
|
this.actions.appendEntry(MESSAGE_RECEIVED_CUSTOM_TYPE, received);
|
|
50
|
-
|
|
58
|
+
// The broker connects before other features finish session_start, and a turn started now
|
|
59
|
+
// would run without the tools they activate. The receipt is durable; open() replays it.
|
|
60
|
+
if (this.ready) {
|
|
61
|
+
this.inject(ctx, received);
|
|
62
|
+
}
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
cancel(): void {
|
|
54
66
|
this.requireContext().abort();
|
|
55
67
|
}
|
|
56
68
|
|
|
57
|
-
replayPending(ctx: ExtensionContext): void {
|
|
69
|
+
private replayPending(ctx: ExtensionContext): void {
|
|
58
70
|
const entries = ctx.sessionManager.getEntries();
|
|
59
71
|
const deliveredMessageIds = new Set<string>();
|
|
60
72
|
const pendingReceipts: ReceivedMessageEntry[] = [];
|
|
@@ -26,7 +26,8 @@ const REACHABLE_USER_SESSION_SCHEMA = Type.Object({
|
|
|
26
26
|
cwd: Type.Optional(Type.String()),
|
|
27
27
|
modifiedAt: Type.Optional(Type.String()),
|
|
28
28
|
relation: Type.Optional(Type.String()),
|
|
29
|
-
|
|
29
|
+
host: Type.Optional(Type.String()),
|
|
30
|
+
state: Type.Union([Type.Literal("live"), Type.Literal("starting"), Type.Literal("dormant")]),
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
const REACHABLE_SUBAGENT_SCHEMA = Type.Object({
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type ToolDefinition,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { HostedSession } from "../../hosts/contract.ts";
|
|
8
9
|
import { isSessionStarting } from "../../session-handoff/metadata.ts";
|
|
9
10
|
import { formatError } from "../../shared/errors.ts";
|
|
10
11
|
import { ExpandableContentLayout } from "../../shared/rendering/expandable-content-layout.ts";
|
|
@@ -46,7 +47,9 @@ export interface ReachableSubagentEntry {
|
|
|
46
47
|
|
|
47
48
|
export interface SessionReachableDeps {
|
|
48
49
|
indexPath: string;
|
|
50
|
+
hostedSessions?: boolean;
|
|
49
51
|
listSessions(): Promise<string[]>;
|
|
52
|
+
listHostedSessions?: () => Promise<HostedSession[]>;
|
|
50
53
|
getRelationTo(sessionId: string): string | undefined;
|
|
51
54
|
listSubagents?:
|
|
52
55
|
| ((
|
|
@@ -75,6 +78,11 @@ export function createSessionReachableTool(deps: SessionReachableDeps): ToolDefi
|
|
|
75
78
|
: "List addressable live sessions",
|
|
76
79
|
promptGuidelines: [
|
|
77
80
|
"Before session_send_message, use session_reachable to find the target session id.",
|
|
81
|
+
...(deps.hostedSessions
|
|
82
|
+
? [
|
|
83
|
+
"User sessions include dormant hosted sessions. session_send_message wakes them automatically; the host field identifies their host.",
|
|
84
|
+
]
|
|
85
|
+
: []),
|
|
78
86
|
],
|
|
79
87
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
80
88
|
parameters: subagentsAvailable ? SESSION_REACHABLE_PARAMS : SESSION_REACHABLE_USER_PARAMS,
|
|
@@ -129,7 +137,11 @@ async function listReachableUserSessions(
|
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
const parentSessionId = deps.getParentSessionId?.();
|
|
132
|
-
const
|
|
140
|
+
const hostedSessions = new Map(
|
|
141
|
+
((await deps.listHostedSessions?.()) ?? []).map((session) => [session.sessionId, session]),
|
|
142
|
+
);
|
|
143
|
+
const liveIds = new Set(liveSessionIds);
|
|
144
|
+
const targetIds = [...new Set([...liveSessionIds, ...hostedSessions.keys()])].filter(
|
|
133
145
|
(sessionId) => sessionId !== currentSessionId && sessionId !== parentSessionId,
|
|
134
146
|
);
|
|
135
147
|
return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
|
|
@@ -140,12 +152,20 @@ async function listReachableUserSessions(
|
|
|
140
152
|
continue;
|
|
141
153
|
}
|
|
142
154
|
const relation = deps.getRelationTo(sessionId);
|
|
155
|
+
const hosted = hostedSessions.get(sessionId);
|
|
156
|
+
const title = hosted?.title ?? row?.sessionName;
|
|
157
|
+
const cwd = hosted?.cwd ?? row?.cwd;
|
|
143
158
|
sessions.push({
|
|
144
159
|
kind: "user",
|
|
145
160
|
sessionId,
|
|
146
|
-
state:
|
|
147
|
-
|
|
148
|
-
|
|
161
|
+
state: !liveIds.has(sessionId)
|
|
162
|
+
? "dormant"
|
|
163
|
+
: row && isStartingSession(row)
|
|
164
|
+
? "starting"
|
|
165
|
+
: "live",
|
|
166
|
+
...(hosted ? { host: hosted.host } : {}),
|
|
167
|
+
...(title ? { title } : {}),
|
|
168
|
+
...(cwd ? { cwd } : {}),
|
|
149
169
|
...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
|
|
150
170
|
...(relation === undefined ? {} : { relation }),
|
|
151
171
|
});
|
|
@@ -53,6 +53,7 @@ export type SessionSendMessageRole =
|
|
|
53
53
|
|
|
54
54
|
export interface SessionSendMessageToolOptions {
|
|
55
55
|
role: SessionSendMessageRole;
|
|
56
|
+
hostedSessions?: boolean;
|
|
56
57
|
getCachedRelationTo(sessionId: string | undefined): string | undefined;
|
|
57
58
|
/**
|
|
58
59
|
* Resolved on every call: subagent identity depends on the active branch, so a rewind can
|
|
@@ -119,7 +120,14 @@ export function createSessionSendMessageTool(
|
|
|
119
120
|
label: "Send Message to Session",
|
|
120
121
|
description,
|
|
121
122
|
promptSnippet,
|
|
122
|
-
promptGuidelines
|
|
123
|
+
promptGuidelines: [
|
|
124
|
+
...promptGuidelines,
|
|
125
|
+
...(options.hostedSessions
|
|
126
|
+
? [
|
|
127
|
+
"session_reachable also lists dormant hosted sessions; sending a message wakes them automatically.",
|
|
128
|
+
]
|
|
129
|
+
: []),
|
|
130
|
+
],
|
|
123
131
|
parameters: SEND_MESSAGE_PARAMS,
|
|
124
132
|
renderCall(args, theme, context) {
|
|
125
133
|
const state = context.state as SendMessageRendererState;
|
|
@@ -11,6 +11,8 @@ import type {
|
|
|
11
11
|
*/
|
|
12
12
|
export interface SessionLifecycle {
|
|
13
13
|
onSessionStart?(event: SessionStartEvent, ctx: ExtensionContext): Promise<void> | void;
|
|
14
|
+
/** Runs once every feature's onSessionStart has resolved. */
|
|
15
|
+
onSessionReady?(ctx: ExtensionContext): void;
|
|
14
16
|
onSessionShutdown?(event: SessionShutdownEvent, ctx: ExtensionContext): Promise<void> | void;
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
SendMessageRequest,
|
|
10
10
|
SendMessageResult,
|
|
11
11
|
} from "../session-messaging/install.ts";
|
|
12
|
+
import type { SessionWaker } from "../session-messaging/message-router.ts";
|
|
12
13
|
import {
|
|
13
14
|
createSessionCancelTool,
|
|
14
15
|
createSessionSendMessageTool,
|
|
@@ -43,7 +44,7 @@ import {
|
|
|
43
44
|
type SubagentChildSessionState,
|
|
44
45
|
} from "./settle.ts";
|
|
45
46
|
import { shouldMessageSubagent } from "./should-message.ts";
|
|
46
|
-
import {
|
|
47
|
+
import { SubagentWaker } from "./wake.ts";
|
|
47
48
|
|
|
48
49
|
const SUBAGENT_PROMPT_SECTION = "pi_sessions_subagent";
|
|
49
50
|
const SUBAGENT_PROMPT = `You are working as a subagent on one task delegated by a parent session. The handoff defines your task. Work independently, stay within its scope, and do not duplicate work assigned to the parent or another subagent. Use the available tools to complete the task and validate your conclusions. Messages from the parent may refine the task or request a follow-up, but they do not replace your original task with unrelated work.`;
|
|
@@ -65,6 +66,7 @@ interface CurrentSubagentSession {
|
|
|
65
66
|
|
|
66
67
|
export interface SubagentsHandle extends SessionLifecycle {
|
|
67
68
|
roster: SubagentRoster;
|
|
69
|
+
waker: SessionWaker;
|
|
68
70
|
getLaunchTargets(): readonly HandoffLaunchTarget[];
|
|
69
71
|
sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
|
|
70
72
|
cancelSession(sessionId: string): Promise<SubagentCancelResult>;
|
|
@@ -79,6 +81,8 @@ export function installSubagents(
|
|
|
79
81
|
settings: SessionSettings;
|
|
80
82
|
messaging: MessagingHandle;
|
|
81
83
|
readCompactionSettings: (cwd: string) => CompactionThresholdSettings;
|
|
84
|
+
sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
|
|
85
|
+
hasHostedSessions?: () => boolean;
|
|
82
86
|
},
|
|
83
87
|
): SubagentsHandle {
|
|
84
88
|
pi.registerMessageRenderer(SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE, renderSubagentReportMessage);
|
|
@@ -107,18 +111,12 @@ export function installSubagents(
|
|
|
107
111
|
reconcile: () => reconciler.reconcile(),
|
|
108
112
|
openSession: openRosterSession,
|
|
109
113
|
});
|
|
110
|
-
const
|
|
111
|
-
pi,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
isCurrentSession,
|
|
115
|
-
{
|
|
116
|
-
onMaterialize: (launch) => pi.appendEntry(SUBAGENT_LAUNCHED_CUSTOM_TYPE, launch),
|
|
117
|
-
afterOwnedSend: async () => {
|
|
118
|
-
await reconciler.reconcile();
|
|
119
|
-
},
|
|
114
|
+
const waker = new SubagentWaker(pi, deps.messaging, () => current?.parent, isCurrentSession, {
|
|
115
|
+
onMaterialize: (launch) => pi.appendEntry(SUBAGENT_LAUNCHED_CUSTOM_TYPE, launch),
|
|
116
|
+
afterSend: async () => {
|
|
117
|
+
await reconciler.reconcile();
|
|
120
118
|
},
|
|
121
|
-
);
|
|
119
|
+
});
|
|
122
120
|
const cancellationRouter = new SubagentCancellationRouter(
|
|
123
121
|
pi,
|
|
124
122
|
deps.messaging,
|
|
@@ -232,6 +230,7 @@ export function installSubagents(
|
|
|
232
230
|
pi.registerTool(
|
|
233
231
|
createSessionSendMessageTool(handle, {
|
|
234
232
|
role: isSubagent ? { kind: "subagent" } : { kind: "wakeCapable" },
|
|
233
|
+
hostedSessions: deps.hasHostedSessions?.() ?? false,
|
|
235
234
|
getCachedRelationTo: deps.messaging.getCachedRelationTo,
|
|
236
235
|
getParentSessionId: () => handle.getParentSessionId(),
|
|
237
236
|
}),
|
|
@@ -257,7 +256,8 @@ export function installSubagents(
|
|
|
257
256
|
|
|
258
257
|
const handle: SubagentsHandle = {
|
|
259
258
|
roster,
|
|
260
|
-
|
|
259
|
+
waker,
|
|
260
|
+
sendMessage: deps.sendMessage,
|
|
261
261
|
cancelSession: (sessionId) => cancellationRouter.cancelSession(sessionId),
|
|
262
262
|
shouldMessageSubagent: (sessionId) =>
|
|
263
263
|
shouldMessageSubagent(sessionId, {
|
|
@@ -170,7 +170,7 @@ export function findSelfSubagentIdentity(
|
|
|
170
170
|
if (
|
|
171
171
|
bootstrap?.launch !== SUBAGENT_LAUNCH ||
|
|
172
172
|
bootstrap.sessionId !== sessionId ||
|
|
173
|
-
bootstrap.subagent
|
|
173
|
+
bootstrap.subagent?.childSessionId !== sessionId
|
|
174
174
|
) {
|
|
175
175
|
return undefined;
|
|
176
176
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type {
|
|
2
|
+
import type { SessionWaker, WakeMessaging } from "../session-messaging/message-router.ts";
|
|
3
3
|
import {
|
|
4
4
|
createTmuxWindow,
|
|
5
5
|
killTmuxWindow,
|
|
@@ -9,125 +9,60 @@ import {
|
|
|
9
9
|
} from "../shared/tmux.ts";
|
|
10
10
|
import { findOwnedSubagentLaunch, type SubagentLaunched } from "./ledger.ts";
|
|
11
11
|
|
|
12
|
-
const SESSION_READY_TIMEOUT_MS = 30_000;
|
|
13
|
-
|
|
14
12
|
export interface WakeParentSession {
|
|
15
13
|
sessionId: string;
|
|
16
14
|
epoch: number;
|
|
17
15
|
getBranch(): readonly SessionEntry[];
|
|
18
16
|
}
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
|
|
22
|
-
listSessions(): Promise<string[]>;
|
|
23
|
-
waitForSession(sessionId: string, timeoutMs: number): Promise<boolean>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface SubagentMessageRouterOptions {
|
|
27
|
-
readyTimeoutMs?: number;
|
|
28
|
-
onMaterialize?(launch: SubagentLaunched): void;
|
|
29
|
-
afterOwnedSend?(): Promise<void> | void;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export class SubagentMessageRouter {
|
|
33
|
-
private readonly wakeBySessionId = new Map<string, Promise<void>>();
|
|
34
|
-
|
|
18
|
+
export class SubagentWaker implements SessionWaker {
|
|
35
19
|
constructor(
|
|
36
20
|
private readonly executor: TmuxExecutor,
|
|
37
21
|
private readonly messaging: WakeMessaging,
|
|
38
22
|
private readonly getParent: () => WakeParentSession | undefined,
|
|
39
23
|
private readonly isCurrent: (epoch: number) => boolean,
|
|
40
|
-
private readonly options:
|
|
24
|
+
private readonly options: {
|
|
25
|
+
onMaterialize?(launch: SubagentLaunched): void;
|
|
26
|
+
afterSend?(): Promise<void> | void;
|
|
27
|
+
} = {},
|
|
41
28
|
) {}
|
|
42
29
|
|
|
43
|
-
|
|
44
|
-
let failedLiveSend: SendMessageResult | undefined;
|
|
45
|
-
if (await this.isLive(request.target)) {
|
|
46
|
-
const result = await this.messaging.sendMessage(request);
|
|
47
|
-
if (result.delivered || !isTargetDeparture(result)) {
|
|
48
|
-
return result;
|
|
49
|
-
}
|
|
50
|
-
failedLiveSend = result;
|
|
51
|
-
}
|
|
52
|
-
|
|
30
|
+
owns(target: string): boolean {
|
|
53
31
|
const parent = this.getParent();
|
|
54
|
-
|
|
55
|
-
? findOwnedSubagentLaunch(parent.getBranch(), parent.sessionId, request.target)
|
|
56
|
-
: undefined;
|
|
57
|
-
if (!parent || !launch) {
|
|
58
|
-
return failedLiveSend ?? this.messaging.sendMessage(request);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
await this.wake(parent, launch);
|
|
63
|
-
const result = await this.sendOwned(parent, request);
|
|
64
|
-
if (result.delivered || !isTargetDeparture(result)) {
|
|
65
|
-
return result;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
await this.wake(parent, launch);
|
|
69
|
-
return this.sendOwned(parent, request);
|
|
70
|
-
} finally {
|
|
71
|
-
await this.options.afterOwnedSend?.();
|
|
72
|
-
}
|
|
32
|
+
return Boolean(parent && findOwnedSubagentLaunch(parent.getBranch(), parent.sessionId, target));
|
|
73
33
|
}
|
|
74
34
|
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
if (existing) {
|
|
78
|
-
return existing;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const wake = this.ensureReady(parent, launch).finally(() => {
|
|
82
|
-
if (this.wakeBySessionId.get(launch.childSessionId) === wake) {
|
|
83
|
-
this.wakeBySessionId.delete(launch.childSessionId);
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
this.wakeBySessionId.set(launch.childSessionId, wake);
|
|
87
|
-
return wake;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
private async ensureReady(parent: WakeParentSession, launch: SubagentLaunched): Promise<void> {
|
|
91
|
-
if (await this.isLive(launch.childSessionId)) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
35
|
+
async wake(target: string): Promise<void> {
|
|
36
|
+
const { parent, launch } = this.resolve(target);
|
|
95
37
|
const tmuxSession = tmuxSessionName(parent.sessionId);
|
|
96
38
|
const hasWindow = (await listTmuxWindows(this.executor, tmuxSession)).some(
|
|
97
|
-
(window) => window.piSessionId ===
|
|
39
|
+
(window) => window.piSessionId === target,
|
|
98
40
|
);
|
|
41
|
+
if (!hasWindow) await this.createWindow(parent, launch, tmuxSession);
|
|
42
|
+
}
|
|
99
43
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
await this.messaging.waitForSession(
|
|
105
|
-
launch.childSessionId,
|
|
106
|
-
this.options.readyTimeoutMs ?? SESSION_READY_TIMEOUT_MS,
|
|
107
|
-
)
|
|
108
|
-
) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
this.requireCurrent(parent);
|
|
113
|
-
const killed = await killTmuxWindow(this.executor, tmuxSession, launch.childSessionId);
|
|
44
|
+
async restart(target: string): Promise<void> {
|
|
45
|
+
const { parent, launch } = this.resolve(target);
|
|
46
|
+
const tmuxSession = tmuxSessionName(parent.sessionId);
|
|
47
|
+
const killed = await killTmuxWindow(this.executor, tmuxSession, target);
|
|
114
48
|
if (!killed) {
|
|
115
49
|
throw new Error(
|
|
116
|
-
`Subagent ${
|
|
50
|
+
`Subagent ${target} did not register and its stale tmux window could not be stopped.`,
|
|
117
51
|
);
|
|
118
52
|
}
|
|
119
|
-
|
|
120
53
|
await this.createWindow(parent, launch, tmuxSession);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterSend(): Promise<void> | void {
|
|
57
|
+
return this.options.afterSend?.();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private resolve(target: string): { parent: WakeParentSession; launch: SubagentLaunched } {
|
|
61
|
+
const parent = this.getParent();
|
|
62
|
+
const launch = parent && findOwnedSubagentLaunch(parent.getBranch(), parent.sessionId, target);
|
|
63
|
+
if (!parent || !launch) throw new Error(`No owned subagent ${target}.`);
|
|
64
|
+
this.requireCurrent(parent);
|
|
65
|
+
return { parent, launch };
|
|
131
66
|
}
|
|
132
67
|
|
|
133
68
|
private async createWindow(
|
|
@@ -135,9 +70,7 @@ export class SubagentMessageRouter {
|
|
|
135
70
|
launch: SubagentLaunched,
|
|
136
71
|
tmuxSession: string,
|
|
137
72
|
): Promise<void> {
|
|
138
|
-
if (await this.
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
73
|
+
if ((await this.messaging.listSessions()).includes(launch.childSessionId)) return;
|
|
141
74
|
this.requireCurrent(parent);
|
|
142
75
|
this.options.onMaterialize?.(launch);
|
|
143
76
|
await createTmuxWindow(this.executor, {
|
|
@@ -149,25 +82,8 @@ export class SubagentMessageRouter {
|
|
|
149
82
|
});
|
|
150
83
|
}
|
|
151
84
|
|
|
152
|
-
private sendOwned(
|
|
153
|
-
parent: WakeParentSession,
|
|
154
|
-
request: SendMessageRequest,
|
|
155
|
-
): Promise<SendMessageResult> {
|
|
156
|
-
this.requireCurrent(parent);
|
|
157
|
-
return this.messaging.sendMessage(request);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
private async isLive(sessionId: string): Promise<boolean> {
|
|
161
|
-
return (await this.messaging.listSessions()).includes(sessionId);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
85
|
private requireCurrent(parent: WakeParentSession): void {
|
|
165
|
-
if (!this.isCurrent(parent.epoch))
|
|
86
|
+
if (!this.isCurrent(parent.epoch))
|
|
166
87
|
throw new Error("The parent session changed while waking its subagent.");
|
|
167
|
-
}
|
|
168
88
|
}
|
|
169
89
|
}
|
|
170
|
-
|
|
171
|
-
function isTargetDeparture(result: SendMessageResult): boolean {
|
|
172
|
-
return !result.delivered && (result.reason === "no_session" || result.reason === "disconnected");
|
|
173
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,10 +38,10 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@biomejs/biome": "^2.5.10",
|
|
41
|
-
"@earendil-works/pi-agent-core": "^1.0.
|
|
42
|
-
"@earendil-works/pi-ai": "^1.0.
|
|
43
|
-
"@earendil-works/pi-coding-agent": "^1.0.
|
|
44
|
-
"@earendil-works/pi-tui": "^1.0.
|
|
41
|
+
"@earendil-works/pi-agent-core": "^1.0.4",
|
|
42
|
+
"@earendil-works/pi-ai": "^1.0.4",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "^1.0.4",
|
|
44
|
+
"@earendil-works/pi-tui": "^1.0.4",
|
|
45
45
|
"@types/node": "^24.13.3",
|
|
46
46
|
"strip-ansi": "^7.2.0",
|
|
47
47
|
"typebox": "1.3.27",
|