pi-sessions 0.14.3 → 0.16.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 +63 -9
- package/extensions/hosts/contract.ts +51 -0
- package/extensions/hosts/registry.ts +129 -0
- package/extensions/pi-sessions.ts +36 -10
- 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/metadata.ts +2 -0
- package/extensions/session-handoff/query.ts +5 -4
- 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-hooks/install.ts +14 -5
- package/extensions/session-index/install.ts +25 -4
- package/extensions/session-index/panel.ts +9 -0
- package/extensions/session-messaging/message-router.ts +91 -0
- 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/session-index/access.ts +42 -5
- package/extensions/shared/session-index/common.ts +1 -0
- package/extensions/shared/session-index/recovery.ts +270 -0
- package/extensions/shared/session-index/schema.ts +9 -1
- package/extensions/shared/session-index/sqlite.ts +20 -10
- package/extensions/{session-search/hooks.ts → shared/session-index/sync.ts} +40 -16
- 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/extensions/session-search/reindex.ts +0 -97
package/README.md
CHANGED
|
@@ -51,9 +51,8 @@ pi -e /absolute/path/to/pi-sessions
|
|
|
51
51
|
## Quick start
|
|
52
52
|
|
|
53
53
|
1. Install the package.
|
|
54
|
-
2. Open Pi
|
|
55
|
-
3.
|
|
56
|
-
4. Try the main flows:
|
|
54
|
+
2. Open Pi. Your prior sessions are indexed automatically in the background.
|
|
55
|
+
3. Try the main flows:
|
|
57
56
|
|
|
58
57
|
```text
|
|
59
58
|
What session did I implement the db layer?
|
|
@@ -75,7 +74,7 @@ Open the frontend implementation task in a session to the right.
|
|
|
75
74
|
| Session Index | `/session-index` slash command | Shows index status and rebuilds the local session index |
|
|
76
75
|
| Session Auto Title | in background, `/title` slash command | Give sessions titles |
|
|
77
76
|
|
|
78
|
-
Every feature is on by default. Turn one off with `enable: false` under its own settings namespace,
|
|
77
|
+
Every feature is on by default. Turn one off with `enable: false` under its own settings namespace. Index recovery and startup/switch sync always run, including when search is disabled. `hooks.enable` controls turn, tree, and compaction sync, not startup recovery.
|
|
79
78
|
|
|
80
79
|
```json
|
|
81
80
|
{
|
|
@@ -110,6 +109,8 @@ The `session_handoff` tool lets the agent create a child session with a self-con
|
|
|
110
109
|
|
|
111
110
|
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
111
|
|
|
112
|
+
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`.
|
|
113
|
+
|
|
113
114
|
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
115
|
|
|
115
116
|
## Session Messaging
|
|
@@ -120,7 +121,61 @@ Agents can coordinate with live Pi sessions and their own subagents:
|
|
|
120
121
|
- `session_send_message` sends a message to a live session or own subagent
|
|
121
122
|
- `session_cancel` aborts another live session's current turn
|
|
122
123
|
|
|
123
|
-
Incoming messages start the recipient agent when idle and steer it when already running. Messaging a dormant owned subagent resumes it automatically
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
## Host extension API (v1)
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
Install a listener during extension initialization, **not** in your `session_start` handler:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
pi.events.on("pi-sessions:hosts:v1", (request) => {
|
|
134
|
+
const { register } = request as HostRegistrationRequest;
|
|
135
|
+
register({ name: "swb", launch, listSessions, wake });
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The structural types below are the public contract; copy them into your extension or define compatible types locally:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
interface HostRegistrationRequest {
|
|
143
|
+
register(host: Host): void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface Host {
|
|
147
|
+
name: string;
|
|
148
|
+
launch(input: HostLaunchInput): Promise<HostLaunchResult>;
|
|
149
|
+
listSessions?(): Promise<HostSession[]>;
|
|
150
|
+
wake?(sessionId: string): Promise<void>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface HostLaunchInput {
|
|
154
|
+
sessionId: string;
|
|
155
|
+
sessionFile: string;
|
|
156
|
+
cwd: string;
|
|
157
|
+
title: string;
|
|
158
|
+
model: string;
|
|
159
|
+
resumeCommand: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type HostLaunchResult =
|
|
163
|
+
| { success: true; clipboardStatus?: "copied" | "failed" }
|
|
164
|
+
| { success: false; error: string };
|
|
165
|
+
|
|
166
|
+
interface HostSession {
|
|
167
|
+
sessionId: string;
|
|
168
|
+
cwd: string;
|
|
169
|
+
title: string;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- **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.
|
|
174
|
+
- **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.
|
|
175
|
+
- **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.
|
|
176
|
+
- **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.
|
|
177
|
+
|
|
178
|
+
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
179
|
|
|
125
180
|
## Session picker
|
|
126
181
|
|
|
@@ -174,12 +229,11 @@ Subagents require the handoff and messaging features. Limit recursive delegation
|
|
|
174
229
|
|
|
175
230
|
## Session Index
|
|
176
231
|
|
|
177
|
-
|
|
232
|
+
The index is a shared cache of your transcripts, used by search, messaging, and the other session features. Pi checks transcripts at startup and syncs files that changed, including sessions run without pi-sessions loaded. Missing, older-schema, or unreadable indexes rebuild automatically in the background; unreadable databases are moved aside rather than overwritten. A malformed transcript is skipped and reported.
|
|
178
233
|
|
|
179
|
-
|
|
180
|
-
- hit `r` to (re)index everything
|
|
234
|
+
During recovery, a tool may say "Session indexing in progress; try again shortly." Only one process rebuilds at a time, and readers retain the old index until its replacement is ready. Failed attempts share a backoff of 1 minute, then 5 minutes, then 30 minutes. `/session-index` shows status; press `r` to rebuild now, ignoring the backoff.
|
|
181
235
|
|
|
182
|
-
|
|
236
|
+
If another Pi writes a newer index schema, the older process stops using the index and displays "pi-sessions was updated; /reload to use it". Reload instead of rebuilding: older code cannot replace a newer schema.
|
|
183
237
|
|
|
184
238
|
By default the index lives at:
|
|
185
239
|
|
|
@@ -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,
|
|
@@ -30,6 +32,7 @@ import { installSubagents } from "./subagents/install.ts";
|
|
|
30
32
|
export default function piSessions(pi: ExtensionAPI): void {
|
|
31
33
|
const settings = loadSettings();
|
|
32
34
|
let sessionEpoch = 0;
|
|
35
|
+
const hosts = new HostRegistry();
|
|
33
36
|
|
|
34
37
|
// The mirrored ModelRuntime is expensive to build and only changes across session
|
|
35
38
|
// boundaries, so cache it per epoch (the root's own invalidation signal) instead of
|
|
@@ -54,31 +57,52 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
54
57
|
if (messaging) {
|
|
55
58
|
lifecycles.push(messaging);
|
|
56
59
|
}
|
|
60
|
+
const wakers: SessionWaker[] = [hosts];
|
|
61
|
+
const messageRouter = messaging
|
|
62
|
+
? new MessageRouter(messaging, wakers, () => sessionEpoch)
|
|
63
|
+
: undefined;
|
|
57
64
|
const subagents =
|
|
58
|
-
settings.features.subagents && messaging
|
|
59
|
-
? 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
|
+
})
|
|
60
73
|
: undefined;
|
|
61
74
|
if (subagents) {
|
|
75
|
+
wakers.unshift(subagents.waker);
|
|
62
76
|
lifecycles.push(subagents);
|
|
63
77
|
}
|
|
64
|
-
|
|
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));
|
|
65
82
|
pi.registerTool(
|
|
66
|
-
createSessionSendMessageTool(
|
|
67
|
-
role:
|
|
83
|
+
createSessionSendMessageTool(messageRouter, {
|
|
84
|
+
role: isSubagent
|
|
85
|
+
? { kind: "subagent" }
|
|
86
|
+
: subagents
|
|
87
|
+
? { kind: "wakeCapable" }
|
|
88
|
+
: { kind: "plain" },
|
|
89
|
+
hostedSessions,
|
|
68
90
|
getCachedRelationTo: messaging.getCachedRelationTo,
|
|
69
91
|
...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
|
|
70
92
|
}),
|
|
71
93
|
);
|
|
72
94
|
pi.registerTool(
|
|
73
95
|
createSessionCancelTool(subagents ?? messaging, {
|
|
74
|
-
role: { kind: "plain" },
|
|
96
|
+
role: isSubagent ? { kind: "subagent" } : { kind: "plain" },
|
|
75
97
|
...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
|
|
76
98
|
}),
|
|
77
99
|
);
|
|
78
100
|
pi.registerTool(
|
|
79
101
|
createSessionReachableTool({
|
|
80
102
|
indexPath: index.path,
|
|
103
|
+
hostedSessions,
|
|
81
104
|
listSessions: () => messaging.listSessions(),
|
|
105
|
+
listHostedSessions: () => hosts.listSessions(),
|
|
82
106
|
getRelationTo: messaging.getCachedRelationTo,
|
|
83
107
|
...(subagents
|
|
84
108
|
? {
|
|
@@ -88,7 +112,8 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
88
112
|
: {}),
|
|
89
113
|
}),
|
|
90
114
|
);
|
|
91
|
-
}
|
|
115
|
+
};
|
|
116
|
+
registerMessagingTools();
|
|
92
117
|
if (settings.features.handoff) {
|
|
93
118
|
const board = {
|
|
94
119
|
roster: subagents?.roster,
|
|
@@ -103,6 +128,7 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
103
128
|
settings,
|
|
104
129
|
index,
|
|
105
130
|
getModelRuntime,
|
|
131
|
+
getHosts: () => hosts.getHosts(),
|
|
106
132
|
...(subagents ? { getLaunchTargets: () => subagents.getLaunchTargets() } : {}),
|
|
107
133
|
board,
|
|
108
134
|
}),
|
|
@@ -130,18 +156,18 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
130
156
|
}),
|
|
131
157
|
);
|
|
132
158
|
}
|
|
133
|
-
|
|
134
|
-
lifecycles.push(installHooks(pi, { index }));
|
|
135
|
-
}
|
|
159
|
+
lifecycles.push(index, installHooks(pi, { index, turnSync: settings.features.hooks }));
|
|
136
160
|
// Messaging leads every session_start so the broker connection is registered before any
|
|
137
161
|
// other feature hook runs. A side effect is that messaging's first relation snapshot can
|
|
138
162
|
// predate hooks' index sync for this session; getCachedRelationTo self-heals on miss, so
|
|
139
163
|
// this ordering is intentional — do not reorder to "fix" it.
|
|
140
164
|
pi.on("session_start", async (event, ctx) => {
|
|
141
165
|
sessionEpoch += 1;
|
|
166
|
+
hosts.discover(pi.events);
|
|
142
167
|
for (const lifecycle of lifecycles) {
|
|
143
168
|
await lifecycle.onSessionStart?.(event, ctx);
|
|
144
169
|
}
|
|
170
|
+
registerMessagingTools();
|
|
145
171
|
for (const lifecycle of lifecycles) {
|
|
146
172
|
lifecycle.onSessionReady?.(ctx);
|
|
147
173
|
}
|
|
@@ -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
|
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from "./launch-target.ts";
|
|
11
11
|
|
|
12
12
|
export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
|
|
13
|
+
// Agent Switchboard's swb adopt (packages/cli/src/commands/adopt.ts) reads this
|
|
14
|
+
// customType and data.subagent; renaming either breaks its subagent check.
|
|
13
15
|
export const HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap";
|
|
14
16
|
export const HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-consumed";
|
|
15
17
|
export const HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-failed";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { stripSearchSnippetMarkers } from "../shared/search-snippet.ts";
|
|
2
2
|
import {
|
|
3
|
+
formatRequiredSessionIndexError,
|
|
3
4
|
getSessionByPath,
|
|
4
5
|
type SearchSessionResult,
|
|
5
6
|
type SessionIndexDatabase,
|
|
@@ -100,7 +101,7 @@ export function listSessionPickerItems(
|
|
|
100
101
|
defaultScopeLabel,
|
|
101
102
|
};
|
|
102
103
|
}) ?? {
|
|
103
|
-
items: [buildIndexErrorItem()],
|
|
104
|
+
items: [buildIndexErrorItem(options.indexPath)],
|
|
104
105
|
scopeMode,
|
|
105
106
|
defaultScopeLabel,
|
|
106
107
|
}
|
|
@@ -309,11 +310,11 @@ function getSessionMarker(
|
|
|
309
310
|
}
|
|
310
311
|
}
|
|
311
312
|
|
|
312
|
-
function buildIndexErrorItem(): SessionPickerNoticeItem {
|
|
313
|
+
function buildIndexErrorItem(indexPath: string): SessionPickerNoticeItem {
|
|
313
314
|
return {
|
|
314
315
|
kind: "error",
|
|
315
|
-
title: "Session index
|
|
316
|
-
description:
|
|
316
|
+
title: "Session index unavailable",
|
|
317
|
+
description: formatRequiredSessionIndexError(indexPath),
|
|
317
318
|
};
|
|
318
319
|
}
|
|
319
320
|
|
|
@@ -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);
|