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.
Files changed (39) hide show
  1. package/README.md +64 -39
  2. package/extensions/hosts/contract.ts +10 -46
  3. package/extensions/hosts/registry.ts +4 -78
  4. package/extensions/pi-sessions.ts +27 -53
  5. package/extensions/session-handoff/agent-profiles.ts +110 -0
  6. package/extensions/session-handoff/board-loading.ts +4 -1
  7. package/extensions/session-handoff/board-view-model.ts +1 -0
  8. package/extensions/session-handoff/board.ts +4 -1
  9. package/extensions/session-handoff/install.ts +78 -34
  10. package/extensions/session-handoff/launch/backend.ts +16 -4
  11. package/extensions/session-handoff/launch-options.ts +2 -17
  12. package/extensions/session-handoff/launch-target.ts +8 -10
  13. package/extensions/session-handoff/metadata.ts +10 -0
  14. package/extensions/session-handoff/model.ts +22 -0
  15. package/extensions/session-handoff/receipt.ts +1 -1
  16. package/extensions/session-handoff/spawn.ts +6 -0
  17. package/extensions/session-handoff/tool-presenter.ts +1 -1
  18. package/extensions/session-handoff/tool-schema.ts +14 -9
  19. package/extensions/session-handoff/tool-view-model.ts +6 -2
  20. package/extensions/session-handoff/tool.ts +20 -6
  21. package/extensions/session-messaging/directory-list.ts +115 -0
  22. package/extensions/session-messaging/directory.ts +114 -0
  23. package/extensions/session-messaging/message-router.ts +3 -8
  24. package/extensions/session-messaging/pi/directory-tools.ts +41 -0
  25. package/extensions/session-messaging/pi/install.ts +102 -0
  26. package/extensions/session-messaging/pi/message-contracts.ts +4 -6
  27. package/extensions/session-messaging/pi/reachable-contract.ts +3 -4
  28. package/extensions/session-messaging/pi/reachable-tool.ts +25 -175
  29. package/extensions/session-messaging/pi/send-message-presenter.ts +2 -1
  30. package/extensions/session-messaging/pi/tools.ts +49 -91
  31. package/extensions/session-search/extract.ts +12 -2
  32. package/extensions/shared/lineage.ts +8 -0
  33. package/extensions/shared/session-index/sync.ts +3 -0
  34. package/extensions/shared/settings.ts +21 -5
  35. package/extensions/subagents/install.ts +11 -31
  36. package/extensions/subagents/launch-target.ts +2 -1
  37. package/extensions/subagents/reconcile.ts +10 -2
  38. package/extensions/subagents/settle.ts +2 -0
  39. 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 and manage focused child sessions |
72
- | Session Messaging | `session_reachable`, `session_send_message`, `session_cancel` pi tools | Coordinate between live Pi sessions and own subagents |
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 lets the agent create a child session with a self-contained task. Ask for a direction when you want a visible split, ask for a deferred handoff when you only want the prepared session, or let the agent delegate suitable independent work to a background subagent.
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 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`.
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
- Agents can coordinate with live Pi sessions and their own subagents:
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
- - `session_reachable` lists the sessions this session can send messages to
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
- 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.
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
- ## Host extension API (v1)
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
- 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.
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:v1", (request) => {
146
+ pi.events.on("pi-sessions:hosts", (request) => {
136
147
  const { register } = request as HostRegistrationRequest;
137
- register({ name: "swb", launch, listSessions, wake });
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
- launch(input: HostLaunchInput): Promise<HostLaunchResult>;
151
- listSessions?(): Promise<HostSession[]>;
152
- wake?(sessionId: string): Promise<void>;
161
+ peers: true;
153
162
  }
163
+ ```
154
164
 
155
- interface HostLaunchInput {
156
- sessionId: string;
157
- sessionFile: string;
158
- cwd: string;
159
- title: string;
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
- type HostLaunchResult =
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
- interface HostSession {
169
- sessionId: string;
170
- cwd: string;
171
- title: string;
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
- - **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.
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, along with `sessions.subagents.enable`, to leave only host and split launches.
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: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
-
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
- 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
- );
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
- 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);
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, type SessionWaker } from "./session-messaging/message-router.ts";
14
- import { createSessionReachableTool } from "./session-messaging/pi/reachable-tool.ts";
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 wakers: SessionWaker[] = [hosts];
61
- const messageRouter = messaging
62
- ? new MessageRouter(messaging, wakers, () => sessionEpoch)
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 registerMessagingTools = (): void => {
79
- if (!messaging || !messageRouter) return;
80
- const isSubagent = subagents?.getParentSessionId() !== undefined;
81
- const hostedSessions = hosts.getHosts().some((host) => Boolean(host.wake));
82
- pi.registerTool(
83
- createSessionSendMessageTool(messageRouter, {
84
- role: isSubagent
85
- ? { kind: "subagent" }
86
- : subagents
87
- ? { kind: "wakeCapable" }
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
- getHosts: () => hosts.getHosts(),
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
- registerMessagingTools();
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 userSessions = collectUserSessions(entries);
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),
@@ -20,6 +20,7 @@ export interface UserSessionEntry {
20
20
  }
21
21
 
22
22
  export interface HandoffBoardSnapshot {
23
+ showUserSessions: boolean;
23
24
  subagents: readonly SubagentRosterEntry[];
24
25
  userSessions: readonly UserSessionEntry[];
25
26
  liveSessionIds: ReadonlySet<string>;
@@ -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();