pi-sessions 0.14.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +63 -9
  2. package/extensions/hosts/contract.ts +51 -0
  3. package/extensions/hosts/registry.ts +129 -0
  4. package/extensions/pi-sessions.ts +36 -10
  5. package/extensions/session-handoff/install.ts +4 -1
  6. package/extensions/session-handoff/launch/backend.ts +3 -10
  7. package/extensions/session-handoff/launch-options.ts +17 -2
  8. package/extensions/session-handoff/launch-target.ts +28 -16
  9. package/extensions/session-handoff/metadata.ts +2 -0
  10. package/extensions/session-handoff/query.ts +5 -4
  11. package/extensions/session-handoff/spawn.ts +2 -1
  12. package/extensions/session-handoff/tool-schema.ts +10 -2
  13. package/extensions/session-handoff/tool.ts +1 -0
  14. package/extensions/session-hooks/install.ts +14 -5
  15. package/extensions/session-index/install.ts +25 -4
  16. package/extensions/session-index/panel.ts +9 -0
  17. package/extensions/session-messaging/message-router.ts +91 -0
  18. package/extensions/session-messaging/pi/reachable-contract.ts +2 -1
  19. package/extensions/session-messaging/pi/reachable-tool.ts +24 -4
  20. package/extensions/session-messaging/pi/tools.ts +9 -1
  21. package/extensions/shared/session-index/access.ts +42 -5
  22. package/extensions/shared/session-index/common.ts +1 -0
  23. package/extensions/shared/session-index/recovery.ts +270 -0
  24. package/extensions/shared/session-index/schema.ts +9 -1
  25. package/extensions/shared/session-index/sqlite.ts +20 -10
  26. package/extensions/{session-search/hooks.ts → shared/session-index/sync.ts} +40 -16
  27. package/extensions/subagents/install.ts +13 -13
  28. package/extensions/subagents/settle.ts +1 -1
  29. package/extensions/subagents/wake.ts +32 -116
  30. package/package.json +5 -5
  31. package/extensions/session-search/reindex.ts +0 -97
package/README.md CHANGED
@@ -51,9 +51,8 @@ pi -e /absolute/path/to/pi-sessions
51
51
  ## Quick start
52
52
 
53
53
  1. Install the package.
54
- 2. Open Pi and run `/session-index`.
55
- 3. Press `r` to build the index for all your prior sessions.
56
- 4. Try the main flows:
54
+ 2. Open Pi. Your prior sessions are indexed automatically in the background.
55
+ 3. Try the main flows:
57
56
 
58
57
  ```text
59
58
  What session did I implement the db layer?
@@ -75,7 +74,7 @@ Open the frontend implementation task in a session to the right.
75
74
  | Session Index | `/session-index` slash command | Shows index status and rebuilds the local session index |
76
75
  | Session Auto Title | in background, `/title` slash command | Give sessions titles |
77
76
 
78
- Every feature is on by default. Turn one off with `enable: false` under its own settings namespace, which unregisters its tools and hooks.
77
+ Every feature is on by default. Turn one off with `enable: false` under its own settings namespace. Index recovery and startup/switch sync always run, including when search is disabled. `hooks.enable` controls turn, tree, and compaction sync, not startup recovery.
79
78
 
80
79
  ```json
81
80
  {
@@ -110,6 +109,8 @@ The `session_handoff` tool lets the agent create a child session with a self-con
110
109
 
111
110
  Directional launches use tmux when the current terminal is inside tmux, or Ghostty on macOS. Deferred launches create the child without starting it and copy its resume command to the clipboard. A background subagent runs in a detached tmux window and reports back when finished; note that subagents are started with `--approve`, meaning pi will always start in the directory as trusted.
112
111
 
112
+ When an external host registers, its name replaces all directional launch values, even inside tmux or Ghostty. Deferred and subagent launches remain available. External-host children start automatically without draft review; their resume commands also carry `--approve`.
113
+
113
114
  Run `/handoff` to open the **Handoffs** board. The Subagents and User sessions tabs show status, age, launch details, and the actions currently available: stop, copy an observation command, or copy a resume command.
114
115
 
115
116
  ## Session Messaging
@@ -120,7 +121,61 @@ Agents can coordinate with live Pi sessions and their own subagents:
120
121
  - `session_send_message` sends a message to a live session or own subagent
121
122
  - `session_cancel` aborts another live session's current turn
122
123
 
123
- Incoming messages start the recipient agent when idle and steer it when already running. Messaging a dormant owned subagent resumes it automatically; other inactive sessions cannot receive messages, but you can still use `session_search` and `session_ask` with them.
124
+ Incoming messages start the recipient agent when idle and steer it when already running. Messaging a dormant owned subagent or a dormant session listed by a wake-capable host resumes it automatically. Hosted sessions appear in `session_reachable` with a `host` field and a broker-derived `live`, `starting`, or `dormant` state. Other inactive sessions cannot receive messages, but you can still use `session_search` and `session_ask` with them.
125
+
126
+ ## Host extension API (v1)
127
+
128
+ A host controls where user-facing handoff children run. Built-in hosts launch tmux splits, Ghostty splits, or deferred resume commands. External extensions register over `pi.events`; neither extension imports the other.
129
+
130
+ Install a listener during extension initialization, **not** in your `session_start` handler:
131
+
132
+ ```ts
133
+ pi.events.on("pi-sessions:hosts:v1", (request) => {
134
+ const { register } = request as HostRegistrationRequest;
135
+ register({ name: "swb", launch, listSessions, wake });
136
+ });
137
+ ```
138
+
139
+ The structural types below are the public contract; copy them into your extension or define compatible types locally:
140
+
141
+ ```ts
142
+ interface HostRegistrationRequest {
143
+ register(host: Host): void;
144
+ }
145
+
146
+ interface Host {
147
+ name: string;
148
+ launch(input: HostLaunchInput): Promise<HostLaunchResult>;
149
+ listSessions?(): Promise<HostSession[]>;
150
+ wake?(sessionId: string): Promise<void>;
151
+ }
152
+
153
+ interface HostLaunchInput {
154
+ sessionId: string;
155
+ sessionFile: string;
156
+ cwd: string;
157
+ title: string;
158
+ model: string;
159
+ resumeCommand: string;
160
+ }
161
+
162
+ type HostLaunchResult =
163
+ | { success: true; clipboardStatus?: "copied" | "failed" }
164
+ | { success: false; error: string };
165
+
166
+ interface HostSession {
167
+ sessionId: string;
168
+ cwd: string;
169
+ title: string;
170
+ }
171
+ ```
172
+
173
+ - **Registration:** pi-sessions emits `pi-sessions:hosts:v1` at each `session_start`, including reloads and session switches. Call `register` synchronously, before returning or awaiting anything. Registration freezes when `emit` returns; late calls throw. Names must match `[a-z][a-z0-9-]*`, be unique, and cannot be `left`, `right`, `up`, `down`, `deferred`, `subagent`, `tmux`, or `ghostty`. Registration and callback results are validated with TypeBox. Multiple external hosts are offered in registration order; any external host suppresses all split targets. Without one, precedence remains tmux, then Ghostty, then deferred. Subagent availability still depends on tmux installation and delegation depth.
174
+ - **Launch:** the child file, lineage, title, and automatic bootstrap are durable before `launch` runs. External-host children always use Pi's default session directory for `cwd`, even when the parent uses a custom directory. Start interactive Pi in that cwd with `--session-id`, `--approve`, and `--model` using the supplied `model` (including its thinking suffix, such as `provider/model:high`), or execute `resumeCommand`. Preserve the same Pi agent directory and load pi-sessions in the child. Do not inject a second initial prompt. Return `{ success: true }` after starting the process; return `{ success: false, error }` or throw on failure. The tool surfaces the surviving child's resume command on failure. `clipboardStatus` is used by the deferred host; external hosts normally omit it.
175
+ - **Discovery:** `listSessions()` returns all open user-facing sessions this host can wake, whether running or dormant. Exclude closed sessions and subagents. It is called on demand by `session_reachable` and dormant-message routing, not polled in the background. Return unique session IDs; competing host ownership is an error. Hosts without both `listSessions` and `wake` do not contribute dormant reachability. `wake` without `listSessions` is invalid.
176
+ - **Wake:** `wake(sessionId)` starts or resumes an owned session and resolves with no value; throw on failure. It must be idempotent across callers because different Pi processes can wake the same target concurrently. Within one sender, concurrent wakes are coalesced. After it resolves, pi-sessions waits **30 seconds** for broker registration before delivering. It retries wake/delivery once if the target disconnects before acceptance, but does not restart a host session on registration timeout. The broker alone supplies liveness; do not wait for broker readiness inside `wake`. Host callbacks have no imposed timeout: bound your own I/O. Built-in split launch commands use a 15-second timeout; subagent stale-window recovery retains two 30-second registration waits.
177
+
178
+ Subagents are not hosts. They retain parent-owned tmux windows, automatic approval, their own ledger, and their own wake/recovery policy. `/handoff` remains a receipt board; it accepts external-host launch receipts but does not provide a separate launch picker or wake action.
124
179
 
125
180
  ## Session picker
126
181
 
@@ -174,12 +229,11 @@ Subagents require the handoff and messaging features. Limit recursive delegation
174
229
 
175
230
  ## Session Index
176
231
 
177
- By default, `pi-sessions` will start indexing all conversations moving forward. If you want to backfill all prior conversations:
232
+ The index is a shared cache of your transcripts, used by search, messaging, and the other session features. Pi checks transcripts at startup and syncs files that changed, including sessions run without pi-sessions loaded. Missing, older-schema, or unreadable indexes rebuild automatically in the background; unreadable databases are moved aside rather than overwritten. A malformed transcript is skipped and reported.
178
233
 
179
- - run `/session-index`
180
- - hit `r` to (re)index everything
234
+ During recovery, a tool may say "Session indexing in progress; try again shortly." Only one process rebuilds at a time, and readers retain the old index until its replacement is ready. Failed attempts share a backoff of 1 minute, then 5 minutes, then 30 minutes. `/session-index` shows status; press `r` to rebuild now, ignoring the backoff.
181
235
 
182
- this is idempotent, so if you run into any issues, or disable pi-sessions for a while, feel free to re-index to see if that resolves anything.
236
+ If another Pi writes a newer index schema, the older process stops using the index and displays "pi-sessions was updated; /reload to use it". Reload instead of rebuilding: older code cannot replace a newer schema.
183
237
 
184
238
  By default the index lives at:
185
239
 
@@ -0,0 +1,51 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ export const HOSTS_CHANNEL = "pi-sessions:hosts:v1";
4
+
5
+ export const HOST_LAUNCH_INPUT_SCHEMA = Type.Object({
6
+ sessionId: Type.String({ minLength: 1 }),
7
+ sessionFile: Type.String({ minLength: 1 }),
8
+ cwd: Type.String({ minLength: 1 }),
9
+ title: Type.String(),
10
+ model: Type.String({ minLength: 1 }),
11
+ resumeCommand: Type.String({ minLength: 1 }),
12
+ });
13
+
14
+ export const HOST_LAUNCH_RESULT_SCHEMA = Type.Union([
15
+ Type.Object({
16
+ success: Type.Literal(true),
17
+ clipboardStatus: Type.Optional(Type.Union([Type.Literal("copied"), Type.Literal("failed")])),
18
+ }),
19
+ Type.Object({ success: Type.Literal(false), error: Type.String() }),
20
+ ]);
21
+
22
+ export const HOST_SESSION_SCHEMA = Type.Object({
23
+ sessionId: Type.String({ minLength: 1 }),
24
+ cwd: Type.String({ minLength: 1 }),
25
+ title: Type.String(),
26
+ });
27
+
28
+ export const HOST_SCHEMA = Type.Object({
29
+ name: Type.String({ pattern: "^[a-z][a-z0-9-]*$" }),
30
+ launch: Type.Function([HOST_LAUNCH_INPUT_SCHEMA], Type.Unknown()),
31
+ listSessions: Type.Optional(Type.Function([], Type.Unknown())),
32
+ wake: Type.Optional(Type.Function([Type.String()], Type.Unknown())),
33
+ });
34
+
35
+ export type HostLaunchInput = Static<typeof HOST_LAUNCH_INPUT_SCHEMA>;
36
+ export type HostLaunchResult = Static<typeof HOST_LAUNCH_RESULT_SCHEMA>;
37
+ export type HostSession = Static<typeof HOST_SESSION_SCHEMA>;
38
+ export interface Host {
39
+ name: string;
40
+ launch(input: HostLaunchInput): Promise<HostLaunchResult>;
41
+ listSessions?(): Promise<HostSession[]>;
42
+ wake?(sessionId: string): Promise<void>;
43
+ }
44
+
45
+ export interface HostedSession extends HostSession {
46
+ host: string;
47
+ }
48
+
49
+ export interface HostRegistrationRequest {
50
+ register(host: Host): void;
51
+ }
@@ -0,0 +1,129 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { parseTypeBoxValue } from "../shared/typebox.ts";
4
+ import {
5
+ HOST_LAUNCH_RESULT_SCHEMA,
6
+ HOST_SCHEMA,
7
+ HOST_SESSION_SCHEMA,
8
+ HOSTS_CHANNEL,
9
+ type Host,
10
+ type HostedSession,
11
+ type HostLaunchInput,
12
+ } from "./contract.ts";
13
+
14
+ const RESERVED_NAMES = new Set([
15
+ "left",
16
+ "right",
17
+ "up",
18
+ "down",
19
+ "deferred",
20
+ "subagent",
21
+ "tmux",
22
+ "ghostty",
23
+ ]);
24
+
25
+ export class HostRegistry {
26
+ private hosts: readonly Host[] = [];
27
+
28
+ discover(events: ExtensionAPI["events"]): void {
29
+ const hosts: Host[] = [];
30
+ let frozen = false;
31
+ let registrationError: unknown;
32
+ this.hosts = [];
33
+ try {
34
+ events.emit(HOSTS_CHANNEL, {
35
+ register(value: unknown) {
36
+ if (frozen) throw new Error("Host registration is closed.");
37
+ try {
38
+ const host = parseTypeBoxValue(HOST_SCHEMA, value, "Invalid host");
39
+ if (RESERVED_NAMES.has(host.name) || hosts.some((entry) => entry.name === host.name)) {
40
+ throw new Error(`Host name is reserved or already registered: ${host.name}`);
41
+ }
42
+ if (host.wake && !host.listSessions) {
43
+ throw new Error(`Host ${host.name}: wake requires listSessions.`);
44
+ }
45
+ const name = host.name;
46
+ const launch = host.launch.bind(host);
47
+ const listSessions = host.listSessions?.bind(host);
48
+ const wake = host.wake?.bind(host);
49
+ hosts.push(
50
+ Object.freeze({
51
+ name,
52
+ async launch(input: HostLaunchInput) {
53
+ return parseTypeBoxValue(
54
+ HOST_LAUNCH_RESULT_SCHEMA,
55
+ await launch(input),
56
+ `Host ${name} launch result`,
57
+ );
58
+ },
59
+ ...(listSessions
60
+ ? {
61
+ listSessions: async () =>
62
+ parseTypeBoxValue(
63
+ Type.Array(HOST_SESSION_SCHEMA),
64
+ await listSessions(),
65
+ `Host ${name} sessions`,
66
+ ),
67
+ }
68
+ : {}),
69
+ ...(wake
70
+ ? {
71
+ async wake(target: string) {
72
+ parseTypeBoxValue(
73
+ Type.Void(),
74
+ await wake(target),
75
+ `Host ${name} wake result`,
76
+ );
77
+ },
78
+ }
79
+ : {}),
80
+ }),
81
+ );
82
+ } catch (error) {
83
+ registrationError = error;
84
+ throw error;
85
+ }
86
+ },
87
+ });
88
+ } finally {
89
+ frozen = true;
90
+ }
91
+ // Pi logs listener errors instead of propagating them through emit.
92
+ if (registrationError) throw registrationError;
93
+ this.hosts = Object.freeze(hosts);
94
+ }
95
+
96
+ getHosts(): readonly Host[] {
97
+ return this.hosts;
98
+ }
99
+
100
+ async listSessions(): Promise<HostedSession[]> {
101
+ const sessions = await Promise.all(
102
+ this.hosts.map(async (host) => {
103
+ if (!host.wake || !host.listSessions) return [];
104
+ return (await host.listSessions()).map((session) => ({ ...session, host: host.name }));
105
+ }),
106
+ );
107
+ const result = sessions.flat();
108
+ const ids = new Set<string>();
109
+ for (const session of result) {
110
+ if (ids.has(session.sessionId))
111
+ throw new Error(`Multiple hosts claim session ${session.sessionId}.`);
112
+ ids.add(session.sessionId);
113
+ }
114
+ return result;
115
+ }
116
+
117
+ async owns(target: string): Promise<boolean> {
118
+ return (await this.listSessions()).some((session) => session.sessionId === target);
119
+ }
120
+
121
+ async wake(target: string): Promise<void> {
122
+ const hosts = this.hosts;
123
+ const session = (await this.listSessions()).find((session) => session.sessionId === target);
124
+ if (this.hosts !== hosts) throw new Error("The host registry changed while waking a session.");
125
+ const host = hosts.find((host) => host.name === session?.host);
126
+ if (!host?.wake) throw new Error(`No host can wake session ${target}.`);
127
+ await host.wake(target);
128
+ }
129
+ }
@@ -3,12 +3,14 @@ import {
3
3
  type ModelRuntime,
4
4
  SessionManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { HostRegistry } from "./hosts/registry.ts";
6
7
  import { installAsk } from "./session-ask/install.ts";
7
8
  import { installAutoTitle } from "./session-auto-title/install.ts";
8
9
  import { installHandoff } from "./session-handoff/install.ts";
9
10
  import { installHooks } from "./session-hooks/install.ts";
10
11
  import { installIndex } from "./session-index/install.ts";
11
12
  import { installMessaging } from "./session-messaging/install.ts";
13
+ import { MessageRouter, type SessionWaker } from "./session-messaging/message-router.ts";
12
14
  import { createSessionReachableTool } from "./session-messaging/pi/reachable-tool.ts";
13
15
  import {
14
16
  createSessionCancelTool,
@@ -30,6 +32,7 @@ import { installSubagents } from "./subagents/install.ts";
30
32
  export default function piSessions(pi: ExtensionAPI): void {
31
33
  const settings = loadSettings();
32
34
  let sessionEpoch = 0;
35
+ const hosts = new HostRegistry();
33
36
 
34
37
  // The mirrored ModelRuntime is expensive to build and only changes across session
35
38
  // boundaries, so cache it per epoch (the root's own invalidation signal) instead of
@@ -54,31 +57,52 @@ export default function piSessions(pi: ExtensionAPI): void {
54
57
  if (messaging) {
55
58
  lifecycles.push(messaging);
56
59
  }
60
+ const wakers: SessionWaker[] = [hosts];
61
+ const messageRouter = messaging
62
+ ? new MessageRouter(messaging, wakers, () => sessionEpoch)
63
+ : undefined;
57
64
  const subagents =
58
- settings.features.subagents && messaging
59
- ? installSubagents(pi, { settings, messaging, readCompactionSettings })
65
+ settings.features.subagents && messaging && messageRouter
66
+ ? installSubagents(pi, {
67
+ settings,
68
+ messaging,
69
+ readCompactionSettings,
70
+ sendMessage: (request) => messageRouter.sendMessage(request),
71
+ hasHostedSessions: () => hosts.getHosts().some((host) => Boolean(host.wake)),
72
+ })
60
73
  : undefined;
61
74
  if (subagents) {
75
+ wakers.unshift(subagents.waker);
62
76
  lifecycles.push(subagents);
63
77
  }
64
- if (messaging) {
78
+ const registerMessagingTools = (): void => {
79
+ if (!messaging || !messageRouter) return;
80
+ const isSubagent = subagents?.getParentSessionId() !== undefined;
81
+ const hostedSessions = hosts.getHosts().some((host) => Boolean(host.wake));
65
82
  pi.registerTool(
66
- createSessionSendMessageTool(subagents ?? messaging, {
67
- role: subagents ? { kind: "wakeCapable" } : { kind: "plain" },
83
+ createSessionSendMessageTool(messageRouter, {
84
+ role: isSubagent
85
+ ? { kind: "subagent" }
86
+ : subagents
87
+ ? { kind: "wakeCapable" }
88
+ : { kind: "plain" },
89
+ hostedSessions,
68
90
  getCachedRelationTo: messaging.getCachedRelationTo,
69
91
  ...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
70
92
  }),
71
93
  );
72
94
  pi.registerTool(
73
95
  createSessionCancelTool(subagents ?? messaging, {
74
- role: { kind: "plain" },
96
+ role: isSubagent ? { kind: "subagent" } : { kind: "plain" },
75
97
  ...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
76
98
  }),
77
99
  );
78
100
  pi.registerTool(
79
101
  createSessionReachableTool({
80
102
  indexPath: index.path,
103
+ hostedSessions,
81
104
  listSessions: () => messaging.listSessions(),
105
+ listHostedSessions: () => hosts.listSessions(),
82
106
  getRelationTo: messaging.getCachedRelationTo,
83
107
  ...(subagents
84
108
  ? {
@@ -88,7 +112,8 @@ export default function piSessions(pi: ExtensionAPI): void {
88
112
  : {}),
89
113
  }),
90
114
  );
91
- }
115
+ };
116
+ registerMessagingTools();
92
117
  if (settings.features.handoff) {
93
118
  const board = {
94
119
  roster: subagents?.roster,
@@ -103,6 +128,7 @@ export default function piSessions(pi: ExtensionAPI): void {
103
128
  settings,
104
129
  index,
105
130
  getModelRuntime,
131
+ getHosts: () => hosts.getHosts(),
106
132
  ...(subagents ? { getLaunchTargets: () => subagents.getLaunchTargets() } : {}),
107
133
  board,
108
134
  }),
@@ -130,18 +156,18 @@ export default function piSessions(pi: ExtensionAPI): void {
130
156
  }),
131
157
  );
132
158
  }
133
- if (settings.features.hooks) {
134
- lifecycles.push(installHooks(pi, { index }));
135
- }
159
+ lifecycles.push(index, installHooks(pi, { index, turnSync: settings.features.hooks }));
136
160
  // Messaging leads every session_start so the broker connection is registered before any
137
161
  // other feature hook runs. A side effect is that messaging's first relation snapshot can
138
162
  // predate hooks' index sync for this session; getCachedRelationTo self-heals on miss, so
139
163
  // this ordering is intentional — do not reorder to "fix" it.
140
164
  pi.on("session_start", async (event, ctx) => {
141
165
  sessionEpoch += 1;
166
+ hosts.discover(pi.events);
142
167
  for (const lifecycle of lifecycles) {
143
168
  await lifecycle.onSessionStart?.(event, ctx);
144
169
  }
170
+ registerMessagingTools();
145
171
  for (const lifecycle of lifecycles) {
146
172
  lifecycle.onSessionReady?.(ctx);
147
173
  }
@@ -2,6 +2,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
+ import type { Host } from "../hosts/contract.ts";
5
6
  import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
6
7
  import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
7
8
  import { isTuiMode } from "../shared/pi-mode.ts";
@@ -46,6 +47,7 @@ export function installHandoff(
46
47
  index: IndexHandle;
47
48
  getModelRuntime: ModelRuntimeProvider;
48
49
  getLaunchTargets?: (() => readonly HandoffLaunchTarget[]) | undefined;
50
+ getHosts?: () => readonly Host[];
49
51
  board: HandoffBoardServices;
50
52
  },
51
53
  ): SessionLifecycle {
@@ -197,7 +199,7 @@ export function installHandoff(
197
199
  });
198
200
 
199
201
  pi.on("before_agent_start", async (event, ctx) => {
200
- if (splitBackend?.identifyTerminalId && ctx) {
202
+ if (!deps.getHosts?.().length && splitBackend?.identifyTerminalId && ctx) {
201
203
  identifiedGhosttyTerminalId =
202
204
  (await splitBackend.identifyTerminalId(ctx.cwd)) ?? identifiedGhosttyTerminalId;
203
205
  }
@@ -215,6 +217,7 @@ export function installHandoff(
215
217
  splitBackend,
216
218
  copyDeferredToClipboard: settings.handoff.deferred.copyToClipboard,
217
219
  additionalTargets: deps.getLaunchTargets?.() ?? [],
220
+ hosts: deps.getHosts?.() ?? [],
218
221
  }),
219
222
  resolveHandoffRoster({
220
223
  models,
@@ -1,16 +1,9 @@
1
- import type { HandoffLaunchTargetOutcome } from "../launch-target.ts";
1
+ import type { Host, HostLaunchInput } from "../../hosts/contract.ts";
2
2
 
3
3
  export type HandoffSplitDirection = "left" | "right" | "up" | "down";
4
4
 
5
- export interface LaunchInput {
6
- cwd: string;
7
- title: string;
8
- resumeCommand: string;
9
- }
5
+ export type LaunchInput = HostLaunchInput;
10
6
 
11
7
  export type ClipboardStatus = "copied" | "failed";
12
8
 
13
- export interface LaunchBackend {
14
- name: string;
15
- launch(input: LaunchInput): Promise<HandoffLaunchTargetOutcome>;
16
- }
9
+ export type LaunchBackend = Host;
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { Host } from "../hosts/contract.ts";
2
3
  import { createDeferredLaunchBackend } from "./launch/deferred.ts";
3
4
  import type { SplitLaunchBackend } from "./launch/resolution.ts";
4
5
  import {
@@ -19,8 +20,10 @@ export function createHandoffLaunchTargets(options: {
19
20
  splitBackend: SplitLaunchBackend | undefined;
20
21
  copyDeferredToClipboard: boolean;
21
22
  additionalTargets: readonly HandoffLaunchTarget[];
23
+ hosts?: readonly Host[];
22
24
  }): HandoffLaunchTarget[] {
23
- const splitBackend = options.splitBackend;
25
+ const hosts = options.hosts ?? [];
26
+ const splitBackend = hosts.length ? undefined : options.splitBackend;
24
27
  const splitTargets = splitBackend
25
28
  ? LAUNCH_DIRECTIONS.map((direction, index) =>
26
29
  createBackendLaunchTarget(
@@ -37,7 +40,19 @@ export function createHandoffLaunchTargets(options: {
37
40
  createDeferredLaunchBackend({ copyToClipboard: options.copyDeferredToClipboard }),
38
41
  "'deferred' creates the session and returns its resume command without opening anything.",
39
42
  );
40
- return [...splitTargets, deferred, ...options.additionalTargets];
43
+ return [
44
+ ...hosts.map((host) =>
45
+ createBackendLaunchTarget(
46
+ host.name,
47
+ host,
48
+ `'${host.name}' opens a session in ${host.name}.`,
49
+ true,
50
+ ),
51
+ ),
52
+ ...splitTargets,
53
+ deferred,
54
+ ...options.additionalTargets,
55
+ ];
41
56
  }
42
57
 
43
58
  export function resolveHandoffLaunchTarget(
@@ -1,6 +1,8 @@
1
1
  import type { SessionManager } from "@earendil-works/pi-coding-agent";
2
2
  import { type Static, Type } from "typebox";
3
- import type { ClipboardStatus, HandoffSplitDirection, LaunchBackend } from "./launch/backend.ts";
3
+ import type { Host } from "../hosts/contract.ts";
4
+ import { formatError } from "../shared/errors.ts";
5
+ import type { ClipboardStatus } from "./launch/backend.ts";
4
6
  import type { HandoffSubagent } from "./metadata.ts";
5
7
  import type { PreparedHandoff } from "./spawn.ts";
6
8
 
@@ -15,10 +17,9 @@ export const HANDOFF_DIRECTION_LAUNCH_SCHEMA = Type.Union([
15
17
  Type.Literal("down"),
16
18
  ]);
17
19
 
18
- export const HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA = Type.Union([
19
- HANDOFF_DIRECTION_LAUNCH_SCHEMA,
20
- Type.Literal(DEFERRED_LAUNCH),
21
- ]);
20
+ export const HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA = Type.String({
21
+ pattern: "^(?!subagent$)[a-z][a-z0-9-]*$",
22
+ });
22
23
 
23
24
  export const HANDOFF_LAUNCH_VALUE_SCHEMA = Type.Union([
24
25
  HANDOFF_NON_SUBAGENT_LAUNCH_SCHEMA,
@@ -49,7 +50,7 @@ export type HandoffLaunchTargetOutcome =
49
50
  | {
50
51
  success: true;
51
52
  backend: string;
52
- clipboardStatus?: ClipboardStatus | undefined;
53
+ clipboardStatus?: ClipboardStatus;
53
54
  }
54
55
  | { success: false; error: string };
55
56
 
@@ -61,6 +62,7 @@ export interface HandoffLaunchTarget {
61
62
  bootstrapMode: "review" | "automatic";
62
63
  /** Unattended children skip the startup trust prompt; watched ones let the user answer it. */
63
64
  approveProjectTrust: boolean;
65
+ useDefaultSessionDir?: boolean;
64
66
  /** Subagent launches stamp their child's identity into the child bootstrap. */
65
67
  describeSubagentChild?(input: {
66
68
  childSessionId: string;
@@ -72,23 +74,33 @@ export interface HandoffLaunchTarget {
72
74
  }
73
75
 
74
76
  export function createBackendLaunchTarget(
75
- value: HandoffSplitDirection | "deferred",
76
- backend: LaunchBackend,
77
+ value: string,
78
+ host: Host,
77
79
  description?: string | undefined,
80
+ external = false,
78
81
  ): HandoffLaunchTarget {
79
82
  return {
80
83
  value,
81
84
  ...(description ? { description } : {}),
82
85
  requestResponseDefault: false,
83
- bootstrapMode: "review",
84
- approveProjectTrust: false,
86
+ bootstrapMode: external ? "automatic" : "review",
87
+ approveProjectTrust: external,
88
+ useDefaultSessionDir: external,
85
89
  prepareChild() {},
86
- launch(input) {
87
- return backend.launch({
88
- cwd: input.cwd,
89
- title: input.title,
90
- resumeCommand: input.prepared.resumeCommand,
91
- });
90
+ async launch(input) {
91
+ try {
92
+ const result = await host.launch({
93
+ sessionId: input.prepared.sessionId,
94
+ sessionFile: input.prepared.sessionFile,
95
+ cwd: input.cwd,
96
+ title: input.title,
97
+ model: input.model,
98
+ resumeCommand: input.prepared.resumeCommand,
99
+ });
100
+ return result.success ? { ...result, backend: host.name } : result;
101
+ } catch (error) {
102
+ return { success: false, error: formatError(error) };
103
+ }
92
104
  },
93
105
  };
94
106
  }
@@ -10,6 +10,8 @@ import {
10
10
  } from "./launch-target.ts";
11
11
 
12
12
  export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
13
+ // Agent Switchboard's swb adopt (packages/cli/src/commands/adopt.ts) reads this
14
+ // customType and data.subagent; renaming either breaks its subagent check.
13
15
  export const HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap";
14
16
  export const HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-consumed";
15
17
  export const HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-failed";
@@ -1,5 +1,6 @@
1
1
  import { stripSearchSnippetMarkers } from "../shared/search-snippet.ts";
2
2
  import {
3
+ formatRequiredSessionIndexError,
3
4
  getSessionByPath,
4
5
  type SearchSessionResult,
5
6
  type SessionIndexDatabase,
@@ -100,7 +101,7 @@ export function listSessionPickerItems(
100
101
  defaultScopeLabel,
101
102
  };
102
103
  }) ?? {
103
- items: [buildIndexErrorItem()],
104
+ items: [buildIndexErrorItem(options.indexPath)],
104
105
  scopeMode,
105
106
  defaultScopeLabel,
106
107
  }
@@ -309,11 +310,11 @@ function getSessionMarker(
309
310
  }
310
311
  }
311
312
 
312
- function buildIndexErrorItem(): SessionPickerNoticeItem {
313
+ function buildIndexErrorItem(indexPath: string): SessionPickerNoticeItem {
313
314
  return {
314
315
  kind: "error",
315
- title: "Session index missing or incompatible",
316
- description: "Run /session-index to rebuild it.",
316
+ title: "Session index unavailable",
317
+ description: formatRequiredSessionIndexError(indexPath),
317
318
  };
318
319
  }
319
320
 
@@ -22,6 +22,7 @@ export function prepareHandoffLaunch(options: {
22
22
  title: string;
23
23
  model: string | undefined;
24
24
  approveProjectTrust: boolean;
25
+ useDefaultSessionDir?: boolean;
25
26
  buildBootstrap: (sessionId: string) => HandoffBootstrap;
26
27
  prepareChild?: ((manager: SessionManager, sessionId: string) => void) | undefined;
27
28
  }): PreparedHandoff {
@@ -31,7 +32,7 @@ export function prepareHandoffLaunch(options: {
31
32
  const sameCwd = options.targetCwd === options.parentCwd;
32
33
  const manager = SessionManager.create(
33
34
  options.targetCwd,
34
- sameCwd ? options.parentSessionDir : undefined,
35
+ sameCwd && !options.useDefaultSessionDir ? options.parentSessionDir : undefined,
35
36
  { parentSession: options.parentSessionFile },
36
37
  );
37
38
  manager.appendSessionInfo(options.title);