pi-sessions 0.14.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,6 +110,8 @@ The `session_handoff` tool lets the agent create a child session with a self-con
110
110
 
111
111
  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
112
 
113
+ 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`.
114
+
113
115
  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
116
 
115
117
  ## Session Messaging
@@ -120,7 +122,61 @@ Agents can coordinate with live Pi sessions and their own subagents:
120
122
  - `session_send_message` sends a message to a live session or own subagent
121
123
  - `session_cancel` aborts another live session's current turn
122
124
 
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.
125
+ 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.
126
+
127
+ ## Host extension API (v1)
128
+
129
+ 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.
130
+
131
+ Install a listener during extension initialization, **not** in your `session_start` handler:
132
+
133
+ ```ts
134
+ pi.events.on("pi-sessions:hosts:v1", (request) => {
135
+ const { register } = request as HostRegistrationRequest;
136
+ register({ name: "swb", launch, listSessions, wake });
137
+ });
138
+ ```
139
+
140
+ The structural types below are the public contract; copy them into your extension or define compatible types locally:
141
+
142
+ ```ts
143
+ interface HostRegistrationRequest {
144
+ register(host: Host): void;
145
+ }
146
+
147
+ interface Host {
148
+ name: string;
149
+ launch(input: HostLaunchInput): Promise<HostLaunchResult>;
150
+ listSessions?(): Promise<HostSession[]>;
151
+ wake?(sessionId: string): Promise<void>;
152
+ }
153
+
154
+ interface HostLaunchInput {
155
+ sessionId: string;
156
+ sessionFile: string;
157
+ cwd: string;
158
+ title: string;
159
+ model: string;
160
+ resumeCommand: string;
161
+ }
162
+
163
+ type HostLaunchResult =
164
+ | { success: true; clipboardStatus?: "copied" | "failed" }
165
+ | { success: false; error: string };
166
+
167
+ interface HostSession {
168
+ sessionId: string;
169
+ cwd: string;
170
+ title: string;
171
+ }
172
+ ```
173
+
174
+ - **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.
175
+ - **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.
176
+ - **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.
177
+ - **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.
178
+
179
+ 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
180
 
125
181
  ## Session picker
126
182
 
@@ -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
  }),
@@ -139,9 +165,11 @@ export default function piSessions(pi: ExtensionAPI): void {
139
165
  // this ordering is intentional — do not reorder to "fix" it.
140
166
  pi.on("session_start", async (event, ctx) => {
141
167
  sessionEpoch += 1;
168
+ hosts.discover(pi.events);
142
169
  for (const lifecycle of lifecycles) {
143
170
  await lifecycle.onSessionStart?.(event, ctx);
144
171
  }
172
+ registerMessagingTools();
145
173
  for (const lifecycle of lifecycles) {
146
174
  lifecycle.onSessionReady?.(ctx);
147
175
  }
@@ -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
  }
@@ -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);
@@ -1,6 +1,6 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import { Type } from "typebox";
3
- import { type HandoffLaunchTarget, SUBAGENT_LAUNCH } from "./launch-target.ts";
3
+ import { type HandoffLaunchTarget, LAUNCH_DIRECTIONS, SUBAGENT_LAUNCH } from "./launch-target.ts";
4
4
  import { formatRosterEntry, type HandoffRoster } from "./roster.ts";
5
5
 
6
6
  export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]) {
@@ -46,11 +46,19 @@ export function buildHandoffPromptGuidelines(
46
46
  roster: HandoffRoster | undefined,
47
47
  ): string[] {
48
48
  const hasSubagent = targets.some((target) => target.value === SUBAGENT_LAUNCH);
49
+ const hasHost = targets.some(
50
+ (target) =>
51
+ target.value !== "subagent" &&
52
+ target.value !== "deferred" &&
53
+ !LAUNCH_DIRECTIONS.some((direction) => direction === target.value),
54
+ );
49
55
  const selectable =
50
56
  roster?.map(formatRosterEntry) ?? models.map((model) => `${model.provider}/${model.id}`);
51
57
  return [
52
58
  ...(hasSubagent ? SUBAGENT_GUIDELINES : []),
53
- LAUNCH_TARGET_GUIDELINE,
59
+ hasHost
60
+ ? "Use session_handoff host or deferred launches only when the user requests one."
61
+ : LAUNCH_TARGET_GUIDELINE,
54
62
  ...MODEL_INHERITANCE_GUIDELINES,
55
63
  hasSubagent ? SUBAGENT_MODEL_OVERRIDE_GUIDELINE : DEFAULT_MODEL_OVERRIDE_GUIDELINE,
56
64
  ...(selectable.length > 0
@@ -108,6 +108,7 @@ export async function executeSessionHandoffTool(
108
108
  title,
109
109
  model,
110
110
  approveProjectTrust: target.approveProjectTrust,
111
+ useDefaultSessionDir: target.useDefaultSessionDir ?? false,
111
112
  buildBootstrap: (sessionId) =>
112
113
  createChildGeneratedHandoffBootstrap({
113
114
  sessionId,
@@ -0,0 +1,91 @@
1
+ import type { SendMessageRequest, SendMessageResult } from "./install.ts";
2
+
3
+ export interface SessionWaker {
4
+ owns(target: string): boolean | Promise<boolean>;
5
+ wake(target: string): Promise<void>;
6
+ restart?(target: string): Promise<void>;
7
+ afterSend?(): Promise<void> | void;
8
+ }
9
+
10
+ export interface WakeMessaging {
11
+ sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
12
+ listSessions(): Promise<string[]>;
13
+ waitForSession(sessionId: string, timeoutMs: number): Promise<boolean>;
14
+ }
15
+
16
+ export class MessageRouter {
17
+ private readonly waking = new Map<string, Promise<void>>();
18
+
19
+ constructor(
20
+ private readonly messaging: WakeMessaging,
21
+ private readonly wakers: readonly SessionWaker[],
22
+ private readonly getEpoch: () => number,
23
+ private readonly readyTimeoutMs = 30_000,
24
+ ) {}
25
+
26
+ async sendMessage(request: SendMessageRequest): Promise<SendMessageResult> {
27
+ const epoch = this.getEpoch();
28
+ let failedLiveSend: SendMessageResult | undefined;
29
+ if (await this.isLive(request.target)) {
30
+ this.requireCurrent(epoch);
31
+ const result = await this.messaging.sendMessage(request);
32
+ if (!isTargetDeparture(result)) return result;
33
+ failedLiveSend = result;
34
+ }
35
+ let owner: SessionWaker | undefined;
36
+ for (const waker of this.wakers) {
37
+ if (await waker.owns(request.target)) {
38
+ owner = waker;
39
+ break;
40
+ }
41
+ }
42
+ this.requireCurrent(epoch);
43
+ if (!owner) return failedLiveSend ?? this.messaging.sendMessage(request);
44
+
45
+ try {
46
+ for (let attempt = 0; ; attempt += 1) {
47
+ await this.wake(request.target, owner, epoch);
48
+ this.requireCurrent(epoch);
49
+ const result = await this.messaging.sendMessage(request);
50
+ if (attempt === 1 || !isTargetDeparture(result)) return result;
51
+ }
52
+ } finally {
53
+ if (epoch === this.getEpoch()) await owner.afterSend?.();
54
+ }
55
+ }
56
+
57
+ private wake(target: string, waker: SessionWaker, epoch: number): Promise<void> {
58
+ const key = `${epoch}:${target}`;
59
+ const existing = this.waking.get(key);
60
+ if (existing) return existing;
61
+ const wake = this.ensureReady(target, waker, epoch).finally(() => this.waking.delete(key));
62
+ this.waking.set(key, wake);
63
+ return wake;
64
+ }
65
+
66
+ private async ensureReady(target: string, waker: SessionWaker, epoch: number): Promise<void> {
67
+ if (await this.isLive(target)) return;
68
+ this.requireCurrent(epoch);
69
+ await waker.wake(target);
70
+ if (await this.messaging.waitForSession(target, this.readyTimeoutMs)) return;
71
+ this.requireCurrent(epoch);
72
+ if (waker.restart) {
73
+ await waker.restart(target);
74
+ if (await this.messaging.waitForSession(target, this.readyTimeoutMs)) return;
75
+ }
76
+ throw new Error(`Session ${target} did not register for messaging.`);
77
+ }
78
+
79
+ private async isLive(target: string): Promise<boolean> {
80
+ return (await this.messaging.listSessions()).includes(target);
81
+ }
82
+
83
+ private requireCurrent(epoch: number): void {
84
+ if (epoch !== this.getEpoch())
85
+ throw new Error("The current session changed while waking a session.");
86
+ }
87
+ }
88
+
89
+ function isTargetDeparture(result: SendMessageResult): boolean {
90
+ return !result.delivered && (result.reason === "no_session" || result.reason === "disconnected");
91
+ }
@@ -26,7 +26,8 @@ const REACHABLE_USER_SESSION_SCHEMA = Type.Object({
26
26
  cwd: Type.Optional(Type.String()),
27
27
  modifiedAt: Type.Optional(Type.String()),
28
28
  relation: Type.Optional(Type.String()),
29
- state: Type.Union([Type.Literal("live"), Type.Literal("starting")]),
29
+ host: Type.Optional(Type.String()),
30
+ state: Type.Union([Type.Literal("live"), Type.Literal("starting"), Type.Literal("dormant")]),
30
31
  });
31
32
 
32
33
  const REACHABLE_SUBAGENT_SCHEMA = Type.Object({
@@ -5,6 +5,7 @@ import {
5
5
  type ToolDefinition,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { Text } from "@earendil-works/pi-tui";
8
+ import type { HostedSession } from "../../hosts/contract.ts";
8
9
  import { isSessionStarting } from "../../session-handoff/metadata.ts";
9
10
  import { formatError } from "../../shared/errors.ts";
10
11
  import { ExpandableContentLayout } from "../../shared/rendering/expandable-content-layout.ts";
@@ -46,7 +47,9 @@ export interface ReachableSubagentEntry {
46
47
 
47
48
  export interface SessionReachableDeps {
48
49
  indexPath: string;
50
+ hostedSessions?: boolean;
49
51
  listSessions(): Promise<string[]>;
52
+ listHostedSessions?: () => Promise<HostedSession[]>;
50
53
  getRelationTo(sessionId: string): string | undefined;
51
54
  listSubagents?:
52
55
  | ((
@@ -75,6 +78,11 @@ export function createSessionReachableTool(deps: SessionReachableDeps): ToolDefi
75
78
  : "List addressable live sessions",
76
79
  promptGuidelines: [
77
80
  "Before session_send_message, use session_reachable to find the target session id.",
81
+ ...(deps.hostedSessions
82
+ ? [
83
+ "User sessions include dormant hosted sessions. session_send_message wakes them automatically; the host field identifies their host.",
84
+ ]
85
+ : []),
78
86
  ],
79
87
  annotations: { readOnlyHint: true, openWorldHint: false },
80
88
  parameters: subagentsAvailable ? SESSION_REACHABLE_PARAMS : SESSION_REACHABLE_USER_PARAMS,
@@ -129,7 +137,11 @@ async function listReachableUserSessions(
129
137
  }
130
138
 
131
139
  const parentSessionId = deps.getParentSessionId?.();
132
- const targetIds = liveSessionIds.filter(
140
+ const hostedSessions = new Map(
141
+ ((await deps.listHostedSessions?.()) ?? []).map((session) => [session.sessionId, session]),
142
+ );
143
+ const liveIds = new Set(liveSessionIds);
144
+ const targetIds = [...new Set([...liveSessionIds, ...hostedSessions.keys()])].filter(
133
145
  (sessionId) => sessionId !== currentSessionId && sessionId !== parentSessionId,
134
146
  );
135
147
  return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
@@ -140,12 +152,20 @@ async function listReachableUserSessions(
140
152
  continue;
141
153
  }
142
154
  const relation = deps.getRelationTo(sessionId);
155
+ const hosted = hostedSessions.get(sessionId);
156
+ const title = hosted?.title ?? row?.sessionName;
157
+ const cwd = hosted?.cwd ?? row?.cwd;
143
158
  sessions.push({
144
159
  kind: "user",
145
160
  sessionId,
146
- state: row && isStartingSession(row) ? "starting" : "live",
147
- ...(row?.sessionName ? { title: row.sessionName } : {}),
148
- ...(row?.cwd ? { cwd: row.cwd } : {}),
161
+ state: !liveIds.has(sessionId)
162
+ ? "dormant"
163
+ : row && isStartingSession(row)
164
+ ? "starting"
165
+ : "live",
166
+ ...(hosted ? { host: hosted.host } : {}),
167
+ ...(title ? { title } : {}),
168
+ ...(cwd ? { cwd } : {}),
149
169
  ...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
150
170
  ...(relation === undefined ? {} : { relation }),
151
171
  });
@@ -53,6 +53,7 @@ export type SessionSendMessageRole =
53
53
 
54
54
  export interface SessionSendMessageToolOptions {
55
55
  role: SessionSendMessageRole;
56
+ hostedSessions?: boolean;
56
57
  getCachedRelationTo(sessionId: string | undefined): string | undefined;
57
58
  /**
58
59
  * Resolved on every call: subagent identity depends on the active branch, so a rewind can
@@ -119,7 +120,14 @@ export function createSessionSendMessageTool(
119
120
  label: "Send Message to Session",
120
121
  description,
121
122
  promptSnippet,
122
- promptGuidelines,
123
+ promptGuidelines: [
124
+ ...promptGuidelines,
125
+ ...(options.hostedSessions
126
+ ? [
127
+ "session_reachable also lists dormant hosted sessions; sending a message wakes them automatically.",
128
+ ]
129
+ : []),
130
+ ],
123
131
  parameters: SEND_MESSAGE_PARAMS,
124
132
  renderCall(args, theme, context) {
125
133
  const state = context.state as SendMessageRendererState;
@@ -9,6 +9,7 @@ import type {
9
9
  SendMessageRequest,
10
10
  SendMessageResult,
11
11
  } from "../session-messaging/install.ts";
12
+ import type { SessionWaker } from "../session-messaging/message-router.ts";
12
13
  import {
13
14
  createSessionCancelTool,
14
15
  createSessionSendMessageTool,
@@ -43,7 +44,7 @@ import {
43
44
  type SubagentChildSessionState,
44
45
  } from "./settle.ts";
45
46
  import { shouldMessageSubagent } from "./should-message.ts";
46
- import { SubagentMessageRouter } from "./wake.ts";
47
+ import { SubagentWaker } from "./wake.ts";
47
48
 
48
49
  const SUBAGENT_PROMPT_SECTION = "pi_sessions_subagent";
49
50
  const SUBAGENT_PROMPT = `You are working as a subagent on one task delegated by a parent session. The handoff defines your task. Work independently, stay within its scope, and do not duplicate work assigned to the parent or another subagent. Use the available tools to complete the task and validate your conclusions. Messages from the parent may refine the task or request a follow-up, but they do not replace your original task with unrelated work.`;
@@ -65,6 +66,7 @@ interface CurrentSubagentSession {
65
66
 
66
67
  export interface SubagentsHandle extends SessionLifecycle {
67
68
  roster: SubagentRoster;
69
+ waker: SessionWaker;
68
70
  getLaunchTargets(): readonly HandoffLaunchTarget[];
69
71
  sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
70
72
  cancelSession(sessionId: string): Promise<SubagentCancelResult>;
@@ -79,6 +81,8 @@ export function installSubagents(
79
81
  settings: SessionSettings;
80
82
  messaging: MessagingHandle;
81
83
  readCompactionSettings: (cwd: string) => CompactionThresholdSettings;
84
+ sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
85
+ hasHostedSessions?: () => boolean;
82
86
  },
83
87
  ): SubagentsHandle {
84
88
  pi.registerMessageRenderer(SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE, renderSubagentReportMessage);
@@ -107,18 +111,12 @@ export function installSubagents(
107
111
  reconcile: () => reconciler.reconcile(),
108
112
  openSession: openRosterSession,
109
113
  });
110
- const messageRouter = new SubagentMessageRouter(
111
- pi,
112
- deps.messaging,
113
- () => current?.parent,
114
- isCurrentSession,
115
- {
116
- onMaterialize: (launch) => pi.appendEntry(SUBAGENT_LAUNCHED_CUSTOM_TYPE, launch),
117
- afterOwnedSend: async () => {
118
- await reconciler.reconcile();
119
- },
114
+ const waker = new SubagentWaker(pi, deps.messaging, () => current?.parent, isCurrentSession, {
115
+ onMaterialize: (launch) => pi.appendEntry(SUBAGENT_LAUNCHED_CUSTOM_TYPE, launch),
116
+ afterSend: async () => {
117
+ await reconciler.reconcile();
120
118
  },
121
- );
119
+ });
122
120
  const cancellationRouter = new SubagentCancellationRouter(
123
121
  pi,
124
122
  deps.messaging,
@@ -232,6 +230,7 @@ export function installSubagents(
232
230
  pi.registerTool(
233
231
  createSessionSendMessageTool(handle, {
234
232
  role: isSubagent ? { kind: "subagent" } : { kind: "wakeCapable" },
233
+ hostedSessions: deps.hasHostedSessions?.() ?? false,
235
234
  getCachedRelationTo: deps.messaging.getCachedRelationTo,
236
235
  getParentSessionId: () => handle.getParentSessionId(),
237
236
  }),
@@ -257,7 +256,8 @@ export function installSubagents(
257
256
 
258
257
  const handle: SubagentsHandle = {
259
258
  roster,
260
- sendMessage: (request) => messageRouter.sendMessage(request),
259
+ waker,
260
+ sendMessage: deps.sendMessage,
261
261
  cancelSession: (sessionId) => cancellationRouter.cancelSession(sessionId),
262
262
  shouldMessageSubagent: (sessionId) =>
263
263
  shouldMessageSubagent(sessionId, {
@@ -170,7 +170,7 @@ export function findSelfSubagentIdentity(
170
170
  if (
171
171
  bootstrap?.launch !== SUBAGENT_LAUNCH ||
172
172
  bootstrap.sessionId !== sessionId ||
173
- bootstrap.subagent.childSessionId !== sessionId
173
+ bootstrap.subagent?.childSessionId !== sessionId
174
174
  ) {
175
175
  return undefined;
176
176
  }
@@ -1,5 +1,5 @@
1
1
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
- import type { SendMessageRequest, SendMessageResult } from "../session-messaging/install.ts";
2
+ import type { SessionWaker, WakeMessaging } from "../session-messaging/message-router.ts";
3
3
  import {
4
4
  createTmuxWindow,
5
5
  killTmuxWindow,
@@ -9,125 +9,60 @@ import {
9
9
  } from "../shared/tmux.ts";
10
10
  import { findOwnedSubagentLaunch, type SubagentLaunched } from "./ledger.ts";
11
11
 
12
- const SESSION_READY_TIMEOUT_MS = 30_000;
13
-
14
12
  export interface WakeParentSession {
15
13
  sessionId: string;
16
14
  epoch: number;
17
15
  getBranch(): readonly SessionEntry[];
18
16
  }
19
17
 
20
- interface WakeMessaging {
21
- sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
22
- listSessions(): Promise<string[]>;
23
- waitForSession(sessionId: string, timeoutMs: number): Promise<boolean>;
24
- }
25
-
26
- export interface SubagentMessageRouterOptions {
27
- readyTimeoutMs?: number;
28
- onMaterialize?(launch: SubagentLaunched): void;
29
- afterOwnedSend?(): Promise<void> | void;
30
- }
31
-
32
- export class SubagentMessageRouter {
33
- private readonly wakeBySessionId = new Map<string, Promise<void>>();
34
-
18
+ export class SubagentWaker implements SessionWaker {
35
19
  constructor(
36
20
  private readonly executor: TmuxExecutor,
37
21
  private readonly messaging: WakeMessaging,
38
22
  private readonly getParent: () => WakeParentSession | undefined,
39
23
  private readonly isCurrent: (epoch: number) => boolean,
40
- private readonly options: SubagentMessageRouterOptions = {},
24
+ private readonly options: {
25
+ onMaterialize?(launch: SubagentLaunched): void;
26
+ afterSend?(): Promise<void> | void;
27
+ } = {},
41
28
  ) {}
42
29
 
43
- async sendMessage(request: SendMessageRequest): Promise<SendMessageResult> {
44
- let failedLiveSend: SendMessageResult | undefined;
45
- if (await this.isLive(request.target)) {
46
- const result = await this.messaging.sendMessage(request);
47
- if (result.delivered || !isTargetDeparture(result)) {
48
- return result;
49
- }
50
- failedLiveSend = result;
51
- }
52
-
30
+ owns(target: string): boolean {
53
31
  const parent = this.getParent();
54
- const launch = parent
55
- ? findOwnedSubagentLaunch(parent.getBranch(), parent.sessionId, request.target)
56
- : undefined;
57
- if (!parent || !launch) {
58
- return failedLiveSend ?? this.messaging.sendMessage(request);
59
- }
60
-
61
- try {
62
- await this.wake(parent, launch);
63
- const result = await this.sendOwned(parent, request);
64
- if (result.delivered || !isTargetDeparture(result)) {
65
- return result;
66
- }
67
-
68
- await this.wake(parent, launch);
69
- return this.sendOwned(parent, request);
70
- } finally {
71
- await this.options.afterOwnedSend?.();
72
- }
32
+ return Boolean(parent && findOwnedSubagentLaunch(parent.getBranch(), parent.sessionId, target));
73
33
  }
74
34
 
75
- private async wake(parent: WakeParentSession, launch: SubagentLaunched): Promise<void> {
76
- const existing = this.wakeBySessionId.get(launch.childSessionId);
77
- if (existing) {
78
- return existing;
79
- }
80
-
81
- const wake = this.ensureReady(parent, launch).finally(() => {
82
- if (this.wakeBySessionId.get(launch.childSessionId) === wake) {
83
- this.wakeBySessionId.delete(launch.childSessionId);
84
- }
85
- });
86
- this.wakeBySessionId.set(launch.childSessionId, wake);
87
- return wake;
88
- }
89
-
90
- private async ensureReady(parent: WakeParentSession, launch: SubagentLaunched): Promise<void> {
91
- if (await this.isLive(launch.childSessionId)) {
92
- return;
93
- }
94
-
35
+ async wake(target: string): Promise<void> {
36
+ const { parent, launch } = this.resolve(target);
95
37
  const tmuxSession = tmuxSessionName(parent.sessionId);
96
38
  const hasWindow = (await listTmuxWindows(this.executor, tmuxSession)).some(
97
- (window) => window.piSessionId === launch.childSessionId,
39
+ (window) => window.piSessionId === target,
98
40
  );
41
+ if (!hasWindow) await this.createWindow(parent, launch, tmuxSession);
42
+ }
99
43
 
100
- if (!hasWindow) {
101
- await this.createWindow(parent, launch, tmuxSession);
102
- }
103
- if (
104
- await this.messaging.waitForSession(
105
- launch.childSessionId,
106
- this.options.readyTimeoutMs ?? SESSION_READY_TIMEOUT_MS,
107
- )
108
- ) {
109
- return;
110
- }
111
-
112
- this.requireCurrent(parent);
113
- const killed = await killTmuxWindow(this.executor, tmuxSession, launch.childSessionId);
44
+ async restart(target: string): Promise<void> {
45
+ const { parent, launch } = this.resolve(target);
46
+ const tmuxSession = tmuxSessionName(parent.sessionId);
47
+ const killed = await killTmuxWindow(this.executor, tmuxSession, target);
114
48
  if (!killed) {
115
49
  throw new Error(
116
- `Subagent ${launch.childSessionId} did not register and its stale tmux window could not be stopped.`,
50
+ `Subagent ${target} did not register and its stale tmux window could not be stopped.`,
117
51
  );
118
52
  }
119
-
120
53
  await this.createWindow(parent, launch, tmuxSession);
121
- if (
122
- !(await this.messaging.waitForSession(
123
- launch.childSessionId,
124
- this.options.readyTimeoutMs ?? SESSION_READY_TIMEOUT_MS,
125
- ))
126
- ) {
127
- throw new Error(
128
- `Subagent ${launch.childSessionId} was restarted but did not register for messaging.`,
129
- );
130
- }
54
+ }
55
+
56
+ afterSend(): Promise<void> | void {
57
+ return this.options.afterSend?.();
58
+ }
59
+
60
+ private resolve(target: string): { parent: WakeParentSession; launch: SubagentLaunched } {
61
+ const parent = this.getParent();
62
+ const launch = parent && findOwnedSubagentLaunch(parent.getBranch(), parent.sessionId, target);
63
+ if (!parent || !launch) throw new Error(`No owned subagent ${target}.`);
64
+ this.requireCurrent(parent);
65
+ return { parent, launch };
131
66
  }
132
67
 
133
68
  private async createWindow(
@@ -135,9 +70,7 @@ export class SubagentMessageRouter {
135
70
  launch: SubagentLaunched,
136
71
  tmuxSession: string,
137
72
  ): Promise<void> {
138
- if (await this.isLive(launch.childSessionId)) {
139
- return;
140
- }
73
+ if ((await this.messaging.listSessions()).includes(launch.childSessionId)) return;
141
74
  this.requireCurrent(parent);
142
75
  this.options.onMaterialize?.(launch);
143
76
  await createTmuxWindow(this.executor, {
@@ -149,25 +82,8 @@ export class SubagentMessageRouter {
149
82
  });
150
83
  }
151
84
 
152
- private sendOwned(
153
- parent: WakeParentSession,
154
- request: SendMessageRequest,
155
- ): Promise<SendMessageResult> {
156
- this.requireCurrent(parent);
157
- return this.messaging.sendMessage(request);
158
- }
159
-
160
- private async isLive(sessionId: string): Promise<boolean> {
161
- return (await this.messaging.listSessions()).includes(sessionId);
162
- }
163
-
164
85
  private requireCurrent(parent: WakeParentSession): void {
165
- if (!this.isCurrent(parent.epoch)) {
86
+ if (!this.isCurrent(parent.epoch))
166
87
  throw new Error("The parent session changed while waking its subagent.");
167
- }
168
88
  }
169
89
  }
170
-
171
- function isTargetDeparture(result: SendMessageResult): boolean {
172
- return !result.delivered && (result.reason === "no_session" || result.reason === "disconnected");
173
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
6
6
  "license": "MIT",
@@ -38,10 +38,10 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@biomejs/biome": "^2.5.10",
41
- "@earendil-works/pi-agent-core": "^1.0.2",
42
- "@earendil-works/pi-ai": "^1.0.2",
43
- "@earendil-works/pi-coding-agent": "^1.0.2",
44
- "@earendil-works/pi-tui": "^1.0.2",
41
+ "@earendil-works/pi-agent-core": "^1.0.4",
42
+ "@earendil-works/pi-ai": "^1.0.4",
43
+ "@earendil-works/pi-coding-agent": "^1.0.4",
44
+ "@earendil-works/pi-tui": "^1.0.4",
45
45
  "@types/node": "^24.13.3",
46
46
  "strip-ansi": "^7.2.0",
47
47
  "typebox": "1.3.27",