pi-sessions 0.6.0 → 0.7.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 (43) hide show
  1. package/README.md +22 -11
  2. package/extensions/session-ask/agent.ts +400 -0
  3. package/extensions/session-ask/navigate.ts +880 -0
  4. package/extensions/session-ask.ts +79 -131
  5. package/extensions/session-auto-title/model.ts +3 -11
  6. package/extensions/session-handoff/picker.ts +50 -4
  7. package/extensions/session-handoff/query.ts +74 -56
  8. package/extensions/session-handoff/refs.ts +12 -18
  9. package/extensions/session-handoff.ts +36 -27
  10. package/extensions/session-messaging/broker/process.ts +23 -26
  11. package/extensions/session-messaging/broker/spawn.ts +7 -2
  12. package/extensions/session-messaging/pi/incoming-runtime.ts +2 -18
  13. package/extensions/session-messaging/pi/message-contracts.ts +9 -10
  14. package/extensions/session-messaging/pi/message-view.ts +12 -1
  15. package/extensions/session-messaging/pi/service.ts +117 -62
  16. package/extensions/session-messaging/pi/tools.ts +4 -56
  17. package/extensions/session-search/extract.ts +86 -436
  18. package/extensions/session-search/hooks.ts +17 -25
  19. package/extensions/session-search/normalize.ts +1 -1
  20. package/extensions/session-search/reindex.ts +1 -0
  21. package/extensions/session-search.ts +157 -128
  22. package/extensions/shared/model.ts +18 -0
  23. package/extensions/shared/session-broker/active.ts +26 -0
  24. package/extensions/{session-messaging/pi → shared/session-broker}/client.ts +16 -19
  25. package/extensions/{session-messaging/shared → shared/session-broker}/framing.ts +1 -1
  26. package/extensions/{session-messaging/shared → shared/session-broker}/protocol.ts +5 -12
  27. package/extensions/shared/session-index/access.ts +128 -0
  28. package/extensions/shared/session-index/common.ts +43 -48
  29. package/extensions/shared/session-index/index.ts +1 -0
  30. package/extensions/shared/session-index/lineage.ts +10 -0
  31. package/extensions/shared/session-index/query/ast.ts +40 -0
  32. package/extensions/shared/session-index/query/compiler.ts +146 -0
  33. package/extensions/shared/session-index/query/lexer.ts +140 -0
  34. package/extensions/shared/session-index/query/parser.ts +178 -0
  35. package/extensions/shared/session-index/schema.ts +22 -6
  36. package/extensions/shared/session-index/scoring.ts +80 -0
  37. package/extensions/shared/session-index/search.ts +552 -278
  38. package/extensions/shared/session-index/sqlite.ts +16 -9
  39. package/extensions/shared/session-index/store.ts +12 -21
  40. package/extensions/shared/settings.ts +61 -4
  41. package/extensions/shared/text.ts +50 -0
  42. package/package.json +1 -1
  43. /package/extensions/{session-messaging/shared → shared/session-broker}/socket-path.ts +0 -0
@@ -1,12 +1,10 @@
1
1
  import path from "node:path";
2
2
  import { parseSessionFile } from "../session-search/extract.ts";
3
3
  import {
4
- getIndexStatus,
5
4
  getSessionById,
6
- INDEX_SCHEMA_VERSION,
7
- openIndexDatabase,
8
5
  type SessionLineageRow,
9
6
  type SessionOrigin,
7
+ withSessionIndex,
10
8
  } from "../shared/session-index/index.ts";
11
9
 
12
10
  const HANDOFF_REF_PREFIX = "@handoff/";
@@ -103,24 +101,20 @@ function resolveIndexedReference(
103
101
  return { error: `Invalid session reference: ${input}. ${SESSION_REFERENCE_HELP}` };
104
102
  }
105
103
 
106
- const status = getIndexStatus(options.indexPath);
107
- if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
104
+ try {
105
+ return withSessionIndex(options.indexPath, { mode: "read", required: true }, ({ db }) => {
106
+ const exactMatch = getSessionById(db, value);
107
+ if (exactMatch) {
108
+ return { resolved: buildResolvedReference(input, kind, exactMatch) };
109
+ }
110
+
111
+ return { error: `No session found for reference: ${input}. ${SESSION_REFERENCE_HELP}` };
112
+ });
113
+ } catch {
108
114
  return {
109
- error: `Session reference resolution requires a current index at ${status.dbPath}. Run /session-index and press r to rebuild it.`,
115
+ error: `Session reference resolution requires a current index at ${options.indexPath}. Run /session-index and press r to rebuild it.`,
110
116
  };
111
117
  }
112
-
113
- const db = openIndexDatabase(status.dbPath, { create: false });
114
- try {
115
- const exactMatch = getSessionById(db, value);
116
- if (exactMatch) {
117
- return { resolved: buildResolvedReference(input, kind, exactMatch) };
118
- }
119
-
120
- return { error: `No session found for reference: ${input}. ${SESSION_REFERENCE_HELP}` };
121
- } finally {
122
- db.close();
123
- }
124
118
  }
125
119
 
126
120
  function buildResolvedReference(
@@ -9,7 +9,7 @@ import type {
9
9
  ExtensionUIContext,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import { buildSessionContext, SessionManager } from "@earendil-works/pi-coding-agent";
12
- import { Key, matchesKey } from "@earendil-works/pi-tui";
12
+ import { Key, matchesKey, Text } from "@earendil-works/pi-tui";
13
13
  import { Type } from "typebox";
14
14
  import {
15
15
  generateHandoffDraft,
@@ -62,7 +62,6 @@ interface HandoffToolParams {
62
62
  }
63
63
 
64
64
  interface HandoffToolDetails {
65
- error: boolean;
66
65
  sessionId?: string | undefined;
67
66
  title?: string | undefined;
68
67
  splitDirection?: HandoffSplitDirection | undefined;
@@ -90,6 +89,7 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
90
89
  "Use session_handoff only when it is clear the work should be forked to a new context.",
91
90
  "Prefer using session_handoff by direction of the user, not as an unsolicited default.",
92
91
  "The goal should capture enough detail to encompass the ask and include any directions the next sessions should consider.",
92
+ "Only request a response when there is a specific ask-and-response expectation: the user asked for a report back, or the current session needs the child result before it can continue. Do not set requestResponse for independent or unrelated background work; most split-off tasks should run without reporting back.",
93
93
  "Only capable of a background handoff; to replace the current session, tell the user to run /handoff instead.",
94
94
  ],
95
95
  executionMode: "sequential",
@@ -118,6 +118,23 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
118
118
  }),
119
119
  ),
120
120
  }),
121
+ renderResult(result, _options, theme, context) {
122
+ const text = getFirstText(result);
123
+ if (context.isError) {
124
+ return new Text(theme.fg("error", text), 0, 0);
125
+ }
126
+
127
+ const details = result.details as HandoffToolDetails | undefined;
128
+ if (!details?.sessionId || !details.splitDirection || !details.cwd) {
129
+ return new Text(text, 0, 0);
130
+ }
131
+
132
+ return new Text(
133
+ `Started handoff session ${theme.bold(details.sessionId)} (${details.splitDirection}) in ${theme.fg("dim", details.cwd)}.`,
134
+ 0,
135
+ 0,
136
+ );
137
+ },
121
138
  async execute(_toolCallId, params: HandoffToolParams, _signal, _onUpdate, ctx) {
122
139
  return executeSessionHandoffTool(pi, params, ctx, identifiedGhosttyTerminalId);
123
140
  },
@@ -361,30 +378,30 @@ async function executeSessionHandoffTool(
361
378
  ) {
362
379
  const goal = params.goal.trim();
363
380
  if (!goal) {
364
- return createHandoffToolError("session_handoff requires a goal.");
381
+ throw new Error("session_handoff requires a goal.");
365
382
  }
366
383
 
367
384
  if (!ctx.model) {
368
- return createHandoffToolError("No model selected.");
385
+ throw new Error("No model selected.");
369
386
  }
370
387
 
371
388
  const preflightError = await validateSplitHandoffPrerequisites(pi, ctx);
372
389
  if (preflightError) {
373
- return createHandoffToolError(preflightError);
390
+ throw new Error(preflightError);
374
391
  }
375
392
 
376
393
  if (!terminalId) {
377
- return createHandoffToolError(NO_IDENTIFIED_TERMINAL_MESSAGE);
394
+ throw new Error(NO_IDENTIFIED_TERMINAL_MESSAGE);
378
395
  }
379
396
 
380
397
  const targetCwd = resolveHandoffCwd(ctx.cwd, params.cwd);
381
398
  if (targetCwd.error) {
382
- return createHandoffToolError(targetCwd.message);
399
+ throw new Error(targetCwd.message);
383
400
  }
384
401
 
385
402
  const parentSessionFile = ctx.sessionManager.getSessionFile();
386
403
  if (!parentSessionFile) {
387
- return createHandoffToolError("Handoff requires a persisted current session.");
404
+ throw new Error("Handoff requires a persisted current session.");
388
405
  }
389
406
 
390
407
  const sessionContext = buildSessionContext(
@@ -392,7 +409,7 @@ async function executeSessionHandoffTool(
392
409
  ctx.sessionManager.getLeafId(),
393
410
  );
394
411
  if (sessionContext.messages.length === 0) {
395
- return createHandoffToolError("No conversation to hand off.");
412
+ throw new Error("No conversation to hand off.");
396
413
  }
397
414
 
398
415
  const requestResponse = params.requestResponse ?? false;
@@ -425,7 +442,7 @@ async function executeSessionHandoffTool(
425
442
  });
426
443
 
427
444
  if (!launchResult.success) {
428
- return createHandoffToolError(
445
+ throw new Error(
429
446
  `${launchResult.error} Created handoff session ${createdSession.sessionId}; start it manually with: ${buildPiResumeCommand(
430
447
  ctx.sessionManager.getSessionDir(),
431
448
  createdSession.sessionId,
@@ -433,17 +450,10 @@ async function executeSessionHandoffTool(
433
450
  TOOL_HANDOFF_PROVISIONAL_TITLE,
434
451
  model,
435
452
  )}`,
436
- {
437
- sessionId: createdSession.sessionId,
438
- title: TOOL_HANDOFF_PROVISIONAL_TITLE,
439
- splitDirection: params.splitDirection,
440
- cwd: targetCwd.path,
441
- },
442
453
  );
443
454
  }
444
455
 
445
456
  const details: HandoffToolDetails = {
446
- error: false,
447
457
  sessionId: createdSession.sessionId,
448
458
  title: TOOL_HANDOFF_PROVISIONAL_TITLE,
449
459
  splitDirection: params.splitDirection,
@@ -454,7 +464,7 @@ async function executeSessionHandoffTool(
454
464
  content: [
455
465
  {
456
466
  type: "text" as const,
457
- text: `Started handoff session ${createdSession.sessionId} (${params.splitDirection}) in ${targetCwd.path}.`,
467
+ text: formatHandoffToolResultForModel(details, requestResponse),
458
468
  },
459
469
  ],
460
470
  details,
@@ -527,16 +537,15 @@ async function startChildGeneratedHandoff(
527
537
  }
528
538
  }
529
539
 
530
- function createHandoffToolError(message: string, details: Partial<HandoffToolDetails> = {}) {
531
- const resultDetails: HandoffToolDetails = {
532
- error: true,
533
- ...details,
534
- };
540
+ function formatHandoffToolResultForModel(
541
+ details: HandoffToolDetails,
542
+ requestResponse: boolean,
543
+ ): string {
544
+ return JSON.stringify({ ...details, requestResponse }, null, 2);
545
+ }
535
546
 
536
- return {
537
- content: [{ type: "text" as const, text: message }],
538
- details: resultDetails,
539
- };
547
+ function getFirstText(result: { content: Array<{ type: string; text?: string }> }): string {
548
+ return result.content.find((item) => item.type === "text")?.text ?? "";
540
549
  }
541
550
 
542
551
  function resolveHandoffCwd(
@@ -1,15 +1,17 @@
1
1
  import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { createServer, type Socket } from "node:net";
3
3
  import { join } from "node:path";
4
- import { readFrames, writeFrame } from "../shared/framing.ts";
4
+ import { readFrames, writeFrame } from "../../shared/session-broker/framing.ts";
5
5
  import {
6
6
  CLIENT_FRAME_SCHEMA,
7
7
  type SessionMessagingClientFrame,
8
8
  type SessionMessagingIncomingAckClientFrame,
9
9
  type SessionMessagingSendClientFrame,
10
- type SessionMessagingSessionInfo,
11
- } from "../shared/protocol.ts";
12
- import { getSessionMessagingDir, getSessionMessagingSocketPath } from "../shared/socket-path.ts";
10
+ } from "../../shared/session-broker/protocol.ts";
11
+ import {
12
+ getSessionMessagingDir,
13
+ getSessionMessagingSocketPath,
14
+ } from "../../shared/session-broker/socket-path.ts";
13
15
 
14
16
  const SEND_TIMEOUT_MS = 10_000;
15
17
  const EXIT_IDLE_MS = 5_000;
@@ -19,11 +21,6 @@ const messagingDir = getSessionMessagingDir();
19
21
  const socketPath = getSessionMessagingSocketPath();
20
22
  const pidPath = join(messagingDir, "broker.pid");
21
23
 
22
- interface ConnectedSession {
23
- socket: Socket;
24
- session: SessionMessagingSessionInfo;
25
- }
26
-
27
24
  interface PendingSend {
28
25
  source: Socket;
29
26
  timeout: NodeJS.Timeout;
@@ -31,7 +28,7 @@ interface PendingSend {
31
28
  targetSessionId: string;
32
29
  }
33
30
 
34
- const sessions = new Map<string, ConnectedSession>();
31
+ const sessions = new Map<string, Socket>();
35
32
  const pendingSends = new Map<string, PendingSend>();
36
33
  let idleTimer: NodeJS.Timeout | undefined;
37
34
  let server: ReturnType<typeof createServer> | undefined;
@@ -63,9 +60,9 @@ function unregister(sessionId: string | undefined): void {
63
60
  if (sessions.size === 0) scheduleIdleExit();
64
61
  }
65
62
 
66
- function resolveTarget(target: string): { session: ConnectedSession } | { error: string } {
67
- const exact = sessions.get(target);
68
- if (exact) return { session: exact };
63
+ function resolveTarget(target: string): { sessionId: string; socket: Socket } | { error: string } {
64
+ const socket = sessions.get(target);
65
+ if (socket) return { sessionId: target, socket };
69
66
 
70
67
  return { error: `No live session found for id: ${target}` };
71
68
  }
@@ -84,8 +81,8 @@ function handleFrame(
84
81
 
85
82
  switch (message.type) {
86
83
  case "register": {
87
- const existing = sessions.get(message.session.sessionId);
88
- if (existing && existing.socket !== socket && !existing.socket.destroyed) {
84
+ const existing = sessions.get(message.sessionId);
85
+ if (existing && existing !== socket && !existing.destroyed) {
89
86
  writeFrame(socket, {
90
87
  type: "register_failed",
91
88
  reason:
@@ -96,10 +93,10 @@ function handleFrame(
96
93
  }
97
94
 
98
95
  clearIdleExit();
99
- setSessionId(message.session.sessionId);
100
- sessions.set(message.session.sessionId, { socket, session: message.session });
96
+ setSessionId(message.sessionId);
97
+ sessions.set(message.sessionId, socket);
101
98
  socket.setKeepAlive(true, KEEPALIVE_DELAY_MS);
102
- writeFrame(socket, { type: "registered", session: message.session });
99
+ writeFrame(socket, { type: "registered", sessionId: message.sessionId });
103
100
  break;
104
101
  }
105
102
 
@@ -113,7 +110,7 @@ function handleFrame(
113
110
  writeFrame(socket, {
114
111
  type: "sessions",
115
112
  requestId: message.requestId,
116
- sessions: [...sessions.values()].map(({ session }) => session),
113
+ sessionIds: [...sessions.keys()],
117
114
  });
118
115
  break;
119
116
 
@@ -135,7 +132,7 @@ function handleSend(
135
132
  const source = currentSessionId ? sessions.get(currentSessionId) : undefined;
136
133
  const { messageId } = message;
137
134
 
138
- if (!source) {
135
+ if (!currentSessionId || !source) {
139
136
  writeFrame(socket, {
140
137
  type: "send_result",
141
138
  requestId: message.requestId,
@@ -158,7 +155,7 @@ function handleSend(
158
155
  return;
159
156
  }
160
157
 
161
- if (resolved.session.session.sessionId === source.session.sessionId) {
158
+ if (resolved.sessionId === currentSessionId) {
162
159
  writeFrame(socket, {
163
160
  type: "send_result",
164
161
  requestId: message.requestId,
@@ -183,16 +180,16 @@ function handleSend(
183
180
  source: socket,
184
181
  timeout,
185
182
  messageId,
186
- targetSessionId: resolved.session.session.sessionId,
183
+ targetSessionId: resolved.sessionId,
187
184
  });
188
185
 
189
- writeFrame(resolved.session.socket, {
186
+ writeFrame(resolved.socket, {
190
187
  type: "incoming",
191
188
  requestId: message.requestId,
192
189
  message: {
193
190
  messageId,
194
- source: source.session,
195
- target: resolved.session.session,
191
+ source: currentSessionId,
192
+ target: resolved.sessionId,
196
193
  body: message.body,
197
194
  ...(message.requestResponse === undefined
198
195
  ? {}
@@ -274,7 +271,7 @@ async function readConnection(
274
271
  }
275
272
 
276
273
  function shutdown(): void {
277
- for (const { socket } of sessions.values()) socket.end();
274
+ for (const socket of sessions.values()) socket.end();
278
275
  sessions.clear();
279
276
  for (const pending of pendingSends.values()) clearTimeout(pending.timeout);
280
277
  pendingSends.clear();
@@ -3,7 +3,10 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "
3
3
  import net from "node:net";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { getSessionMessagingDir, getSessionMessagingSocketPath } from "../shared/socket-path.ts";
6
+ import {
7
+ getSessionMessagingDir,
8
+ getSessionMessagingSocketPath,
9
+ } from "../../shared/session-broker/socket-path.ts";
7
10
 
8
11
  const BROKER_START_TIMEOUT_MS = 5_000;
9
12
  const BROKER_CONNECT_TIMEOUT_MS = 1_000;
@@ -107,7 +110,9 @@ async function waitForBroker(): Promise<void> {
107
110
  function acquireSpawnLock(): boolean {
108
111
  for (let attempt = 0; attempt < 5; attempt += 1) {
109
112
  try {
110
- writeFileSync(brokerSpawnLockPath, `${process.pid}\n${Date.now()}\n`, { flag: "wx" });
113
+ writeFileSync(brokerSpawnLockPath, `${process.pid}\n${Date.now()}\n`, {
114
+ flag: "wx",
115
+ });
111
116
  return true;
112
117
  } catch (error) {
113
118
  if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") {
@@ -1,8 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { SessionLineageRelation } from "../../shared/session-index/index.ts";
3
2
  import { safeParseTypeBoxValue } from "../../shared/typebox.ts";
4
- import type { SessionMessagePayload } from "../shared/protocol.ts";
5
3
  import { RECEIVED_MESSAGE_ENTRY_SCHEMA, type ReceivedMessageEntry } from "./message-contracts.ts";
4
+
6
5
  export const MESSAGE_RECEIVED_CUSTOM_TYPE = "pi-sessions.message_received";
7
6
  export const SESSION_MESSAGE_CUSTOM_TYPE = "pi-sessions.session_message";
8
7
 
@@ -39,17 +38,13 @@ export class IncomingSessionMessageRuntime {
39
38
  });
40
39
  }
41
40
 
42
- deliver(
43
- message: SessionMessagePayload,
44
- relation: SessionLineageRelation | undefined,
45
- ): DeliveryResult {
41
+ deliver(received: ReceivedMessageEntry): DeliveryResult {
46
42
  const ctx = this.context;
47
43
  if (!ctx) {
48
44
  return { delivered: false, error: "Target session is not ready." };
49
45
  }
50
46
 
51
47
  try {
52
- const received = buildReceivedMessageEntry(message, relation);
53
48
  this.actions.appendEntry(MESSAGE_RECEIVED_CUSTOM_TYPE, received);
54
49
  this.inject(ctx, received);
55
50
  return { delivered: true };
@@ -106,17 +101,6 @@ export class IncomingSessionMessageRuntime {
106
101
  }
107
102
  }
108
103
 
109
- function buildReceivedMessageEntry(
110
- message: SessionMessagePayload,
111
- relation: SessionLineageRelation | undefined,
112
- ): ReceivedMessageEntry {
113
- return {
114
- ...message,
115
- receivedAt: new Date().toISOString(),
116
- ...(relation === undefined ? {} : { relation }),
117
- };
118
- }
119
-
120
104
  function formatIncomingMessageForModel(received: ReceivedMessageEntry): string {
121
105
  const title = received.source.sessionName?.trim();
122
106
  const titlePart = title ? ` "${title}"` : "";
@@ -1,5 +1,11 @@
1
1
  import { type Static, Type } from "typebox";
2
2
 
3
+ const RECEIVED_MESSAGE_ENDPOINT_SCHEMA = Type.Object({
4
+ sessionId: Type.String(),
5
+ sessionName: Type.Optional(Type.String()),
6
+ cwd: Type.Optional(Type.String()),
7
+ });
8
+
3
9
  export const SEND_MESSAGE_PARAMS = Type.Object({
4
10
  session: Type.String({ description: "Pi session UUID." }),
5
11
  message: Type.String({
@@ -15,16 +21,8 @@ export const SEND_MESSAGE_PARAMS = Type.Object({
15
21
 
16
22
  export const RECEIVED_MESSAGE_ENTRY_SCHEMA = Type.Object({
17
23
  messageId: Type.String(),
18
- source: Type.Object({
19
- sessionId: Type.String(),
20
- sessionName: Type.Optional(Type.String()),
21
- cwd: Type.String(),
22
- }),
23
- target: Type.Object({
24
- sessionId: Type.String(),
25
- sessionName: Type.Optional(Type.String()),
26
- cwd: Type.String(),
27
- }),
24
+ source: RECEIVED_MESSAGE_ENDPOINT_SCHEMA,
25
+ target: RECEIVED_MESSAGE_ENDPOINT_SCHEMA,
28
26
  body: Type.String(),
29
27
  sentAt: Type.String(),
30
28
  receivedAt: Type.String(),
@@ -34,4 +32,5 @@ export const RECEIVED_MESSAGE_ENTRY_SCHEMA = Type.Object({
34
32
  });
35
33
 
36
34
  export type SendMessageParams = Static<typeof SEND_MESSAGE_PARAMS>;
35
+ export type ReceivedMessageEndpoint = Static<typeof RECEIVED_MESSAGE_ENDPOINT_SCHEMA>;
37
36
  export type ReceivedMessageEntry = Static<typeof RECEIVED_MESSAGE_ENTRY_SCHEMA>;
@@ -14,6 +14,7 @@ interface MessageViewOptions {
14
14
  status: "sending" | "received";
15
15
  targetSessionId?: string | undefined;
16
16
  sourceSessionId?: string | undefined;
17
+ sourceSessionName?: string | undefined;
17
18
  message: string;
18
19
  requestResponse?: boolean | undefined;
19
20
  relation?: string | undefined;
@@ -41,6 +42,7 @@ export function createReceivedSessionMessageComponent(
41
42
  expanded,
42
43
  status: "received",
43
44
  sourceSessionId: received.source.sessionId,
45
+ sourceSessionName: received.source.sessionName,
44
46
  message: received.body,
45
47
  requestResponse: received.requestResponse,
46
48
  relation: received.relation,
@@ -84,12 +86,21 @@ function formatHeader(options: MessageViewOptions, theme: MessageViewTheme): str
84
86
  const metadataHint = metadata.length > 0 ? theme.fg("muted", ` (${metadata.join(", ")})`) : "";
85
87
  switch (options.status) {
86
88
  case "received":
87
- return `${name} ${theme.fg("muted", `from ${options.sourceSessionId ?? "[source unknown]"}`)}${metadataHint}`;
89
+ return `${name} ${theme.fg("muted", `from ${formatSourceLabel(options)}`)}${metadataHint}`;
88
90
  case "sending":
89
91
  return `${name} ${theme.fg("muted", `to ${options.targetSessionId ?? "[target pending]"}`)}${metadataHint}`;
90
92
  }
91
93
  }
92
94
 
95
+ function formatSourceLabel(options: MessageViewOptions): string {
96
+ if (!options.sourceSessionId) {
97
+ return "[source unknown]";
98
+ }
99
+
100
+ const title = options.sourceSessionName?.trim();
101
+ return title ? `${title} (${options.sourceSessionId})` : options.sourceSessionId;
102
+ }
103
+
93
104
  function formatKeyHint(
94
105
  keybinding: Keybinding,
95
106
  description: string,