pi-sessions 0.18.1 → 0.20.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 +64 -39
- 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/agent-profiles.ts +110 -0
- 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 +78 -34
- 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 +8 -10
- package/extensions/session-handoff/metadata.ts +10 -0
- package/extensions/session-handoff/model.ts +22 -0
- package/extensions/session-handoff/receipt.ts +1 -1
- package/extensions/session-handoff/spawn.ts +6 -0
- package/extensions/session-handoff/tool-presenter.ts +1 -1
- package/extensions/session-handoff/tool-schema.ts +14 -9
- package/extensions/session-handoff/tool-view-model.ts +6 -2
- package/extensions/session-handoff/tool.ts +20 -6
- 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 +21 -5
- package/extensions/subagents/install.ts +11 -31
- package/extensions/subagents/launch-target.ts +2 -1
- package/extensions/subagents/reconcile.ts +10 -2
- package/extensions/subagents/settle.ts +2 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -68,8 +68,9 @@ Open the frontend implementation task in a session to the right.
|
|
|
68
68
|
| ------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
69
69
|
| Session Search | `session_search` pi tool | Search through old sessions |
|
|
70
70
|
| Session Ask | `session_ask` pi tool | Ask questions about old sessions |
|
|
71
|
-
| Session Handoff | `session_handoff` pi tool, `/handoff` board | Start
|
|
72
|
-
| Session Messaging | `session_reachable`, `session_send_message`, `session_cancel` pi tools | Coordinate between live Pi sessions
|
|
71
|
+
| Session Handoff | `session_handoff` pi tool, `/handoff` board | Start user-facing child sessions |
|
|
72
|
+
| Session Messaging | `session_reachable`, `session_send_message`, `session_cancel` pi tools | Coordinate between live user-facing Pi sessions |
|
|
73
|
+
| Subagents | `subagent_list`, `subagent_send`, `subagent_cancel`, `subagent_launch`, `submit_task_report` | Delegate and manage background work |
|
|
73
74
|
| Session Picker | `Alt+O` | Reference old sessions in your prompt |
|
|
74
75
|
| Session Index | `/session-index` slash command | Shows index status and rebuilds the local session index |
|
|
75
76
|
| Session Auto Title | in background, `/title` slash command | Give sessions titles |
|
|
@@ -107,34 +108,44 @@ Use `kind: "user"` or `kind: "subagent"` to filter by session type across the wh
|
|
|
107
108
|
|
|
108
109
|
## Session Handoff
|
|
109
110
|
|
|
110
|
-
The `session_handoff` tool
|
|
111
|
+
The `session_handoff` tool creates a user-facing child session with a self-contained task. Ask for a direction when you want a visible split, or a deferred handoff when you only want the prepared session. Use `subagent_launch` to delegate suitable independent work to a background subagent.
|
|
111
112
|
|
|
112
113
|
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.
|
|
113
114
|
|
|
114
|
-
When
|
|
115
|
+
When a host claims peers, pi-sessions leaves all user-facing coordination and launches to it. Subagent tools remain available; external-host launch targets are not part of pi-sessions.
|
|
115
116
|
|
|
116
|
-
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.
|
|
117
|
+
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. With a peer-claiming host, only Subagents are shown.
|
|
118
|
+
|
|
119
|
+
Both launch tools take `goal`, `title` (at most 64 characters), and `requestResponse`, with optional `cwd`, `provider`, `model`, and `thinkingLevel` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`). `session_handoff` additionally requires `launch` (`left`, `right`, `up`, `down` when a split backend is available, or `deferred` when enabled). `subagent_launch` has no `launch` parameter, but takes an optional `agent` naming an [agent profile](#agent-profiles). Provider and model must be supplied together.
|
|
117
120
|
|
|
118
121
|
## Session Messaging
|
|
119
122
|
|
|
120
|
-
|
|
123
|
+
Standalone agents can coordinate with live user-facing Pi sessions:
|
|
124
|
+
|
|
125
|
+
- `session_reachable({})` lists live user sessions only; there is no scope parameter
|
|
126
|
+
- `session_send_message({ session, message, requestResponse })` sends a message to a live user session
|
|
127
|
+
- `session_cancel({ session })` aborts another live user session's current turn
|
|
121
128
|
|
|
122
|
-
|
|
123
|
-
- `session_send_message` sends a message to a live session or own subagent
|
|
124
|
-
- `session_cancel` aborts another live session's current turn
|
|
129
|
+
Subagent coordination is a separate addressable directory:
|
|
125
130
|
|
|
126
|
-
|
|
131
|
+
- `subagent_list({ scope? })` lists the ledger/roster with `branch` (default) or `tree` scope
|
|
132
|
+
- `subagent_send({ session, message, requestResponse })` sends to a live subagent (including siblings) or a dormant owned worker, waking owned workers automatically
|
|
133
|
+
- `subagent_cancel({ session })` stops a subagent, recording owned cancellation intent before teardown
|
|
134
|
+
- `subagent_launch` creates a delegated background worker (parameters above)
|
|
135
|
+
- `submit_task_report({ status, summary, details?, references?, nextSteps? })` reports to the parent; available only in a subagent session
|
|
127
136
|
|
|
128
|
-
|
|
137
|
+
Pass bare session UUIDs, not `@session:` tokens. A subagent cannot message or cancel its parent; it reports through `submit_task_report`. Incoming messages start an idle recipient and steer a running one. Other dormant sessions cannot receive messages, but remain searchable with `session_search` and `session_ask`. Cancellation results describe delivery and, when available, the resulting worker state; they do not expose transport/managed subsystem tags.
|
|
129
138
|
|
|
130
|
-
|
|
139
|
+
## Host extension API
|
|
140
|
+
|
|
141
|
+
A host claims ownership of peer coordination, rather than supplying launch, listing, or wake callbacks. External extensions register over `pi.events`; neither extension imports the other.
|
|
131
142
|
|
|
132
143
|
Install a listener during extension initialization, **not** in your `session_start` handler:
|
|
133
144
|
|
|
134
145
|
```ts
|
|
135
|
-
pi.events.on("pi-sessions:hosts
|
|
146
|
+
pi.events.on("pi-sessions:hosts", (request) => {
|
|
136
147
|
const { register } = request as HostRegistrationRequest;
|
|
137
|
-
register({ name: "swb",
|
|
148
|
+
register({ name: "swb", peers: true });
|
|
138
149
|
});
|
|
139
150
|
```
|
|
140
151
|
|
|
@@ -147,37 +158,30 @@ interface HostRegistrationRequest {
|
|
|
147
158
|
|
|
148
159
|
interface Host {
|
|
149
160
|
name: string;
|
|
150
|
-
|
|
151
|
-
listSessions?(): Promise<HostSession[]>;
|
|
152
|
-
wake?(sessionId: string): Promise<void>;
|
|
161
|
+
peers: true;
|
|
153
162
|
}
|
|
163
|
+
```
|
|
154
164
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
model: string;
|
|
161
|
-
resumeCommand: string;
|
|
162
|
-
}
|
|
165
|
+
- **Registration:** pi-sessions emits `pi-sessions:hosts` at every `session_start`, including reloads and switches. 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`. TypeBox validates exactly `{ name, peers: true }`; additional properties are rejected.
|
|
166
|
+
- **Ownership:** any registered claim suppresses pi-sessions' `session_reachable`, `session_send_message`, `session_cancel`, and `session_handoff`. Peer tools are never registered at extension load, so a host may register its own tools at load without conflicts. Discovery runs before late registration. Pi currently has no tool-unregistration API: definitions retained from a prior standalone session are deactivated with `setActiveTools` and execution-guarded; reload rebuilds registrations. A new standalone session reactivates pi-sessions' tools.
|
|
167
|
+
- **Subagents:** `subagent_list`, `subagent_send`, `subagent_cancel`, `subagent_launch`, and `submit_task_report` remain pi-sessions-owned under a host. Availability still follows feature settings, tmux installation, delegation depth, and whether the caller is a subagent. The host supplies no waker; owned-worker recovery retains its two 30-second broker-registration waits.
|
|
168
|
+
|
|
169
|
+
## Durable lineage entry
|
|
163
170
|
|
|
164
|
-
|
|
165
|
-
| { success: true; clipboardStatus?: "copied" | "failed" }
|
|
166
|
-
| { success: false; error: string };
|
|
171
|
+
Any extension may append this custom entry to a child session:
|
|
167
172
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
173
|
+
```ts
|
|
174
|
+
{
|
|
175
|
+
type: "custom",
|
|
176
|
+
customType: "pi-sessions.lineage",
|
|
177
|
+
data: {
|
|
178
|
+
parentSessionId: "parent-session-uuid",
|
|
179
|
+
parentSessionFile: "/absolute/path/to/parent.jsonl", // optional
|
|
180
|
+
},
|
|
172
181
|
}
|
|
173
182
|
```
|
|
174
183
|
|
|
175
|
-
|
|
176
|
-
- **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.
|
|
177
|
-
- **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.
|
|
178
|
-
- **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.
|
|
179
|
-
|
|
180
|
-
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.
|
|
184
|
+
Using Pi's extension API, call `pi.appendEntry("pi-sessions.lineage", { parentSessionId, parentSessionFile? })` in the child (Pi supplies the entry id, timestamp, and tree link). The index uses the first TypeBox-valid lineage entry only when the session header has no `parentSession`; the header takes precedence. The parent id works even without an accessible parent file. This durable relation survives leaving a host; the SQLite index is only its rebuildable cache.
|
|
181
185
|
|
|
182
186
|
## Session picker
|
|
183
187
|
|
|
@@ -213,7 +217,7 @@ If you want to override the shortcut, put this in your `~/.pi/agent/settings.jso
|
|
|
213
217
|
|
|
214
218
|
`roster` limits which models a handoff may launch a child session on. Defaults to pi's own `enabledModels` scoping, then to all configured models. May optionally include a thinking level; listing a model more than once adds to the levels it allows.
|
|
215
219
|
|
|
216
|
-
`deferred.enable` (default `true`) offers the `deferred` launch. Turn it off
|
|
220
|
+
`deferred.enable` (default `true`) offers the `deferred` peer launch. Turn it off to leave only split launches. Hosts own their own launch policy.
|
|
217
221
|
|
|
218
222
|
`deferred.copyToClipboard` (default `true`) controls whether deferred handoffs copy the resume command to the clipboard. When off, the resume command is only shown in the tool call.
|
|
219
223
|
|
|
@@ -232,6 +236,27 @@ Subagents require the handoff and messaging features. Limit recursive delegation
|
|
|
232
236
|
|
|
233
237
|
`contextLimit` sets a max context limit before compaction is triggered. It works identically to setting a global value in pi itself, but only applies to subagents.
|
|
234
238
|
|
|
239
|
+
### Agent profiles
|
|
240
|
+
|
|
241
|
+
An agent profile lets `subagent_launch` start a subagent with extra instructions, a pinned model, and a thinking level, by name. Profiles are Markdown files in `~/.pi/agent/agents/`, in Claude Code's subagent format, so the same file works in both:
|
|
242
|
+
|
|
243
|
+
```markdown
|
|
244
|
+
---
|
|
245
|
+
name: oracle
|
|
246
|
+
description: Read-only second opinion on a plan, diff, or hard bug. Use for reviews and high-impact judgment calls.
|
|
247
|
+
model: anthropic/claude-fable-5-1:high
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
You are the Oracle: a read-only reviewer. Do not modify files. ...
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- `name` must match the file name, and `description` tells the calling agent when to use the profile; both are required. The `subagent_launch` prompt guidelines list every valid profile with its description.
|
|
254
|
+
- `model` is a pi model pattern (`provider/id` or a bare id, optionally with `:<thinking>`), and `thinkingLevel` sets the level separately. Explicit `provider`/`model` and `thinkingLevel` on the launch win, and the resolved model must still be on the handoff roster.
|
|
255
|
+
- `tools` is a comma-separated allowlist passed to `--tools`; `submit_task_report` is always kept. Without it, the subagent has every tool.
|
|
256
|
+
- The body is added to the subagent's system prompt, in the subagent section after its standard instructions; `AGENTS.md`, extensions, and the report flow are unchanged. The instructions are stored in the child session when it launches, so later edits to the file don't change a running subagent.
|
|
257
|
+
|
|
258
|
+
Naming a missing or malformed profile fails the launch with the reason. Profiles apply only to subagents, not to `session_handoff`. Move the directory with `sessions.subagents.agentsDir` (an absolute path or one starting with `~/`).
|
|
259
|
+
|
|
235
260
|
## Session Index
|
|
236
261
|
|
|
237
262
|
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.
|
|
@@ -1,51 +1,15 @@
|
|
|
1
1
|
import { type Static, Type } from "typebox";
|
|
2
2
|
|
|
3
|
-
export const HOSTS_CHANNEL = "pi-sessions:hosts
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
3
|
+
export const HOSTS_CHANNEL = "pi-sessions:hosts";
|
|
4
|
+
export const HOST_SCHEMA = Type.Object(
|
|
5
|
+
{
|
|
6
|
+
name: Type.String({ pattern: "^[a-z][a-z0-9-]*$" }),
|
|
7
|
+
peers: Type.Literal(true),
|
|
8
|
+
},
|
|
9
|
+
{ additionalProperties: false },
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export type Host = Static<typeof HOST_SCHEMA>;
|
|
49
13
|
export interface HostRegistrationRequest {
|
|
50
14
|
register(host: Host): void;
|
|
51
15
|
}
|
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Type } from "typebox";
|
|
3
2
|
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";
|
|
3
|
+
import { HOST_SCHEMA, HOSTS_CHANNEL, type Host } from "./contract.ts";
|
|
13
4
|
|
|
14
5
|
const RESERVED_NAMES = new Set([
|
|
15
6
|
"left",
|
|
@@ -39,46 +30,7 @@ export class HostRegistry {
|
|
|
39
30
|
if (RESERVED_NAMES.has(host.name) || hosts.some((entry) => entry.name === host.name)) {
|
|
40
31
|
throw new Error(`Host name is reserved or already registered: ${host.name}`);
|
|
41
32
|
}
|
|
42
|
-
|
|
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
|
-
);
|
|
33
|
+
hosts.push(Object.freeze({ ...host }));
|
|
82
34
|
} catch (error) {
|
|
83
35
|
registrationError = error;
|
|
84
36
|
throw error;
|
|
@@ -97,33 +49,7 @@ export class HostRegistry {
|
|
|
97
49
|
return this.hosts;
|
|
98
50
|
}
|
|
99
51
|
|
|
100
|
-
|
|
101
|
-
|
|
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);
|
|
52
|
+
claimsPeers(): boolean {
|
|
53
|
+
return this.hosts.length > 0;
|
|
128
54
|
}
|
|
129
55
|
}
|
|
@@ -9,18 +9,15 @@ import { installAutoTitle } from "./session-auto-title/install.ts";
|
|
|
9
9
|
import { installHandoff } from "./session-handoff/install.ts";
|
|
10
10
|
import { installHooks } from "./session-hooks/install.ts";
|
|
11
11
|
import { installIndex } from "./session-index/install.ts";
|
|
12
|
+
import { createPeerDirectory, createSubagentDirectory } from "./session-messaging/directory.ts";
|
|
12
13
|
import { installMessaging } from "./session-messaging/install.ts";
|
|
13
|
-
import { MessageRouter
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
createSessionCancelTool,
|
|
17
|
-
createSessionSendMessageTool,
|
|
18
|
-
} from "./session-messaging/pi/tools.ts";
|
|
14
|
+
import { MessageRouter } from "./session-messaging/message-router.ts";
|
|
15
|
+
import { installDirectoryTools } from "./session-messaging/pi/install.ts";
|
|
19
16
|
import { installSearch } from "./session-search/install.ts";
|
|
20
17
|
import type { SessionLifecycle } from "./shared/composition.ts";
|
|
21
18
|
import { createSessionModelRuntime, type ModelRuntimeProvider } from "./shared/model-runtime.ts";
|
|
22
19
|
import { loadSettings, readCompactionSettings } from "./shared/settings.ts";
|
|
23
|
-
import { installSubagents } from "./subagents/install.ts";
|
|
20
|
+
import { installSubagents, type SubagentsHandle } from "./subagents/install.ts";
|
|
24
21
|
|
|
25
22
|
/**
|
|
26
23
|
* The single advertised entrypoint. It loads settings once, constructs each feature in
|
|
@@ -57,65 +54,42 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
57
54
|
if (messaging) {
|
|
58
55
|
lifecycles.push(messaging);
|
|
59
56
|
}
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
const messageRouter: MessageRouter | undefined = messaging
|
|
58
|
+
? new MessageRouter(
|
|
59
|
+
messaging,
|
|
60
|
+
() => subagents?.waker,
|
|
61
|
+
() => sessionEpoch,
|
|
62
|
+
)
|
|
63
63
|
: undefined;
|
|
64
|
-
const subagents =
|
|
64
|
+
const subagents: SubagentsHandle | undefined =
|
|
65
65
|
settings.features.subagents && messaging && messageRouter
|
|
66
66
|
? installSubagents(pi, {
|
|
67
67
|
settings,
|
|
68
68
|
messaging,
|
|
69
69
|
readCompactionSettings,
|
|
70
70
|
sendMessage: (request) => messageRouter.sendMessage(request),
|
|
71
|
-
hasHostedSessions: () => hosts.getHosts().some((host) => Boolean(host.wake)),
|
|
72
71
|
})
|
|
73
72
|
: undefined;
|
|
74
73
|
if (subagents) {
|
|
75
|
-
wakers.unshift(subagents.waker);
|
|
76
74
|
lifecycles.push(subagents);
|
|
77
75
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
: { kind: "plain" },
|
|
89
|
-
hostedSessions,
|
|
90
|
-
getCachedRelationTo: messaging.getCachedRelationTo,
|
|
91
|
-
...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
pi.registerTool(
|
|
95
|
-
createSessionCancelTool(subagents ?? messaging, {
|
|
96
|
-
role: isSubagent ? { kind: "subagent" } : { kind: "plain" },
|
|
97
|
-
...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
|
|
98
|
-
}),
|
|
99
|
-
);
|
|
100
|
-
pi.registerTool(
|
|
101
|
-
createSessionReachableTool({
|
|
102
|
-
indexPath: index.path,
|
|
103
|
-
hostedSessions,
|
|
104
|
-
listSessions: () => messaging.listSessions(),
|
|
105
|
-
listHostedSessions: () => hosts.listSessions(),
|
|
106
|
-
getRelationTo: messaging.getCachedRelationTo,
|
|
107
|
-
...(subagents
|
|
108
|
-
? {
|
|
109
|
-
listSubagents: async (scope) => (await subagents.roster.resolve(scope)).entries,
|
|
110
|
-
getParentSessionId: () => subagents.getParentSessionId(),
|
|
111
|
-
}
|
|
76
|
+
const directoryTools = messaging
|
|
77
|
+
? installDirectoryTools(pi, {
|
|
78
|
+
peers: createPeerDirectory(messaging, {
|
|
79
|
+
indexPath: index.path,
|
|
80
|
+
listSessions: () => messaging.listSessions(),
|
|
81
|
+
getRelationTo: messaging.getCachedRelationTo,
|
|
82
|
+
getParentSessionId: () => subagents?.getParentSessionId(),
|
|
83
|
+
}),
|
|
84
|
+
...(subagents && messageRouter
|
|
85
|
+
? { subagents: createSubagentDirectory(subagents, messageRouter, messaging, index.path) }
|
|
112
86
|
: {}),
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
registerMessagingTools();
|
|
87
|
+
claimsPeers: () => hosts.claimsPeers(),
|
|
88
|
+
})
|
|
89
|
+
: undefined;
|
|
117
90
|
if (settings.features.handoff) {
|
|
118
91
|
const board = {
|
|
92
|
+
showUserSessions: () => !hosts.claimsPeers(),
|
|
119
93
|
roster: subagents?.roster,
|
|
120
94
|
cancelSubagent: subagents
|
|
121
95
|
? (sessionId: string) => subagents.cancelSession(sessionId)
|
|
@@ -128,7 +102,7 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
128
102
|
settings,
|
|
129
103
|
index,
|
|
130
104
|
getModelRuntime,
|
|
131
|
-
|
|
105
|
+
claimsPeers: () => hosts.claimsPeers(),
|
|
132
106
|
...(subagents ? { getLaunchTargets: () => subagents.getLaunchTargets() } : {}),
|
|
133
107
|
board,
|
|
134
108
|
}),
|
|
@@ -167,7 +141,7 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
167
141
|
for (const lifecycle of lifecycles) {
|
|
168
142
|
await lifecycle.onSessionStart?.(event, ctx);
|
|
169
143
|
}
|
|
170
|
-
|
|
144
|
+
directoryTools?.refresh();
|
|
171
145
|
for (const lifecycle of lifecycles) {
|
|
172
146
|
lifecycle.onSessionReady?.(ctx);
|
|
173
147
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { formatError } from "../shared/errors.ts";
|
|
7
|
+
import { THINKING_LEVELS } from "../shared/thinking-levels.ts";
|
|
8
|
+
import { parseTypeBoxValue } from "../shared/typebox.ts";
|
|
9
|
+
|
|
10
|
+
// Claude Code's subagent file format, so one file can serve several harnesses.
|
|
11
|
+
// Keys other harnesses define, such as Claude Code's `color`, are ignored.
|
|
12
|
+
const AGENT_PROFILE_FRONTMATTER_SCHEMA = Type.Object({
|
|
13
|
+
name: Type.String({ minLength: 1 }),
|
|
14
|
+
description: Type.String({ minLength: 1 }),
|
|
15
|
+
model: Type.Optional(Type.String({ minLength: 1 })),
|
|
16
|
+
thinkingLevel: Type.Optional(Type.Union(THINKING_LEVELS.map((level) => Type.Literal(level)))),
|
|
17
|
+
tools: Type.Optional(Type.String({ minLength: 1 })),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export interface AgentProfile {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
/** A Pi model pattern; a `:<thinking>` suffix travels with the model it names. */
|
|
24
|
+
model: string | undefined;
|
|
25
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
26
|
+
/** An allowlist for the child's `--tools`, or undefined to inherit every tool. */
|
|
27
|
+
tools: readonly string[] | undefined;
|
|
28
|
+
instructions: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AgentProfileCatalog {
|
|
32
|
+
profiles: readonly AgentProfile[];
|
|
33
|
+
/** Validation errors by file name, so a launch naming a broken profile reports why. */
|
|
34
|
+
invalid: ReadonlyMap<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const EMPTY_AGENT_PROFILE_CATALOG: AgentProfileCatalog = {
|
|
38
|
+
profiles: [],
|
|
39
|
+
invalid: new Map(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function loadAgentProfiles(dir: string): AgentProfileCatalog {
|
|
43
|
+
let fileNames: string[];
|
|
44
|
+
try {
|
|
45
|
+
fileNames = readdirSync(dir)
|
|
46
|
+
.filter((fileName) => fileName.endsWith(".md"))
|
|
47
|
+
.sort();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
50
|
+
return EMPTY_AGENT_PROFILE_CATALOG;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const profiles: AgentProfile[] = [];
|
|
56
|
+
const invalid = new Map<string, string>();
|
|
57
|
+
for (const fileName of fileNames) {
|
|
58
|
+
const path = join(dir, fileName);
|
|
59
|
+
try {
|
|
60
|
+
profiles.push(parseAgentProfile(path, readFileSync(path, "utf8")));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
invalid.set(basename(fileName, ".md"), `${path}: ${formatError(error)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { profiles, invalid };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseAgentProfile(path: string, content: string): AgentProfile {
|
|
69
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
70
|
+
const fields = parseTypeBoxValue(AGENT_PROFILE_FRONTMATTER_SCHEMA, frontmatter, "frontmatter");
|
|
71
|
+
const fileName = basename(path, ".md");
|
|
72
|
+
if (fields.name !== fileName) {
|
|
73
|
+
throw new Error(`name "${fields.name}" must match the file name "${fileName}".`);
|
|
74
|
+
}
|
|
75
|
+
const instructions = body.trim();
|
|
76
|
+
if (!instructions) {
|
|
77
|
+
throw new Error("the body must hold the profile's instructions.");
|
|
78
|
+
}
|
|
79
|
+
const tools = fields.tools
|
|
80
|
+
?.split(",")
|
|
81
|
+
.map((tool) => tool.trim())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
if (tools?.length === 0) {
|
|
84
|
+
throw new Error("tools must name at least one tool.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
name: fields.name,
|
|
89
|
+
description: fields.description.replace(/\s+/g, " ").trim(),
|
|
90
|
+
model: fields.model?.trim(),
|
|
91
|
+
thinkingLevel: fields.thinkingLevel,
|
|
92
|
+
tools,
|
|
93
|
+
instructions,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function requireAgentProfile(catalog: AgentProfileCatalog, name: string): AgentProfile {
|
|
98
|
+
const profile = catalog.profiles.find((candidate) => candidate.name === name);
|
|
99
|
+
if (profile) {
|
|
100
|
+
return profile;
|
|
101
|
+
}
|
|
102
|
+
const error = catalog.invalid.get(name);
|
|
103
|
+
if (error) {
|
|
104
|
+
throw new Error(`Agent profile "${name}" is invalid: ${error}`);
|
|
105
|
+
}
|
|
106
|
+
const available = catalog.profiles.map((candidate) => candidate.name).join(", ");
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Unknown agent profile "${name}". Available agent profiles: ${available || "none"}.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -5,6 +5,7 @@ import { HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE } from "./metadata.ts";
|
|
|
5
5
|
import { parseHandoffLaunchReceiptEntry } from "./receipt.ts";
|
|
6
6
|
|
|
7
7
|
export interface HandoffBoardServices {
|
|
8
|
+
showUserSessions: () => boolean;
|
|
8
9
|
roster?: SubagentRoster | undefined;
|
|
9
10
|
cancelSubagent?: ((sessionId: string) => Promise<unknown>) | undefined;
|
|
10
11
|
listLiveSessions?: (() => Promise<string[]>) | undefined;
|
|
@@ -15,7 +16,8 @@ export async function loadHandoffBoardSnapshot(
|
|
|
15
16
|
entries: readonly SessionEntry[],
|
|
16
17
|
services: HandoffBoardServices,
|
|
17
18
|
): Promise<HandoffBoardSnapshot> {
|
|
18
|
-
const
|
|
19
|
+
const showUserSessions = services.showUserSessions();
|
|
20
|
+
const userSessions = showUserSessions ? collectUserSessions(entries) : [];
|
|
19
21
|
const [branchRoster, liveSessionIds, hydratedUserSessions] = await Promise.all([
|
|
20
22
|
services.roster?.resolve("branch") ?? Promise.resolve({ entries: [], total: 0 }),
|
|
21
23
|
services.listLiveSessions?.() ?? Promise.resolve([]),
|
|
@@ -29,6 +31,7 @@ export async function loadHandoffBoardSnapshot(
|
|
|
29
31
|
),
|
|
30
32
|
]);
|
|
31
33
|
return {
|
|
34
|
+
showUserSessions,
|
|
32
35
|
subagents: branchRoster.entries,
|
|
33
36
|
userSessions: hydratedUserSessions,
|
|
34
37
|
liveSessionIds: new Set(liveSessionIds),
|
|
@@ -278,6 +278,8 @@ export class HandoffBoard implements Focusable {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
private renderTabs(row: number, width: number): string {
|
|
281
|
+
if (!this.snapshot.showUserSessions)
|
|
282
|
+
return this.theme.fg("accent", this.theme.bold("Subagents"));
|
|
281
283
|
this.controlHits.set(
|
|
282
284
|
row,
|
|
283
285
|
[
|
|
@@ -493,7 +495,7 @@ export class HandoffBoard implements Focusable {
|
|
|
493
495
|
|
|
494
496
|
const action = view.action;
|
|
495
497
|
const items: LegendItem[] = [
|
|
496
|
-
{ key: "<>", description: "tab" },
|
|
498
|
+
...(!this.snapshot.showUserSessions ? [] : [{ key: "<>", description: "tab" }]),
|
|
497
499
|
{ key: "↑↓", description: "select" },
|
|
498
500
|
];
|
|
499
501
|
if (action?.canStop) {
|
|
@@ -557,6 +559,7 @@ export class HandoffBoard implements Focusable {
|
|
|
557
559
|
}
|
|
558
560
|
|
|
559
561
|
private setTab(tab: HandoffBoardTab): void {
|
|
562
|
+
if (tab === "user-sessions" && !this.snapshot.showUserSessions) return;
|
|
560
563
|
this.tab = tab;
|
|
561
564
|
this.clearTransientState();
|
|
562
565
|
this.clampSelection();
|