pi-sessions 0.9.0 → 0.11.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 (42) hide show
  1. package/README.md +29 -20
  2. package/dist/session-messaging/broker/client-frame.js +62 -0
  3. package/dist/session-messaging/broker/process.js +234 -0
  4. package/dist/shared/session-broker/framing.js +59 -0
  5. package/dist/shared/session-broker/socket-path.js +19 -0
  6. package/extensions/pi-sessions.ts +14 -1
  7. package/extensions/session-auto-title/generate.ts +56 -66
  8. package/extensions/session-auto-title/install.ts +2 -0
  9. package/extensions/session-auto-title/runs.ts +50 -0
  10. package/extensions/session-handoff/board-view-model.ts +2 -4
  11. package/extensions/session-handoff/bootstrap.ts +14 -16
  12. package/extensions/session-handoff/extract.ts +34 -16
  13. package/extensions/session-handoff/install.ts +1 -1
  14. package/extensions/session-handoff/kickoff.ts +4 -3
  15. package/extensions/session-handoff/launch-target.ts +1 -1
  16. package/extensions/session-handoff/metadata.ts +72 -23
  17. package/extensions/session-messaging/broker/client-frame.ts +113 -0
  18. package/extensions/session-messaging/broker/process.ts +12 -21
  19. package/extensions/session-messaging/broker/spawn.ts +12 -7
  20. package/extensions/session-messaging/pi/reachable-contract.ts +59 -0
  21. package/extensions/session-messaging/pi/reachable-presenter.ts +49 -0
  22. package/extensions/session-messaging/pi/reachable-tool.ts +190 -0
  23. package/extensions/session-messaging/pi/reachable-view-model.ts +59 -0
  24. package/extensions/session-messaging/pi/renderer.ts +1 -1
  25. package/extensions/session-messaging/pi/tools.ts +8 -8
  26. package/extensions/session-search/hooks.ts +45 -52
  27. package/extensions/session-search/install.ts +3 -158
  28. package/extensions/session-search/reindex.ts +3 -3
  29. package/extensions/session-search/renderer.ts +1 -32
  30. package/extensions/session-search/tool-contract.ts +1 -21
  31. package/extensions/shared/model-runtime.ts +8 -1
  32. package/extensions/shared/session-broker/client.ts +3 -4
  33. package/extensions/shared/session-broker/framing.ts +6 -7
  34. package/extensions/shared/session-index/lineage.ts +6 -2
  35. package/extensions/shared/session-index/sqlite.ts +44 -17
  36. package/extensions/shared/settings.ts +25 -9
  37. package/extensions/shared/tmux.ts +22 -0
  38. package/extensions/subagents/classify.ts +6 -0
  39. package/extensions/subagents/install.ts +22 -137
  40. package/extensions/subagents/report-message-renderer.ts +2 -2
  41. package/extensions/subagents/settle.ts +182 -0
  42. package/package.json +18 -26
@@ -0,0 +1,113 @@
1
+ // The broker runs as a detached raw-Node process with nothing installed next to
2
+ // it, so client frames are validated by hand rather than against the TypeBox
3
+ // schemas in shared/session-broker/protocol.ts. The broker only routes
4
+ // envelopes, so it validates the routing fields and treats `envelope` as an
5
+ // opaque object; the receiving client validates the envelope body in full.
6
+
7
+ export interface RoutedEnvelope {
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ export interface RegisterClientFrame {
12
+ type: "register";
13
+ sessionId: string;
14
+ }
15
+
16
+ export interface UnregisterClientFrame {
17
+ type: "unregister";
18
+ }
19
+
20
+ export interface ListClientFrame {
21
+ type: "list";
22
+ requestId: string;
23
+ }
24
+
25
+ export interface SendClientFrame {
26
+ type: "send";
27
+ requestId: string;
28
+ target: string;
29
+ envelope: RoutedEnvelope;
30
+ }
31
+
32
+ export interface IncomingAckClientFrame {
33
+ type: "incoming_ack";
34
+ requestId: string;
35
+ delivered: boolean;
36
+ error?: string;
37
+ }
38
+
39
+ export type BrokerClientFrame =
40
+ | RegisterClientFrame
41
+ | UnregisterClientFrame
42
+ | ListClientFrame
43
+ | SendClientFrame
44
+ | IncomingAckClientFrame;
45
+
46
+ const CONTEXT = "Invalid session messaging client frame";
47
+
48
+ export function parseClientFrame(value: unknown): BrokerClientFrame {
49
+ const frame = requireObject(value, "/");
50
+
51
+ switch (frame.type) {
52
+ case "register":
53
+ return { type: "register", sessionId: requireString(frame.sessionId, "/sessionId") };
54
+ case "unregister":
55
+ return { type: "unregister" };
56
+ case "list":
57
+ return { type: "list", requestId: requireString(frame.requestId, "/requestId") };
58
+ case "send":
59
+ return {
60
+ type: "send",
61
+ requestId: requireString(frame.requestId, "/requestId"),
62
+ target: requireString(frame.target, "/target"),
63
+ envelope: requireObject(frame.envelope, "/envelope"),
64
+ };
65
+ case "incoming_ack": {
66
+ const error = optionalString(frame.error, "/error");
67
+ return {
68
+ type: "incoming_ack",
69
+ requestId: requireString(frame.requestId, "/requestId"),
70
+ delivered: requireBoolean(frame.delivered, "/delivered"),
71
+ ...(error === undefined ? {} : { error }),
72
+ };
73
+ }
74
+ default:
75
+ throw invalid("/type", "expected a known client frame type");
76
+ }
77
+ }
78
+
79
+ function requireObject(value: unknown, path: string): Record<string, unknown> {
80
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
81
+ throw invalid(path, "expected an object");
82
+ }
83
+
84
+ return value as Record<string, unknown>;
85
+ }
86
+
87
+ function requireString(value: unknown, path: string): string {
88
+ if (typeof value !== "string") {
89
+ throw invalid(path, "expected a string");
90
+ }
91
+
92
+ return value;
93
+ }
94
+
95
+ function requireBoolean(value: unknown, path: string): boolean {
96
+ if (typeof value !== "boolean") {
97
+ throw invalid(path, "expected a boolean");
98
+ }
99
+
100
+ return value;
101
+ }
102
+
103
+ function optionalString(value: unknown, path: string): string | undefined {
104
+ if (value === undefined) {
105
+ return undefined;
106
+ }
107
+
108
+ return requireString(value, path);
109
+ }
110
+
111
+ function invalid(path: string, message: string): Error {
112
+ return new Error(`${CONTEXT}: ${path} ${message}`);
113
+ }
@@ -2,18 +2,17 @@ import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { createServer, type Socket } from "node:net";
3
3
  import { join } from "node:path";
4
4
  import { readFrames, writeFrame } from "../../shared/session-broker/framing.ts";
5
- import {
6
- CLIENT_FRAME_SCHEMA,
7
- type OutboundSessionEnvelope,
8
- type SessionEnvelope,
9
- type SessionMessagingClientFrame,
10
- type SessionMessagingIncomingAckClientFrame,
11
- type SessionMessagingSendClientFrame,
12
- } from "../../shared/session-broker/protocol.ts";
13
5
  import {
14
6
  getSessionMessagingDir,
15
7
  getSessionMessagingSocketPath,
16
8
  } from "../../shared/session-broker/socket-path.ts";
9
+ import {
10
+ type BrokerClientFrame,
11
+ type IncomingAckClientFrame,
12
+ parseClientFrame,
13
+ type RoutedEnvelope,
14
+ type SendClientFrame,
15
+ } from "./client-frame.ts";
17
16
 
18
17
  const SEND_TIMEOUT_MS = 10_000;
19
18
  const EXIT_IDLE_MS = 5_000;
@@ -71,7 +70,7 @@ function resolveTarget(target: string): { sessionId: string; socket: Socket } |
71
70
 
72
71
  function handleFrame(
73
72
  socket: Socket,
74
- message: SessionMessagingClientFrame,
73
+ message: BrokerClientFrame,
75
74
  getSessionId: () => string | undefined,
76
75
  setSessionId: (sessionId: string | undefined) => void,
77
76
  ): void {
@@ -129,7 +128,7 @@ function handleFrame(
129
128
  function handleSend(
130
129
  socket: Socket,
131
130
  currentSessionId: string | undefined,
132
- message: SessionMessagingSendClientFrame,
131
+ message: SendClientFrame,
133
132
  ): void {
134
133
  const source = currentSessionId ? sessions.get(currentSessionId) : undefined;
135
134
 
@@ -206,7 +205,7 @@ function failPendingSendsTo(targetSessionId: string): void {
206
205
  }
207
206
  }
208
207
 
209
- function handleIncomingAck(socket: Socket, message: SessionMessagingIncomingAckClientFrame): void {
208
+ function handleIncomingAck(socket: Socket, message: IncomingAckClientFrame): void {
210
209
  const pending = pendingSends.get(message.requestId);
211
210
  if (!pending || pending.target !== socket) return;
212
211
 
@@ -220,11 +219,7 @@ function handleIncomingAck(socket: Socket, message: SessionMessagingIncomingAckC
220
219
  });
221
220
  }
222
221
 
223
- function stampEnvelope(
224
- envelope: OutboundSessionEnvelope,
225
- source: string,
226
- target: string,
227
- ): SessionEnvelope {
222
+ function stampEnvelope(envelope: RoutedEnvelope, source: string, target: string): RoutedEnvelope {
228
223
  return { ...envelope, source, target };
229
224
  }
230
225
 
@@ -251,11 +246,7 @@ async function readConnection(
251
246
  setSessionId: (sessionId: string | undefined) => void,
252
247
  ): Promise<void> {
253
248
  try {
254
- for await (const frame of readFrames(
255
- socket,
256
- CLIENT_FRAME_SCHEMA,
257
- "Invalid session messaging client frame",
258
- )) {
249
+ for await (const frame of readFrames(socket, parseClientFrame)) {
259
250
  handleFrame(socket, frame, getSessionId, setSessionId);
260
251
  }
261
252
  } catch (error) {
@@ -34,7 +34,15 @@ export async function spawnSessionMessagingBrokerIfNeeded(): Promise<void> {
34
34
  return;
35
35
  }
36
36
 
37
- const brokerPath = join(dirname(fileURLToPath(import.meta.url)), "process.ts");
37
+ const brokerPath = join(
38
+ dirname(fileURLToPath(import.meta.url)),
39
+ "../../../dist/session-messaging/broker/process.js",
40
+ );
41
+ if (!existsSync(brokerPath)) {
42
+ throw new Error(
43
+ `Session messaging broker build not found: ${brokerPath}. Run npm install in the pi-sessions package directory.`,
44
+ );
45
+ }
38
46
  const child = spawn(process.execPath, [brokerPath], {
39
47
  detached: true,
40
48
  stdio: "ignore",
@@ -164,15 +172,12 @@ function releaseSpawnLock(): void {
164
172
  }
165
173
 
166
174
  function brokerSpawnEnv(): NodeJS.ProcessEnv {
167
- // NODE_NO_WARNINGS silences Node's experimental-TypeScript warning when the
168
- // broker is launched as `node process.ts`. Redundant while stdio is ignored,
169
- // but cheap insurance if that changes or an older Node revives the warning.
170
- const env: NodeJS.ProcessEnv = { ...process.env, NODE_NO_WARNINGS: "1" };
175
+ const env: NodeJS.ProcessEnv = { ...process.env };
171
176
  // process.execPath is the Pi executable. As a Bun-compiled binary it treats a
172
177
  // positional path as a file argument to the agent, not a script to run, which
173
178
  // would relaunch a full agent session and recurse. BUN_BE_BUN makes it behave
174
- // as the Bun CLI and execute process.ts directly. It is set whenever Bun is the
175
- // runtime (compiled binary or `bun run`) and ignored by a plain Node launch.
179
+ // as the Bun CLI and execute the broker. It is set whenever Bun is the runtime
180
+ // (compiled binary or `bun run`) and ignored by a plain Node launch.
176
181
  if (process.versions.bun) {
177
182
  env.BUN_BE_BUN = "1";
178
183
  }
@@ -0,0 +1,59 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ const SCOPE_SCHEMA = Type.Union([
4
+ Type.Literal("user"),
5
+ Type.Literal("branch"),
6
+ Type.Literal("tree"),
7
+ ]);
8
+
9
+ /** Registered when subagents are active; the roster is what makes branch/tree answerable. */
10
+ export const SESSION_REACHABLE_PARAMS = Type.Object({
11
+ scope: Type.Optional(
12
+ Type.Union([Type.Literal("user"), Type.Literal("branch"), Type.Literal("tree")], {
13
+ description:
14
+ 'Which sessions to list: "user" for live user-facing sessions, "branch" for subagents launched from the active conversation branch, "tree" for subagents launched anywhere in this session\'s conversation tree. Defaults to "user".',
15
+ }),
16
+ ),
17
+ });
18
+
19
+ /** Registered when subagents are inactive; only live user sessions can ever be returned. */
20
+ export const SESSION_REACHABLE_USER_PARAMS = Type.Object({});
21
+
22
+ const REACHABLE_USER_SESSION_SCHEMA = Type.Object({
23
+ kind: Type.Literal("user"),
24
+ sessionId: Type.String(),
25
+ title: Type.Optional(Type.String()),
26
+ cwd: Type.Optional(Type.String()),
27
+ modifiedAt: Type.Optional(Type.String()),
28
+ relation: Type.Optional(Type.String()),
29
+ state: Type.Union([Type.Literal("live"), Type.Literal("starting")]),
30
+ });
31
+
32
+ const REACHABLE_SUBAGENT_SCHEMA = Type.Object({
33
+ kind: Type.Literal("subagent"),
34
+ sessionId: Type.String(),
35
+ title: Type.String(),
36
+ goal: Type.String(),
37
+ cwd: Type.String(),
38
+ state: Type.String(),
39
+ depth: Type.Number(),
40
+ onActiveBranch: Type.Boolean(),
41
+ launchedAt: Type.String(),
42
+ model: Type.Optional(Type.String()),
43
+ ownerSessionId: Type.String(),
44
+ ownerTitle: Type.String(),
45
+ ownerIsCurrentSession: Type.Boolean(),
46
+ resumeCommand: Type.String(),
47
+ });
48
+
49
+ export const SESSION_REACHABLE_TOOL_DETAILS_SCHEMA = Type.Object({
50
+ scope: SCOPE_SCHEMA,
51
+ sessions: Type.Array(Type.Union([REACHABLE_USER_SESSION_SCHEMA, REACHABLE_SUBAGENT_SCHEMA])),
52
+ });
53
+
54
+ export type SessionReachableScope = Static<typeof SCOPE_SCHEMA>;
55
+ export type SessionReachableParams = Static<typeof SESSION_REACHABLE_PARAMS>;
56
+ export type ReachableUserSession = Static<typeof REACHABLE_USER_SESSION_SCHEMA>;
57
+ export type ReachableSubagent = Static<typeof REACHABLE_SUBAGENT_SCHEMA>;
58
+ export type ReachableSession = ReachableUserSession | ReachableSubagent;
59
+ export type SessionReachableToolDetails = Static<typeof SESSION_REACHABLE_TOOL_DETAILS_SCHEMA>;
@@ -0,0 +1,49 @@
1
+ import type { ExpandableContentPresentation } from "../../shared/rendering/expandable-content-layout.ts";
2
+ import type { RenderTheme } from "../../shared/rendering/theme.ts";
3
+ import type {
4
+ ReachableSessionRowViewModel,
5
+ SessionReachableViewModel,
6
+ } from "./reachable-view-model.ts";
7
+
8
+ const COLLAPSED_ROW_COUNT = 6;
9
+
10
+ export function buildSessionReachablePresentation(
11
+ model: SessionReachableViewModel,
12
+ theme: RenderTheme,
13
+ ): ExpandableContentPresentation {
14
+ const header = theme.fg(
15
+ "muted",
16
+ `scope: ${model.scope} • ${formatSessionCount(model.rows.length)}`,
17
+ );
18
+
19
+ if (model.rows.length === 0) {
20
+ return {
21
+ header,
22
+ metadata: [theme.fg("warning", formatEmptyText(model.scope))],
23
+ };
24
+ }
25
+
26
+ return {
27
+ header,
28
+ body: {
29
+ text: model.rows.map((row, index) => formatRow(row, index, theme)).join("\n"),
30
+ collapsedRows: COLLAPSED_ROW_COUNT,
31
+ },
32
+ };
33
+ }
34
+
35
+ function formatRow(row: ReachableSessionRowViewModel, index: number, theme: RenderTheme): string {
36
+ const annotations =
37
+ row.annotations.length > 0 ? ` ${theme.fg("dim", `[${row.annotations.join(" • ")}]`)}` : "";
38
+ const location = row.location ? ` ${theme.fg("dim", `(${row.location})`)}` : "";
39
+ const heading = `${index + 1}. ${theme.bold(row.label)}${annotations}${location}`;
40
+ return row.detail ? `${heading}\n${theme.fg("dim", ` ${row.detail}`)}` : heading;
41
+ }
42
+
43
+ function formatSessionCount(count: number): string {
44
+ return count === 1 ? "1 session" : `${count} sessions`;
45
+ }
46
+
47
+ function formatEmptyText(scope: SessionReachableViewModel["scope"]): string {
48
+ return scope === "user" ? "No other live sessions." : "No subagents launched from this session.";
49
+ }
@@ -0,0 +1,190 @@
1
+ import {
2
+ defineTool,
3
+ type ExtensionContext,
4
+ SessionManager,
5
+ type ToolDefinition,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Text } from "@earendil-works/pi-tui";
8
+ import { isSessionStarting } from "../../session-handoff/metadata.ts";
9
+ import { formatError } from "../../shared/errors.ts";
10
+ import { ExpandableContentLayout } from "../../shared/rendering/expandable-content-layout.ts";
11
+ import {
12
+ getSessionById,
13
+ type SessionLineageRow,
14
+ withSessionIndex,
15
+ } from "../../shared/session-index/index.ts";
16
+ import { safeParseTypeBoxValue } from "../../shared/typebox.ts";
17
+ import {
18
+ type ReachableSubagent,
19
+ type ReachableUserSession,
20
+ SESSION_REACHABLE_PARAMS,
21
+ SESSION_REACHABLE_TOOL_DETAILS_SCHEMA,
22
+ SESSION_REACHABLE_USER_PARAMS,
23
+ type SessionReachableParams,
24
+ type SessionReachableScope,
25
+ type SessionReachableToolDetails,
26
+ } from "./reachable-contract.ts";
27
+ import { buildSessionReachablePresentation } from "./reachable-presenter.ts";
28
+ import { buildSessionReachableView } from "./reachable-view-model.ts";
29
+
30
+ /** The subagent facts this tool needs; the roster satisfies it structurally. */
31
+ export interface ReachableSubagentEntry {
32
+ sessionId: string;
33
+ title: string;
34
+ goal: string;
35
+ cwd: string;
36
+ state: string;
37
+ depth: number;
38
+ onActiveBranch: boolean;
39
+ launchedAt: string;
40
+ model?: string | undefined;
41
+ ownerSessionId: string;
42
+ ownerTitle: string;
43
+ ownerIsCurrentSession: boolean;
44
+ resumeCommand: string;
45
+ }
46
+
47
+ export interface SessionReachableDeps {
48
+ indexPath: string;
49
+ listSessions(): Promise<string[]>;
50
+ getRelationTo(sessionId: string): string | undefined;
51
+ listSubagents?:
52
+ | ((
53
+ scope: Exclude<SessionReachableScope, "user">,
54
+ ) => Promise<readonly ReachableSubagentEntry[]>)
55
+ | undefined;
56
+ }
57
+
58
+ interface SessionReachableRendererState {
59
+ layout?: ExpandableContentLayout | undefined;
60
+ }
61
+
62
+ export function createSessionReachableTool(deps: SessionReachableDeps): ToolDefinition {
63
+ const subagentsAvailable = Boolean(deps.listSubagents);
64
+
65
+ return defineTool({
66
+ name: "session_reachable",
67
+ label: "Reachable Sessions",
68
+ description: subagentsAvailable
69
+ ? "List the sessions this session can address: live user sessions, or its own subagents."
70
+ : "List the live user sessions this session can address.",
71
+ promptSnippet: subagentsAvailable
72
+ ? "List addressable live sessions and owned subagents"
73
+ : "List addressable live sessions",
74
+ promptGuidelines: [
75
+ "Before session_send_message, use session_reachable to find the target session id.",
76
+ ],
77
+ parameters: subagentsAvailable ? SESSION_REACHABLE_PARAMS : SESSION_REACHABLE_USER_PARAMS,
78
+ renderResult(result, options, theme, context) {
79
+ const output = result.content.find((item) => item.type === "text")?.text ?? "";
80
+ if (context.isError) {
81
+ return new Text(output ? theme.fg("error", output) : "", 0, 0);
82
+ }
83
+
84
+ const details = safeParseTypeBoxValue(SESSION_REACHABLE_TOOL_DETAILS_SCHEMA, result.details);
85
+ if (!details) {
86
+ return new Text(output, 0, 0);
87
+ }
88
+
89
+ const state = context.state as SessionReachableRendererState;
90
+ const layout = state.layout ?? new ExpandableContentLayout(theme);
91
+ state.layout = layout;
92
+ layout.update(
93
+ buildSessionReachablePresentation(buildSessionReachableView(details), theme),
94
+ options.expanded,
95
+ );
96
+ return layout;
97
+ },
98
+ async execute(_toolCallId, params: SessionReachableParams, _signal, _onUpdate, ctx) {
99
+ const scope = params.scope ?? "user";
100
+ const sessions =
101
+ scope === "user"
102
+ ? await listReachableUserSessions(deps, ctx)
103
+ : await listReachableSubagents(deps, scope);
104
+
105
+ const details: SessionReachableToolDetails = { scope, sessions };
106
+ return {
107
+ content: [{ type: "text" as const, text: JSON.stringify(details, null, 2) }],
108
+ details,
109
+ };
110
+ },
111
+ });
112
+ }
113
+
114
+ async function listReachableUserSessions(
115
+ deps: SessionReachableDeps,
116
+ ctx: ExtensionContext,
117
+ ): Promise<ReachableUserSession[]> {
118
+ const currentSessionId = ctx.sessionManager.getSessionId();
119
+ let liveSessionIds: string[];
120
+ try {
121
+ liveSessionIds = await deps.listSessions();
122
+ } catch (error) {
123
+ throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
124
+ }
125
+
126
+ const targetIds = liveSessionIds.filter((sessionId) => sessionId !== currentSessionId);
127
+ return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
128
+ const sessions: ReachableUserSession[] = [];
129
+ for (const sessionId of targetIds) {
130
+ const row = getSessionById(db, sessionId);
131
+ if (row?.sessionOrigin === "subagent") {
132
+ continue;
133
+ }
134
+ const relation = deps.getRelationTo(sessionId);
135
+ sessions.push({
136
+ kind: "user",
137
+ sessionId,
138
+ state: row && isStartingSession(row) ? "starting" : "live",
139
+ ...(row?.sessionName ? { title: row.sessionName } : {}),
140
+ ...(row?.cwd ? { cwd: row.cwd } : {}),
141
+ ...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
142
+ ...(relation === undefined ? {} : { relation }),
143
+ });
144
+ }
145
+ return sessions.sort(compareByModifiedDesc);
146
+ });
147
+ }
148
+
149
+ async function listReachableSubagents(
150
+ deps: SessionReachableDeps,
151
+ scope: Exclude<SessionReachableScope, "user">,
152
+ ): Promise<ReachableSubagent[]> {
153
+ if (!deps.listSubagents) {
154
+ throw new Error(`session_reachable scope "${scope}" requires subagents to be active.`);
155
+ }
156
+
157
+ try {
158
+ const entries = await deps.listSubagents(scope);
159
+ return entries.map((entry) => ({
160
+ kind: "subagent",
161
+ sessionId: entry.sessionId,
162
+ title: entry.title,
163
+ goal: entry.goal,
164
+ cwd: entry.cwd,
165
+ state: entry.state,
166
+ depth: entry.depth,
167
+ onActiveBranch: entry.onActiveBranch,
168
+ launchedAt: entry.launchedAt,
169
+ ...(entry.model === undefined ? {} : { model: entry.model }),
170
+ ownerSessionId: entry.ownerSessionId,
171
+ ownerTitle: entry.ownerTitle,
172
+ ownerIsCurrentSession: entry.ownerIsCurrentSession,
173
+ resumeCommand: entry.resumeCommand,
174
+ }));
175
+ } catch (error) {
176
+ throw new Error(`Subagent roster is unavailable: ${formatError(error)}`);
177
+ }
178
+ }
179
+
180
+ function isStartingSession(row: SessionLineageRow): boolean {
181
+ try {
182
+ return isSessionStarting(SessionManager.open(row.sessionPath).getBranch());
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ function compareByModifiedDesc(left: ReachableUserSession, right: ReachableUserSession): number {
189
+ return (right.modifiedAt ?? "").localeCompare(left.modifiedAt ?? "");
190
+ }
@@ -0,0 +1,59 @@
1
+ import path from "node:path";
2
+ import { formatSessionTitleOrShortId } from "../../shared/session-ui.ts";
3
+ import { normalizeOptionalText } from "../../shared/text.ts";
4
+ import type {
5
+ ReachableSession,
6
+ SessionReachableScope,
7
+ SessionReachableToolDetails,
8
+ } from "./reachable-contract.ts";
9
+
10
+ export interface ReachableSessionRowViewModel {
11
+ label: string;
12
+ sessionId: string;
13
+ annotations: string[];
14
+ location?: string | undefined;
15
+ detail?: string | undefined;
16
+ }
17
+
18
+ export interface SessionReachableViewModel {
19
+ scope: SessionReachableScope;
20
+ rows: ReachableSessionRowViewModel[];
21
+ }
22
+
23
+ export function buildSessionReachableView(
24
+ details: SessionReachableToolDetails,
25
+ ): SessionReachableViewModel {
26
+ return {
27
+ scope: details.scope,
28
+ rows: details.sessions.map(buildReachableSessionRow),
29
+ };
30
+ }
31
+
32
+ function buildReachableSessionRow(session: ReachableSession): ReachableSessionRowViewModel {
33
+ if (session.kind === "user") {
34
+ return {
35
+ label: formatSessionTitleOrShortId(session.title, session.sessionId),
36
+ sessionId: session.sessionId,
37
+ annotations: [session.state, ...(session.relation ? [session.relation] : [])],
38
+ location: formatLocation(session.cwd),
39
+ };
40
+ }
41
+
42
+ return {
43
+ label: formatSessionTitleOrShortId(session.title, session.sessionId),
44
+ sessionId: session.sessionId,
45
+ annotations: [
46
+ session.state,
47
+ `depth ${session.depth}`,
48
+ ...(session.onActiveBranch ? [] : ["history"]),
49
+ ...(session.ownerIsCurrentSession ? [] : [`owner ${session.ownerTitle}`]),
50
+ ],
51
+ location: formatLocation(session.cwd),
52
+ detail: normalizeOptionalText(session.goal),
53
+ };
54
+ }
55
+
56
+ function formatLocation(cwd: string | undefined): string | undefined {
57
+ if (!cwd) return undefined;
58
+ return path.basename(cwd) || cwd;
59
+ }
@@ -17,7 +17,7 @@ export const renderIncomingSessionMessage: MessageRenderer = (message, options,
17
17
  buildIncomingMessagePresentation(buildIncomingMessageView(details), theme),
18
18
  options.expanded,
19
19
  );
20
- const box = new Box(1, 1, (text) => theme.bg("toolSuccessBg", text));
20
+ const box = new Box(options.outputPad, 1, (text) => theme.bg("toolSuccessBg", text));
21
21
  box.addChild(layout);
22
22
  return box;
23
23
  };
@@ -66,13 +66,8 @@ export function createSessionSendMessageTool(
66
66
  ? "Send a message to another pi session or a subagent"
67
67
  : "Send a message to another live pi session";
68
68
  const promptGuidelines = options.wakeCapable
69
- ? [
70
- "Use session_search with live: true to discover live sessions.",
71
- "It is always possible to session_send_message to an owned subagent",
72
- ]
73
- : [
74
- "Before session_send_message, use session_search with live: true to list all live sessions and find the target.",
75
- ];
69
+ ? ["Use session_reachable to discover live sessions and owned subagents."]
70
+ : ["Use session_reachable to list live sessions and find the target."];
76
71
 
77
72
  return defineTool({
78
73
  name: "session_send_message",
@@ -194,7 +189,12 @@ export function createSessionCancelTool(service: CancelSessionService): ToolDefi
194
189
  throw new Error(buildUnknownCancellationError(result.target));
195
190
  }
196
191
  return {
197
- content: [{ type: "text" as const, text: buildCancelSessionModelText(details) }],
192
+ content: [
193
+ {
194
+ type: "text" as const,
195
+ text: buildCancelSessionModelText(details),
196
+ },
197
+ ],
198
198
  details,
199
199
  };
200
200
  }