pi-remote-control 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/docs/adr/0001-package-extension-as-control-shim.md +19 -0
- package/docs/adr/0002-use-sqlite-for-daemon-state.md +19 -0
- package/docs/adr/0003-use-lock-file-as-process-state.md +19 -0
- package/docs/adr/0004-allow-loopback-pair-code-without-token.md +19 -0
- package/docs/adr/0005-defer-os-service-installation.md +19 -0
- package/docs/adr/0006-use-tui-activated-remote-control-sessions.md +24 -0
- package/docs/adr/0007-require-tui-originated-pairing.md +19 -0
- package/docs/adr/0008-use-qr-pairing-links.md +21 -0
- package/docs/adr/0009-rename-package-to-remote-control.md +19 -0
- package/docs/adr/0010-clean-stale-lock-on-status.md +19 -0
- package/docs/adr/0011-use-loopback-tui-control.md +19 -0
- package/docs/adr/0012-use-paginated-session-transcript-loading.md +37 -0
- package/docs/adr/0013-require-manual-reactivation-after-tui-entry.md +31 -0
- package/docs/adr/0014-read-transcripts-from-session-files.md +33 -0
- package/docs/adr/0015-normalize-transcript-messages-and-stream-events.md +35 -0
- package/docs/adr/0016-expose-turn-lifecycle-events.md +31 -0
- package/docs/adr/0017-bound-initial-websocket-session-state.md +31 -0
- package/docs/adr/0018-reregister-active-tui-session-on-heartbeat-miss.md +33 -0
- package/docs/adr/0019-display-only-pairing-qr-and-expiry.md +25 -0
- package/docs/adr/0020-expose-session-status-snapshots.md +31 -0
- package/docs/adr/0021-support-remote-compact-action.md +31 -0
- package/docs/adr/0022-rename-session-status-to-runtime-status.md +27 -0
- package/docs/adr/0023-return-remote-compact-results.md +29 -0
- package/docs/architecture.md +96 -0
- package/docs/data-model.md +284 -0
- package/docs/interfaces.md +470 -0
- package/package.json +37 -0
- package/scripts/http-smoke-test.sh +100 -0
- package/src/active-session-registry.ts +205 -0
- package/src/auth/pairing.ts +30 -0
- package/src/auth/tokens.ts +30 -0
- package/src/cli-runner.cjs +15 -0
- package/src/cli.ts +254 -0
- package/src/config.ts +26 -0
- package/src/extension/index.ts +422 -0
- package/src/index.ts +16 -0
- package/src/lock.ts +26 -0
- package/src/pairing-link.ts +15 -0
- package/src/paths.ts +21 -0
- package/src/persistence/daemon-store.ts +56 -0
- package/src/persistence/schema.ts +21 -0
- package/src/qr.ts +23 -0
- package/src/runtime-status.ts +116 -0
- package/src/server/http.ts +529 -0
- package/src/session-index.ts +9 -0
- package/src/session-transcript.ts +34 -0
- package/src/transcript-message.ts +76 -0
- package/src/transcript-pagination.ts +68 -0
- package/src/transcript-preview.ts +102 -0
- package/src/transcript-stream.ts +89 -0
- package/src/types.ts +116 -0
- package/tests/active-session-registry.test.ts +170 -0
- package/tests/auth.test.ts +18 -0
- package/tests/cli.test.ts +361 -0
- package/tests/config.test.ts +35 -0
- package/tests/daemon-store.test.ts +54 -0
- package/tests/extension.test.ts +617 -0
- package/tests/lock.test.ts +36 -0
- package/tests/pairing-link.test.ts +26 -0
- package/tests/pairing.test.ts +26 -0
- package/tests/paths.test.ts +29 -0
- package/tests/qr.test.ts +25 -0
- package/tests/schema.test.ts +18 -0
- package/tests/server-http.test.ts +932 -0
- package/tests/session-index.test.ts +10 -0
- package/tests/session-transcript.test.ts +75 -0
- package/tests/transcript-pagination.test.ts +54 -0
- package/tests/transcript-preview.test.ts +64 -0
- package/tests/transcript-stream.test.ts +103 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { RemoteTuiCommand } from "../active-session-registry.js";
|
|
6
|
+
import type { RemoteCompactResultEvent } from "../types.js";
|
|
7
|
+
import { loadDaemonConfig } from "../config.js";
|
|
8
|
+
import { getDaemonStateDir } from "../paths.js";
|
|
9
|
+
import { collectRuntimeStatus } from "../runtime-status.js";
|
|
10
|
+
import { projectIdForPath } from "../session-index.js";
|
|
11
|
+
|
|
12
|
+
export { collectRuntimeStatus } from "../runtime-status.js";
|
|
13
|
+
|
|
14
|
+
export default function remoteControlExtension(pi: ExtensionAPI): void {
|
|
15
|
+
const activeSessionIds = new Set<string>();
|
|
16
|
+
const pollTimers = new Map<string, NodeJS.Timeout>();
|
|
17
|
+
const runtimeStatusCache = new Map<string, string>();
|
|
18
|
+
const forward = (event: unknown, ctx: ExtensionContext) => {
|
|
19
|
+
const sessionId = daemonSessionId(ctx);
|
|
20
|
+
if (activeSessionIds.has(sessionId)) {
|
|
21
|
+
void postTuiEvent(sessionId, enrichTuiEventForDaemon(event, ctx));
|
|
22
|
+
void postRuntimeStatusIfChanged(pi, ctx, sessionId, runtimeStatusCache);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const resetLocalState = (ctx: ExtensionContext) => {
|
|
26
|
+
const sessionId = daemonSessionId(ctx);
|
|
27
|
+
runtimeStatusCache.delete(sessionId);
|
|
28
|
+
deactivateLocalSession(ctx, sessionId, activeSessionIds, pollTimers);
|
|
29
|
+
};
|
|
30
|
+
const cleanup = async (ctx: ExtensionContext) => {
|
|
31
|
+
const sessionId = daemonSessionId(ctx);
|
|
32
|
+
resetLocalState(ctx);
|
|
33
|
+
await unregisterTuiSession(sessionId).catch(() => undefined);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
registerEventForwarders(pi, forward, cleanup, resetLocalState);
|
|
37
|
+
|
|
38
|
+
pi.registerCommand("remote-control", {
|
|
39
|
+
description: "Toggle Pi remote control for this TUI session",
|
|
40
|
+
handler: async (_args, ctx) => {
|
|
41
|
+
try {
|
|
42
|
+
await ensureDaemonStarted(pi);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}; see /tmp/pi-remote-control.log`, "error");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const sessionId = daemonSessionId(ctx);
|
|
48
|
+
if (activeSessionIds.has(sessionId)) {
|
|
49
|
+
try {
|
|
50
|
+
await unregisterTuiSession(sessionId);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
ctx.ui.notify(`Remote control disable failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
runtimeStatusCache.delete(sessionId);
|
|
56
|
+
deactivateLocalSession(ctx, sessionId, activeSessionIds, pollTimers);
|
|
57
|
+
ctx.ui.notify("Remote control disabled for this session", "info");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let response: Response;
|
|
62
|
+
try {
|
|
63
|
+
response = await fetch(`${await daemonBaseUrl()}/v1/tui/sessions`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: await tuiHeaders(),
|
|
66
|
+
body: JSON.stringify(toRegistration(pi, ctx)),
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
ctx.ui.notify(`Remote control enable failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
ctx.ui.notify(`Remote control enable failed: HTTP ${response.status}`, "error");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
runtimeStatusCache.set(sessionId, comparableRuntimeStatus(collectRuntimeStatus(pi, ctx)));
|
|
77
|
+
activateLocalSession(pi, ctx, sessionId, activeSessionIds, pollTimers, runtimeStatusCache);
|
|
78
|
+
ctx.ui.notify("Remote control enabled for this session", "info");
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.registerCommand("remote-control-pair", {
|
|
83
|
+
description: "Display a QR pairing link for Pi Remote Control",
|
|
84
|
+
handler: async (_args, ctx) => {
|
|
85
|
+
try {
|
|
86
|
+
await ensureDaemonStarted(pi);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}; see /tmp/pi-remote-control.log`, "error");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const cli = cliCommand();
|
|
92
|
+
const result = await pi.exec(cli.command, [...cli.args, "pair"]);
|
|
93
|
+
const stdout = result.stdout.trim();
|
|
94
|
+
const stderr = result.stderr.trim();
|
|
95
|
+
const output = stdout || stderr || `pi-remote-control pair exited ${result.code}`;
|
|
96
|
+
ctx.ui.notify(output, result.code === 0 ? "info" : "error");
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function registerEventForwarders(
|
|
102
|
+
pi: ExtensionAPI,
|
|
103
|
+
forward: (event: unknown, ctx: ExtensionContext) => void,
|
|
104
|
+
cleanup: (ctx: ExtensionContext) => void | Promise<void>,
|
|
105
|
+
resetLocalState: (ctx: ExtensionContext) => void,
|
|
106
|
+
): void {
|
|
107
|
+
pi.on("session_start", (_event, ctx) => resetLocalState(ctx));
|
|
108
|
+
pi.on("session_shutdown", (_event, ctx) => cleanup(ctx));
|
|
109
|
+
pi.on("turn_start", forward);
|
|
110
|
+
pi.on("turn_end", forward);
|
|
111
|
+
pi.on("message_start", forward);
|
|
112
|
+
pi.on("message_update", forward);
|
|
113
|
+
pi.on("message_end", forward);
|
|
114
|
+
pi.on("tool_execution_start", forward);
|
|
115
|
+
pi.on("tool_execution_update", forward);
|
|
116
|
+
pi.on("tool_execution_end", forward);
|
|
117
|
+
pi.on("agent_start", forward);
|
|
118
|
+
pi.on("agent_end", forward);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function enrichTuiEventForDaemon(event: unknown, ctx: Pick<ExtensionContext, "sessionManager">): unknown {
|
|
122
|
+
const record = asRecord(event);
|
|
123
|
+
if (record.type !== "message_start" && record.type !== "message_update" && record.type !== "message_end") return event;
|
|
124
|
+
const message = asRecord(record.message);
|
|
125
|
+
const entry = [...ctx.sessionManager.getEntries()].reverse().find((candidate) => {
|
|
126
|
+
const candidateRecord = asRecord(candidate);
|
|
127
|
+
return candidateRecord.type === "message" && messagesMatch(candidateRecord.message, message);
|
|
128
|
+
});
|
|
129
|
+
const entryRecord = asRecord(entry);
|
|
130
|
+
const id = readString(entryRecord.id);
|
|
131
|
+
if (!id) return event;
|
|
132
|
+
return {
|
|
133
|
+
...record,
|
|
134
|
+
id,
|
|
135
|
+
timestamp: readString(entryRecord.timestamp) ?? record.timestamp,
|
|
136
|
+
message: { ...message, id },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function messagesMatch(left: unknown, right: unknown): boolean {
|
|
141
|
+
const leftRecord = asRecord(left);
|
|
142
|
+
const rightRecord = asRecord(right);
|
|
143
|
+
if (leftRecord.role !== rightRecord.role) return false;
|
|
144
|
+
if (leftRecord.timestamp !== undefined && rightRecord.timestamp !== undefined) return leftRecord.timestamp === rightRecord.timestamp;
|
|
145
|
+
return JSON.stringify(leftRecord.content) === JSON.stringify(rightRecord.content);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
149
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readString(value: unknown): string | undefined {
|
|
153
|
+
return typeof value === "string" ? value : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readNumber(value: unknown): number | undefined {
|
|
157
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function setRemoteControlStatus(ctx: ExtensionContext): void {
|
|
161
|
+
ctx.ui.setStatus("remote-control", ctx.ui.theme.fg("success", "Remote Control Active"));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function clearRemoteControlStatus(ctx: ExtensionContext): void {
|
|
165
|
+
ctx.ui.setStatus("remote-control", undefined);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function unregisterTuiSession(sessionId: string): Promise<void> {
|
|
169
|
+
await fetch(`${await daemonBaseUrl()}/v1/tui/sessions/${encodeURIComponent(sessionId)}`, {
|
|
170
|
+
method: "DELETE",
|
|
171
|
+
headers: await tuiHeaders(false),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function activateLocalSession(
|
|
176
|
+
pi: ExtensionAPI,
|
|
177
|
+
ctx: ExtensionCommandContext,
|
|
178
|
+
sessionId: string,
|
|
179
|
+
activeSessionIds: Set<string>,
|
|
180
|
+
pollTimers: Map<string, NodeJS.Timeout>,
|
|
181
|
+
runtimeStatusCache: Map<string, string>,
|
|
182
|
+
): void {
|
|
183
|
+
activeSessionIds.add(sessionId);
|
|
184
|
+
setRemoteControlStatus(ctx);
|
|
185
|
+
if (pollTimers.has(sessionId)) return;
|
|
186
|
+
const timer = setInterval(() => void pollRemoteCommands(pi, ctx, sessionId, activeSessionIds, pollTimers, runtimeStatusCache).catch(() => undefined), 1000);
|
|
187
|
+
timer.unref?.();
|
|
188
|
+
pollTimers.set(sessionId, timer);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function deactivateLocalSession(
|
|
192
|
+
ctx: ExtensionContext,
|
|
193
|
+
sessionId: string,
|
|
194
|
+
activeSessionIds: Set<string>,
|
|
195
|
+
pollTimers: Map<string, NodeJS.Timeout>,
|
|
196
|
+
): void {
|
|
197
|
+
activeSessionIds.delete(sessionId);
|
|
198
|
+
clearInterval(pollTimers.get(sessionId));
|
|
199
|
+
pollTimers.delete(sessionId);
|
|
200
|
+
clearRemoteControlStatus(ctx);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function ensureDaemonStarted(pi: ExtensionAPI): Promise<void> {
|
|
204
|
+
const cli = cliCommand();
|
|
205
|
+
const status = await pi.exec(cli.command, [...cli.args, "status"]);
|
|
206
|
+
if (status.code === 0) return;
|
|
207
|
+
|
|
208
|
+
const shellLine = `nohup ${shellQuote(cli.command)} ${[...cli.args, "start"].map(shellQuote).join(" ")} </dev/null >/tmp/pi-remote-control.log 2>&1 &`;
|
|
209
|
+
await pi.exec("sh", ["-lc", shellLine]);
|
|
210
|
+
await waitForDaemonReady();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function waitForDaemonReady(attempts = Number.parseInt(process.env.PI_REMOTE_CONTROL_READY_ATTEMPTS ?? "100", 10), delayMs = 100): Promise<void> {
|
|
214
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
215
|
+
try {
|
|
216
|
+
const response = await fetch(`${await daemonBaseUrl()}/v1/health`);
|
|
217
|
+
if (response.ok) return;
|
|
218
|
+
} catch {
|
|
219
|
+
// The daemon may not have bound its HTTP port yet.
|
|
220
|
+
}
|
|
221
|
+
await delay(delayMs);
|
|
222
|
+
}
|
|
223
|
+
throw new Error("pi-remote-control did not become ready");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function delay(ms: number): Promise<void> {
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function toRegistration(pi: unknown, ctx: ExtensionCommandContext): unknown {
|
|
231
|
+
const cwd = ctx.cwd;
|
|
232
|
+
const piSessionId = ctx.sessionManager.getSessionId();
|
|
233
|
+
const sessionFile = ctx.sessionManager.getSessionFile() ?? "";
|
|
234
|
+
return {
|
|
235
|
+
id: daemonSessionId(ctx),
|
|
236
|
+
piSessionId,
|
|
237
|
+
project: { id: projectIdForPath(cwd), name: basename(cwd), path: cwd },
|
|
238
|
+
sessionFile,
|
|
239
|
+
name: ctx.sessionManager.getSessionName(),
|
|
240
|
+
pid: process.pid,
|
|
241
|
+
messageCount: ctx.sessionManager.getEntries().length,
|
|
242
|
+
entries: ctx.sessionManager.getEntries(),
|
|
243
|
+
isStreaming: !ctx.isIdle(),
|
|
244
|
+
runtimeStatus: collectRuntimeStatus(pi, ctx),
|
|
245
|
+
updatedAt: new Date().toISOString(),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function daemonSessionId(ctx: Pick<ExtensionContext, "sessionManager">): string {
|
|
250
|
+
return `sess_${ctx.sessionManager.getSessionId()}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function pollRemoteCommands(
|
|
254
|
+
pi: ExtensionAPI,
|
|
255
|
+
ctx: ExtensionCommandContext,
|
|
256
|
+
sessionId: string,
|
|
257
|
+
activeSessionIds: Set<string>,
|
|
258
|
+
pollTimers: Map<string, NodeJS.Timeout>,
|
|
259
|
+
runtimeStatusCache: Map<string, string>,
|
|
260
|
+
): Promise<void> {
|
|
261
|
+
const response = await fetch(`${await daemonBaseUrl()}/v1/tui/sessions/${encodeURIComponent(sessionId)}/commands`, {
|
|
262
|
+
headers: await tuiHeaders(false),
|
|
263
|
+
});
|
|
264
|
+
if (response.status === 404) {
|
|
265
|
+
await reRegisterTuiSessionAfterHeartbeatMiss(pi, ctx, sessionId, activeSessionIds, pollTimers, runtimeStatusCache);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!response.ok) return;
|
|
269
|
+
const body = (await response.json()) as { commands?: RemoteTuiCommand[] };
|
|
270
|
+
for (const command of body.commands ?? []) handleRemoteCommand(pi, ctx, command, sessionId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function reRegisterTuiSessionAfterHeartbeatMiss(
|
|
274
|
+
pi: ExtensionAPI,
|
|
275
|
+
ctx: ExtensionCommandContext,
|
|
276
|
+
sessionId: string,
|
|
277
|
+
activeSessionIds: Set<string>,
|
|
278
|
+
pollTimers: Map<string, NodeJS.Timeout>,
|
|
279
|
+
runtimeStatusCache: Map<string, string>,
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
if (!activeSessionIds.has(sessionId)) return;
|
|
282
|
+
|
|
283
|
+
let response: Response;
|
|
284
|
+
try {
|
|
285
|
+
response = await fetch(`${await daemonBaseUrl()}/v1/tui/sessions`, {
|
|
286
|
+
method: "POST",
|
|
287
|
+
headers: await tuiHeaders(),
|
|
288
|
+
body: JSON.stringify(toRegistration(pi, ctx)),
|
|
289
|
+
});
|
|
290
|
+
} catch {
|
|
291
|
+
disconnectLocalSession(ctx, sessionId, activeSessionIds, pollTimers);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (response.ok) {
|
|
296
|
+
runtimeStatusCache.set(sessionId, comparableRuntimeStatus(collectRuntimeStatus(pi, ctx)));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
disconnectLocalSession(ctx, sessionId, activeSessionIds, pollTimers);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function disconnectLocalSession(
|
|
303
|
+
ctx: ExtensionContext,
|
|
304
|
+
sessionId: string,
|
|
305
|
+
activeSessionIds: Set<string>,
|
|
306
|
+
pollTimers: Map<string, NodeJS.Timeout>,
|
|
307
|
+
): void {
|
|
308
|
+
if (!activeSessionIds.has(sessionId)) return;
|
|
309
|
+
deactivateLocalSession(ctx, sessionId, activeSessionIds, pollTimers);
|
|
310
|
+
ctx.ui.notify("Remote control disconnected; run /remote-control to re-enable", "warning");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function handleRemoteCommand(pi: Pick<ExtensionAPI, "sendUserMessage">, ctx: Pick<ExtensionCommandContext, "abort" | "compact" | "isIdle">, command: RemoteTuiCommand, sessionId?: string): void {
|
|
314
|
+
if (command.type === "remote_prompt") {
|
|
315
|
+
pi.sendUserMessage(command.text, remotePromptDeliveryOptions(ctx, command.streamingBehavior));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (command.type === "remote_abort") ctx.abort();
|
|
319
|
+
if (command.type === "remote_compact") handleRemoteCompactCommand(ctx, command.requestId, sessionId);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function remotePromptDeliveryOptions(ctx: Pick<ExtensionCommandContext, "isIdle">, streamingBehavior: "steer" | "followUp" | null | undefined): { deliverAs: "steer" | "followUp" } | undefined {
|
|
323
|
+
if (streamingBehavior) return { deliverAs: streamingBehavior };
|
|
324
|
+
return ctx.isIdle() ? undefined : { deliverAs: "followUp" };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function handleRemoteCompactCommand(ctx: Pick<ExtensionCommandContext, "compact">, requestId: string, sessionId: string | undefined): void {
|
|
328
|
+
const postResult = (event: RemoteCompactResultEvent) => {
|
|
329
|
+
if (sessionId) void postTuiEvent(sessionId, event).catch(() => undefined);
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
ctx.compact({
|
|
333
|
+
onComplete: (result) => postResult(toRemoteCompactSuccessEvent(requestId, result)),
|
|
334
|
+
onError: (error) => postResult(toRemoteCompactErrorEvent(requestId, error)),
|
|
335
|
+
});
|
|
336
|
+
} catch (error) {
|
|
337
|
+
postResult(toRemoteCompactErrorEvent(requestId, error));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function toRemoteCompactSuccessEvent(requestId: string, result: unknown): RemoteCompactResultEvent {
|
|
342
|
+
const record = asRecord(result);
|
|
343
|
+
return {
|
|
344
|
+
type: "remote_compact_result",
|
|
345
|
+
requestId,
|
|
346
|
+
ok: true,
|
|
347
|
+
summary: readString(record.summary) ?? "",
|
|
348
|
+
firstKeptEntryId: readString(record.firstKeptEntryId) ?? "",
|
|
349
|
+
tokensBefore: readNumber(record.tokensBefore) ?? 0,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function toRemoteCompactErrorEvent(requestId: string, error: unknown): RemoteCompactResultEvent {
|
|
354
|
+
return {
|
|
355
|
+
type: "remote_compact_result",
|
|
356
|
+
requestId,
|
|
357
|
+
ok: false,
|
|
358
|
+
message: error instanceof Error ? error.message : String(error),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function postTuiEvent(sessionId: string, event: unknown): Promise<void> {
|
|
363
|
+
await fetch(`${await daemonBaseUrl()}/v1/tui/sessions/${encodeURIComponent(sessionId)}/events`, {
|
|
364
|
+
method: "POST",
|
|
365
|
+
headers: await tuiHeaders(),
|
|
366
|
+
body: JSON.stringify(event),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function postRuntimeStatusIfChanged(pi: unknown, ctx: ExtensionContext, sessionId: string, runtimeStatusCache: Map<string, string>): Promise<void> {
|
|
371
|
+
const status = collectRuntimeStatus(pi, ctx);
|
|
372
|
+
const comparable = comparableRuntimeStatus(status);
|
|
373
|
+
if (runtimeStatusCache.get(sessionId) === comparable) return;
|
|
374
|
+
runtimeStatusCache.set(sessionId, comparable);
|
|
375
|
+
await postTuiEvent(sessionId, { type: "runtime_status", status });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function comparableRuntimeStatus(status: ReturnType<typeof collectRuntimeStatus>): string {
|
|
379
|
+
const { updatedAt: _updatedAt, ...rest } = status;
|
|
380
|
+
return JSON.stringify(rest);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function daemonBaseUrl(): Promise<string> {
|
|
384
|
+
if (process.env.PI_REMOTE_CONTROL_LOCAL_URL) return process.env.PI_REMOTE_CONTROL_LOCAL_URL;
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const config = await loadDaemonConfig(getDaemonStateDir());
|
|
388
|
+
return bindAddressToBaseUrl(config.bindAddress);
|
|
389
|
+
} catch {
|
|
390
|
+
return "http://127.0.0.1:17373";
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function bindAddressToBaseUrl(bindAddress: string): string {
|
|
395
|
+
const index = bindAddress.lastIndexOf(":");
|
|
396
|
+
if (index === -1) return `http://127.0.0.1:17373`;
|
|
397
|
+
const port = bindAddress.slice(index + 1);
|
|
398
|
+
return `http://127.0.0.1:${port}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function tuiHeaders(includeContentType = true): Promise<Record<string, string>> {
|
|
402
|
+
const headers: Record<string, string> = includeContentType ? { "content-type": "application/json" } : {};
|
|
403
|
+
const token = process.env.PI_REMOTE_CONTROL_DEV_TOKEN;
|
|
404
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
405
|
+
return headers;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function cliCommand(): { command: string; args: string[] } {
|
|
409
|
+
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
410
|
+
const packageRoot = resolve(extensionDir, "..", "..");
|
|
411
|
+
const sourceRunner = resolve(packageRoot, "src", "cli-runner.cjs");
|
|
412
|
+
if (existsSync(sourceRunner)) return { command: process.execPath, args: [sourceRunner] };
|
|
413
|
+
|
|
414
|
+
const distCli = resolve(packageRoot, "dist", "cli.js");
|
|
415
|
+
if (existsSync(distCli)) return { command: process.execPath, args: [distCli] };
|
|
416
|
+
|
|
417
|
+
return { command: "pi-remote-control", args: [] };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function shellQuote(value: string): string {
|
|
421
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
422
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from "./auth/pairing.js";
|
|
2
|
+
export * from "./auth/tokens.js";
|
|
3
|
+
export * from "./config.js";
|
|
4
|
+
export * from "./lock.js";
|
|
5
|
+
export * from "./paths.js";
|
|
6
|
+
export * from "./active-session-registry.js";
|
|
7
|
+
export * from "./persistence/daemon-store.js";
|
|
8
|
+
export * from "./persistence/schema.js";
|
|
9
|
+
export * from "./server/http.js";
|
|
10
|
+
export * from "./session-index.js";
|
|
11
|
+
export * from "./session-transcript.js";
|
|
12
|
+
export * from "./transcript-message.js";
|
|
13
|
+
export * from "./transcript-pagination.js";
|
|
14
|
+
export * from "./transcript-preview.js";
|
|
15
|
+
export * from "./transcript-stream.js";
|
|
16
|
+
export * from "./types.js";
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { open, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type DaemonLock = {
|
|
5
|
+
path: string;
|
|
6
|
+
release(): Promise<void>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function acquireDaemonLock(stateDir: string, pid = process.pid): Promise<DaemonLock | undefined> {
|
|
10
|
+
const path = join(stateDir, "daemon.lock");
|
|
11
|
+
let handle;
|
|
12
|
+
try {
|
|
13
|
+
handle = await open(path, "wx", 0o600);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") return undefined;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
await handle.writeFile(`${pid}\n`, "utf8");
|
|
20
|
+
await handle.close();
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
path,
|
|
24
|
+
release: () => rm(path, { force: true }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type PairingLinkOptions = {
|
|
2
|
+
advertisedBaseUrl?: string;
|
|
3
|
+
pairCode: string;
|
|
4
|
+
expiresAt: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function buildPairingLink(options: PairingLinkOptions): string {
|
|
8
|
+
if (!options.advertisedBaseUrl) throw new Error("advertisedBaseUrl is required for QR pairing");
|
|
9
|
+
const params = new URLSearchParams({
|
|
10
|
+
baseUrl: options.advertisedBaseUrl,
|
|
11
|
+
code: options.pairCode,
|
|
12
|
+
expiresAt: options.expiresAt,
|
|
13
|
+
});
|
|
14
|
+
return `pi-remote://pair?${params.toString()}`;
|
|
15
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
export type StateDirOptions = {
|
|
6
|
+
env?: NodeJS.ProcessEnv;
|
|
7
|
+
homeDir?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function getDaemonStateDir(options: StateDirOptions = {}): string {
|
|
11
|
+
const env = options.env ?? process.env;
|
|
12
|
+
if (env.PI_REMOTE_CONTROL_DIR) return resolve(env.PI_REMOTE_CONTROL_DIR);
|
|
13
|
+
|
|
14
|
+
const home = options.homeDir ?? homedir();
|
|
15
|
+
return join(home, ".pi", "remote-control");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function ensureDaemonStateDir(path: string): Promise<void> {
|
|
19
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
20
|
+
await chmod(path, 0o700);
|
|
21
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createPairingCode, hashPairingCode, canClaimPairingCode } from "../auth/pairing.js";
|
|
4
|
+
import { issueDeviceToken, verifyDeviceToken } from "../auth/tokens.js";
|
|
5
|
+
import type { PairClaimResponse, PairCodeResponse } from "../server/http.js";
|
|
6
|
+
import type { PairingCode } from "../types.js";
|
|
7
|
+
import { migrateSchemaSql } from "./schema.js";
|
|
8
|
+
|
|
9
|
+
export type DaemonStore = {
|
|
10
|
+
close(): void;
|
|
11
|
+
createPairingCode(now: Date, ttlMs: number): Promise<PairCodeResponse>;
|
|
12
|
+
claimPairingCode(rawCode: string, deviceName: string, now: Date): Promise<PairClaimResponse | undefined>;
|
|
13
|
+
authenticateToken(rawToken: string): Promise<boolean>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function openDaemonStore(stateDir: string): DaemonStore {
|
|
17
|
+
const database = new DatabaseSync(join(stateDir, "daemon.sqlite"));
|
|
18
|
+
for (const sql of migrateSchemaSql(0)) database.exec(sql);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
close() {
|
|
22
|
+
database.close();
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async createPairingCode(now: Date, ttlMs: number): Promise<PairCodeResponse> {
|
|
26
|
+
const pair = createPairingCode(now, ttlMs);
|
|
27
|
+
database
|
|
28
|
+
.prepare("insert into pairing_codes (id, code_hash, created_at, expires_at, consumed_at) values (?, ?, ?, ?, null)")
|
|
29
|
+
.run(pair.id, pair.codeHash, pair.createdAt, pair.expiresAt);
|
|
30
|
+
return { pairCode: pair.rawCode, expiresAt: pair.expiresAt };
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async claimPairingCode(rawCode: string, deviceName: string, now: Date): Promise<PairClaimResponse | undefined> {
|
|
34
|
+
const row = database
|
|
35
|
+
.prepare("select id, code_hash as codeHash, created_at as createdAt, expires_at as expiresAt, consumed_at as consumedAt from pairing_codes where code_hash = ? order by created_at desc limit 1")
|
|
36
|
+
.get(hashPairingCode(rawCode)) as PairingCode | undefined;
|
|
37
|
+
if (!row || !canClaimPairingCode(row, rawCode, now)) return undefined;
|
|
38
|
+
|
|
39
|
+
const token = issueDeviceToken();
|
|
40
|
+
const deviceId = `dev_${Date.now().toString(36)}`;
|
|
41
|
+
database.prepare("update pairing_codes set consumed_at = ? where id = ?").run(now.toISOString(), row.id);
|
|
42
|
+
database
|
|
43
|
+
.prepare("insert into devices (id, name, token_hash, created_at, last_seen_at, revoked_at) values (?, ?, ?, ?, null, null)")
|
|
44
|
+
.run(deviceId, deviceName, token.tokenHash, now.toISOString());
|
|
45
|
+
return { deviceId, token: token.rawToken, daemonName: "pi-remote-control" };
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async authenticateToken(rawToken: string): Promise<boolean> {
|
|
49
|
+
const rows = database.prepare("select token_hash as tokenHash from devices where revoked_at is null").all() as Array<{ tokenHash: string }>;
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
if (await verifyDeviceToken(rawToken, row.tokenHash)) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const SCHEMA_VERSION = 1;
|
|
2
|
+
|
|
3
|
+
export function createSchemaSql(): string[] {
|
|
4
|
+
return [
|
|
5
|
+
"create table if not exists meta (key text primary key, value text not null)",
|
|
6
|
+
"create table if not exists devices (id text primary key, name text not null, token_hash text not null unique, created_at text not null, last_seen_at text, revoked_at text)",
|
|
7
|
+
"create table if not exists pairing_codes (id text primary key, code_hash text not null unique, created_at text not null, expires_at text not null, consumed_at text)",
|
|
8
|
+
];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function migrateSchemaSql(fromVersion: number, toVersion = SCHEMA_VERSION): string[] {
|
|
12
|
+
if (fromVersion === toVersion) return [];
|
|
13
|
+
if (fromVersion !== 0 || toVersion !== SCHEMA_VERSION) {
|
|
14
|
+
throw new Error(`Unsupported schema migration: ${fromVersion} -> ${toVersion}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return [
|
|
18
|
+
...createSchemaSql(),
|
|
19
|
+
`insert or replace into meta (key, value) values ('schema_version', '${SCHEMA_VERSION}')`,
|
|
20
|
+
];
|
|
21
|
+
}
|
package/src/qr.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import qrcode from "qrcode-terminal";
|
|
2
|
+
|
|
3
|
+
export type PairingDisplay = {
|
|
4
|
+
expiresAt: string;
|
|
5
|
+
pairingLink: string;
|
|
6
|
+
qrCode?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function renderPairingQr(pairingLink: string): string {
|
|
10
|
+
let rendered = "";
|
|
11
|
+
qrcode.generate(pairingLink, { small: true }, (qr) => {
|
|
12
|
+
rendered = qr.trimEnd();
|
|
13
|
+
});
|
|
14
|
+
return rendered;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatPairingDisplay(display: PairingDisplay): string[] {
|
|
18
|
+
return [
|
|
19
|
+
"Scan with Pi iOS app:",
|
|
20
|
+
display.qrCode ?? renderPairingQr(display.pairingLink),
|
|
21
|
+
`Expires at: ${display.expiresAt}`,
|
|
22
|
+
];
|
|
23
|
+
}
|