@docyrus/docyrus 0.0.19 → 0.0.21
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/agent-loader.js +37 -3
- package/agent-loader.js.map +2 -2
- package/main.js +498 -93
- package/main.js.map +4 -4
- package/package.json +14 -4
- package/resources/chrome-tools/browser-content.js +103 -0
- package/resources/chrome-tools/browser-cookies.js +35 -0
- package/resources/chrome-tools/browser-eval.js +53 -0
- package/resources/chrome-tools/browser-hn-scraper.js +108 -0
- package/resources/chrome-tools/browser-nav.js +44 -0
- package/resources/chrome-tools/browser-pick.js +162 -0
- package/resources/chrome-tools/browser-screenshot.js +34 -0
- package/resources/chrome-tools/browser-start.js +86 -0
- package/resources/pi-agent/extensions/answer.ts +532 -0
- package/resources/pi-agent/extensions/context.ts +578 -0
- package/resources/pi-agent/extensions/control.ts +1779 -0
- package/resources/pi-agent/extensions/diff.ts +218 -0
- package/resources/pi-agent/extensions/files.ts +199 -0
- package/resources/pi-agent/extensions/loop.ts +446 -0
- package/resources/pi-agent/extensions/multi-edit.ts +835 -0
- package/resources/pi-agent/extensions/notify.ts +88 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/CHANGELOG.md +192 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/LICENSE +21 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/README.md +296 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/app-bridge.bundle.js +67 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/cli.js +108 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/commands.ts +211 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/config.ts +227 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/consent-manager.ts +64 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/direct-tools.ts +301 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/errors.ts +219 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/glimpse-ui.ts +80 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/host-html-template.ts +427 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/index.ts +232 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/init.ts +319 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/lifecycle.ts +93 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/logger.ts +169 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/mcp-panel.ts +713 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/metadata-cache.ts +191 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/npx-resolver.ts +419 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/oauth-handler.ts +56 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/package.json +85 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/paths.ts +29 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/proxy-modes.ts +635 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/resource-tools.ts +17 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/server-manager.ts +330 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/state.ts +41 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-metadata.ts +144 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-registrar.ts +46 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/types.ts +367 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-resource-handler.ts +145 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-server.ts +623 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-session.ts +384 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-stream-types.ts +89 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/utils.ts +75 -0
- package/resources/pi-agent/extensions/prompt-editor.ts +1315 -0
- package/resources/pi-agent/extensions/prompt-url-widget.ts +158 -0
- package/resources/pi-agent/extensions/redraws.ts +24 -0
- package/resources/pi-agent/extensions/review.ts +2160 -0
- package/resources/pi-agent/extensions/todos.ts +2076 -0
- package/resources/pi-agent/extensions/tps.ts +47 -0
- package/resources/pi-agent/extensions/whimsical.ts +474 -0
- package/resources/pi-agent/prompts/coder-system.md +106 -0
- package/resources/pi-agent/skills/changelog-generator/SKILL.md +425 -0
- package/resources/pi-agent/skills/docyrus-chrome-devtools-cli/SKILL.md +80 -0
- package/resources/pi-agent/skills/docyrus-platform/SKILL.md +71 -0
- package/resources/pi-agent/skills/docyrus-platform/references/ai-capabilities.md +43 -0
- package/resources/pi-agent/skills/docyrus-platform/references/auth-and-multi-tenancy.md +35 -0
- package/resources/pi-agent/skills/docyrus-platform/references/automation-and-workflows.md +30 -0
- package/resources/pi-agent/skills/docyrus-platform/references/core-building-blocks.md +53 -0
- package/resources/pi-agent/skills/{docyrus-api-dev → docyrus-platform}/references/data-source-query-guide.md +32 -28
- package/resources/pi-agent/skills/docyrus-platform/references/developer-tools.md +28 -0
- package/resources/pi-agent/skills/docyrus-platform/references/docyrus-cli-usage.md +554 -0
- package/resources/pi-agent/skills/{docyrus-api-dev → docyrus-platform}/references/formula-design-guide-llm.md +15 -23
- package/resources/pi-agent/skills/docyrus-platform/references/integrations-and-events.md +60 -0
- package/resources/pi-agent/skills/docyrus-platform/references/platform-services.md +58 -0
- package/resources/pi-agent/skills/docyrus-platform/references/querying-and-data-operations.md +27 -0
- package/resources/pi-agent/prompts/coder-append-system.md +0 -19
- package/resources/pi-agent/skills/docyrus-ai/SKILL.md +0 -28
- package/resources/pi-agent/skills/docyrus-api-dev/SKILL.md +0 -161
- package/resources/pi-agent/skills/docyrus-api-dev/references/api-client.md +0 -349
- package/resources/pi-agent/skills/docyrus-api-dev/references/authentication.md +0 -238
- package/resources/pi-agent/skills/docyrus-api-dev/references/query-and-formulas.md +0 -592
- package/resources/pi-agent/skills/docyrus-api-doctor/SKILL.md +0 -70
- package/resources/pi-agent/skills/docyrus-api-doctor/references/checklist-details.md +0 -588
- package/resources/pi-agent/skills/docyrus-app-dev/SKILL.md +0 -159
- package/resources/pi-agent/skills/docyrus-app-dev/references/api-client-and-auth.md +0 -275
- package/resources/pi-agent/skills/docyrus-app-dev/references/collections-and-patterns.md +0 -352
- package/resources/pi-agent/skills/docyrus-app-dev/references/data-source-query-guide.md +0 -2059
- package/resources/pi-agent/skills/docyrus-app-dev/references/formula-design-guide-llm.md +0 -320
- package/resources/pi-agent/skills/docyrus-app-dev/references/query-guide.md +0 -525
- package/resources/pi-agent/skills/docyrus-app-ui-design/SKILL.md +0 -466
- package/resources/pi-agent/skills/docyrus-app-ui-design/references/component-selection-guide.md +0 -602
- package/resources/pi-agent/skills/docyrus-app-ui-design/references/icon-usage-guide.md +0 -463
- package/resources/pi-agent/skills/docyrus-app-ui-design/references/preferred-components-catalog.md +0 -242
- package/resources/pi-agent/skills/docyrus-apps/SKILL.md +0 -54
- package/resources/pi-agent/skills/docyrus-architect/SKILL.md +0 -174
- package/resources/pi-agent/skills/docyrus-architect/references/custom-query-guide.md +0 -410
- package/resources/pi-agent/skills/docyrus-architect/references/data-source-query-guide.md +0 -2059
- package/resources/pi-agent/skills/docyrus-architect/references/formula-design-guide-llm.md +0 -320
- package/resources/pi-agent/skills/docyrus-architect/references/formula-reference.md +0 -145
- package/resources/pi-agent/skills/docyrus-auth/SKILL.md +0 -100
- package/resources/pi-agent/skills/docyrus-cli-app/SKILL.md +0 -279
- package/resources/pi-agent/skills/docyrus-cli-app/references/cli-manifest.md +0 -532
- package/resources/pi-agent/skills/docyrus-cli-app/references/list-query-examples.md +0 -248
- package/resources/pi-agent/skills/docyrus-curl/SKILL.md +0 -32
- package/resources/pi-agent/skills/docyrus-discover/SKILL.md +0 -63
- package/resources/pi-agent/skills/docyrus-ds/SKILL.md +0 -95
- package/resources/pi-agent/skills/docyrus-env/SKILL.md +0 -21
- package/resources/pi-agent/skills/docyrus-studio/SKILL.md +0 -369
- package/resources/pi-agent/skills/docyrus-tui/SKILL.md +0 -15
|
@@ -0,0 +1,1779 @@
|
|
|
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
|
+
* `<agent-dir>/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
|
+
* via tool (`send_to_session`) or startup CLI flags (`--control-session`, `--send-session-message`)
|
|
11
|
+
* - Retrieve the last assistant message from a session
|
|
12
|
+
* - Get AI-generated summaries of session activity
|
|
13
|
+
* - Clear/rewind sessions to their initial state
|
|
14
|
+
* - Subscribe to turn_end events for async coordination
|
|
15
|
+
*
|
|
16
|
+
* Once loaded the extension registers a `send_to_session` tool that allows the AI to
|
|
17
|
+
* communicate with other pi sessions programmatically.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* pi --session-control
|
|
21
|
+
*
|
|
22
|
+
* One-shot startup send:
|
|
23
|
+
* pi -p --session-control --control-session <session-name|session-id> --send-session-message <text>
|
|
24
|
+
* [--send-session-mode steer|follow_up] [--send-session-wait turn_end|message_processed]
|
|
25
|
+
* [--send-session-include-sender-info]
|
|
26
|
+
* (startup send is one-way by default; use --send-session-wait turn_end to capture response on stdout)
|
|
27
|
+
*
|
|
28
|
+
* Environment:
|
|
29
|
+
* Sets PI_SESSION_ID when enabled, allowing child processes to discover
|
|
30
|
+
* the current session.
|
|
31
|
+
*
|
|
32
|
+
* RPC Protocol:
|
|
33
|
+
* Commands are newline-delimited JSON objects with a `type` field:
|
|
34
|
+
* - { type: "send", message: "...", mode?: "steer"|"follow_up" }
|
|
35
|
+
* - { type: "get_message" }
|
|
36
|
+
* - { type: "get_summary" }
|
|
37
|
+
* - { type: "clear", summarize?: boolean }
|
|
38
|
+
* - { type: "abort" }
|
|
39
|
+
* - { type: "subscribe", event: "turn_end" }
|
|
40
|
+
*
|
|
41
|
+
* Responses are JSON objects with { type: "response", command, success, data?, error? }
|
|
42
|
+
* Events are JSON objects with { type: "event", event, data?, subscriptionId? }
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { ExtensionAPI, ExtensionContext, TurnEndEvent, MessageRenderer } from "@mariozechner/pi-coding-agent";
|
|
46
|
+
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
|
47
|
+
import { complete, type Model, type Api, type UserMessage, type TextContent } from "@mariozechner/pi-ai";
|
|
48
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
49
|
+
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
|
50
|
+
import { Type } from "@sinclair/typebox";
|
|
51
|
+
import { promises as fs } from "node:fs";
|
|
52
|
+
import * as net from "node:net";
|
|
53
|
+
import * as os from "node:os";
|
|
54
|
+
import * as path from "node:path";
|
|
55
|
+
|
|
56
|
+
const CONTROL_FLAG = "session-control";
|
|
57
|
+
const CONTROL_TARGET_FLAG = "control-session";
|
|
58
|
+
const CONTROL_SEND_MESSAGE_FLAG = "send-session-message";
|
|
59
|
+
const CONTROL_SEND_MODE_FLAG = "send-session-mode";
|
|
60
|
+
const CONTROL_SEND_WAIT_FLAG = "send-session-wait";
|
|
61
|
+
const CONTROL_SEND_INCLUDE_SENDER_FLAG = "send-session-include-sender-info";
|
|
62
|
+
const SOCKET_SUFFIX = ".sock";
|
|
63
|
+
const SESSION_MESSAGE_TYPE = "session-message";
|
|
64
|
+
const SENDER_INFO_PATTERN = /<sender_info>[\s\S]*?<\/sender_info>/g;
|
|
65
|
+
|
|
66
|
+
function expandUserPath(inputPath: string): string {
|
|
67
|
+
if (inputPath === "~") return os.homedir();
|
|
68
|
+
if (inputPath.startsWith("~/")) return path.join(os.homedir(), inputPath.slice(2));
|
|
69
|
+
return inputPath;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getScopedAgentDir(): string {
|
|
73
|
+
const envCandidates = ["PI_CODING_AGENT_DIR", "TAU_CODING_AGENT_DIR"];
|
|
74
|
+
for (const key of envCandidates) {
|
|
75
|
+
const value = process.env[key]?.trim();
|
|
76
|
+
if (value) {
|
|
77
|
+
return expandUserPath(value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
82
|
+
if (!key.endsWith("_CODING_AGENT_DIR") || !value?.trim()) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return expandUserPath(value.trim());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getControlDir(): string {
|
|
93
|
+
return path.join(getScopedAgentDir(), "session-control");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const CONTROL_DIR = getControlDir();
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// RPC Types
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
interface RpcResponse {
|
|
103
|
+
type: "response";
|
|
104
|
+
command: string;
|
|
105
|
+
success: boolean;
|
|
106
|
+
error?: string;
|
|
107
|
+
data?: unknown;
|
|
108
|
+
id?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface RpcEvent {
|
|
112
|
+
type: "event";
|
|
113
|
+
event: string;
|
|
114
|
+
data?: unknown;
|
|
115
|
+
subscriptionId?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Unified command structure
|
|
119
|
+
interface RpcSendCommand {
|
|
120
|
+
type: "send";
|
|
121
|
+
message: string;
|
|
122
|
+
mode?: "steer" | "follow_up";
|
|
123
|
+
id?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface RpcGetMessageCommand {
|
|
127
|
+
type: "get_message";
|
|
128
|
+
id?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface RpcGetSummaryCommand {
|
|
132
|
+
type: "get_summary";
|
|
133
|
+
id?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface RpcClearCommand {
|
|
137
|
+
type: "clear";
|
|
138
|
+
summarize?: boolean;
|
|
139
|
+
id?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface RpcAbortCommand {
|
|
143
|
+
type: "abort";
|
|
144
|
+
id?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface RpcSubscribeCommand {
|
|
148
|
+
type: "subscribe";
|
|
149
|
+
event: "turn_end";
|
|
150
|
+
id?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type RpcCommand =
|
|
154
|
+
| RpcSendCommand
|
|
155
|
+
| RpcGetMessageCommand
|
|
156
|
+
| RpcGetSummaryCommand
|
|
157
|
+
| RpcClearCommand
|
|
158
|
+
| RpcAbortCommand
|
|
159
|
+
| RpcSubscribeCommand;
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// Subscription Management
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
interface TurnEndSubscription {
|
|
166
|
+
socket: net.Socket;
|
|
167
|
+
subscriptionId: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface SocketState {
|
|
171
|
+
server: net.Server | null;
|
|
172
|
+
socketPath: string | null;
|
|
173
|
+
context: ExtensionContext | null;
|
|
174
|
+
alias: string | null;
|
|
175
|
+
aliasTimer: ReturnType<typeof setInterval> | null;
|
|
176
|
+
turnEndSubscriptions: TurnEndSubscription[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// Summarization
|
|
181
|
+
// ============================================================================
|
|
182
|
+
|
|
183
|
+
const CODEX_MODEL_ID = "gpt-5.1-codex-mini";
|
|
184
|
+
const HAIKU_MODEL_ID = "claude-haiku-4-5";
|
|
185
|
+
|
|
186
|
+
const SUMMARIZATION_SYSTEM_PROMPT = `You are a conversation summarizer. Create concise, accurate summaries that preserve key information, decisions, and outcomes.`;
|
|
187
|
+
|
|
188
|
+
const TURN_SUMMARY_PROMPT = `Summarize what happened in this conversation since the last user prompt. Focus on:
|
|
189
|
+
- What was accomplished
|
|
190
|
+
- Any decisions made
|
|
191
|
+
- Files that were read, modified, or created
|
|
192
|
+
- Any errors or issues encountered
|
|
193
|
+
- Current state/next steps
|
|
194
|
+
|
|
195
|
+
Be concise but comprehensive. Preserve exact file paths, function names, and error messages.`;
|
|
196
|
+
|
|
197
|
+
async function selectSummarizationModel(
|
|
198
|
+
currentModel: Model<Api> | undefined,
|
|
199
|
+
modelRegistry: {
|
|
200
|
+
find: (provider: string, modelId: string) => Model<Api> | undefined;
|
|
201
|
+
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
|
|
202
|
+
},
|
|
203
|
+
): Promise<Model<Api> | undefined> {
|
|
204
|
+
const codexModel = modelRegistry.find("openai-codex", CODEX_MODEL_ID);
|
|
205
|
+
if (codexModel) {
|
|
206
|
+
const apiKey = await modelRegistry.getApiKey(codexModel);
|
|
207
|
+
if (apiKey) return codexModel;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const haikuModel = modelRegistry.find("anthropic", HAIKU_MODEL_ID);
|
|
211
|
+
if (haikuModel) {
|
|
212
|
+
const apiKey = await modelRegistry.getApiKey(haikuModel);
|
|
213
|
+
if (apiKey) return haikuModel;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return currentModel;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// Utilities
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
const STATUS_KEY = "session-control";
|
|
224
|
+
|
|
225
|
+
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
226
|
+
return typeof error === "object" && error !== null && "code" in error;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getSocketPath(sessionId: string): string {
|
|
230
|
+
return path.join(CONTROL_DIR, `${sessionId}${SOCKET_SUFFIX}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isSafeSessionId(sessionId: string): boolean {
|
|
234
|
+
return !sessionId.includes("/") && !sessionId.includes("\\") && !sessionId.includes("..") && sessionId.length > 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isSafeAlias(alias: string): boolean {
|
|
238
|
+
return !alias.includes("/") && !alias.includes("\\") && !alias.includes("..") && alias.length > 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getAliasPath(alias: string): string {
|
|
242
|
+
return path.join(CONTROL_DIR, `${alias}.alias`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function getSessionAlias(ctx: ExtensionContext): string | null {
|
|
246
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
247
|
+
const alias = sessionName ? sessionName.trim() : "";
|
|
248
|
+
if (!alias || !isSafeAlias(alias)) return null;
|
|
249
|
+
return alias;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function ensureControlDir(): Promise<void> {
|
|
253
|
+
await fs.mkdir(CONTROL_DIR, { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function removeSocket(socketPath: string | null): Promise<void> {
|
|
257
|
+
if (!socketPath) return;
|
|
258
|
+
try {
|
|
259
|
+
await fs.unlink(socketPath);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (isErrnoException(error) && error.code !== "ENOENT") {
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// TODO: add GC for stale sockets/aliases older than 7 days.
|
|
268
|
+
async function removeAliasesForSocket(socketPath: string | null): Promise<void> {
|
|
269
|
+
if (!socketPath) return;
|
|
270
|
+
try {
|
|
271
|
+
const entries = await fs.readdir(CONTROL_DIR, { withFileTypes: true });
|
|
272
|
+
for (const entry of entries) {
|
|
273
|
+
if (!entry.isSymbolicLink()) continue;
|
|
274
|
+
const aliasPath = path.join(CONTROL_DIR, entry.name);
|
|
275
|
+
let target: string;
|
|
276
|
+
try {
|
|
277
|
+
target = await fs.readlink(aliasPath);
|
|
278
|
+
} catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const resolvedTarget = path.resolve(CONTROL_DIR, target);
|
|
282
|
+
if (resolvedTarget === socketPath) {
|
|
283
|
+
await fs.unlink(aliasPath);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (isErrnoException(error) && error.code === "ENOENT") return;
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function createAliasSymlink(sessionId: string, alias: string): Promise<void> {
|
|
293
|
+
if (!alias || !isSafeAlias(alias)) return;
|
|
294
|
+
const aliasPath = getAliasPath(alias);
|
|
295
|
+
const target = `${sessionId}${SOCKET_SUFFIX}`;
|
|
296
|
+
try {
|
|
297
|
+
await fs.unlink(aliasPath);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (isErrnoException(error) && error.code !== "ENOENT") {
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
await fs.symlink(target, aliasPath);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (isErrnoException(error) && error.code !== "EEXIST") {
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function resolveSessionIdFromAlias(alias: string): Promise<string | null> {
|
|
313
|
+
if (!alias || !isSafeAlias(alias)) return null;
|
|
314
|
+
const aliasPath = getAliasPath(alias);
|
|
315
|
+
try {
|
|
316
|
+
const target = await fs.readlink(aliasPath);
|
|
317
|
+
const resolvedTarget = path.resolve(CONTROL_DIR, target);
|
|
318
|
+
const base = path.basename(resolvedTarget);
|
|
319
|
+
if (!base.endsWith(SOCKET_SUFFIX)) return null;
|
|
320
|
+
const sessionId = base.slice(0, -SOCKET_SUFFIX.length);
|
|
321
|
+
return isSafeSessionId(sessionId) ? sessionId : null;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (isErrnoException(error) && error.code === "ENOENT") return null;
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function getAliasMap(): Promise<Map<string, string[]>> {
|
|
329
|
+
const aliasMap = new Map<string, string[]>();
|
|
330
|
+
const entries = await fs.readdir(CONTROL_DIR, { withFileTypes: true });
|
|
331
|
+
for (const entry of entries) {
|
|
332
|
+
if (!entry.isSymbolicLink()) continue;
|
|
333
|
+
if (!entry.name.endsWith(".alias")) continue;
|
|
334
|
+
const aliasPath = path.join(CONTROL_DIR, entry.name);
|
|
335
|
+
let target: string;
|
|
336
|
+
try {
|
|
337
|
+
target = await fs.readlink(aliasPath);
|
|
338
|
+
} catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const resolvedTarget = path.resolve(CONTROL_DIR, target);
|
|
342
|
+
const aliases = aliasMap.get(resolvedTarget);
|
|
343
|
+
const aliasName = entry.name.slice(0, -".alias".length);
|
|
344
|
+
if (aliases) {
|
|
345
|
+
aliases.push(aliasName);
|
|
346
|
+
} else {
|
|
347
|
+
aliasMap.set(resolvedTarget, [aliasName]);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return aliasMap;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function isSocketAlive(socketPath: string): Promise<boolean> {
|
|
354
|
+
return await new Promise((resolve) => {
|
|
355
|
+
const socket = net.createConnection(socketPath);
|
|
356
|
+
const timeout = setTimeout(() => {
|
|
357
|
+
socket.destroy();
|
|
358
|
+
resolve(false);
|
|
359
|
+
}, 300);
|
|
360
|
+
|
|
361
|
+
const cleanup = (alive: boolean) => {
|
|
362
|
+
clearTimeout(timeout);
|
|
363
|
+
socket.removeAllListeners();
|
|
364
|
+
resolve(alive);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
socket.once("connect", () => {
|
|
368
|
+
socket.end();
|
|
369
|
+
cleanup(true);
|
|
370
|
+
});
|
|
371
|
+
socket.once("error", () => {
|
|
372
|
+
cleanup(false);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
type LiveSessionInfo = {
|
|
378
|
+
sessionId: string;
|
|
379
|
+
name?: string;
|
|
380
|
+
aliases: string[];
|
|
381
|
+
socketPath: string;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
async function getLiveSessions(): Promise<LiveSessionInfo[]> {
|
|
385
|
+
await ensureControlDir();
|
|
386
|
+
const entries = await fs.readdir(CONTROL_DIR, { withFileTypes: true });
|
|
387
|
+
const aliasMap = await getAliasMap();
|
|
388
|
+
const sessions: LiveSessionInfo[] = [];
|
|
389
|
+
|
|
390
|
+
for (const entry of entries) {
|
|
391
|
+
if (!entry.name.endsWith(SOCKET_SUFFIX)) continue;
|
|
392
|
+
const socketPath = path.join(CONTROL_DIR, entry.name);
|
|
393
|
+
const alive = await isSocketAlive(socketPath);
|
|
394
|
+
if (!alive) continue;
|
|
395
|
+
const sessionId = entry.name.slice(0, -SOCKET_SUFFIX.length);
|
|
396
|
+
if (!isSafeSessionId(sessionId)) continue;
|
|
397
|
+
const aliases = aliasMap.get(socketPath) ?? [];
|
|
398
|
+
const name = aliases[0];
|
|
399
|
+
sessions.push({ sessionId, name, aliases, socketPath });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
sessions.sort((a, b) => (a.name ?? a.sessionId).localeCompare(b.name ?? b.sessionId));
|
|
403
|
+
return sessions;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function syncAlias(state: SocketState, ctx: ExtensionContext): Promise<void> {
|
|
407
|
+
if (!state.server || !state.socketPath) return;
|
|
408
|
+
const alias = getSessionAlias(ctx);
|
|
409
|
+
if (alias && alias !== state.alias) {
|
|
410
|
+
await removeAliasesForSocket(state.socketPath);
|
|
411
|
+
await createAliasSymlink(ctx.sessionManager.getSessionId(), alias);
|
|
412
|
+
state.alias = alias;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (!alias && state.alias) {
|
|
416
|
+
await removeAliasesForSocket(state.socketPath);
|
|
417
|
+
state.alias = null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function writeResponse(socket: net.Socket, response: RpcResponse): void {
|
|
422
|
+
try {
|
|
423
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
424
|
+
} catch {
|
|
425
|
+
// Socket may be closed
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function writeEvent(socket: net.Socket, event: RpcEvent): void {
|
|
430
|
+
try {
|
|
431
|
+
socket.write(`${JSON.stringify(event)}\n`);
|
|
432
|
+
} catch {
|
|
433
|
+
// Socket may be closed
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function parseCommand(line: string): { command?: RpcCommand; error?: string } {
|
|
438
|
+
try {
|
|
439
|
+
const parsed = JSON.parse(line) as RpcCommand;
|
|
440
|
+
if (!parsed || typeof parsed !== "object") {
|
|
441
|
+
return { error: "Invalid command" };
|
|
442
|
+
}
|
|
443
|
+
if (typeof parsed.type !== "string") {
|
|
444
|
+
return { error: "Missing command type" };
|
|
445
|
+
}
|
|
446
|
+
return { command: parsed };
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return { error: error instanceof Error ? error.message : "Failed to parse command" };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ============================================================================
|
|
453
|
+
// Message Extraction
|
|
454
|
+
// ============================================================================
|
|
455
|
+
|
|
456
|
+
interface ExtractedMessage {
|
|
457
|
+
role: "user" | "assistant";
|
|
458
|
+
content: string;
|
|
459
|
+
timestamp: number;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function getLastAssistantMessage(ctx: ExtensionContext): ExtractedMessage | undefined {
|
|
463
|
+
const branch = ctx.sessionManager.getBranch();
|
|
464
|
+
|
|
465
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
466
|
+
const entry = branch[i];
|
|
467
|
+
if (entry.type === "message") {
|
|
468
|
+
const msg = entry.message;
|
|
469
|
+
if ("role" in msg && msg.role === "assistant") {
|
|
470
|
+
const textParts = msg.content
|
|
471
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
472
|
+
.map((c) => c.text);
|
|
473
|
+
if (textParts.length > 0) {
|
|
474
|
+
return {
|
|
475
|
+
role: "assistant",
|
|
476
|
+
content: textParts.join("\n"),
|
|
477
|
+
timestamp: msg.timestamp,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function getMessagesSinceLastPrompt(ctx: ExtensionContext): ExtractedMessage[] {
|
|
487
|
+
const branch = ctx.sessionManager.getBranch();
|
|
488
|
+
const messages: ExtractedMessage[] = [];
|
|
489
|
+
|
|
490
|
+
let lastUserIndex = -1;
|
|
491
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
492
|
+
const entry = branch[i];
|
|
493
|
+
if (entry.type === "message" && "role" in entry.message && entry.message.role === "user") {
|
|
494
|
+
lastUserIndex = i;
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (lastUserIndex === -1) return [];
|
|
500
|
+
|
|
501
|
+
for (let i = lastUserIndex; i < branch.length; i++) {
|
|
502
|
+
const entry = branch[i];
|
|
503
|
+
if (entry.type === "message") {
|
|
504
|
+
const msg = entry.message;
|
|
505
|
+
if ("role" in msg && (msg.role === "user" || msg.role === "assistant")) {
|
|
506
|
+
const textParts = msg.content
|
|
507
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
508
|
+
.map((c) => c.text);
|
|
509
|
+
if (textParts.length > 0) {
|
|
510
|
+
messages.push({
|
|
511
|
+
role: msg.role,
|
|
512
|
+
content: textParts.join("\n"),
|
|
513
|
+
timestamp: msg.timestamp,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return messages;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function getFirstEntryId(ctx: ExtensionContext): string | undefined {
|
|
524
|
+
const entries = ctx.sessionManager.getEntries();
|
|
525
|
+
if (entries.length === 0) return undefined;
|
|
526
|
+
const root = entries.find((e) => e.parentId === null);
|
|
527
|
+
return root?.id ?? entries[0]?.id;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function extractTextContent(content: string | Array<TextContent | { type: string }>): string {
|
|
531
|
+
if (typeof content === "string") return content;
|
|
532
|
+
return content
|
|
533
|
+
.filter((c): c is TextContent => c.type === "text")
|
|
534
|
+
.map((c) => c.text)
|
|
535
|
+
.join("\n");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function stripSenderInfo(text: string): string {
|
|
539
|
+
return text.replace(SENDER_INFO_PATTERN, "").trim();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
interface SenderInfo {
|
|
543
|
+
sessionId?: string;
|
|
544
|
+
sessionName?: string;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function parseSenderInfo(text: string): SenderInfo | null {
|
|
548
|
+
const match = text.match(/<sender_info>([\s\S]*?)<\/sender_info>/);
|
|
549
|
+
if (!match) return null;
|
|
550
|
+
const raw = match[1].trim();
|
|
551
|
+
if (!raw) return null;
|
|
552
|
+
|
|
553
|
+
if (raw.startsWith("{")) {
|
|
554
|
+
try {
|
|
555
|
+
const parsed = JSON.parse(raw) as { sessionId?: unknown; sessionName?: unknown };
|
|
556
|
+
const sessionId = typeof parsed.sessionId === "string" ? parsed.sessionId.trim() : "";
|
|
557
|
+
const sessionName = typeof parsed.sessionName === "string" ? parsed.sessionName.trim() : "";
|
|
558
|
+
if (sessionId || sessionName) {
|
|
559
|
+
return {
|
|
560
|
+
sessionId: sessionId || undefined,
|
|
561
|
+
sessionName: sessionName || undefined,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
// Ignore JSON parse errors, fall back to legacy parsing.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const legacyIdMatch = raw.match(/session\s+([a-f0-9-]{6,})/i);
|
|
570
|
+
if (legacyIdMatch) {
|
|
571
|
+
return { sessionId: legacyIdMatch[1] };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function formatSenderInfo(info: SenderInfo | null): string | null {
|
|
578
|
+
if (!info) return null;
|
|
579
|
+
const { sessionName, sessionId } = info;
|
|
580
|
+
if (sessionName && sessionId) return `${sessionName} (${sessionId})`;
|
|
581
|
+
if (sessionName) return sessionName;
|
|
582
|
+
if (sessionId) return sessionId;
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const renderSessionMessage: MessageRenderer = (message, { expanded }, theme) => {
|
|
587
|
+
const rawContent = extractTextContent(message.content);
|
|
588
|
+
const senderInfo = parseSenderInfo(rawContent);
|
|
589
|
+
let text = stripSenderInfo(rawContent);
|
|
590
|
+
if (!text) text = "(no content)";
|
|
591
|
+
|
|
592
|
+
if (!expanded) {
|
|
593
|
+
const lines = text.split("\n");
|
|
594
|
+
if (lines.length > 5) {
|
|
595
|
+
text = `${lines.slice(0, 5).join("\n")}\n...`;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
600
|
+
const labelBase = theme.fg("customMessageLabel", `\x1b[1m[${message.customType}]\x1b[22m`);
|
|
601
|
+
const senderText = formatSenderInfo(senderInfo);
|
|
602
|
+
const label = senderText ? `${labelBase} ${theme.fg("dim", `from ${senderText}`)}` : labelBase;
|
|
603
|
+
box.addChild(new Text(label, 0, 0));
|
|
604
|
+
box.addChild(new Spacer(1));
|
|
605
|
+
box.addChild(
|
|
606
|
+
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
|
607
|
+
color: (value: string) => theme.fg("customMessageText", value),
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
return box;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// ============================================================================
|
|
614
|
+
// Command Handlers
|
|
615
|
+
// ============================================================================
|
|
616
|
+
|
|
617
|
+
async function handleCommand(
|
|
618
|
+
pi: ExtensionAPI,
|
|
619
|
+
state: SocketState,
|
|
620
|
+
command: RpcCommand,
|
|
621
|
+
socket: net.Socket,
|
|
622
|
+
): Promise<void> {
|
|
623
|
+
const id = "id" in command && typeof command.id === "string" ? command.id : undefined;
|
|
624
|
+
const respond = (success: boolean, commandName: string, data?: unknown, error?: string) => {
|
|
625
|
+
if (state.context) {
|
|
626
|
+
void syncAlias(state, state.context);
|
|
627
|
+
}
|
|
628
|
+
writeResponse(socket, { type: "response", command: commandName, success, data, error, id });
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const ctx = state.context;
|
|
632
|
+
if (!ctx) {
|
|
633
|
+
respond(false, command.type, undefined, "Session not ready");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
void syncAlias(state, ctx);
|
|
638
|
+
|
|
639
|
+
// Abort
|
|
640
|
+
if (command.type === "abort") {
|
|
641
|
+
ctx.abort();
|
|
642
|
+
respond(true, "abort");
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Subscribe to turn_end
|
|
647
|
+
if (command.type === "subscribe") {
|
|
648
|
+
if (command.event === "turn_end") {
|
|
649
|
+
const subscriptionId = id ?? `sub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
650
|
+
state.turnEndSubscriptions.push({ socket, subscriptionId });
|
|
651
|
+
|
|
652
|
+
const cleanup = () => {
|
|
653
|
+
const idx = state.turnEndSubscriptions.findIndex((s) => s.subscriptionId === subscriptionId);
|
|
654
|
+
if (idx !== -1) state.turnEndSubscriptions.splice(idx, 1);
|
|
655
|
+
};
|
|
656
|
+
socket.once("close", cleanup);
|
|
657
|
+
socket.once("error", cleanup);
|
|
658
|
+
|
|
659
|
+
respond(true, "subscribe", { subscriptionId, event: "turn_end" });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
respond(false, "subscribe", undefined, `Unknown event type: ${command.event}`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Get last message
|
|
667
|
+
if (command.type === "get_message") {
|
|
668
|
+
const message = getLastAssistantMessage(ctx);
|
|
669
|
+
if (!message) {
|
|
670
|
+
respond(true, "get_message", { message: null });
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
respond(true, "get_message", { message });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Get summary
|
|
678
|
+
if (command.type === "get_summary") {
|
|
679
|
+
const messages = getMessagesSinceLastPrompt(ctx);
|
|
680
|
+
if (messages.length === 0) {
|
|
681
|
+
respond(false, "get_summary", undefined, "No messages to summarize");
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const model = await selectSummarizationModel(ctx.model, ctx.modelRegistry);
|
|
686
|
+
if (!model) {
|
|
687
|
+
respond(false, "get_summary", undefined, "No model available for summarization");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
692
|
+
if (!apiKey) {
|
|
693
|
+
respond(false, "get_summary", undefined, "No API key available for summarization model");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
const conversationText = messages
|
|
699
|
+
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
|
|
700
|
+
.join("\n\n");
|
|
701
|
+
|
|
702
|
+
const userMessage: UserMessage = {
|
|
703
|
+
role: "user",
|
|
704
|
+
content: [{ type: "text", text: `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_SUMMARY_PROMPT}` }],
|
|
705
|
+
timestamp: Date.now(),
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const response = await complete(
|
|
709
|
+
model,
|
|
710
|
+
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: [userMessage] },
|
|
711
|
+
{ apiKey },
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
if (response.stopReason === "aborted" || response.stopReason === "error") {
|
|
715
|
+
respond(false, "get_summary", undefined, "Summarization failed");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const summary = response.content
|
|
720
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
721
|
+
.map((c) => c.text)
|
|
722
|
+
.join("\n");
|
|
723
|
+
|
|
724
|
+
respond(true, "get_summary", { summary, model: model.id });
|
|
725
|
+
} catch (error) {
|
|
726
|
+
respond(false, "get_summary", undefined, error instanceof Error ? error.message : "Summarization failed");
|
|
727
|
+
}
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Clear session
|
|
732
|
+
if (command.type === "clear") {
|
|
733
|
+
if (!ctx.isIdle()) {
|
|
734
|
+
respond(false, "clear", undefined, "Session is busy - wait for turn to complete");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const firstEntryId = getFirstEntryId(ctx);
|
|
739
|
+
if (!firstEntryId) {
|
|
740
|
+
respond(false, "clear", undefined, "No entries in session");
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const currentLeafId = ctx.sessionManager.getLeafId();
|
|
745
|
+
if (currentLeafId === firstEntryId) {
|
|
746
|
+
respond(true, "clear", { cleared: true, alreadyAtRoot: true });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (command.summarize) {
|
|
751
|
+
// Summarization requires navigateTree which we don't have direct access to
|
|
752
|
+
// Return an error for now - the caller should clear without summarize
|
|
753
|
+
// or use a different approach
|
|
754
|
+
respond(false, "clear", undefined, "Clear with summarization not supported via RPC - use summarize=false");
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Access internal session manager to rewind (type assertion to access non-readonly methods)
|
|
759
|
+
try {
|
|
760
|
+
const sessionManager = ctx.sessionManager as unknown as { rewindTo(id: string): void };
|
|
761
|
+
sessionManager.rewindTo(firstEntryId);
|
|
762
|
+
respond(true, "clear", { cleared: true, targetId: firstEntryId });
|
|
763
|
+
} catch (error) {
|
|
764
|
+
respond(false, "clear", undefined, error instanceof Error ? error.message : "Clear failed");
|
|
765
|
+
}
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Send message
|
|
770
|
+
if (command.type === "send") {
|
|
771
|
+
const message = command.message;
|
|
772
|
+
if (typeof message !== "string" || message.trim().length === 0) {
|
|
773
|
+
respond(false, "send", undefined, "Missing message");
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const mode = command.mode ?? "steer";
|
|
778
|
+
const isIdle = ctx.isIdle();
|
|
779
|
+
const customMessage = {
|
|
780
|
+
customType: SESSION_MESSAGE_TYPE,
|
|
781
|
+
content: message,
|
|
782
|
+
display: true,
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
if (isIdle) {
|
|
786
|
+
pi.sendMessage(customMessage, { triggerTurn: true });
|
|
787
|
+
} else {
|
|
788
|
+
pi.sendMessage(customMessage, {
|
|
789
|
+
triggerTurn: true,
|
|
790
|
+
deliverAs: mode === "follow_up" ? "followUp" : "steer",
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
respond(true, "send", { delivered: true, mode: isIdle ? "direct" : mode });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
respond(false, command.type, undefined, `Unsupported command: ${command.type}`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ============================================================================
|
|
802
|
+
// Server Management
|
|
803
|
+
// ============================================================================
|
|
804
|
+
|
|
805
|
+
async function createServer(pi: ExtensionAPI, state: SocketState, socketPath: string): Promise<net.Server> {
|
|
806
|
+
const server = net.createServer((socket) => {
|
|
807
|
+
socket.setEncoding("utf8");
|
|
808
|
+
let buffer = "";
|
|
809
|
+
socket.on("data", (chunk) => {
|
|
810
|
+
buffer += chunk;
|
|
811
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
812
|
+
while (newlineIndex !== -1) {
|
|
813
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
814
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
815
|
+
newlineIndex = buffer.indexOf("\n");
|
|
816
|
+
if (!line) continue;
|
|
817
|
+
|
|
818
|
+
const parsed = parseCommand(line);
|
|
819
|
+
if (parsed.error) {
|
|
820
|
+
if (state.context) {
|
|
821
|
+
void syncAlias(state, state.context);
|
|
822
|
+
}
|
|
823
|
+
writeResponse(socket, {
|
|
824
|
+
type: "response",
|
|
825
|
+
command: "parse",
|
|
826
|
+
success: false,
|
|
827
|
+
error: `Failed to parse command: ${parsed.error}`,
|
|
828
|
+
});
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
handleCommand(pi, state, parsed.command!, socket);
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
// Wait for server to start listening, with error handling
|
|
838
|
+
await new Promise<void>((resolve, reject) => {
|
|
839
|
+
server.once("error", reject);
|
|
840
|
+
server.listen(socketPath, () => {
|
|
841
|
+
server.removeListener("error", reject);
|
|
842
|
+
resolve();
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
return server;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
interface RpcClientOptions {
|
|
850
|
+
timeout?: number;
|
|
851
|
+
waitForEvent?: "turn_end";
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function sendRpcCommand(
|
|
855
|
+
socketPath: string,
|
|
856
|
+
command: RpcCommand,
|
|
857
|
+
options: RpcClientOptions = {},
|
|
858
|
+
): Promise<{ response: RpcResponse; event?: { message?: ExtractedMessage; turnIndex?: number } }> {
|
|
859
|
+
const { timeout = 5000, waitForEvent } = options;
|
|
860
|
+
|
|
861
|
+
return new Promise((resolve, reject) => {
|
|
862
|
+
const socket = net.createConnection(socketPath);
|
|
863
|
+
socket.setEncoding("utf8");
|
|
864
|
+
|
|
865
|
+
const timeoutHandle = setTimeout(() => {
|
|
866
|
+
socket.destroy(new Error("timeout"));
|
|
867
|
+
}, timeout);
|
|
868
|
+
|
|
869
|
+
let buffer = "";
|
|
870
|
+
let response: RpcResponse | null = null;
|
|
871
|
+
|
|
872
|
+
const cleanup = () => {
|
|
873
|
+
clearTimeout(timeoutHandle);
|
|
874
|
+
socket.removeAllListeners();
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
socket.on("connect", () => {
|
|
878
|
+
socket.write(`${JSON.stringify(command)}\n`);
|
|
879
|
+
|
|
880
|
+
// If waiting for turn_end, also subscribe
|
|
881
|
+
if (waitForEvent === "turn_end") {
|
|
882
|
+
const subscribeCmd: RpcSubscribeCommand = { type: "subscribe", event: "turn_end" };
|
|
883
|
+
socket.write(`${JSON.stringify(subscribeCmd)}\n`);
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
socket.on("data", (chunk) => {
|
|
888
|
+
buffer += chunk;
|
|
889
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
890
|
+
while (newlineIndex !== -1) {
|
|
891
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
892
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
893
|
+
newlineIndex = buffer.indexOf("\n");
|
|
894
|
+
if (!line) continue;
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
const msg = JSON.parse(line);
|
|
898
|
+
|
|
899
|
+
// Handle response
|
|
900
|
+
if (msg.type === "response") {
|
|
901
|
+
if (msg.command === command.type) {
|
|
902
|
+
response = msg;
|
|
903
|
+
// If not waiting for event, we're done
|
|
904
|
+
if (!waitForEvent) {
|
|
905
|
+
cleanup();
|
|
906
|
+
socket.end();
|
|
907
|
+
resolve({ response });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// Ignore subscribe response
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Handle turn_end event
|
|
916
|
+
if (msg.type === "event" && msg.event === "turn_end" && waitForEvent === "turn_end") {
|
|
917
|
+
cleanup();
|
|
918
|
+
socket.end();
|
|
919
|
+
if (!response) {
|
|
920
|
+
reject(new Error("Received event before response"));
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
resolve({ response, event: msg.data || {} });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
} catch {
|
|
927
|
+
// Ignore parse errors, keep waiting
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
socket.on("error", (error) => {
|
|
933
|
+
cleanup();
|
|
934
|
+
reject(error);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function startControlServer(pi: ExtensionAPI, state: SocketState, ctx: ExtensionContext): Promise<void> {
|
|
940
|
+
await ensureControlDir();
|
|
941
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
942
|
+
const socketPath = getSocketPath(sessionId);
|
|
943
|
+
|
|
944
|
+
if (state.socketPath === socketPath && state.server) {
|
|
945
|
+
state.context = ctx;
|
|
946
|
+
await syncAlias(state, ctx);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
await stopControlServer(state);
|
|
951
|
+
await removeSocket(socketPath);
|
|
952
|
+
|
|
953
|
+
state.context = ctx;
|
|
954
|
+
state.socketPath = socketPath;
|
|
955
|
+
state.server = await createServer(pi, state, socketPath);
|
|
956
|
+
state.alias = null;
|
|
957
|
+
await syncAlias(state, ctx);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function stopControlServer(state: SocketState): Promise<void> {
|
|
961
|
+
if (!state.server) {
|
|
962
|
+
await removeAliasesForSocket(state.socketPath);
|
|
963
|
+
await removeSocket(state.socketPath);
|
|
964
|
+
state.socketPath = null;
|
|
965
|
+
state.alias = null;
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const socketPath = state.socketPath;
|
|
970
|
+
state.socketPath = null;
|
|
971
|
+
state.turnEndSubscriptions = [];
|
|
972
|
+
await new Promise<void>((resolve) => state.server?.close(() => resolve()));
|
|
973
|
+
state.server = null;
|
|
974
|
+
await removeAliasesForSocket(socketPath);
|
|
975
|
+
await removeSocket(socketPath);
|
|
976
|
+
state.alias = null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function updateStatus(ctx: ExtensionContext | null, enabled: boolean): void {
|
|
980
|
+
if (!ctx?.hasUI) return;
|
|
981
|
+
if (!enabled) {
|
|
982
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
986
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", `session ${sessionId}`));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function updateSessionEnv(ctx: ExtensionContext | null, enabled: boolean): void {
|
|
990
|
+
if (!enabled) {
|
|
991
|
+
delete process.env.PI_SESSION_ID;
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (!ctx) return;
|
|
995
|
+
process.env.PI_SESSION_ID = ctx.sessionManager.getSessionId();
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Extension factories run before extension flag values are hydrated into runtime.flagValues,
|
|
999
|
+
// so we inspect argv directly when deciding whether to register tools at load time.
|
|
1000
|
+
function wasBooleanFlagPassed(flagName: string): boolean {
|
|
1001
|
+
const flag = `--${flagName}`;
|
|
1002
|
+
return process.argv.slice(2).includes(flag);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function shouldRegisterControlTools(pi: ExtensionAPI): boolean {
|
|
1006
|
+
return pi.getFlag(CONTROL_FLAG) === true || wasBooleanFlagPassed(CONTROL_FLAG);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// ============================================================================
|
|
1010
|
+
// Extension Export
|
|
1011
|
+
// ============================================================================
|
|
1012
|
+
|
|
1013
|
+
export default function (pi: ExtensionAPI) {
|
|
1014
|
+
pi.registerFlag(CONTROL_FLAG, {
|
|
1015
|
+
description: "Enable per-session control socket under the scoped agent directory",
|
|
1016
|
+
type: "boolean",
|
|
1017
|
+
});
|
|
1018
|
+
pi.registerFlag(CONTROL_TARGET_FLAG, {
|
|
1019
|
+
description: "Target session name or session id for startup control send",
|
|
1020
|
+
type: "string",
|
|
1021
|
+
});
|
|
1022
|
+
pi.registerFlag(CONTROL_SEND_MESSAGE_FLAG, {
|
|
1023
|
+
description: "Message to send to --control-session at startup",
|
|
1024
|
+
type: "string",
|
|
1025
|
+
});
|
|
1026
|
+
pi.registerFlag(CONTROL_SEND_MODE_FLAG, {
|
|
1027
|
+
description: "Startup send mode: steer or follow_up",
|
|
1028
|
+
type: "string",
|
|
1029
|
+
default: "steer",
|
|
1030
|
+
});
|
|
1031
|
+
pi.registerFlag(CONTROL_SEND_WAIT_FLAG, {
|
|
1032
|
+
description: "Startup send wait mode: turn_end or message_processed",
|
|
1033
|
+
type: "string",
|
|
1034
|
+
});
|
|
1035
|
+
pi.registerFlag(CONTROL_SEND_INCLUDE_SENDER_FLAG, {
|
|
1036
|
+
description: "Include <sender_info> in startup messages (advanced; default: false)",
|
|
1037
|
+
type: "boolean",
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
let cliSendHandled = false;
|
|
1041
|
+
|
|
1042
|
+
const state: SocketState = {
|
|
1043
|
+
server: null,
|
|
1044
|
+
socketPath: null,
|
|
1045
|
+
context: null,
|
|
1046
|
+
alias: null,
|
|
1047
|
+
aliasTimer: null,
|
|
1048
|
+
turnEndSubscriptions: [],
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
pi.registerMessageRenderer(SESSION_MESSAGE_TYPE, renderSessionMessage);
|
|
1052
|
+
|
|
1053
|
+
if (shouldRegisterControlTools(pi)) {
|
|
1054
|
+
registerSessionTool(pi, state);
|
|
1055
|
+
registerListSessionsTool(pi);
|
|
1056
|
+
}
|
|
1057
|
+
registerControlSessionsCommand(pi);
|
|
1058
|
+
|
|
1059
|
+
const refreshServer = async (ctx: ExtensionContext) => {
|
|
1060
|
+
const enabled = pi.getFlag(CONTROL_FLAG) === true;
|
|
1061
|
+
if (!enabled) {
|
|
1062
|
+
if (state.aliasTimer) {
|
|
1063
|
+
clearInterval(state.aliasTimer);
|
|
1064
|
+
state.aliasTimer = null;
|
|
1065
|
+
}
|
|
1066
|
+
await stopControlServer(state);
|
|
1067
|
+
updateStatus(ctx, false);
|
|
1068
|
+
updateSessionEnv(ctx, false);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
await startControlServer(pi, state, ctx);
|
|
1072
|
+
if (!state.aliasTimer) {
|
|
1073
|
+
state.aliasTimer = setInterval(() => {
|
|
1074
|
+
if (!state.context) return;
|
|
1075
|
+
void syncAlias(state, state.context);
|
|
1076
|
+
}, 1000);
|
|
1077
|
+
}
|
|
1078
|
+
updateStatus(ctx, true);
|
|
1079
|
+
updateSessionEnv(ctx, true);
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1083
|
+
await refreshServer(ctx);
|
|
1084
|
+
if (!cliSendHandled) {
|
|
1085
|
+
cliSendHandled = true;
|
|
1086
|
+
await maybeHandleStartupControlSend(pi, ctx);
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
1091
|
+
await refreshServer(ctx);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
pi.on("session_fork", async (_event, ctx) => {
|
|
1095
|
+
await refreshServer(ctx);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
pi.on("session_shutdown", async () => {
|
|
1099
|
+
if (state.aliasTimer) {
|
|
1100
|
+
clearInterval(state.aliasTimer);
|
|
1101
|
+
state.aliasTimer = null;
|
|
1102
|
+
}
|
|
1103
|
+
updateStatus(state.context, false);
|
|
1104
|
+
updateSessionEnv(state.context, false);
|
|
1105
|
+
await stopControlServer(state);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// Fire turn_end events to subscribers
|
|
1109
|
+
pi.on("turn_end", (event: TurnEndEvent, ctx: ExtensionContext) => {
|
|
1110
|
+
if (state.turnEndSubscriptions.length === 0) return;
|
|
1111
|
+
|
|
1112
|
+
void syncAlias(state, ctx);
|
|
1113
|
+
const lastMessage = getLastAssistantMessage(ctx);
|
|
1114
|
+
const eventData = { message: lastMessage, turnIndex: event.turnIndex };
|
|
1115
|
+
|
|
1116
|
+
// Fire to all subscribers (one-shot)
|
|
1117
|
+
const subscriptions = [...state.turnEndSubscriptions];
|
|
1118
|
+
state.turnEndSubscriptions = [];
|
|
1119
|
+
|
|
1120
|
+
for (const sub of subscriptions) {
|
|
1121
|
+
writeEvent(sub.socket, {
|
|
1122
|
+
type: "event",
|
|
1123
|
+
event: "turn_end",
|
|
1124
|
+
data: eventData,
|
|
1125
|
+
subscriptionId: sub.subscriptionId,
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ============================================================================
|
|
1132
|
+
// Tool: send_to_session
|
|
1133
|
+
// ============================================================================
|
|
1134
|
+
|
|
1135
|
+
function registerSessionTool(pi: ExtensionAPI, state: SocketState): void {
|
|
1136
|
+
pi.registerTool({
|
|
1137
|
+
name: "send_to_session",
|
|
1138
|
+
label: "Send To Session",
|
|
1139
|
+
description: `Interact with another running pi session via its control socket.
|
|
1140
|
+
|
|
1141
|
+
Actions:
|
|
1142
|
+
- send: Send a message (default). Requires 'message' parameter.
|
|
1143
|
+
- get_message: Get the most recent assistant message.
|
|
1144
|
+
- get_summary: Get a summary of activity since the last user prompt.
|
|
1145
|
+
- clear: Rewind session to initial state.
|
|
1146
|
+
|
|
1147
|
+
Target selection:
|
|
1148
|
+
- sessionId: UUID of the session.
|
|
1149
|
+
- sessionName: session name (alias from /name).
|
|
1150
|
+
|
|
1151
|
+
Wait behavior (only for action=send):
|
|
1152
|
+
- wait_until=turn_end: Wait for the turn to complete, returns last assistant message.
|
|
1153
|
+
- wait_until=message_processed: Returns immediately after message is queued.
|
|
1154
|
+
|
|
1155
|
+
CLI bridge (for shell scripts/background jobs):
|
|
1156
|
+
- Current session id is available in shell/bash as $PI_SESSION_ID (set when --session-control is enabled).
|
|
1157
|
+
- Use $PI_SESSION_ID when you need the current session; do not call list_sessions just to discover your own id.
|
|
1158
|
+
- Target session must be running with --session-control.
|
|
1159
|
+
- One-shot startup send is available via extension flags:
|
|
1160
|
+
--session-control
|
|
1161
|
+
--control-session <session-name|session-id>
|
|
1162
|
+
--send-session-message <text>
|
|
1163
|
+
--send-session-mode <steer|follow_up> (optional, default: steer)
|
|
1164
|
+
--send-session-wait <turn_end|message_processed> (optional)
|
|
1165
|
+
--send-session-include-sender-info (optional, advanced; default: off)
|
|
1166
|
+
- Startup sends are one-way by default (no sender_info), which avoids reply attempts to short-lived 'pi -p' sender sessions.
|
|
1167
|
+
- If a script needs a response, use --send-session-wait turn_end and read stdout.
|
|
1168
|
+
- Example script usage (one-way):
|
|
1169
|
+
pi -p --session-control --control-session "$PI_SESSION_ID" --send-session-message "Background task finished" --send-session-mode follow_up --send-session-wait message_processed
|
|
1170
|
+
- Example request/response usage:
|
|
1171
|
+
pi -p --session-control --control-session "$PI_SESSION_ID" --send-session-message "What is the current time?" --send-session-wait turn_end
|
|
1172
|
+
|
|
1173
|
+
Note: If you ask the target session to reply back via sender_info, do not use wait_until; waiting is redundant and can duplicate responses.
|
|
1174
|
+
|
|
1175
|
+
Messages automatically include sender session info for replies. When you want a response, instruct the target session to reply directly to the sender by calling send_to_session with the sender_info reference (do not poll get_message).`,
|
|
1176
|
+
parameters: Type.Object({
|
|
1177
|
+
sessionId: Type.Optional(Type.String({ description: "Target session id (UUID)" })),
|
|
1178
|
+
sessionName: Type.Optional(Type.String({ description: "Target session name (alias)" })),
|
|
1179
|
+
action: Type.Optional(
|
|
1180
|
+
StringEnum(["send", "get_message", "get_summary", "clear"] as const, {
|
|
1181
|
+
description: "Action to perform (default: send)",
|
|
1182
|
+
default: "send",
|
|
1183
|
+
}),
|
|
1184
|
+
),
|
|
1185
|
+
message: Type.Optional(Type.String({ description: "Message to send (required for action=send)" })),
|
|
1186
|
+
mode: Type.Optional(
|
|
1187
|
+
StringEnum(["steer", "follow_up"] as const, {
|
|
1188
|
+
description: "Delivery mode for send: steer (immediate) or follow_up (after task)",
|
|
1189
|
+
default: "steer",
|
|
1190
|
+
}),
|
|
1191
|
+
),
|
|
1192
|
+
wait_until: Type.Optional(
|
|
1193
|
+
StringEnum(["turn_end", "message_processed"] as const, {
|
|
1194
|
+
description: "Wait behavior for send action",
|
|
1195
|
+
}),
|
|
1196
|
+
),
|
|
1197
|
+
}),
|
|
1198
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1199
|
+
const action = params.action ?? "send";
|
|
1200
|
+
const sessionName = params.sessionName?.trim();
|
|
1201
|
+
const sessionId = params.sessionId?.trim();
|
|
1202
|
+
let targetSessionId: string | null = null;
|
|
1203
|
+
const displayTarget = sessionName || sessionId || "";
|
|
1204
|
+
|
|
1205
|
+
if (sessionName) {
|
|
1206
|
+
targetSessionId = await resolveSessionIdFromAlias(sessionName);
|
|
1207
|
+
if (!targetSessionId) {
|
|
1208
|
+
return {
|
|
1209
|
+
content: [{ type: "text", text: "Unknown session name" }],
|
|
1210
|
+
isError: true,
|
|
1211
|
+
details: { error: "Unknown session name" },
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (sessionId) {
|
|
1217
|
+
if (!isSafeSessionId(sessionId)) {
|
|
1218
|
+
return {
|
|
1219
|
+
content: [{ type: "text", text: "Invalid session id" }],
|
|
1220
|
+
isError: true,
|
|
1221
|
+
details: { error: "Invalid session id" },
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
if (targetSessionId && targetSessionId !== sessionId) {
|
|
1225
|
+
return {
|
|
1226
|
+
content: [{ type: "text", text: "Session name does not match session id" }],
|
|
1227
|
+
isError: true,
|
|
1228
|
+
details: { error: "Session name does not match session id" },
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
targetSessionId = sessionId;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (!targetSessionId) {
|
|
1235
|
+
return {
|
|
1236
|
+
content: [{ type: "text", text: "Missing session id or session name" }],
|
|
1237
|
+
isError: true,
|
|
1238
|
+
details: { error: "Missing session id or session name" },
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const socketPath = getSocketPath(targetSessionId);
|
|
1243
|
+
const senderSessionId = state.context?.sessionManager.getSessionId();
|
|
1244
|
+
|
|
1245
|
+
try {
|
|
1246
|
+
// Handle each action
|
|
1247
|
+
if (action === "get_message") {
|
|
1248
|
+
const result = await sendRpcCommand(socketPath, { type: "get_message" });
|
|
1249
|
+
if (!result.response.success) {
|
|
1250
|
+
return {
|
|
1251
|
+
content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
|
|
1252
|
+
isError: true,
|
|
1253
|
+
details: result,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
const data = result.response.data as { message?: ExtractedMessage };
|
|
1257
|
+
if (!data?.message) {
|
|
1258
|
+
return {
|
|
1259
|
+
content: [{ type: "text", text: "No assistant message found in session" }],
|
|
1260
|
+
details: result,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
return {
|
|
1264
|
+
content: [{ type: "text", text: data.message.content }],
|
|
1265
|
+
details: { message: data.message },
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if (action === "get_summary") {
|
|
1270
|
+
const result = await sendRpcCommand(socketPath, { type: "get_summary" }, { timeout: 60000 });
|
|
1271
|
+
if (!result.response.success) {
|
|
1272
|
+
return {
|
|
1273
|
+
content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
|
|
1274
|
+
isError: true,
|
|
1275
|
+
details: result,
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
const data = result.response.data as { summary?: string; model?: string };
|
|
1279
|
+
if (!data?.summary) {
|
|
1280
|
+
return {
|
|
1281
|
+
content: [{ type: "text", text: "No summary generated" }],
|
|
1282
|
+
details: result,
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
return {
|
|
1286
|
+
content: [{ type: "text", text: `Summary (via ${data.model}):\n\n${data.summary}` }],
|
|
1287
|
+
details: { summary: data.summary, model: data.model },
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (action === "clear") {
|
|
1292
|
+
const result = await sendRpcCommand(socketPath, { type: "clear", summarize: false }, { timeout: 10000 });
|
|
1293
|
+
if (!result.response.success) {
|
|
1294
|
+
return {
|
|
1295
|
+
content: [{ type: "text", text: `Failed to clear: ${result.response.error ?? "unknown error"}` }],
|
|
1296
|
+
isError: true,
|
|
1297
|
+
details: result,
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
const data = result.response.data as { cleared?: boolean; alreadyAtRoot?: boolean };
|
|
1301
|
+
const msg = data?.alreadyAtRoot ? "Session already at root" : "Session cleared";
|
|
1302
|
+
return {
|
|
1303
|
+
content: [{ type: "text", text: msg }],
|
|
1304
|
+
details: data,
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// action === "send"
|
|
1309
|
+
if (!params.message || params.message.trim().length === 0) {
|
|
1310
|
+
return {
|
|
1311
|
+
content: [{ type: "text", text: "Missing message for send action" }],
|
|
1312
|
+
isError: true,
|
|
1313
|
+
details: { error: "Missing message" },
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const senderSessionName = state.context?.sessionManager.getSessionName()?.trim();
|
|
1318
|
+
const senderInfo = senderSessionId
|
|
1319
|
+
? `\n\n<sender_info>${JSON.stringify({
|
|
1320
|
+
sessionId: senderSessionId,
|
|
1321
|
+
sessionName: senderSessionName || undefined,
|
|
1322
|
+
})}</sender_info>`
|
|
1323
|
+
: "";
|
|
1324
|
+
|
|
1325
|
+
const sendCommand: RpcSendCommand = {
|
|
1326
|
+
type: "send",
|
|
1327
|
+
message: params.message + senderInfo,
|
|
1328
|
+
mode: params.mode ?? "steer",
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
// Determine wait behavior
|
|
1332
|
+
if (params.wait_until === "message_processed") {
|
|
1333
|
+
// Just send and confirm delivery
|
|
1334
|
+
const result = await sendRpcCommand(socketPath, sendCommand);
|
|
1335
|
+
if (!result.response.success) {
|
|
1336
|
+
return {
|
|
1337
|
+
content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
|
|
1338
|
+
isError: true,
|
|
1339
|
+
details: result,
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
return {
|
|
1343
|
+
content: [{ type: "text", text: "Message delivered to session" }],
|
|
1344
|
+
details: result.response.data,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (params.wait_until === "turn_end") {
|
|
1349
|
+
// Send and wait for turn to complete
|
|
1350
|
+
const result = await sendRpcCommand(socketPath, sendCommand, {
|
|
1351
|
+
timeout: 300000, // 5 minutes
|
|
1352
|
+
waitForEvent: "turn_end",
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
if (!result.response.success) {
|
|
1356
|
+
return {
|
|
1357
|
+
content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
|
|
1358
|
+
isError: true,
|
|
1359
|
+
details: result,
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const lastMessage = result.event?.message;
|
|
1364
|
+
if (!lastMessage) {
|
|
1365
|
+
return {
|
|
1366
|
+
content: [{ type: "text", text: "Turn completed but no assistant message found" }],
|
|
1367
|
+
details: { turnIndex: result.event?.turnIndex },
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
return {
|
|
1372
|
+
content: [{ type: "text", text: lastMessage.content }],
|
|
1373
|
+
details: { message: lastMessage, turnIndex: result.event?.turnIndex },
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// No wait - just send
|
|
1378
|
+
const result = await sendRpcCommand(socketPath, sendCommand);
|
|
1379
|
+
if (!result.response.success) {
|
|
1380
|
+
return {
|
|
1381
|
+
content: [{ type: "text", text: `Failed: ${result.response.error ?? "unknown error"}` }],
|
|
1382
|
+
isError: true,
|
|
1383
|
+
details: result,
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
return {
|
|
1388
|
+
content: [{ type: "text", text: `Message sent to session ${displayTarget || targetSessionId}` }],
|
|
1389
|
+
details: result.response.data,
|
|
1390
|
+
};
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1393
|
+
return {
|
|
1394
|
+
content: [{ type: "text", text: `Failed: ${message}` }],
|
|
1395
|
+
isError: true,
|
|
1396
|
+
details: { error: message },
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
|
|
1401
|
+
renderCall(args, theme) {
|
|
1402
|
+
const action = args.action ?? "send";
|
|
1403
|
+
const sessionRef = args.sessionName ?? args.sessionId ?? "...";
|
|
1404
|
+
const shortSessionRef = sessionRef.length > 12 ? sessionRef.slice(0, 8) + "..." : sessionRef;
|
|
1405
|
+
|
|
1406
|
+
// Build the header line
|
|
1407
|
+
let header = theme.fg("toolTitle", theme.bold("→ session "));
|
|
1408
|
+
header += theme.fg("accent", shortSessionRef);
|
|
1409
|
+
|
|
1410
|
+
// Add action-specific info
|
|
1411
|
+
if (action === "send") {
|
|
1412
|
+
const mode = args.mode ?? "steer";
|
|
1413
|
+
const wait = args.wait_until;
|
|
1414
|
+
let info = theme.fg("muted", ` (${mode}`);
|
|
1415
|
+
if (wait) info += theme.fg("dim", `, wait: ${wait}`);
|
|
1416
|
+
info += theme.fg("muted", ")");
|
|
1417
|
+
header += info;
|
|
1418
|
+
} else {
|
|
1419
|
+
header += theme.fg("muted", ` (${action})`);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// For send action, show the message
|
|
1423
|
+
if (action === "send" && args.message) {
|
|
1424
|
+
const msg = args.message;
|
|
1425
|
+
const preview = msg.length > 80 ? msg.slice(0, 80) + "..." : msg;
|
|
1426
|
+
// Handle multi-line messages
|
|
1427
|
+
const firstLine = preview.split("\n")[0];
|
|
1428
|
+
const hasMore = preview.includes("\n") || msg.length > 80;
|
|
1429
|
+
return new Text(
|
|
1430
|
+
header + "\n " + theme.fg("dim", `"${firstLine}${hasMore ? "..." : ""}"`),
|
|
1431
|
+
0,
|
|
1432
|
+
0,
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
return new Text(header, 0, 0);
|
|
1437
|
+
},
|
|
1438
|
+
|
|
1439
|
+
renderResult(result, { expanded }, theme) {
|
|
1440
|
+
const details = result.details as Record<string, unknown> | undefined;
|
|
1441
|
+
const isError = result.isError === true;
|
|
1442
|
+
|
|
1443
|
+
// Error case
|
|
1444
|
+
if (isError || details?.error) {
|
|
1445
|
+
const errorMsg = (details?.error as string) || result.content[0]?.type === "text" ? (result.content[0] as { type: "text"; text: string }).text : "Unknown error";
|
|
1446
|
+
return new Text(theme.fg("error", "✗ ") + theme.fg("error", errorMsg), 0, 0);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Detect action from details structure
|
|
1450
|
+
const hasMessage = details && "message" in details && details.message;
|
|
1451
|
+
const hasSummary = details && "summary" in details;
|
|
1452
|
+
const hasCleared = details && "cleared" in details;
|
|
1453
|
+
const hasTurnIndex = details && "turnIndex" in details;
|
|
1454
|
+
|
|
1455
|
+
// get_message or turn_end result with message
|
|
1456
|
+
if (hasMessage) {
|
|
1457
|
+
const message = details.message as ExtractedMessage;
|
|
1458
|
+
const icon = theme.fg("success", "✓");
|
|
1459
|
+
|
|
1460
|
+
if (expanded) {
|
|
1461
|
+
const container = new Container();
|
|
1462
|
+
container.addChild(new Text(icon + theme.fg("muted", " Message received"), 0, 0));
|
|
1463
|
+
container.addChild(new Spacer(1));
|
|
1464
|
+
container.addChild(new Markdown(message.content, 0, 0, getMarkdownTheme()));
|
|
1465
|
+
if (hasTurnIndex) {
|
|
1466
|
+
container.addChild(new Spacer(1));
|
|
1467
|
+
container.addChild(new Text(theme.fg("dim", `Turn #${details.turnIndex}`), 0, 0));
|
|
1468
|
+
}
|
|
1469
|
+
return container;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Collapsed view - show preview
|
|
1473
|
+
const preview = message.content.length > 200
|
|
1474
|
+
? message.content.slice(0, 200) + "..."
|
|
1475
|
+
: message.content;
|
|
1476
|
+
const lines = preview.split("\n").slice(0, 5);
|
|
1477
|
+
let text = icon + theme.fg("muted", " Message received");
|
|
1478
|
+
if (hasTurnIndex) text += theme.fg("dim", ` (turn #${details.turnIndex})`);
|
|
1479
|
+
text += "\n" + theme.fg("toolOutput", lines.join("\n"));
|
|
1480
|
+
if (message.content.split("\n").length > 5 || message.content.length > 200) {
|
|
1481
|
+
text += "\n" + theme.fg("dim", "(Ctrl+O to expand)");
|
|
1482
|
+
}
|
|
1483
|
+
return new Text(text, 0, 0);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// get_summary result
|
|
1487
|
+
if (hasSummary) {
|
|
1488
|
+
const summary = details.summary as string;
|
|
1489
|
+
const model = details.model as string | undefined;
|
|
1490
|
+
const icon = theme.fg("success", "✓");
|
|
1491
|
+
|
|
1492
|
+
if (expanded) {
|
|
1493
|
+
const container = new Container();
|
|
1494
|
+
let header = icon + theme.fg("muted", " Summary");
|
|
1495
|
+
if (model) header += theme.fg("dim", ` via ${model}`);
|
|
1496
|
+
container.addChild(new Text(header, 0, 0));
|
|
1497
|
+
container.addChild(new Spacer(1));
|
|
1498
|
+
container.addChild(new Markdown(summary, 0, 0, getMarkdownTheme()));
|
|
1499
|
+
return container;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const preview = summary.length > 200 ? summary.slice(0, 200) + "..." : summary;
|
|
1503
|
+
const lines = preview.split("\n").slice(0, 5);
|
|
1504
|
+
let text = icon + theme.fg("muted", " Summary");
|
|
1505
|
+
if (model) text += theme.fg("dim", ` via ${model}`);
|
|
1506
|
+
text += "\n" + theme.fg("toolOutput", lines.join("\n"));
|
|
1507
|
+
if (summary.split("\n").length > 5 || summary.length > 200) {
|
|
1508
|
+
text += "\n" + theme.fg("dim", "(Ctrl+O to expand)");
|
|
1509
|
+
}
|
|
1510
|
+
return new Text(text, 0, 0);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// clear result
|
|
1514
|
+
if (hasCleared) {
|
|
1515
|
+
const alreadyAtRoot = details.alreadyAtRoot as boolean | undefined;
|
|
1516
|
+
const icon = theme.fg("success", "✓");
|
|
1517
|
+
const msg = alreadyAtRoot ? "Session already at root" : "Session cleared";
|
|
1518
|
+
return new Text(icon + " " + theme.fg("muted", msg), 0, 0);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// send result (no wait or message_processed)
|
|
1522
|
+
if (details && "delivered" in details) {
|
|
1523
|
+
const mode = details.mode as string | undefined;
|
|
1524
|
+
const icon = theme.fg("success", "✓");
|
|
1525
|
+
let text = icon + theme.fg("muted", " Message delivered");
|
|
1526
|
+
if (mode) text += theme.fg("dim", ` (${mode})`);
|
|
1527
|
+
return new Text(text, 0, 0);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Fallback - just show the text content
|
|
1531
|
+
const text = result.content[0];
|
|
1532
|
+
const content = text?.type === "text" ? text.text : "(no output)";
|
|
1533
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", content), 0, 0);
|
|
1534
|
+
},
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// ============================================================================
|
|
1539
|
+
// Tool: list_sessions
|
|
1540
|
+
// ============================================================================
|
|
1541
|
+
|
|
1542
|
+
function registerListSessionsTool(pi: ExtensionAPI): void {
|
|
1543
|
+
pi.registerTool({
|
|
1544
|
+
name: "list_sessions",
|
|
1545
|
+
label: "List Sessions",
|
|
1546
|
+
description: "List live sessions that expose a control socket (optionally with session names). Use this for discovery only; for the current session id in shell/bash use $PI_SESSION_ID.",
|
|
1547
|
+
parameters: Type.Object({}),
|
|
1548
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
1549
|
+
const sessions = await getLiveSessions();
|
|
1550
|
+
|
|
1551
|
+
if (sessions.length === 0) {
|
|
1552
|
+
return {
|
|
1553
|
+
content: [{ type: "text", text: "No live sessions found." }],
|
|
1554
|
+
details: { sessions: [] },
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const lines = sessions.map((session) => {
|
|
1559
|
+
const name = session.name ? ` (${session.name})` : "";
|
|
1560
|
+
return `- ${session.sessionId}${name}`;
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
return {
|
|
1564
|
+
content: [{ type: "text", text: `Live sessions:\n${lines.join("\n")}` }],
|
|
1565
|
+
details: { sessions },
|
|
1566
|
+
};
|
|
1567
|
+
},
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
type StartupControlSendOptions = {
|
|
1572
|
+
target: string;
|
|
1573
|
+
message: string;
|
|
1574
|
+
mode: "steer" | "follow_up";
|
|
1575
|
+
waitUntil?: "turn_end" | "message_processed";
|
|
1576
|
+
includeSenderInfo: boolean;
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
function normalizeMode(raw: string): "steer" | "follow_up" | null {
|
|
1580
|
+
const value = raw.trim().toLowerCase();
|
|
1581
|
+
if (value === "steer") return "steer";
|
|
1582
|
+
if (value === "follow_up" || value === "follow-up" || value === "followup") return "follow_up";
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function normalizeWaitUntil(raw: string): "turn_end" | "message_processed" | null {
|
|
1587
|
+
const value = raw.trim().toLowerCase();
|
|
1588
|
+
if (value === "turn_end" || value === "turn-end") return "turn_end";
|
|
1589
|
+
if (value === "message_processed" || value === "message-processed") return "message_processed";
|
|
1590
|
+
return null;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function getStringFlag(pi: ExtensionAPI, name: string): string | undefined {
|
|
1594
|
+
const value = pi.getFlag(name);
|
|
1595
|
+
if (typeof value !== "string") return undefined;
|
|
1596
|
+
const trimmed = value.trim();
|
|
1597
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function parseStartupControlSendOptions(pi: ExtensionAPI): { options?: StartupControlSendOptions; error?: string } {
|
|
1601
|
+
const target = getStringFlag(pi, CONTROL_TARGET_FLAG);
|
|
1602
|
+
const message = getStringFlag(pi, CONTROL_SEND_MESSAGE_FLAG);
|
|
1603
|
+
|
|
1604
|
+
if (!target && !message) {
|
|
1605
|
+
return {};
|
|
1606
|
+
}
|
|
1607
|
+
if (target && !message) {
|
|
1608
|
+
return { error: `Missing --${CONTROL_SEND_MESSAGE_FLAG} (required with --${CONTROL_TARGET_FLAG})` };
|
|
1609
|
+
}
|
|
1610
|
+
if (!target && message) {
|
|
1611
|
+
return { error: `Missing --${CONTROL_TARGET_FLAG} (required with --${CONTROL_SEND_MESSAGE_FLAG})` };
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
const rawMode = getStringFlag(pi, CONTROL_SEND_MODE_FLAG) ?? "steer";
|
|
1615
|
+
const mode = normalizeMode(rawMode);
|
|
1616
|
+
if (!mode) {
|
|
1617
|
+
return { error: `Invalid --${CONTROL_SEND_MODE_FLAG}: ${rawMode}. Use steer|follow_up.` };
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
const rawWait = getStringFlag(pi, CONTROL_SEND_WAIT_FLAG);
|
|
1621
|
+
let waitUntil: "turn_end" | "message_processed" | undefined;
|
|
1622
|
+
if (rawWait) {
|
|
1623
|
+
const normalized = normalizeWaitUntil(rawWait);
|
|
1624
|
+
if (!normalized) {
|
|
1625
|
+
return {
|
|
1626
|
+
error: `Invalid --${CONTROL_SEND_WAIT_FLAG}: ${rawWait}. Use turn_end|message_processed.`,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
waitUntil = normalized;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
const includeSenderInfo = pi.getFlag(CONTROL_SEND_INCLUDE_SENDER_FLAG) === true;
|
|
1633
|
+
|
|
1634
|
+
return {
|
|
1635
|
+
options: {
|
|
1636
|
+
target: target!,
|
|
1637
|
+
message: message!,
|
|
1638
|
+
mode,
|
|
1639
|
+
waitUntil,
|
|
1640
|
+
includeSenderInfo,
|
|
1641
|
+
},
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function reportStartupControlSend(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
1646
|
+
if (ctx.hasUI) {
|
|
1647
|
+
ctx.ui.notify(message, level);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (level === "error") {
|
|
1651
|
+
console.error(message);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
console.log(message);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
async function maybeHandleStartupControlSend(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
1658
|
+
const parsed = parseStartupControlSendOptions(pi);
|
|
1659
|
+
if (!parsed.options) {
|
|
1660
|
+
if (parsed.error) {
|
|
1661
|
+
reportStartupControlSend(ctx, parsed.error, "error");
|
|
1662
|
+
}
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const { target, message, mode, waitUntil, includeSenderInfo } = parsed.options;
|
|
1667
|
+
let targetSessionId = await resolveSessionIdFromAlias(target);
|
|
1668
|
+
if (!targetSessionId && isSafeSessionId(target)) {
|
|
1669
|
+
targetSessionId = target;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
if (!targetSessionId) {
|
|
1673
|
+
reportStartupControlSend(ctx, `Unknown target session: ${target}`, "error");
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const socketPath = getSocketPath(targetSessionId);
|
|
1678
|
+
const alive = await isSocketAlive(socketPath);
|
|
1679
|
+
if (!alive) {
|
|
1680
|
+
reportStartupControlSend(ctx, `Target session not reachable: ${target}`, "error");
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const senderInfo = includeSenderInfo
|
|
1685
|
+
? (() => {
|
|
1686
|
+
const senderSessionId = ctx.sessionManager.getSessionId();
|
|
1687
|
+
const senderSessionName = ctx.sessionManager.getSessionName()?.trim();
|
|
1688
|
+
return senderSessionId
|
|
1689
|
+
? `\n\n<sender_info>${JSON.stringify({
|
|
1690
|
+
sessionId: senderSessionId,
|
|
1691
|
+
sessionName: senderSessionName || undefined,
|
|
1692
|
+
})}</sender_info>`
|
|
1693
|
+
: "";
|
|
1694
|
+
})()
|
|
1695
|
+
: "";
|
|
1696
|
+
|
|
1697
|
+
const sendCommand: RpcSendCommand = {
|
|
1698
|
+
type: "send",
|
|
1699
|
+
message: message + senderInfo,
|
|
1700
|
+
mode,
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
try {
|
|
1704
|
+
if (waitUntil === "turn_end") {
|
|
1705
|
+
const result = await sendRpcCommand(socketPath, sendCommand, {
|
|
1706
|
+
timeout: 300000,
|
|
1707
|
+
waitForEvent: "turn_end",
|
|
1708
|
+
});
|
|
1709
|
+
if (!result.response.success) {
|
|
1710
|
+
reportStartupControlSend(ctx, `Failed to send: ${result.response.error ?? "unknown error"}`, "error");
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
const lastMessage = result.event?.message;
|
|
1714
|
+
if (!lastMessage?.content) {
|
|
1715
|
+
reportStartupControlSend(ctx, `Message delivered to ${target}; turn completed without assistant output.`);
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
if (ctx.hasUI) {
|
|
1719
|
+
pi.sendMessage(
|
|
1720
|
+
{
|
|
1721
|
+
customType: "control-send",
|
|
1722
|
+
content: `Startup response from ${target}:\n\n${lastMessage.content}`,
|
|
1723
|
+
display: true,
|
|
1724
|
+
},
|
|
1725
|
+
{ triggerTurn: false },
|
|
1726
|
+
);
|
|
1727
|
+
} else {
|
|
1728
|
+
console.log(lastMessage.content);
|
|
1729
|
+
}
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
const result = await sendRpcCommand(socketPath, sendCommand, { timeout: 30000 });
|
|
1734
|
+
if (!result.response.success) {
|
|
1735
|
+
reportStartupControlSend(ctx, `Failed to send: ${result.response.error ?? "unknown error"}`, "error");
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
const waitLabel = waitUntil === "message_processed" ? " (message processed)" : "";
|
|
1740
|
+
reportStartupControlSend(ctx, `Message sent to ${target}${waitLabel}`);
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
const msg = error instanceof Error ? error.message : "unknown error";
|
|
1743
|
+
reportStartupControlSend(ctx, `Failed to send to ${target}: ${msg}`, "error");
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function registerControlSessionsCommand(pi: ExtensionAPI): void {
|
|
1748
|
+
pi.registerCommand("control-sessions", {
|
|
1749
|
+
description: "List controllable sessions (from session-control sockets)",
|
|
1750
|
+
handler: async (_args, ctx) => {
|
|
1751
|
+
if (pi.getFlag(CONTROL_FLAG) !== true) {
|
|
1752
|
+
if (ctx.hasUI) {
|
|
1753
|
+
ctx.ui.notify("Session control not enabled (use --session-control)", "warning");
|
|
1754
|
+
}
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
const sessions = await getLiveSessions();
|
|
1759
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
1760
|
+
const lines = sessions.map((session) => {
|
|
1761
|
+
const name = session.name ? ` (${session.name})` : "";
|
|
1762
|
+
const current = session.sessionId === currentSessionId ? " (current)" : "";
|
|
1763
|
+
return `- ${session.sessionId}${name}${current}`;
|
|
1764
|
+
});
|
|
1765
|
+
const content = sessions.length === 0
|
|
1766
|
+
? "No live sessions found."
|
|
1767
|
+
: `Controllable sessions:\n${lines.join("\n")}`;
|
|
1768
|
+
|
|
1769
|
+
pi.sendMessage(
|
|
1770
|
+
{
|
|
1771
|
+
customType: "control-sessions",
|
|
1772
|
+
content,
|
|
1773
|
+
display: true,
|
|
1774
|
+
},
|
|
1775
|
+
{ triggerTurn: false },
|
|
1776
|
+
);
|
|
1777
|
+
},
|
|
1778
|
+
});
|
|
1779
|
+
}
|