pi-extensions 0.1.11 → 0.1.12

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.
@@ -0,0 +1,1397 @@
1
+ /**
2
+ * Session Control Extension
3
+ *
4
+ * Enables inter-session communication via Unix domain sockets. When enabled with
5
+ * the `--session-control` flag, each pi session creates a control socket at
6
+ * `~/.pi/session-control/<session-id>.sock` that accepts JSON-RPC commands.
7
+ *
8
+ * Features:
9
+ * - Send messages to other running pi sessions (steer or follow-up mode)
10
+ * - Retrieve the last assistant message from a session
11
+ * - Get AI-generated summaries of session activity
12
+ * - Clear/rewind sessions to their initial state
13
+ * - Subscribe to turn_end events for async coordination
14
+ *
15
+ * Once loaded the extension registers a `send_to_session` tool that allows the AI to
16
+ * communicate with other pi sessions programmatically.
17
+ *
18
+ * Usage:
19
+ * pi --session-control
20
+ *
21
+ * Environment:
22
+ * Sets PI_SESSION_ID when enabled, allowing child processes to discover
23
+ * the current session.
24
+ *
25
+ * RPC Protocol:
26
+ * Commands are newline-delimited JSON objects with a `type` field:
27
+ * - { type: "send", message: "...", mode?: "steer"|"follow_up" }
28
+ * - { type: "get_message" }
29
+ * - { type: "get_summary" }
30
+ * - { type: "clear", summarize?: boolean }
31
+ * - { type: "abort" }
32
+ * - { type: "subscribe", event: "turn_end" }
33
+ *
34
+ * Responses are JSON objects with { type: "response", command, success, data?, error? }
35
+ * Events are JSON objects with { type: "event", event, data?, subscriptionId? }
36
+ */
37
+
38
+ import type { ExtensionAPI, ExtensionContext, TurnEndEvent } from "@mariozechner/pi-coding-agent";
39
+ import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
40
+ import { complete, type Model, type Api, type UserMessage } from "@mariozechner/pi-ai";
41
+ import { StringEnum } from "@mariozechner/pi-ai";
42
+ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
43
+ import { Type } from "@sinclair/typebox";
44
+ import { promises as fs } from "node:fs";
45
+ import * as net from "node:net";
46
+ import * as os from "node:os";
47
+ import * as path from "node:path";
48
+
49
+ const CONTROL_FLAG = "session-control";
50
+ const CONTROL_DIR = path.join(os.homedir(), ".pi", "session-control");
51
+ const SOCKET_SUFFIX = ".sock";
52
+
53
+ // ============================================================================
54
+ // RPC Types
55
+ // ============================================================================
56
+
57
+ interface RpcResponse {
58
+ type: "response";
59
+ command: string;
60
+ success: boolean;
61
+ error?: string;
62
+ data?: unknown;
63
+ id?: string;
64
+ }
65
+
66
+ interface RpcEvent {
67
+ type: "event";
68
+ event: string;
69
+ data?: unknown;
70
+ subscriptionId?: string;
71
+ }
72
+
73
+ // Unified command structure
74
+ interface RpcSendCommand {
75
+ type: "send";
76
+ message: string;
77
+ mode?: "steer" | "follow_up";
78
+ id?: string;
79
+ }
80
+
81
+ interface RpcGetMessageCommand {
82
+ type: "get_message";
83
+ id?: string;
84
+ }
85
+
86
+ interface RpcGetSummaryCommand {
87
+ type: "get_summary";
88
+ id?: string;
89
+ }
90
+
91
+ interface RpcClearCommand {
92
+ type: "clear";
93
+ summarize?: boolean;
94
+ id?: string;
95
+ }
96
+
97
+ interface RpcAbortCommand {
98
+ type: "abort";
99
+ id?: string;
100
+ }
101
+
102
+ interface RpcSubscribeCommand {
103
+ type: "subscribe";
104
+ event: "turn_end";
105
+ id?: string;
106
+ }
107
+
108
+ type RpcCommand =
109
+ | RpcSendCommand
110
+ | RpcGetMessageCommand
111
+ | RpcGetSummaryCommand
112
+ | RpcClearCommand
113
+ | RpcAbortCommand
114
+ | RpcSubscribeCommand;
115
+
116
+ // ============================================================================
117
+ // Subscription Management
118
+ // ============================================================================
119
+
120
+ interface TurnEndSubscription {
121
+ socket: net.Socket;
122
+ subscriptionId: string;
123
+ }
124
+
125
+ interface SocketState {
126
+ server: net.Server | null;
127
+ socketPath: string | null;
128
+ context: ExtensionContext | null;
129
+ alias: string | null;
130
+ aliasTimer: ReturnType<typeof setInterval> | null;
131
+ turnEndSubscriptions: TurnEndSubscription[];
132
+ }
133
+
134
+ // ============================================================================
135
+ // Summarization
136
+ // ============================================================================
137
+
138
+ const CODEX_MODEL_ID = "gpt-5.1-codex-mini";
139
+ const HAIKU_MODEL_ID = "claude-haiku-4-5";
140
+
141
+ const SUMMARIZATION_SYSTEM_PROMPT = `You are a conversation summarizer. Create concise, accurate summaries that preserve key information, decisions, and outcomes.`;
142
+
143
+ const TURN_SUMMARY_PROMPT = `Summarize what happened in this conversation since the last user prompt. Focus on:
144
+ - What was accomplished
145
+ - Any decisions made
146
+ - Files that were read, modified, or created
147
+ - Any errors or issues encountered
148
+ - Current state/next steps
149
+
150
+ Be concise but comprehensive. Preserve exact file paths, function names, and error messages.`;
151
+
152
+ async function selectSummarizationModel(
153
+ currentModel: Model<Api> | undefined,
154
+ modelRegistry: {
155
+ find: (provider: string, modelId: string) => Model<Api> | undefined;
156
+ getApiKey: (model: Model<Api>) => Promise<string | undefined>;
157
+ },
158
+ ): Promise<Model<Api> | undefined> {
159
+ const codexModel = modelRegistry.find("openai-codex", CODEX_MODEL_ID);
160
+ if (codexModel) {
161
+ const apiKey = await modelRegistry.getApiKey(codexModel);
162
+ if (apiKey) return codexModel;
163
+ }
164
+
165
+ const haikuModel = modelRegistry.find("anthropic", HAIKU_MODEL_ID);
166
+ if (haikuModel) {
167
+ const apiKey = await modelRegistry.getApiKey(haikuModel);
168
+ if (apiKey) return haikuModel;
169
+ }
170
+
171
+ return currentModel;
172
+ }
173
+
174
+ // ============================================================================
175
+ // Utilities
176
+ // ============================================================================
177
+
178
+ const STATUS_KEY = "session-control";
179
+
180
+ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
181
+ return typeof error === "object" && error !== null && "code" in error;
182
+ }
183
+
184
+ function getSocketPath(sessionId: string): string {
185
+ return path.join(CONTROL_DIR, `${sessionId}${SOCKET_SUFFIX}`);
186
+ }
187
+
188
+ function isSafeSessionId(sessionId: string): boolean {
189
+ return !sessionId.includes("/") && !sessionId.includes("\\") && !sessionId.includes("..") && sessionId.length > 0;
190
+ }
191
+
192
+ function isSafeAlias(alias: string): boolean {
193
+ return !alias.includes("/") && !alias.includes("\\") && !alias.includes("..") && alias.length > 0;
194
+ }
195
+
196
+ function getAliasPath(alias: string): string {
197
+ return path.join(CONTROL_DIR, `${alias}.alias`);
198
+ }
199
+
200
+ function getSessionAlias(ctx: ExtensionContext): string | null {
201
+ const sessionName = ctx.sessionManager.getSessionName();
202
+ const alias = sessionName ? sessionName.trim() : "";
203
+ if (!alias || !isSafeAlias(alias)) return null;
204
+ return alias;
205
+ }
206
+
207
+ async function ensureControlDir(): Promise<void> {
208
+ await fs.mkdir(CONTROL_DIR, { recursive: true });
209
+ }
210
+
211
+ async function removeSocket(socketPath: string | null): Promise<void> {
212
+ if (!socketPath) return;
213
+ try {
214
+ await fs.unlink(socketPath);
215
+ } catch (error) {
216
+ if (isErrnoException(error) && error.code !== "ENOENT") {
217
+ throw error;
218
+ }
219
+ }
220
+ }
221
+
222
+ // TODO: add GC for stale sockets/aliases older than 7 days.
223
+ async function removeAliasesForSocket(socketPath: string | null): Promise<void> {
224
+ if (!socketPath) return;
225
+ try {
226
+ const entries = await fs.readdir(CONTROL_DIR, { withFileTypes: true });
227
+ for (const entry of entries) {
228
+ if (!entry.isSymbolicLink()) continue;
229
+ const aliasPath = path.join(CONTROL_DIR, entry.name);
230
+ let target: string;
231
+ try {
232
+ target = await fs.readlink(aliasPath);
233
+ } catch {
234
+ continue;
235
+ }
236
+ const resolvedTarget = path.resolve(CONTROL_DIR, target);
237
+ if (resolvedTarget === socketPath) {
238
+ await fs.unlink(aliasPath);
239
+ }
240
+ }
241
+ } catch (error) {
242
+ if (isErrnoException(error) && error.code === "ENOENT") return;
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ async function createAliasSymlink(sessionId: string, alias: string): Promise<void> {
248
+ if (!alias || !isSafeAlias(alias)) return;
249
+ const aliasPath = getAliasPath(alias);
250
+ const target = `${sessionId}${SOCKET_SUFFIX}`;
251
+ try {
252
+ await fs.unlink(aliasPath);
253
+ } catch (error) {
254
+ if (isErrnoException(error) && error.code !== "ENOENT") {
255
+ throw error;
256
+ }
257
+ }
258
+ try {
259
+ await fs.symlink(target, aliasPath);
260
+ } catch (error) {
261
+ if (isErrnoException(error) && error.code !== "EEXIST") {
262
+ throw error;
263
+ }
264
+ }
265
+ }
266
+
267
+ async function resolveSessionIdFromAlias(alias: string): Promise<string | null> {
268
+ if (!alias || !isSafeAlias(alias)) return null;
269
+ const aliasPath = getAliasPath(alias);
270
+ try {
271
+ const target = await fs.readlink(aliasPath);
272
+ const resolvedTarget = path.resolve(CONTROL_DIR, target);
273
+ const base = path.basename(resolvedTarget);
274
+ if (!base.endsWith(SOCKET_SUFFIX)) return null;
275
+ const sessionId = base.slice(0, -SOCKET_SUFFIX.length);
276
+ return isSafeSessionId(sessionId) ? sessionId : null;
277
+ } catch (error) {
278
+ if (isErrnoException(error) && error.code === "ENOENT") return null;
279
+ return null;
280
+ }
281
+ }
282
+
283
+ async function getAliasMap(): Promise<Map<string, string[]>> {
284
+ const aliasMap = new Map<string, string[]>();
285
+ const entries = await fs.readdir(CONTROL_DIR, { withFileTypes: true });
286
+ for (const entry of entries) {
287
+ if (!entry.isSymbolicLink()) continue;
288
+ if (!entry.name.endsWith(".alias")) continue;
289
+ const aliasPath = path.join(CONTROL_DIR, entry.name);
290
+ let target: string;
291
+ try {
292
+ target = await fs.readlink(aliasPath);
293
+ } catch {
294
+ continue;
295
+ }
296
+ const resolvedTarget = path.resolve(CONTROL_DIR, target);
297
+ const aliases = aliasMap.get(resolvedTarget);
298
+ const aliasName = entry.name.slice(0, -".alias".length);
299
+ if (aliases) {
300
+ aliases.push(aliasName);
301
+ } else {
302
+ aliasMap.set(resolvedTarget, [aliasName]);
303
+ }
304
+ }
305
+ return aliasMap;
306
+ }
307
+
308
+ async function isSocketAlive(socketPath: string): Promise<boolean> {
309
+ return await new Promise((resolve) => {
310
+ const socket = net.createConnection(socketPath);
311
+ const timeout = setTimeout(() => {
312
+ socket.destroy();
313
+ resolve(false);
314
+ }, 300);
315
+
316
+ const cleanup = (alive: boolean) => {
317
+ clearTimeout(timeout);
318
+ socket.removeAllListeners();
319
+ resolve(alive);
320
+ };
321
+
322
+ socket.once("connect", () => {
323
+ socket.end();
324
+ cleanup(true);
325
+ });
326
+ socket.once("error", () => {
327
+ cleanup(false);
328
+ });
329
+ });
330
+ }
331
+
332
+ type LiveSessionInfo = {
333
+ sessionId: string;
334
+ name?: string;
335
+ aliases: string[];
336
+ socketPath: string;
337
+ };
338
+
339
+ async function getLiveSessions(): Promise<LiveSessionInfo[]> {
340
+ await ensureControlDir();
341
+ const entries = await fs.readdir(CONTROL_DIR, { withFileTypes: true });
342
+ const aliasMap = await getAliasMap();
343
+ const sessions: LiveSessionInfo[] = [];
344
+
345
+ for (const entry of entries) {
346
+ if (!entry.name.endsWith(SOCKET_SUFFIX)) continue;
347
+ const socketPath = path.join(CONTROL_DIR, entry.name);
348
+ const alive = await isSocketAlive(socketPath);
349
+ if (!alive) continue;
350
+ const sessionId = entry.name.slice(0, -SOCKET_SUFFIX.length);
351
+ if (!isSafeSessionId(sessionId)) continue;
352
+ const aliases = aliasMap.get(socketPath) ?? [];
353
+ const name = aliases[0];
354
+ sessions.push({ sessionId, name, aliases, socketPath });
355
+ }
356
+
357
+ sessions.sort((a, b) => (a.name ?? a.sessionId).localeCompare(b.name ?? b.sessionId));
358
+ return sessions;
359
+ }
360
+
361
+ async function syncAlias(state: SocketState, ctx: ExtensionContext): Promise<void> {
362
+ if (!state.server || !state.socketPath) return;
363
+ const alias = getSessionAlias(ctx);
364
+ if (alias && alias !== state.alias) {
365
+ await removeAliasesForSocket(state.socketPath);
366
+ await createAliasSymlink(ctx.sessionManager.getSessionId(), alias);
367
+ state.alias = alias;
368
+ return;
369
+ }
370
+ if (!alias && state.alias) {
371
+ await removeAliasesForSocket(state.socketPath);
372
+ state.alias = null;
373
+ }
374
+ }
375
+
376
+ function writeResponse(socket: net.Socket, response: RpcResponse): void {
377
+ try {
378
+ socket.write(`${JSON.stringify(response)}\n`);
379
+ } catch {
380
+ // Socket may be closed
381
+ }
382
+ }
383
+
384
+ function writeEvent(socket: net.Socket, event: RpcEvent): void {
385
+ try {
386
+ socket.write(`${JSON.stringify(event)}\n`);
387
+ } catch {
388
+ // Socket may be closed
389
+ }
390
+ }
391
+
392
+ function parseCommand(line: string): { command?: RpcCommand; error?: string } {
393
+ try {
394
+ const parsed = JSON.parse(line) as RpcCommand;
395
+ if (!parsed || typeof parsed !== "object") {
396
+ return { error: "Invalid command" };
397
+ }
398
+ if (typeof parsed.type !== "string") {
399
+ return { error: "Missing command type" };
400
+ }
401
+ return { command: parsed };
402
+ } catch (error) {
403
+ return { error: error instanceof Error ? error.message : "Failed to parse command" };
404
+ }
405
+ }
406
+
407
+ // ============================================================================
408
+ // Message Extraction
409
+ // ============================================================================
410
+
411
+ interface ExtractedMessage {
412
+ role: "user" | "assistant";
413
+ content: string;
414
+ timestamp: number;
415
+ }
416
+
417
+ function getLastAssistantMessage(ctx: ExtensionContext): ExtractedMessage | undefined {
418
+ const branch = ctx.sessionManager.getBranch();
419
+
420
+ for (let i = branch.length - 1; i >= 0; i--) {
421
+ const entry = branch[i];
422
+ if (entry.type === "message") {
423
+ const msg = entry.message;
424
+ if ("role" in msg && msg.role === "assistant") {
425
+ const textParts = msg.content
426
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
427
+ .map((c) => c.text);
428
+ if (textParts.length > 0) {
429
+ return {
430
+ role: "assistant",
431
+ content: textParts.join("\n"),
432
+ timestamp: msg.timestamp,
433
+ };
434
+ }
435
+ }
436
+ }
437
+ }
438
+ return undefined;
439
+ }
440
+
441
+ function getMessagesSinceLastPrompt(ctx: ExtensionContext): ExtractedMessage[] {
442
+ const branch = ctx.sessionManager.getBranch();
443
+ const messages: ExtractedMessage[] = [];
444
+
445
+ let lastUserIndex = -1;
446
+ for (let i = branch.length - 1; i >= 0; i--) {
447
+ const entry = branch[i];
448
+ if (entry.type === "message" && "role" in entry.message && entry.message.role === "user") {
449
+ lastUserIndex = i;
450
+ break;
451
+ }
452
+ }
453
+
454
+ if (lastUserIndex === -1) return [];
455
+
456
+ for (let i = lastUserIndex; i < branch.length; i++) {
457
+ const entry = branch[i];
458
+ if (entry.type === "message") {
459
+ const msg = entry.message;
460
+ if ("role" in msg && (msg.role === "user" || msg.role === "assistant")) {
461
+ const textParts = msg.content
462
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
463
+ .map((c) => c.text);
464
+ if (textParts.length > 0) {
465
+ messages.push({
466
+ role: msg.role,
467
+ content: textParts.join("\n"),
468
+ timestamp: msg.timestamp,
469
+ });
470
+ }
471
+ }
472
+ }
473
+ }
474
+
475
+ return messages;
476
+ }
477
+
478
+ function getFirstEntryId(ctx: ExtensionContext): string | undefined {
479
+ const entries = ctx.sessionManager.getEntries();
480
+ if (entries.length === 0) return undefined;
481
+ const root = entries.find((e) => e.parentId === null);
482
+ return root?.id ?? entries[0]?.id;
483
+ }
484
+
485
+ // ============================================================================
486
+ // Command Handlers
487
+ // ============================================================================
488
+
489
+ async function handleCommand(
490
+ pi: ExtensionAPI,
491
+ state: SocketState,
492
+ command: RpcCommand,
493
+ socket: net.Socket,
494
+ ): Promise<void> {
495
+ const id = "id" in command && typeof command.id === "string" ? command.id : undefined;
496
+ const respond = (success: boolean, commandName: string, data?: unknown, error?: string) => {
497
+ if (state.context) {
498
+ void syncAlias(state, state.context);
499
+ }
500
+ writeResponse(socket, { type: "response", command: commandName, success, data, error, id });
501
+ };
502
+
503
+ const ctx = state.context;
504
+ if (!ctx) {
505
+ respond(false, command.type, undefined, "Session not ready");
506
+ return;
507
+ }
508
+
509
+ void syncAlias(state, ctx);
510
+
511
+ // Abort
512
+ if (command.type === "abort") {
513
+ ctx.abort();
514
+ respond(true, "abort");
515
+ return;
516
+ }
517
+
518
+ // Subscribe to turn_end
519
+ if (command.type === "subscribe") {
520
+ if (command.event === "turn_end") {
521
+ const subscriptionId = id ?? `sub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
522
+ state.turnEndSubscriptions.push({ socket, subscriptionId });
523
+
524
+ const cleanup = () => {
525
+ const idx = state.turnEndSubscriptions.findIndex((s) => s.subscriptionId === subscriptionId);
526
+ if (idx !== -1) state.turnEndSubscriptions.splice(idx, 1);
527
+ };
528
+ socket.once("close", cleanup);
529
+ socket.once("error", cleanup);
530
+
531
+ respond(true, "subscribe", { subscriptionId, event: "turn_end" });
532
+ return;
533
+ }
534
+ respond(false, "subscribe", undefined, `Unknown event type: ${command.event}`);
535
+ return;
536
+ }
537
+
538
+ // Get last message
539
+ if (command.type === "get_message") {
540
+ const message = getLastAssistantMessage(ctx);
541
+ if (!message) {
542
+ respond(true, "get_message", { message: null });
543
+ return;
544
+ }
545
+ respond(true, "get_message", { message });
546
+ return;
547
+ }
548
+
549
+ // Get summary
550
+ if (command.type === "get_summary") {
551
+ const messages = getMessagesSinceLastPrompt(ctx);
552
+ if (messages.length === 0) {
553
+ respond(false, "get_summary", undefined, "No messages to summarize");
554
+ return;
555
+ }
556
+
557
+ const model = await selectSummarizationModel(ctx.model, ctx.modelRegistry);
558
+ if (!model) {
559
+ respond(false, "get_summary", undefined, "No model available for summarization");
560
+ return;
561
+ }
562
+
563
+ const apiKey = await ctx.modelRegistry.getApiKey(model);
564
+ if (!apiKey) {
565
+ respond(false, "get_summary", undefined, "No API key available for summarization model");
566
+ return;
567
+ }
568
+
569
+ try {
570
+ const conversationText = messages
571
+ .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
572
+ .join("\n\n");
573
+
574
+ const userMessage: UserMessage = {
575
+ role: "user",
576
+ content: [{ type: "text", text: `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_SUMMARY_PROMPT}` }],
577
+ timestamp: Date.now(),
578
+ };
579
+
580
+ const response = await complete(
581
+ model,
582
+ { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: [userMessage] },
583
+ { apiKey },
584
+ );
585
+
586
+ if (response.stopReason === "aborted" || response.stopReason === "error") {
587
+ respond(false, "get_summary", undefined, "Summarization failed");
588
+ return;
589
+ }
590
+
591
+ const summary = response.content
592
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
593
+ .map((c) => c.text)
594
+ .join("\n");
595
+
596
+ respond(true, "get_summary", { summary, model: model.id });
597
+ } catch (error) {
598
+ respond(false, "get_summary", undefined, error instanceof Error ? error.message : "Summarization failed");
599
+ }
600
+ return;
601
+ }
602
+
603
+ // Clear session
604
+ if (command.type === "clear") {
605
+ if (!ctx.isIdle()) {
606
+ respond(false, "clear", undefined, "Session is busy - wait for turn to complete");
607
+ return;
608
+ }
609
+
610
+ const firstEntryId = getFirstEntryId(ctx);
611
+ if (!firstEntryId) {
612
+ respond(false, "clear", undefined, "No entries in session");
613
+ return;
614
+ }
615
+
616
+ const currentLeafId = ctx.sessionManager.getLeafId();
617
+ if (currentLeafId === firstEntryId) {
618
+ respond(true, "clear", { cleared: true, alreadyAtRoot: true });
619
+ return;
620
+ }
621
+
622
+ if (command.summarize) {
623
+ // Summarization requires navigateTree which we don't have direct access to
624
+ // Return an error for now - the caller should clear without summarize
625
+ // or use a different approach
626
+ respond(false, "clear", undefined, "Clear with summarization not supported via RPC - use summarize=false");
627
+ return;
628
+ }
629
+
630
+ // Access internal session manager to rewind (type assertion to access non-readonly methods)
631
+ try {
632
+ const sessionManager = ctx.sessionManager as unknown as { rewindTo(id: string): void };
633
+ sessionManager.rewindTo(firstEntryId);
634
+ respond(true, "clear", { cleared: true, targetId: firstEntryId });
635
+ } catch (error) {
636
+ respond(false, "clear", undefined, error instanceof Error ? error.message : "Clear failed");
637
+ }
638
+ return;
639
+ }
640
+
641
+ // Send message
642
+ if (command.type === "send") {
643
+ const message = command.message;
644
+ if (typeof message !== "string" || message.trim().length === 0) {
645
+ respond(false, "send", undefined, "Missing message");
646
+ return;
647
+ }
648
+
649
+ const mode = command.mode ?? "steer";
650
+ const isIdle = ctx.isIdle();
651
+
652
+ if (isIdle) {
653
+ pi.sendUserMessage(message);
654
+ } else {
655
+ pi.sendUserMessage(message, { deliverAs: mode === "follow_up" ? "followUp" : "steer" });
656
+ }
657
+
658
+ respond(true, "send", { delivered: true, mode: isIdle ? "direct" : mode });
659
+ return;
660
+ }
661
+
662
+ respond(false, command.type, undefined, `Unsupported command: ${command.type}`);
663
+ }
664
+
665
+ // ============================================================================
666
+ // Server Management
667
+ // ============================================================================
668
+
669
+ async function createServer(pi: ExtensionAPI, state: SocketState, socketPath: string): Promise<net.Server> {
670
+ const server = net.createServer((socket) => {
671
+ socket.setEncoding("utf8");
672
+ let buffer = "";
673
+ socket.on("data", (chunk) => {
674
+ buffer += chunk;
675
+ let newlineIndex = buffer.indexOf("\n");
676
+ while (newlineIndex !== -1) {
677
+ const line = buffer.slice(0, newlineIndex).trim();
678
+ buffer = buffer.slice(newlineIndex + 1);
679
+ newlineIndex = buffer.indexOf("\n");
680
+ if (!line) continue;
681
+
682
+ const parsed = parseCommand(line);
683
+ if (parsed.error) {
684
+ if (state.context) {
685
+ void syncAlias(state, state.context);
686
+ }
687
+ writeResponse(socket, {
688
+ type: "response",
689
+ command: "parse",
690
+ success: false,
691
+ error: `Failed to parse command: ${parsed.error}`,
692
+ });
693
+ continue;
694
+ }
695
+
696
+ handleCommand(pi, state, parsed.command!, socket);
697
+ }
698
+ });
699
+ });
700
+
701
+ // Wait for server to start listening, with error handling
702
+ await new Promise<void>((resolve, reject) => {
703
+ server.once("error", reject);
704
+ server.listen(socketPath, () => {
705
+ server.removeListener("error", reject);
706
+ resolve();
707
+ });
708
+ });
709
+
710
+ return server;
711
+ }
712
+
713
+ interface RpcClientOptions {
714
+ timeout?: number;
715
+ waitForEvent?: "turn_end";
716
+ }
717
+
718
+ async function sendRpcCommand(
719
+ socketPath: string,
720
+ command: RpcCommand,
721
+ options: RpcClientOptions = {},
722
+ ): Promise<{ response: RpcResponse; event?: { message?: ExtractedMessage; turnIndex?: number } }> {
723
+ const { timeout = 5000, waitForEvent } = options;
724
+
725
+ return new Promise((resolve, reject) => {
726
+ const socket = net.createConnection(socketPath);
727
+ socket.setEncoding("utf8");
728
+
729
+ const timeoutHandle = setTimeout(() => {
730
+ socket.destroy(new Error("timeout"));
731
+ }, timeout);
732
+
733
+ let buffer = "";
734
+ let response: RpcResponse | null = null;
735
+
736
+ const cleanup = () => {
737
+ clearTimeout(timeoutHandle);
738
+ socket.removeAllListeners();
739
+ };
740
+
741
+ socket.on("connect", () => {
742
+ socket.write(`${JSON.stringify(command)}\n`);
743
+
744
+ // If waiting for turn_end, also subscribe
745
+ if (waitForEvent === "turn_end") {
746
+ const subscribeCmd: RpcSubscribeCommand = { type: "subscribe", event: "turn_end" };
747
+ socket.write(`${JSON.stringify(subscribeCmd)}\n`);
748
+ }
749
+ });
750
+
751
+ socket.on("data", (chunk) => {
752
+ buffer += chunk;
753
+ let newlineIndex = buffer.indexOf("\n");
754
+ while (newlineIndex !== -1) {
755
+ const line = buffer.slice(0, newlineIndex).trim();
756
+ buffer = buffer.slice(newlineIndex + 1);
757
+ newlineIndex = buffer.indexOf("\n");
758
+ if (!line) continue;
759
+
760
+ try {
761
+ const msg = JSON.parse(line);
762
+
763
+ // Handle response
764
+ if (msg.type === "response") {
765
+ if (msg.command === command.type) {
766
+ response = msg;
767
+ // If not waiting for event, we're done
768
+ if (!waitForEvent) {
769
+ cleanup();
770
+ socket.end();
771
+ resolve({ response });
772
+ return;
773
+ }
774
+ }
775
+ // Ignore subscribe response
776
+ continue;
777
+ }
778
+
779
+ // Handle turn_end event
780
+ if (msg.type === "event" && msg.event === "turn_end" && waitForEvent === "turn_end") {
781
+ cleanup();
782
+ socket.end();
783
+ if (!response) {
784
+ reject(new Error("Received event before response"));
785
+ return;
786
+ }
787
+ resolve({ response, event: msg.data || {} });
788
+ return;
789
+ }
790
+ } catch {
791
+ // Ignore parse errors, keep waiting
792
+ }
793
+ }
794
+ });
795
+
796
+ socket.on("error", (error) => {
797
+ cleanup();
798
+ reject(error);
799
+ });
800
+ });
801
+ }
802
+
803
+ async function startControlServer(pi: ExtensionAPI, state: SocketState, ctx: ExtensionContext): Promise<void> {
804
+ await ensureControlDir();
805
+ const sessionId = ctx.sessionManager.getSessionId();
806
+ const socketPath = getSocketPath(sessionId);
807
+
808
+ if (state.socketPath === socketPath && state.server) {
809
+ state.context = ctx;
810
+ await syncAlias(state, ctx);
811
+ return;
812
+ }
813
+
814
+ await stopControlServer(state);
815
+ await removeSocket(socketPath);
816
+
817
+ state.context = ctx;
818
+ state.socketPath = socketPath;
819
+ state.server = await createServer(pi, state, socketPath);
820
+ state.alias = null;
821
+ await syncAlias(state, ctx);
822
+ }
823
+
824
+ async function stopControlServer(state: SocketState): Promise<void> {
825
+ if (!state.server) {
826
+ await removeAliasesForSocket(state.socketPath);
827
+ await removeSocket(state.socketPath);
828
+ state.socketPath = null;
829
+ state.alias = null;
830
+ return;
831
+ }
832
+
833
+ const socketPath = state.socketPath;
834
+ state.socketPath = null;
835
+ state.turnEndSubscriptions = [];
836
+ await new Promise<void>((resolve) => state.server?.close(() => resolve()));
837
+ state.server = null;
838
+ await removeAliasesForSocket(socketPath);
839
+ await removeSocket(socketPath);
840
+ state.alias = null;
841
+ }
842
+
843
+ function updateStatus(ctx: ExtensionContext | null, enabled: boolean): void {
844
+ if (!ctx?.hasUI) return;
845
+ if (!enabled) {
846
+ ctx.ui.setStatus(STATUS_KEY, undefined);
847
+ return;
848
+ }
849
+ const sessionId = ctx.sessionManager.getSessionId();
850
+ ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", `session ${sessionId}`));
851
+ }
852
+
853
+ function updateSessionEnv(ctx: ExtensionContext | null, enabled: boolean): void {
854
+ if (!enabled) {
855
+ delete process.env.PI_SESSION_ID;
856
+ return;
857
+ }
858
+ if (!ctx) return;
859
+ process.env.PI_SESSION_ID = ctx.sessionManager.getSessionId();
860
+ }
861
+
862
+ // ============================================================================
863
+ // Extension Export
864
+ // ============================================================================
865
+
866
+ export default function (pi: ExtensionAPI) {
867
+ pi.registerFlag(CONTROL_FLAG, {
868
+ description: "Enable per-session control socket under ~/.pi/session-control",
869
+ type: "boolean",
870
+ });
871
+
872
+ const state: SocketState = {
873
+ server: null,
874
+ socketPath: null,
875
+ context: null,
876
+ alias: null,
877
+ aliasTimer: null,
878
+ turnEndSubscriptions: [],
879
+ };
880
+
881
+ registerSessionTool(pi, state);
882
+ registerListSessionsTool(pi);
883
+ registerControlSessionsCommand(pi);
884
+
885
+ const refreshServer = async (ctx: ExtensionContext) => {
886
+ const enabled = pi.getFlag(CONTROL_FLAG) === true;
887
+ if (!enabled) {
888
+ if (state.aliasTimer) {
889
+ clearInterval(state.aliasTimer);
890
+ state.aliasTimer = null;
891
+ }
892
+ await stopControlServer(state);
893
+ updateStatus(ctx, false);
894
+ updateSessionEnv(ctx, false);
895
+ return;
896
+ }
897
+ await startControlServer(pi, state, ctx);
898
+ if (!state.aliasTimer) {
899
+ state.aliasTimer = setInterval(() => {
900
+ if (!state.context) return;
901
+ void syncAlias(state, state.context);
902
+ }, 1000);
903
+ }
904
+ updateStatus(ctx, true);
905
+ updateSessionEnv(ctx, true);
906
+ };
907
+
908
+ pi.on("session_start", async (_event, ctx) => {
909
+ await refreshServer(ctx);
910
+ });
911
+
912
+ pi.on("session_switch", async (_event, ctx) => {
913
+ await refreshServer(ctx);
914
+ });
915
+
916
+ pi.on("session_shutdown", async () => {
917
+ if (state.aliasTimer) {
918
+ clearInterval(state.aliasTimer);
919
+ state.aliasTimer = null;
920
+ }
921
+ updateStatus(state.context, false);
922
+ updateSessionEnv(state.context, false);
923
+ await stopControlServer(state);
924
+ });
925
+
926
+ // Fire turn_end events to subscribers
927
+ pi.on("turn_end", (event: TurnEndEvent, ctx: ExtensionContext) => {
928
+ if (state.turnEndSubscriptions.length === 0) return;
929
+
930
+ void syncAlias(state, ctx);
931
+ const lastMessage = getLastAssistantMessage(ctx);
932
+ const eventData = { message: lastMessage, turnIndex: event.turnIndex };
933
+
934
+ // Fire to all subscribers (one-shot)
935
+ const subscriptions = [...state.turnEndSubscriptions];
936
+ state.turnEndSubscriptions = [];
937
+
938
+ for (const sub of subscriptions) {
939
+ writeEvent(sub.socket, {
940
+ type: "event",
941
+ event: "turn_end",
942
+ data: eventData,
943
+ subscriptionId: sub.subscriptionId,
944
+ });
945
+ }
946
+ });
947
+ }
948
+
949
+ // ============================================================================
950
+ // Tool: send_to_session
951
+ // ============================================================================
952
+
953
+ function registerSessionTool(pi: ExtensionAPI, state: SocketState): void {
954
+ pi.registerTool({
955
+ name: "send_to_session",
956
+ label: "Send To Session",
957
+ description: `Interact with another running pi session via its control socket.
958
+
959
+ Actions:
960
+ - send: Send a message (default). Requires 'message' parameter.
961
+ - get_message: Get the most recent assistant message.
962
+ - get_summary: Get a summary of activity since the last user prompt.
963
+ - clear: Rewind session to initial state.
964
+
965
+ Target selection:
966
+ - sessionId: UUID of the session.
967
+ - sessionName: session name (alias from /name).
968
+
969
+ Wait behavior (only for action=send):
970
+ - wait_until=turn_end: Wait for the turn to complete, returns last assistant message.
971
+ - wait_until=message_processed: Returns immediately after message is queued.
972
+
973
+ Messages automatically include sender session info for replies.`,
974
+ parameters: Type.Object({
975
+ sessionId: Type.Optional(Type.String({ description: "Target session id (UUID)" })),
976
+ sessionName: Type.Optional(Type.String({ description: "Target session name (alias)" })),
977
+ action: Type.Optional(
978
+ StringEnum(["send", "get_message", "get_summary", "clear"] as const, {
979
+ description: "Action to perform (default: send)",
980
+ default: "send",
981
+ }),
982
+ ),
983
+ message: Type.Optional(Type.String({ description: "Message to send (required for action=send)" })),
984
+ mode: Type.Optional(
985
+ StringEnum(["steer", "follow_up"] as const, {
986
+ description: "Delivery mode for send: steer (immediate) or follow_up (after task)",
987
+ default: "steer",
988
+ }),
989
+ ),
990
+ wait_until: Type.Optional(
991
+ StringEnum(["turn_end", "message_processed"] as const, {
992
+ description: "Wait behavior for send action",
993
+ }),
994
+ ),
995
+ }),
996
+ async execute(_toolCallId, params) {
997
+ const action = params.action ?? "send";
998
+ const sessionName = params.sessionName?.trim();
999
+ const sessionId = params.sessionId?.trim();
1000
+ let targetSessionId: string | null = null;
1001
+ const displayTarget = sessionName || sessionId || "";
1002
+
1003
+ if (sessionName) {
1004
+ targetSessionId = await resolveSessionIdFromAlias(sessionName);
1005
+ if (!targetSessionId) {
1006
+ return {
1007
+ content: [{ type: "text", text: "Unknown session name" }],
1008
+ isError: true,
1009
+ details: { error: "Unknown session name" },
1010
+ };
1011
+ }
1012
+ }
1013
+
1014
+ if (sessionId) {
1015
+ if (!isSafeSessionId(sessionId)) {
1016
+ return {
1017
+ content: [{ type: "text", text: "Invalid session id" }],
1018
+ isError: true,
1019
+ details: { error: "Invalid session id" },
1020
+ };
1021
+ }
1022
+ if (targetSessionId && targetSessionId !== sessionId) {
1023
+ return {
1024
+ content: [{ type: "text", text: "Session name does not match session id" }],
1025
+ isError: true,
1026
+ details: { error: "Session name does not match session id" },
1027
+ };
1028
+ }
1029
+ targetSessionId = sessionId;
1030
+ }
1031
+
1032
+ if (!targetSessionId) {
1033
+ return {
1034
+ content: [{ type: "text", text: "Missing session id or session name" }],
1035
+ isError: true,
1036
+ details: { error: "Missing session id or session name" },
1037
+ };
1038
+ }
1039
+
1040
+ const socketPath = getSocketPath(targetSessionId);
1041
+ const senderSessionId = state.context?.sessionManager.getSessionId();
1042
+
1043
+ try {
1044
+ // Handle each action
1045
+ if (action === "get_message") {
1046
+ const result = await sendRpcCommand(socketPath, { type: "get_message" });
1047
+ if (!result.response.success) {
1048
+ return {
1049
+ content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
1050
+ isError: true,
1051
+ details: result,
1052
+ };
1053
+ }
1054
+ const data = result.response.data as { message?: ExtractedMessage };
1055
+ if (!data?.message) {
1056
+ return {
1057
+ content: [{ type: "text", text: "No assistant message found in session" }],
1058
+ details: result,
1059
+ };
1060
+ }
1061
+ return {
1062
+ content: [{ type: "text", text: data.message.content }],
1063
+ details: { message: data.message },
1064
+ };
1065
+ }
1066
+
1067
+ if (action === "get_summary") {
1068
+ const result = await sendRpcCommand(socketPath, { type: "get_summary" }, { timeout: 60000 });
1069
+ if (!result.response.success) {
1070
+ return {
1071
+ content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
1072
+ isError: true,
1073
+ details: result,
1074
+ };
1075
+ }
1076
+ const data = result.response.data as { summary?: string; model?: string };
1077
+ if (!data?.summary) {
1078
+ return {
1079
+ content: [{ type: "text", text: "No summary generated" }],
1080
+ details: result,
1081
+ };
1082
+ }
1083
+ return {
1084
+ content: [{ type: "text", text: `Summary (via ${data.model}):\n\n${data.summary}` }],
1085
+ details: { summary: data.summary, model: data.model },
1086
+ };
1087
+ }
1088
+
1089
+ if (action === "clear") {
1090
+ const result = await sendRpcCommand(socketPath, { type: "clear", summarize: false }, { timeout: 10000 });
1091
+ if (!result.response.success) {
1092
+ return {
1093
+ content: [{ type: "text", text: `Failed to clear: ${result.response.error ?? "unknown error"}` }],
1094
+ isError: true,
1095
+ details: result,
1096
+ };
1097
+ }
1098
+ const data = result.response.data as { cleared?: boolean; alreadyAtRoot?: boolean };
1099
+ const msg = data?.alreadyAtRoot ? "Session already at root" : "Session cleared";
1100
+ return {
1101
+ content: [{ type: "text", text: msg }],
1102
+ details: data,
1103
+ };
1104
+ }
1105
+
1106
+ // action === "send"
1107
+ if (!params.message || params.message.trim().length === 0) {
1108
+ return {
1109
+ content: [{ type: "text", text: "Missing message for send action" }],
1110
+ isError: true,
1111
+ details: { error: "Missing message" },
1112
+ };
1113
+ }
1114
+
1115
+ const senderInfo = senderSessionId
1116
+ ? `\n\n<sender_info>This message was sent by session ${senderSessionId}</sender_info>`
1117
+ : "";
1118
+
1119
+ const sendCommand: RpcSendCommand = {
1120
+ type: "send",
1121
+ message: params.message + senderInfo,
1122
+ mode: params.mode ?? "steer",
1123
+ };
1124
+
1125
+ // Determine wait behavior
1126
+ if (params.wait_until === "message_processed") {
1127
+ // Just send and confirm delivery
1128
+ const result = await sendRpcCommand(socketPath, sendCommand);
1129
+ if (!result.response.success) {
1130
+ return {
1131
+ content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
1132
+ isError: true,
1133
+ details: result,
1134
+ };
1135
+ }
1136
+ return {
1137
+ content: [{ type: "text", text: "Message delivered to session" }],
1138
+ details: result.response.data,
1139
+ };
1140
+ }
1141
+
1142
+ if (params.wait_until === "turn_end") {
1143
+ // Send and wait for turn to complete
1144
+ const result = await sendRpcCommand(socketPath, sendCommand, {
1145
+ timeout: 300000, // 5 minutes
1146
+ waitForEvent: "turn_end",
1147
+ });
1148
+
1149
+ if (!result.response.success) {
1150
+ return {
1151
+ content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
1152
+ isError: true,
1153
+ details: result,
1154
+ };
1155
+ }
1156
+
1157
+ const lastMessage = result.event?.message;
1158
+ if (!lastMessage) {
1159
+ return {
1160
+ content: [{ type: "text", text: "Turn completed but no assistant message found" }],
1161
+ details: { turnIndex: result.event?.turnIndex },
1162
+ };
1163
+ }
1164
+
1165
+ return {
1166
+ content: [{ type: "text", text: lastMessage.content }],
1167
+ details: { message: lastMessage, turnIndex: result.event?.turnIndex },
1168
+ };
1169
+ }
1170
+
1171
+ // No wait - just send
1172
+ const result = await sendRpcCommand(socketPath, sendCommand);
1173
+ if (!result.response.success) {
1174
+ return {
1175
+ content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
1176
+ isError: true,
1177
+ details: result,
1178
+ };
1179
+ }
1180
+
1181
+ return {
1182
+ content: [{ type: "text", text: `Message sent to session ${displayTarget || targetSessionId}` }],
1183
+ details: result.response.data,
1184
+ };
1185
+ } catch (error) {
1186
+ const message = error instanceof Error ? error.message : "Unknown error";
1187
+ return {
1188
+ content: [{ type: "text", text: `Failed: ${message}` }],
1189
+ isError: true,
1190
+ details: { error: message },
1191
+ };
1192
+ }
1193
+ },
1194
+
1195
+ renderCall(args, theme) {
1196
+ const action = args.action ?? "send";
1197
+ const sessionRef = args.sessionName ?? args.sessionId ?? "...";
1198
+ const shortSessionRef = sessionRef.length > 12 ? sessionRef.slice(0, 8) + "..." : sessionRef;
1199
+
1200
+ // Build the header line
1201
+ let header = theme.fg("toolTitle", theme.bold("→ session "));
1202
+ header += theme.fg("accent", shortSessionRef);
1203
+
1204
+ // Add action-specific info
1205
+ if (action === "send") {
1206
+ const mode = args.mode ?? "steer";
1207
+ const wait = args.wait_until;
1208
+ let info = theme.fg("muted", ` (${mode}`);
1209
+ if (wait) info += theme.fg("dim", `, wait: ${wait}`);
1210
+ info += theme.fg("muted", ")");
1211
+ header += info;
1212
+ } else {
1213
+ header += theme.fg("muted", ` (${action})`);
1214
+ }
1215
+
1216
+ // For send action, show the message
1217
+ if (action === "send" && args.message) {
1218
+ const msg = args.message;
1219
+ const preview = msg.length > 80 ? msg.slice(0, 80) + "..." : msg;
1220
+ // Handle multi-line messages
1221
+ const firstLine = preview.split("\n")[0];
1222
+ const hasMore = preview.includes("\n") || msg.length > 80;
1223
+ return new Text(
1224
+ header + "\n " + theme.fg("dim", `"${firstLine}${hasMore ? "..." : ""}"`),
1225
+ 0,
1226
+ 0,
1227
+ );
1228
+ }
1229
+
1230
+ return new Text(header, 0, 0);
1231
+ },
1232
+
1233
+ renderResult(result, { expanded }, theme) {
1234
+ const details = result.details as Record<string, unknown> | undefined;
1235
+ const isError = result.isError === true;
1236
+
1237
+ // Error case
1238
+ if (isError || details?.error) {
1239
+ const errorMsg = (details?.error as string) || result.content[0]?.type === "text" ? (result.content[0] as { type: "text"; text: string }).text : "Unknown error";
1240
+ return new Text(theme.fg("error", "✗ ") + theme.fg("error", errorMsg), 0, 0);
1241
+ }
1242
+
1243
+ // Detect action from details structure
1244
+ const hasMessage = details && "message" in details && details.message;
1245
+ const hasSummary = details && "summary" in details;
1246
+ const hasCleared = details && "cleared" in details;
1247
+ const hasTurnIndex = details && "turnIndex" in details;
1248
+
1249
+ // get_message or turn_end result with message
1250
+ if (hasMessage) {
1251
+ const message = details.message as ExtractedMessage;
1252
+ const icon = theme.fg("success", "✓");
1253
+
1254
+ if (expanded) {
1255
+ const container = new Container();
1256
+ container.addChild(new Text(icon + theme.fg("muted", " Message received"), 0, 0));
1257
+ container.addChild(new Spacer(1));
1258
+ container.addChild(new Markdown(message.content, 0, 0, getMarkdownTheme()));
1259
+ if (hasTurnIndex) {
1260
+ container.addChild(new Spacer(1));
1261
+ container.addChild(new Text(theme.fg("dim", `Turn #${details.turnIndex}`), 0, 0));
1262
+ }
1263
+ return container;
1264
+ }
1265
+
1266
+ // Collapsed view - show preview
1267
+ const preview = message.content.length > 200
1268
+ ? message.content.slice(0, 200) + "..."
1269
+ : message.content;
1270
+ const lines = preview.split("\n").slice(0, 5);
1271
+ let text = icon + theme.fg("muted", " Message received");
1272
+ if (hasTurnIndex) text += theme.fg("dim", ` (turn #${details.turnIndex})`);
1273
+ text += "\n" + theme.fg("toolOutput", lines.join("\n"));
1274
+ if (message.content.split("\n").length > 5 || message.content.length > 200) {
1275
+ text += "\n" + theme.fg("dim", "(Ctrl+O to expand)");
1276
+ }
1277
+ return new Text(text, 0, 0);
1278
+ }
1279
+
1280
+ // get_summary result
1281
+ if (hasSummary) {
1282
+ const summary = details.summary as string;
1283
+ const model = details.model as string | undefined;
1284
+ const icon = theme.fg("success", "✓");
1285
+
1286
+ if (expanded) {
1287
+ const container = new Container();
1288
+ let header = icon + theme.fg("muted", " Summary");
1289
+ if (model) header += theme.fg("dim", ` via ${model}`);
1290
+ container.addChild(new Text(header, 0, 0));
1291
+ container.addChild(new Spacer(1));
1292
+ container.addChild(new Markdown(summary, 0, 0, getMarkdownTheme()));
1293
+ return container;
1294
+ }
1295
+
1296
+ const preview = summary.length > 200 ? summary.slice(0, 200) + "..." : summary;
1297
+ const lines = preview.split("\n").slice(0, 5);
1298
+ let text = icon + theme.fg("muted", " Summary");
1299
+ if (model) text += theme.fg("dim", ` via ${model}`);
1300
+ text += "\n" + theme.fg("toolOutput", lines.join("\n"));
1301
+ if (summary.split("\n").length > 5 || summary.length > 200) {
1302
+ text += "\n" + theme.fg("dim", "(Ctrl+O to expand)");
1303
+ }
1304
+ return new Text(text, 0, 0);
1305
+ }
1306
+
1307
+ // clear result
1308
+ if (hasCleared) {
1309
+ const alreadyAtRoot = details.alreadyAtRoot as boolean | undefined;
1310
+ const icon = theme.fg("success", "✓");
1311
+ const msg = alreadyAtRoot ? "Session already at root" : "Session cleared";
1312
+ return new Text(icon + " " + theme.fg("muted", msg), 0, 0);
1313
+ }
1314
+
1315
+ // send result (no wait or message_processed)
1316
+ if (details && "delivered" in details) {
1317
+ const mode = details.mode as string | undefined;
1318
+ const icon = theme.fg("success", "✓");
1319
+ let text = icon + theme.fg("muted", " Message delivered");
1320
+ if (mode) text += theme.fg("dim", ` (${mode})`);
1321
+ return new Text(text, 0, 0);
1322
+ }
1323
+
1324
+ // Fallback - just show the text content
1325
+ const text = result.content[0];
1326
+ const content = text?.type === "text" ? text.text : "(no output)";
1327
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", content), 0, 0);
1328
+ },
1329
+ });
1330
+ }
1331
+
1332
+ // ============================================================================
1333
+ // Tool: list_sessions
1334
+ // ============================================================================
1335
+
1336
+ function registerListSessionsTool(pi: ExtensionAPI): void {
1337
+ pi.registerTool({
1338
+ name: "list_sessions",
1339
+ label: "List Sessions",
1340
+ description: "List live sessions that expose a control socket (optionally with session names).",
1341
+ parameters: Type.Object({}),
1342
+ async execute() {
1343
+ const sessions = await getLiveSessions();
1344
+
1345
+ if (sessions.length === 0) {
1346
+ return {
1347
+ content: [{ type: "text", text: "No live sessions found." }],
1348
+ details: { sessions: [] },
1349
+ };
1350
+ }
1351
+
1352
+ const lines = sessions.map((session) => {
1353
+ const name = session.name ? ` (${session.name})` : "";
1354
+ return `- ${session.sessionId}${name}`;
1355
+ });
1356
+
1357
+ return {
1358
+ content: [{ type: "text", text: `Live sessions:\n${lines.join("\n")}` }],
1359
+ details: { sessions },
1360
+ };
1361
+ },
1362
+ });
1363
+ }
1364
+
1365
+ function registerControlSessionsCommand(pi: ExtensionAPI): void {
1366
+ pi.registerCommand("control-sessions", {
1367
+ description: "List controllable sessions (from session-control sockets)",
1368
+ handler: async (_args, ctx) => {
1369
+ if (pi.getFlag(CONTROL_FLAG) !== true) {
1370
+ if (ctx.hasUI) {
1371
+ ctx.ui.notify("Session control not enabled (use --session-control)", "warning");
1372
+ }
1373
+ return;
1374
+ }
1375
+
1376
+ const sessions = await getLiveSessions();
1377
+ const currentSessionId = ctx.sessionManager.getSessionId();
1378
+ const lines = sessions.map((session) => {
1379
+ const name = session.name ? ` (${session.name})` : "";
1380
+ const current = session.sessionId === currentSessionId ? " (current)" : "";
1381
+ return `- ${session.sessionId}${name}${current}`;
1382
+ });
1383
+ const content = sessions.length === 0
1384
+ ? "No live sessions found."
1385
+ : `Controllable sessions:\n${lines.join("\n")}`;
1386
+
1387
+ pi.sendMessage(
1388
+ {
1389
+ customType: "control-sessions",
1390
+ content,
1391
+ display: true,
1392
+ },
1393
+ { triggerTurn: false },
1394
+ );
1395
+ },
1396
+ });
1397
+ }