pi-sessions 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +43 -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/board-loading.ts +4 -1
  6. package/extensions/session-handoff/board-view-model.ts +1 -0
  7. package/extensions/session-handoff/board.ts +4 -1
  8. package/extensions/session-handoff/install.ts +47 -33
  9. package/extensions/session-handoff/launch/backend.ts +16 -4
  10. package/extensions/session-handoff/launch-options.ts +2 -17
  11. package/extensions/session-handoff/launch-target.ts +4 -9
  12. package/extensions/session-handoff/receipt.ts +1 -1
  13. package/extensions/session-handoff/tool-presenter.ts +1 -1
  14. package/extensions/session-handoff/tool-schema.ts +1 -9
  15. package/extensions/session-handoff/tool-view-model.ts +6 -2
  16. package/extensions/session-handoff/tool.ts +2 -2
  17. package/extensions/session-messaging/directory-list.ts +115 -0
  18. package/extensions/session-messaging/directory.ts +114 -0
  19. package/extensions/session-messaging/message-router.ts +3 -8
  20. package/extensions/session-messaging/pi/directory-tools.ts +41 -0
  21. package/extensions/session-messaging/pi/install.ts +102 -0
  22. package/extensions/session-messaging/pi/message-contracts.ts +4 -6
  23. package/extensions/session-messaging/pi/reachable-contract.ts +3 -4
  24. package/extensions/session-messaging/pi/reachable-tool.ts +25 -175
  25. package/extensions/session-messaging/pi/send-message-presenter.ts +2 -1
  26. package/extensions/session-messaging/pi/tools.ts +49 -91
  27. package/extensions/session-search/extract.ts +12 -2
  28. package/extensions/shared/lineage.ts +8 -0
  29. package/extensions/shared/session-index/sync.ts +3 -0
  30. package/extensions/subagents/install.ts +0 -27
  31. package/extensions/subagents/launch-target.ts +0 -1
  32. package/extensions/subagents/reconcile.ts +10 -2
  33. 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. 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
128
+
129
+ Subagent coordination is a separate addressable directory:
121
130
 
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
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
125
136
 
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.
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.
127
138
 
128
- ## Host extension API (v1)
139
+ ## Host extension API
129
140
 
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.
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.
163
168
 
164
- type HostLaunchResult =
165
- | { success: true; clipboardStatus?: "copied" | "failed" }
166
- | { success: false; error: string };
169
+ ## Durable lineage entry
167
170
 
168
- interface HostSession {
169
- sessionId: string;
170
- cwd: string;
171
- title: string;
171
+ Any extension may append this custom entry to a child session:
172
+
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
 
@@ -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
  }
@@ -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();