pi-sessions 0.18.0 → 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 (34) hide show
  1. package/README.md +44 -40
  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/shared/settings.ts +1 -1
  31. package/extensions/subagents/install.ts +0 -27
  32. package/extensions/subagents/launch-target.ts +0 -1
  33. package/extensions/subagents/reconcile.ts +10 -2
  34. package/package.json +5 -5
@@ -2,7 +2,6 @@ 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";
6
5
  import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
7
6
  import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
8
7
  import { isTuiMode } from "../shared/pi-mode.ts";
@@ -21,11 +20,7 @@ import { HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE } from "./metadata.ts";
21
20
  import { openSessionReferencePicker } from "./picker.ts";
22
21
  import { SESSION_TOKEN_PREFIX } from "./query.ts";
23
22
  import { type HandoffRoster, resolveHandoffRoster } from "./roster.ts";
24
- import {
25
- executeSessionHandoffTool,
26
- type HandoffToolParams,
27
- MAX_HANDOFF_TITLE_LENGTH,
28
- } from "./tool.ts";
23
+ import { executeSessionHandoffTool, MAX_HANDOFF_TITLE_LENGTH } from "./tool.ts";
29
24
  import { HANDOFF_TOOL_DETAILS_SCHEMA } from "./tool-contract.ts";
30
25
  import { HandoffToolComponent } from "./tool-renderer.ts";
31
26
  import {
@@ -51,7 +46,7 @@ export function installHandoff(
51
46
  index: IndexHandle;
52
47
  getModelRuntime: ModelRuntimeProvider;
53
48
  getLaunchTargets?: (() => readonly HandoffLaunchTarget[]) | undefined;
54
- getHosts?: () => readonly Host[];
49
+ claimsPeers: () => boolean;
55
50
  board: HandoffBoardServices;
56
51
  },
57
52
  ): SessionLifecycle {
@@ -69,16 +64,17 @@ export function installHandoff(
69
64
  models: readonly Model<Api>[],
70
65
  launchTargets: readonly HandoffLaunchTarget[],
71
66
  roster: HandoffRoster | undefined,
67
+ name: "session_handoff" | "subagent_launch",
72
68
  ): void {
73
69
  const offersSubagent = offersSubagentLaunch(launchTargets);
74
70
  pi.registerTool({
75
- name: "session_handoff",
71
+ name,
76
72
  label: "Session Handoff",
77
73
  description: "Start a new Pi session with a self-contained task.",
78
74
  // Nested calls write no toolResult entry, so the board would never see the launch receipt.
79
75
  exposure: "model-only",
80
76
  promptSnippet: offersSubagent
81
- ? "Delegate bounded work to a background subagent or hand off context to another Pi session"
77
+ ? "Delegate bounded work to a background subagent"
82
78
  : "Hand off context to another Pi session",
83
79
  promptGuidelines: buildHandoffPromptGuidelines(launchTargets, models, roster),
84
80
  parameters: Type.Object({
@@ -91,17 +87,16 @@ export function installHandoff(
91
87
  description:
92
88
  "Short display title for the child session, 64 characters or less. Summarize the mission; do not repeat the full goal.",
93
89
  }),
94
- launch: buildHandoffLaunchSchema(launchTargets),
90
+ ...(name === "session_handoff" ? { launch: buildHandoffLaunchSchema(launchTargets) } : {}),
95
91
  cwd: Type.Optional(
96
92
  Type.String({
97
93
  description: "Optional target working directory (defaults to current).",
98
94
  }),
99
95
  ),
100
- requestResponse: Type.Optional(
101
- Type.Boolean({
102
- description: `Whether the child session should report completion/results of its task back to this session. ${offersSubagent ? "Defaults to true for subagent launches and false otherwise." : "Defaults to false."}`,
103
- }),
104
- ),
96
+ requestResponse: Type.Boolean({
97
+ description:
98
+ "Whether the child session should report completion/results of its task back to this session.",
99
+ }),
105
100
  provider: Type.Optional(
106
101
  Type.String({
107
102
  description: "Model provider for the child session.",
@@ -127,7 +122,7 @@ export function installHandoff(
127
122
  const state = context.state as HandoffToolRendererState;
128
123
  const component = state.callComponent ?? new HandoffToolComponent(theme);
129
124
  state.callComponent = component;
130
- component.update(buildHandoffToolView(args), context.expanded);
125
+ component.update(buildHandoffToolView(args, undefined, name), context.expanded);
131
126
  return component;
132
127
  },
133
128
  renderResult(result, options, theme, context) {
@@ -143,17 +138,23 @@ export function installHandoff(
143
138
  }
144
139
 
145
140
  state.callComponent.update(
146
- buildHandoffToolView(context.args, details),
141
+ buildHandoffToolView(context.args, details, name),
147
142
  options.expanded,
148
143
  clipboardStatusBySessionId.get(details.sessionId),
149
144
  );
150
145
  return new Text("", 0, 0);
151
146
  },
152
- async execute(_toolCallId, params: HandoffToolParams, _signal, _onUpdate, ctx) {
147
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
148
+ if (name === "session_handoff" && deps.claimsPeers())
149
+ throw new Error("Peer tools belong to the current host.");
150
+ const launch = name === "subagent_launch" ? "subagent" : params.launch;
151
+ if (typeof launch !== "string") throw new Error("session_handoff requires launch.");
152
+ if (name === "subagent_launch" && !deps.getLaunchTargets?.().length)
153
+ throw new Error("Subagent launch is unavailable.");
153
154
  const modelRuntime = await deps.getModelRuntime(ctx.modelRegistry);
154
155
  return executeSessionHandoffTool(
155
156
  pi,
156
- params,
157
+ { ...params, launch },
157
158
  ctx,
158
159
  modelRuntime,
159
160
  launchTargets,
@@ -205,7 +206,7 @@ export function installHandoff(
205
206
  });
206
207
 
207
208
  pi.on("before_agent_start", async (event, ctx) => {
208
- if (!deps.getHosts?.().length && splitBackend?.identifyTerminalId && ctx) {
209
+ if (!deps.claimsPeers() && splitBackend?.identifyTerminalId && ctx) {
209
210
  identifiedGhosttyTerminalId =
210
211
  (await splitBackend.identifyTerminalId(ctx.cwd)) ?? identifiedGhosttyTerminalId;
211
212
  }
@@ -216,21 +217,34 @@ export function installHandoff(
216
217
  return {
217
218
  async onSessionStart(_event, ctx) {
218
219
  const models = ctx.modelRegistry.getAvailable();
219
- registerHandoffTool(
220
+ const roster = resolveHandoffRoster({
220
221
  models,
221
- createHandoffLaunchTargets({
222
- pi,
223
- splitBackend,
224
- deferred: settings.handoff.deferred,
225
- additionalTargets: deps.getLaunchTargets?.() ?? [],
226
- hosts: deps.getHosts?.() ?? [],
227
- }),
228
- resolveHandoffRoster({
222
+ patterns: settings.handoff.roster,
223
+ scopedModels: ctx.scopedModels,
224
+ });
225
+ if (!deps.claimsPeers?.()) {
226
+ registerHandoffTool(
229
227
  models,
230
- patterns: settings.handoff.roster,
231
- scopedModels: ctx.scopedModels,
232
- }),
233
- );
228
+ createHandoffLaunchTargets({
229
+ pi,
230
+ splitBackend,
231
+ deferred: settings.handoff.deferred,
232
+ additionalTargets: [],
233
+ }),
234
+ roster,
235
+ "session_handoff",
236
+ );
237
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), "session_handoff"])]);
238
+ } else {
239
+ pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "session_handoff"));
240
+ }
241
+ const subagentTargets = deps.getLaunchTargets?.() ?? [];
242
+ if (subagentTargets.length) {
243
+ registerHandoffTool(models, subagentTargets, roster, "subagent_launch");
244
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), "subagent_launch"])]);
245
+ } else {
246
+ pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "subagent_launch"));
247
+ }
234
248
 
235
249
  await consumePendingHandoffBootstrap(
236
250
  pi,
@@ -1,9 +1,21 @@
1
- import type { Host, HostLaunchInput } from "../../hosts/contract.ts";
2
-
3
1
  export type HandoffSplitDirection = "left" | "right" | "up" | "down";
4
2
 
5
- export type LaunchInput = HostLaunchInput;
3
+ export interface LaunchInput {
4
+ sessionId: string;
5
+ sessionFile: string;
6
+ cwd: string;
7
+ title: string;
8
+ model: string;
9
+ resumeCommand: string;
10
+ }
6
11
 
7
12
  export type ClipboardStatus = "copied" | "failed";
8
13
 
9
- export type LaunchBackend = Host;
14
+ export interface LaunchBackend {
15
+ name: string;
16
+ launch(
17
+ input: LaunchInput,
18
+ ): Promise<
19
+ { success: true; clipboardStatus?: ClipboardStatus } | { success: false; error: string }
20
+ >;
21
+ }
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { Host } from "../hosts/contract.ts";
3
2
  import type { HandoffSettings } from "../shared/settings.ts";
4
3
  import { createDeferredLaunchBackend } from "./launch/deferred.ts";
5
4
  import type { SplitLaunchBackend } from "./launch/resolution.ts";
@@ -21,10 +20,8 @@ export function createHandoffLaunchTargets(options: {
21
20
  splitBackend: SplitLaunchBackend | undefined;
22
21
  deferred: HandoffSettings["deferred"];
23
22
  additionalTargets: readonly HandoffLaunchTarget[];
24
- hosts?: readonly Host[];
25
23
  }): HandoffLaunchTarget[] {
26
- const hosts = options.hosts ?? [];
27
- const splitBackend = hosts.length ? undefined : options.splitBackend;
24
+ const splitBackend = options.splitBackend;
28
25
  const splitTargets = splitBackend
29
26
  ? LAUNCH_DIRECTIONS.map((direction, index) =>
30
27
  createBackendLaunchTarget(
@@ -45,19 +42,7 @@ export function createHandoffLaunchTargets(options: {
45
42
  ),
46
43
  ]
47
44
  : [];
48
- return [
49
- ...hosts.map((host) =>
50
- createBackendLaunchTarget(
51
- host.name,
52
- host,
53
- `'${host.name}' opens a session in ${host.name}.`,
54
- true,
55
- ),
56
- ),
57
- ...splitTargets,
58
- ...deferredTargets,
59
- ...options.additionalTargets,
60
- ];
45
+ return [...splitTargets, ...deferredTargets, ...options.additionalTargets];
61
46
  }
62
47
 
63
48
  export function resolveHandoffLaunchTarget(
@@ -1,8 +1,7 @@
1
1
  import type { SessionManager } from "@earendil-works/pi-coding-agent";
2
2
  import { type Static, Type } from "typebox";
3
- import type { Host } from "../hosts/contract.ts";
4
3
  import { formatError } from "../shared/errors.ts";
5
- import type { ClipboardStatus } from "./launch/backend.ts";
4
+ import type { ClipboardStatus, LaunchBackend } from "./launch/backend.ts";
6
5
  import type { HandoffSubagent } from "./metadata.ts";
7
6
  import type { PreparedHandoff } from "./spawn.ts";
8
7
 
@@ -58,7 +57,6 @@ export type HandoffLaunchTargetOutcome =
58
57
  export interface HandoffLaunchTarget {
59
58
  value: HandoffLaunchValue;
60
59
  description?: string | undefined;
61
- requestResponseDefault: boolean;
62
60
  bootstrapMode: "review" | "automatic";
63
61
  /** Unattended children skip the startup trust prompt; watched ones let the user answer it. */
64
62
  approveProjectTrust: boolean;
@@ -75,17 +73,14 @@ export interface HandoffLaunchTarget {
75
73
 
76
74
  export function createBackendLaunchTarget(
77
75
  value: string,
78
- host: Host,
76
+ host: LaunchBackend,
79
77
  description?: string | undefined,
80
- external = false,
81
78
  ): HandoffLaunchTarget {
82
79
  return {
83
80
  value,
84
81
  ...(description ? { description } : {}),
85
- requestResponseDefault: false,
86
- bootstrapMode: external ? "automatic" : "review",
87
- approveProjectTrust: external,
88
- useDefaultSessionDir: external,
82
+ bootstrapMode: "review",
83
+ approveProjectTrust: false,
89
84
  prepareChild() {},
90
85
  async launch(input) {
91
86
  try {
@@ -35,7 +35,7 @@ export function parseHandoffLaunchReceiptEntry(
35
35
  if (
36
36
  entry.type === "message" &&
37
37
  entry.message.role === "toolResult" &&
38
- entry.message.toolName === "session_handoff"
38
+ (entry.message.toolName === "session_handoff" || entry.message.toolName === "subagent_launch")
39
39
  ) {
40
40
  return safeParseTypeBoxValue(HANDOFF_LAUNCH_RECEIPT_SCHEMA, entry.message.details);
41
41
  }
@@ -18,7 +18,7 @@ export function buildHandoffToolPresentation(
18
18
 
19
19
  return {
20
20
  header: [
21
- theme.fg("accent", "session_handoff"),
21
+ theme.fg("accent", model.toolName ?? "session_handoff"),
22
22
  theme.fg("dim", `[${model.launch ?? PENDING}]`),
23
23
  theme.bold(model.title ?? PENDING),
24
24
  ].join(" "),
@@ -21,7 +21,7 @@ export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]
21
21
  }
22
22
 
23
23
  const SUBAGENT_GUIDELINES = [
24
- 'Use session_handoff with launch: "subagent" for a concrete, bounded task that can proceed independently while useful work continues in the current session.',
24
+ "Use subagent_launch for a concrete, bounded task that can proceed independently while useful work continues in the current session.",
25
25
  "Do not launch a subagent for work that is trivial, duplicates work already underway, requires frequent coordination, or is tightly coupled to the current session's immediate next step.",
26
26
  "Tell every subagent explicitly whether it may modify files or should only investigate and report.",
27
27
  "Give concurrent subagents disjoint responsibilities and non-overlapping write scopes.",
@@ -73,14 +73,6 @@ function describeUserFacingLaunchKinds(targets: readonly HandoffLaunchTarget[]):
73
73
  const isDirection = (value: string) => LAUNCH_DIRECTIONS.some((direction) => direction === value);
74
74
  const kinds = [
75
75
  targets.some((target) => isDirection(target.value)) ? "directional" : undefined,
76
- targets.some(
77
- (target) =>
78
- target.value !== SUBAGENT_LAUNCH &&
79
- target.value !== DEFERRED_LAUNCH &&
80
- !isDirection(target.value),
81
- )
82
- ? "host"
83
- : undefined,
84
76
  targets.some((target) => target.value === DEFERRED_LAUNCH) ? "deferred" : undefined,
85
77
  ];
86
78
  return kinds.filter((kind) => kind !== undefined).join(" or ");
@@ -4,6 +4,7 @@ import { isThinkingLevel } from "../shared/thinking-levels.ts";
4
4
  import type { HandoffToolDetails } from "./tool-contract.ts";
5
5
 
6
6
  export interface HandoffToolViewModel {
7
+ toolName: string;
7
8
  launch?: string | undefined;
8
9
  title?: string | undefined;
9
10
  goal?: string | undefined;
@@ -15,14 +16,17 @@ export interface HandoffToolViewModel {
15
16
 
16
17
  export function buildHandoffToolView(
17
18
  args: unknown,
18
- result?: HandoffToolDetails | undefined,
19
+ result: HandoffToolDetails | undefined,
20
+ toolName: string,
19
21
  ): HandoffToolViewModel {
20
22
  const record = isRecord(args) ? args : {};
21
23
  const provider = normalizeOptionalText(result?.provider) ?? readString(record.provider);
22
24
  const model = normalizeOptionalText(result?.modelName) ?? readString(record.model);
23
25
  const thinkingLevel = result?.thinkingLevel ?? readThinkingLevel(record.thinkingLevel);
24
26
  return {
25
- launch: readString(record.launch) ?? result?.launch,
27
+ toolName,
28
+ launch:
29
+ toolName === "subagent_launch" ? "subagent" : (readString(record.launch) ?? result?.launch),
26
30
  title: readString(record.title) ?? result?.title,
27
31
  goal: readString(record.goal),
28
32
  ...(provider ? { provider } : {}),
@@ -28,7 +28,7 @@ export interface HandoffToolParams {
28
28
  title: string;
29
29
  launch: HandoffLaunchValue;
30
30
  cwd?: string | undefined;
31
- requestResponse?: boolean | undefined;
31
+ requestResponse: boolean;
32
32
  provider?: string | undefined;
33
33
  model?: string | undefined;
34
34
  thinkingLevel?: ThinkingLevel | undefined;
@@ -84,7 +84,7 @@ export async function executeSessionHandoffTool(
84
84
 
85
85
  resolveHandoffSource(ctx.sessionManager, sourceLeafId);
86
86
 
87
- const requestResponse = params.requestResponse ?? target.requestResponseDefault;
87
+ const requestResponse = params.requestResponse;
88
88
  const provider = params.provider?.trim();
89
89
  const modelId = params.model?.trim();
90
90
  if (Boolean(provider) !== Boolean(modelId)) {
@@ -0,0 +1,115 @@
1
+ import { type ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { isSessionStarting } from "../session-handoff/metadata.ts";
3
+ import { formatError } from "../shared/errors.ts";
4
+ import {
5
+ getSessionById,
6
+ type SessionLineageRow,
7
+ withSessionIndex,
8
+ } from "../shared/session-index/index.ts";
9
+ import type {
10
+ ReachableSubagent,
11
+ ReachableUserSession,
12
+ SessionReachableScope,
13
+ } from "./pi/reachable-contract.ts";
14
+
15
+ /** The subagent facts the directory needs; the roster satisfies it structurally. */
16
+ export interface ReachableSubagentEntry {
17
+ sessionId: string;
18
+ title: string;
19
+ goal: string;
20
+ cwd: string;
21
+ state: string;
22
+ depth: number;
23
+ onActiveBranch: boolean;
24
+ launchedAt: string;
25
+ model?: string | undefined;
26
+ ownerSessionId: string;
27
+ ownerTitle: string;
28
+ ownerIsCurrentSession: boolean;
29
+ resumeCommand: string;
30
+ }
31
+
32
+ export interface SessionReachableDeps {
33
+ indexPath: string;
34
+ listSessions(): Promise<string[]>;
35
+ getRelationTo(sessionId: string): string | undefined;
36
+ listSubagents?:
37
+ | ((
38
+ scope: Exclude<SessionReachableScope, "user">,
39
+ ) => Promise<readonly ReachableSubagentEntry[]>)
40
+ | undefined;
41
+ getParentSessionId?: (() => string | undefined) | undefined;
42
+ }
43
+
44
+ export async function listReachableUserSessions(
45
+ deps: SessionReachableDeps,
46
+ ctx: ExtensionContext,
47
+ ): Promise<ReachableUserSession[]> {
48
+ const currentSessionId = ctx.sessionManager.getSessionId();
49
+ let liveSessionIds: string[];
50
+ try {
51
+ liveSessionIds = await deps.listSessions();
52
+ } catch (error) {
53
+ throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
54
+ }
55
+ const parentSessionId = deps.getParentSessionId?.();
56
+ const targetIds = [...new Set(liveSessionIds)].filter(
57
+ (sessionId) => sessionId !== currentSessionId && sessionId !== parentSessionId,
58
+ );
59
+ return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
60
+ const sessions: ReachableUserSession[] = [];
61
+ for (const sessionId of targetIds) {
62
+ const row = getSessionById(db, sessionId);
63
+ if (row?.sessionOrigin === "subagent") continue;
64
+ const relation = deps.getRelationTo(sessionId);
65
+ sessions.push({
66
+ kind: "user",
67
+ sessionId,
68
+ state: row && isStartingSession(row) ? "starting" : "live",
69
+ ...(row?.sessionName ? { title: row.sessionName } : {}),
70
+ ...(row?.cwd ? { cwd: row.cwd } : {}),
71
+ ...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
72
+ ...(relation === undefined ? {} : { relation }),
73
+ });
74
+ }
75
+ return sessions.sort((left, right) =>
76
+ (right.modifiedAt ?? "").localeCompare(left.modifiedAt ?? ""),
77
+ );
78
+ });
79
+ }
80
+
81
+ export async function listReachableSubagents(
82
+ deps: SessionReachableDeps,
83
+ scope: Exclude<SessionReachableScope, "user">,
84
+ ): Promise<ReachableSubagent[]> {
85
+ if (!deps.listSubagents) throw new Error("Subagent roster is unavailable.");
86
+ try {
87
+ const entries = await deps.listSubagents(scope);
88
+ return entries.map((entry) => ({
89
+ kind: "subagent",
90
+ sessionId: entry.sessionId,
91
+ title: entry.title,
92
+ goal: entry.goal,
93
+ cwd: entry.cwd,
94
+ state: entry.state,
95
+ depth: entry.depth,
96
+ onActiveBranch: entry.onActiveBranch,
97
+ launchedAt: entry.launchedAt,
98
+ ...(entry.model === undefined ? {} : { model: entry.model }),
99
+ ownerSessionId: entry.ownerSessionId,
100
+ ownerTitle: entry.ownerTitle,
101
+ ownerIsCurrentSession: entry.ownerIsCurrentSession,
102
+ resumeCommand: entry.resumeCommand,
103
+ }));
104
+ } catch (error) {
105
+ throw new Error(`Subagent roster is unavailable: ${formatError(error)}`);
106
+ }
107
+ }
108
+
109
+ function isStartingSession(row: SessionLineageRow): boolean {
110
+ try {
111
+ return isSessionStarting(SessionManager.open(row.sessionPath).getBranch());
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
@@ -0,0 +1,114 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getSessionById, withSessionIndex } from "../shared/session-index/index.ts";
3
+ import type { SubagentsHandle } from "../subagents/install.ts";
4
+ import {
5
+ listReachableSubagents,
6
+ listReachableUserSessions,
7
+ type SessionReachableDeps,
8
+ } from "./directory-list.ts";
9
+ import type { MessagingHandle } from "./install.ts";
10
+ import type { MessageRouter } from "./message-router.ts";
11
+ import type { ReachableSession, SessionReachableScope } from "./pi/reachable-contract.ts";
12
+ import type { CancelSessionResult, SendMessageRequest, SendMessageResult } from "./pi/service.ts";
13
+ import type { ManagedCancelSessionResult } from "./pi/tools.ts";
14
+
15
+ /** The addressable set, not a transport. Resolution is enforced before every mutation. */
16
+ export interface SessionDirectory {
17
+ list(scope: SessionReachableScope, ctx: ExtensionContext): Promise<ReachableSession[]>;
18
+ resolve(sessionId: string, ctx: ExtensionContext): Promise<{ sessionId: string }>;
19
+ send(request: SendMessageRequest, ctx: ExtensionContext): Promise<SendMessageResult>;
20
+ cancel(
21
+ sessionId: string,
22
+ ctx: ExtensionContext,
23
+ ): Promise<CancelSessionResult | ManagedCancelSessionResult>;
24
+ wake(sessionId: string): Promise<void>;
25
+ getRelationTo(sessionId: string | undefined): string | undefined;
26
+ getParentSessionId(): string | undefined;
27
+ }
28
+
29
+ export function createPeerDirectory(
30
+ messaging: MessagingHandle,
31
+ deps: SessionReachableDeps,
32
+ ): SessionDirectory {
33
+ const directory: SessionDirectory = {
34
+ list: (scope, ctx) => {
35
+ if (scope !== "user") throw new Error("Peer discovery has no subagent scope.");
36
+ return listReachableUserSessions(deps, ctx);
37
+ },
38
+ async resolve(sessionId, ctx) {
39
+ const target = (await directory.list("user", ctx)).find(
40
+ (entry) => entry.sessionId === sessionId,
41
+ );
42
+ if (!target) throw new Error(`No reachable user session: ${sessionId}`);
43
+ return target;
44
+ },
45
+ async send(request, ctx) {
46
+ await directory.resolve(request.target, ctx);
47
+ return messaging.sendMessage(request);
48
+ },
49
+ async cancel(sessionId, ctx) {
50
+ await directory.resolve(sessionId, ctx);
51
+ return messaging.cancelSession(sessionId);
52
+ },
53
+ async wake() {
54
+ throw new Error("Peer sessions must already be live.");
55
+ },
56
+ getRelationTo: messaging.getCachedRelationTo,
57
+ getParentSessionId: () => deps.getParentSessionId?.(),
58
+ };
59
+ return directory;
60
+ }
61
+
62
+ export function createSubagentDirectory(
63
+ subagents: SubagentsHandle,
64
+ router: MessageRouter,
65
+ messaging: MessagingHandle,
66
+ indexPath: string,
67
+ ): SessionDirectory {
68
+ const deps: SessionReachableDeps = {
69
+ indexPath,
70
+ listSessions: () => messaging.listSessions(),
71
+ getRelationTo: messaging.getCachedRelationTo,
72
+ listSubagents: async (scope) => (await subagents.roster.resolve(scope)).entries,
73
+ };
74
+ const directory: SessionDirectory = {
75
+ list: (scope) => {
76
+ if (scope === "user") throw new Error("Subagent discovery requires branch or tree scope.");
77
+ return listReachableSubagents(deps, scope);
78
+ },
79
+ async resolve(sessionId, ctx) {
80
+ if (sessionId === subagents.getParentSessionId())
81
+ throw new Error("The parent session is not addressable. Use submit_task_report.");
82
+ if (sessionId === ctx.sessionManager.getSessionId())
83
+ throw new Error("The current session is not a subagent target.");
84
+ const target = (await directory.list("tree", ctx)).find(
85
+ (entry) => entry.sessionId === sessionId,
86
+ );
87
+ if (target) return target;
88
+ // Known live workers (including siblings) remain addressable without transferring
89
+ // ownership. Only the caller's own ledger can authorize dormant wake or teardown.
90
+ const live = (await messaging.listSessions()).includes(sessionId);
91
+ const worker =
92
+ live &&
93
+ withSessionIndex(
94
+ indexPath,
95
+ { mode: "read", required: true },
96
+ ({ db }) => getSessionById(db, sessionId)?.sessionOrigin === "subagent",
97
+ );
98
+ if (worker) return { sessionId };
99
+ throw new Error(`No reachable subagent: ${sessionId}`);
100
+ },
101
+ async send(request, ctx) {
102
+ await directory.resolve(request.target, ctx);
103
+ return router.sendMessage(request);
104
+ },
105
+ async cancel(sessionId, ctx) {
106
+ await directory.resolve(sessionId, ctx);
107
+ return subagents.cancelSession(sessionId);
108
+ },
109
+ wake: (sessionId) => subagents.waker.wake(sessionId),
110
+ getRelationTo: messaging.getCachedRelationTo,
111
+ getParentSessionId: () => subagents.getParentSessionId(),
112
+ };
113
+ return directory;
114
+ }
@@ -18,7 +18,7 @@ export class MessageRouter {
18
18
 
19
19
  constructor(
20
20
  private readonly messaging: WakeMessaging,
21
- private readonly wakers: readonly SessionWaker[],
21
+ private readonly getWaker: () => SessionWaker | undefined,
22
22
  private readonly getEpoch: () => number,
23
23
  private readonly readyTimeoutMs = 30_000,
24
24
  ) {}
@@ -32,13 +32,8 @@ export class MessageRouter {
32
32
  if (!isTargetDeparture(result)) return result;
33
33
  failedLiveSend = result;
34
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
- }
35
+ const waker = this.getWaker();
36
+ const owner = waker && (await waker.owns(request.target)) ? waker : undefined;
42
37
  this.requireCurrent(epoch);
43
38
  if (!owner) return failedLiveSend ?? this.messaging.sendMessage(request);
44
39
 
@@ -0,0 +1,41 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import type { SessionDirectory } from "../directory.ts";
3
+ import { createSessionReachableTool, type ReachableToolOptions } from "./reachable-tool.ts";
4
+ import {
5
+ createSessionCancelTool,
6
+ createSessionSendMessageTool,
7
+ type SendMessageToolWording,
8
+ } from "./tools.ts";
9
+
10
+ export interface DirectoryToolWording {
11
+ list: ReachableToolOptions;
12
+ send: SendMessageToolWording & { name: string };
13
+ cancel: SendMessageToolWording & { name: string };
14
+ }
15
+
16
+ /** Both public tool sets share execution, validation and native rendering. */
17
+ export function createDirectoryTools(
18
+ directory: SessionDirectory,
19
+ wording: DirectoryToolWording,
20
+ ): ToolDefinition[] {
21
+ return [
22
+ createSessionReachableTool(directory, wording.list),
23
+ createSessionSendMessageTool(
24
+ { sendMessage: directory.send },
25
+ {
26
+ name: wording.send.name,
27
+ wording: wording.send,
28
+ getCachedRelationTo: directory.getRelationTo,
29
+ getParentSessionId: directory.getParentSessionId,
30
+ },
31
+ ),
32
+ createSessionCancelTool(
33
+ { cancelSession: directory.cancel },
34
+ {
35
+ name: wording.cancel.name,
36
+ wording: wording.cancel,
37
+ getParentSessionId: directory.getParentSessionId,
38
+ },
39
+ ),
40
+ ];
41
+ }