pi-extensions 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/files-widget/CHANGELOG.md +7 -0
- package/files-widget/README.md +12 -6
- package/files-widget/index.ts +10 -6
- package/files-widget/package.json +4 -1
- package/package.json +1 -1
- package/.ralph/import-cc-codex.md +0 -31
- package/.ralph/import-cc-codex.state.json +0 -14
- package/.ralph/mario-not-impl.md +0 -69
- package/.ralph/mario-not-impl.state.json +0 -14
- package/.ralph/mario-not-spec.md +0 -163
- package/.ralph/mario-not-spec.state.json +0 -14
- package/.ralph/todo-app-plan.state.json +0 -14
- package/control/control.ts +0 -1397
- package/review/review.ts +0 -807
package/control/control.ts
DELETED
|
@@ -1,1397 +0,0 @@
|
|
|
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
|
-
}
|