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.
Files changed (71) hide show
  1. package/README.md +46 -0
  2. package/docs/adr/0001-package-extension-as-control-shim.md +19 -0
  3. package/docs/adr/0002-use-sqlite-for-daemon-state.md +19 -0
  4. package/docs/adr/0003-use-lock-file-as-process-state.md +19 -0
  5. package/docs/adr/0004-allow-loopback-pair-code-without-token.md +19 -0
  6. package/docs/adr/0005-defer-os-service-installation.md +19 -0
  7. package/docs/adr/0006-use-tui-activated-remote-control-sessions.md +24 -0
  8. package/docs/adr/0007-require-tui-originated-pairing.md +19 -0
  9. package/docs/adr/0008-use-qr-pairing-links.md +21 -0
  10. package/docs/adr/0009-rename-package-to-remote-control.md +19 -0
  11. package/docs/adr/0010-clean-stale-lock-on-status.md +19 -0
  12. package/docs/adr/0011-use-loopback-tui-control.md +19 -0
  13. package/docs/adr/0012-use-paginated-session-transcript-loading.md +37 -0
  14. package/docs/adr/0013-require-manual-reactivation-after-tui-entry.md +31 -0
  15. package/docs/adr/0014-read-transcripts-from-session-files.md +33 -0
  16. package/docs/adr/0015-normalize-transcript-messages-and-stream-events.md +35 -0
  17. package/docs/adr/0016-expose-turn-lifecycle-events.md +31 -0
  18. package/docs/adr/0017-bound-initial-websocket-session-state.md +31 -0
  19. package/docs/adr/0018-reregister-active-tui-session-on-heartbeat-miss.md +33 -0
  20. package/docs/adr/0019-display-only-pairing-qr-and-expiry.md +25 -0
  21. package/docs/adr/0020-expose-session-status-snapshots.md +31 -0
  22. package/docs/adr/0021-support-remote-compact-action.md +31 -0
  23. package/docs/adr/0022-rename-session-status-to-runtime-status.md +27 -0
  24. package/docs/adr/0023-return-remote-compact-results.md +29 -0
  25. package/docs/architecture.md +96 -0
  26. package/docs/data-model.md +284 -0
  27. package/docs/interfaces.md +470 -0
  28. package/package.json +37 -0
  29. package/scripts/http-smoke-test.sh +100 -0
  30. package/src/active-session-registry.ts +205 -0
  31. package/src/auth/pairing.ts +30 -0
  32. package/src/auth/tokens.ts +30 -0
  33. package/src/cli-runner.cjs +15 -0
  34. package/src/cli.ts +254 -0
  35. package/src/config.ts +26 -0
  36. package/src/extension/index.ts +422 -0
  37. package/src/index.ts +16 -0
  38. package/src/lock.ts +26 -0
  39. package/src/pairing-link.ts +15 -0
  40. package/src/paths.ts +21 -0
  41. package/src/persistence/daemon-store.ts +56 -0
  42. package/src/persistence/schema.ts +21 -0
  43. package/src/qr.ts +23 -0
  44. package/src/runtime-status.ts +116 -0
  45. package/src/server/http.ts +529 -0
  46. package/src/session-index.ts +9 -0
  47. package/src/session-transcript.ts +34 -0
  48. package/src/transcript-message.ts +76 -0
  49. package/src/transcript-pagination.ts +68 -0
  50. package/src/transcript-preview.ts +102 -0
  51. package/src/transcript-stream.ts +89 -0
  52. package/src/types.ts +116 -0
  53. package/tests/active-session-registry.test.ts +170 -0
  54. package/tests/auth.test.ts +18 -0
  55. package/tests/cli.test.ts +361 -0
  56. package/tests/config.test.ts +35 -0
  57. package/tests/daemon-store.test.ts +54 -0
  58. package/tests/extension.test.ts +617 -0
  59. package/tests/lock.test.ts +36 -0
  60. package/tests/pairing-link.test.ts +26 -0
  61. package/tests/pairing.test.ts +26 -0
  62. package/tests/paths.test.ts +29 -0
  63. package/tests/qr.test.ts +25 -0
  64. package/tests/schema.test.ts +18 -0
  65. package/tests/server-http.test.ts +932 -0
  66. package/tests/session-index.test.ts +10 -0
  67. package/tests/session-transcript.test.ts +75 -0
  68. package/tests/transcript-pagination.test.ts +54 -0
  69. package/tests/transcript-preview.test.ts +64 -0
  70. package/tests/transcript-stream.test.ts +103 -0
  71. package/tsconfig.json +17 -0
@@ -0,0 +1,116 @@
1
+ import type { RuntimeStatus } from "./types.js";
2
+
3
+ const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]);
4
+
5
+ type RuntimeStatusUsage = RuntimeStatus["usage"];
6
+
7
+ export function collectRuntimeStatus(pi: unknown, ctx: unknown, now: () => Date = () => new Date()): RuntimeStatus {
8
+ const context = asRecord(ctx);
9
+ return {
10
+ model: collectModel(context.model),
11
+ thinkingLevel: collectThinkingLevel(pi),
12
+ usage: collectUsage(readSessionEntries(context)),
13
+ context: collectContextUsage(callMethod(context, "getContextUsage")),
14
+ updatedAt: now().toISOString(),
15
+ };
16
+ }
17
+
18
+ function collectModel(value: unknown): RuntimeStatus["model"] {
19
+ const model = asRecord(value);
20
+ const provider = readString(model.provider);
21
+ const id = readString(model.id) ?? readString(model.model);
22
+ if (!provider || !id) return null;
23
+ return withoutUndefined({
24
+ provider,
25
+ id,
26
+ name: readString(model.name),
27
+ contextWindow: readNumber(model.contextWindow) ?? readNumber(model.context_window),
28
+ maxTokens: readNumber(model.maxTokens) ?? readNumber(model.max_tokens),
29
+ reasoning: readBoolean(model.reasoning),
30
+ });
31
+ }
32
+
33
+ function collectThinkingLevel(pi: unknown): RuntimeStatus["thinkingLevel"] {
34
+ const level = callMethod(asRecord(pi), "getThinkingLevel");
35
+ return typeof level === "string" && THINKING_LEVELS.has(level) ? level as RuntimeStatus["thinkingLevel"] : null;
36
+ }
37
+
38
+ function collectContextUsage(value: unknown): RuntimeStatus["context"] {
39
+ const usage = asRecord(value);
40
+ const contextWindow = readNumber(usage.contextWindow) ?? readNumber(usage.context_window) ?? readNumber(usage.maxTokens) ?? readNumber(usage.max_tokens);
41
+ if (contextWindow === undefined) return null;
42
+ const tokens = readNumber(usage.tokens) ?? readNumber(usage.currentTokens) ?? readNumber(usage.current_tokens);
43
+ const percent = readNumber(usage.percent) ?? readNumber(usage.percentage);
44
+ return {
45
+ tokens: tokens ?? null,
46
+ contextWindow,
47
+ percent: percent ?? null,
48
+ };
49
+ }
50
+
51
+ function collectUsage(entries: unknown[]): RuntimeStatusUsage {
52
+ const usage: RuntimeStatusUsage = {
53
+ input: 0,
54
+ output: 0,
55
+ cacheRead: 0,
56
+ cacheWrite: 0,
57
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
58
+ };
59
+
60
+ for (const entry of entries) {
61
+ const record = asRecord(entry);
62
+ const message = asRecord(record.message);
63
+ if (message.role !== "assistant" && record.role !== "assistant") continue;
64
+ const entryUsage = asRecord(record.usage ?? message.usage);
65
+ usage.input += readNumber(entryUsage.input) ?? 0;
66
+ usage.output += readNumber(entryUsage.output) ?? 0;
67
+ usage.cacheRead += readNumber(entryUsage.cacheRead) ?? readNumber(entryUsage.cache_read) ?? 0;
68
+ usage.cacheWrite += readNumber(entryUsage.cacheWrite) ?? readNumber(entryUsage.cache_write) ?? 0;
69
+
70
+ const cost = asRecord(entryUsage.cost);
71
+ usage.cost.input += readNumber(cost.input) ?? 0;
72
+ usage.cost.output += readNumber(cost.output) ?? 0;
73
+ usage.cost.cacheRead += readNumber(cost.cacheRead) ?? readNumber(cost.cache_read) ?? 0;
74
+ usage.cost.cacheWrite += readNumber(cost.cacheWrite) ?? readNumber(cost.cache_write) ?? 0;
75
+ usage.cost.total += readNumber(cost.total) ?? 0;
76
+ }
77
+
78
+ usage.cost.total = usage.cost.total || usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
79
+ return usage;
80
+ }
81
+
82
+ function readSessionEntries(context: Record<string, unknown>): unknown[] {
83
+ const sessionManager = asRecord(context.sessionManager);
84
+ const entries = callMethod(sessionManager, "getEntries");
85
+ return Array.isArray(entries) ? entries : [];
86
+ }
87
+
88
+ function callMethod(record: Record<string, unknown>, key: string): unknown {
89
+ const value = record[key];
90
+ if (typeof value !== "function") return undefined;
91
+ try {
92
+ return (value as () => unknown).call(record);
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ function asRecord(value: unknown): Record<string, unknown> {
99
+ return value && typeof value === "object" ? value as Record<string, unknown> : {};
100
+ }
101
+
102
+ function readString(value: unknown): string | undefined {
103
+ return typeof value === "string" ? value : undefined;
104
+ }
105
+
106
+ function readNumber(value: unknown): number | undefined {
107
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
108
+ }
109
+
110
+ function readBoolean(value: unknown): boolean | undefined {
111
+ return typeof value === "boolean" ? value : undefined;
112
+ }
113
+
114
+ function withoutUndefined<T extends Record<string, unknown>>(value: T): T {
115
+ return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)) as T;
116
+ }
@@ -0,0 +1,529 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import type { Duplex } from "node:stream";
4
+ import { WebSocket, WebSocketServer } from "ws";
5
+ import type { ActiveSessionRegistry } from "../active-session-registry.js";
6
+ import {
7
+ DEFAULT_TRANSCRIPT_PAGE_LIMIT,
8
+ decodeTranscriptCursor,
9
+ InvalidTranscriptCursorError,
10
+ MAX_TRANSCRIPT_PAGE_LIMIT,
11
+ type TranscriptPage,
12
+ } from "../transcript-pagination.js";
13
+ import { INITIAL_WEBSOCKET_SESSION_MESSAGE_LIMIT, previewInitialSessionState } from "../transcript-preview.js";
14
+ import { normalizeTuiEvent } from "../transcript-stream.js";
15
+ import type { DaemonConfig, RemoteCompactResultEvent, RuntimeStatus } from "../types.js";
16
+
17
+ export type RemoteSessionSummary = {
18
+ id: string;
19
+ piSessionId: string;
20
+ projectId: string;
21
+ name: string | null;
22
+ path: string;
23
+ updatedAt: string;
24
+ messageCount: number;
25
+ };
26
+
27
+ export type RemoteSessionState = {
28
+ session: unknown;
29
+ messages: unknown[];
30
+ olderMessagesCursor: string | null;
31
+ hasOlderMessages: boolean;
32
+ tools: unknown[];
33
+ isStreaming: boolean;
34
+ pendingMessageCount: number;
35
+ runtimeStatus?: unknown;
36
+ };
37
+
38
+ export type PromptSessionRequest = {
39
+ text: string;
40
+ streamingBehavior?: "steer" | "followUp" | null;
41
+ };
42
+
43
+ export type SessionService = {
44
+ listProjectSessions?(projectId: string): Promise<RemoteSessionSummary[]>;
45
+ createProjectSession?(projectId: string): Promise<RemoteSessionSummary>;
46
+ getSessionState?(sessionId: string, options?: { messageLimit: number }): Promise<RemoteSessionState>;
47
+ getSessionMessages?(sessionId: string, request: { before: string; limit: number }): Promise<TranscriptPage>;
48
+ promptSession?(sessionId: string, request: PromptSessionRequest): Promise<void>;
49
+ abortSession?(sessionId: string): Promise<void>;
50
+ compactSession?(sessionId: string): Promise<void>;
51
+ streamSession?(sessionId: string, send: (event: unknown) => void): Promise<void>;
52
+ };
53
+
54
+ export type DaemonServer = {
55
+ address: string;
56
+ close(): Promise<void>;
57
+ };
58
+
59
+ export type PairClaimRequest = {
60
+ pairCode: string;
61
+ deviceName: string;
62
+ };
63
+
64
+ export type PairClaimResponse = {
65
+ deviceId: string;
66
+ token: string;
67
+ daemonName: string;
68
+ };
69
+
70
+ export type PairCodeResponse = {
71
+ pairCode: string;
72
+ expiresAt: string;
73
+ };
74
+
75
+ export type PairService = {
76
+ createPairingCode?(): Promise<PairCodeResponse>;
77
+ claimPairingCode(request: PairClaimRequest): Promise<PairClaimResponse>;
78
+ };
79
+
80
+ export type StartServerOptions = {
81
+ stateDir: string;
82
+ config: DaemonConfig;
83
+ piVersion?: string;
84
+ daemonVersion?: string;
85
+ authenticateToken?: (token: string) => boolean | Promise<boolean>;
86
+ sessionService?: SessionService;
87
+ activeSessions?: ActiveSessionRegistry;
88
+ pairService?: PairService;
89
+ sessionSweepIntervalMs?: number;
90
+ };
91
+
92
+ const DEFAULT_SESSION_SWEEP_INTERVAL_MS = 1_000;
93
+
94
+ type StreamHub = Map<string, Set<WebSocket>>;
95
+
96
+ export function bindAddressesForConfig(bindAddress: string): string[] {
97
+ const { host, port } = parseBindAddress(bindAddress);
98
+ if (isLoopbackHost(host) || isWildcardHost(host)) return [bindAddress];
99
+ return [bindAddress, `127.0.0.1:${port}`];
100
+ }
101
+
102
+ export async function startDaemonServer(options: StartServerOptions): Promise<DaemonServer> {
103
+ const streamHub: StreamHub = new Map();
104
+ const webSockets = new WebSocketServer({ noServer: true });
105
+ const servers = bindAddressesForConfig(options.config.bindAddress).map(() => createHttpServer(options, streamHub, webSockets));
106
+ const bindAddresses = bindAddressesForConfig(options.config.bindAddress);
107
+ const sweepTimer = options.activeSessions
108
+ ? setInterval(() => closeSessions(options.activeSessions!.pruneInactiveSessions(), streamHub), options.sessionSweepIntervalMs ?? DEFAULT_SESSION_SWEEP_INTERVAL_MS)
109
+ : undefined;
110
+ sweepTimer?.unref?.();
111
+
112
+ for (let index = 0; index < servers.length; index += 1) {
113
+ const { host, port } = parseBindAddress(bindAddresses[index] ?? options.config.bindAddress);
114
+ await listen(servers[index]!, host, port);
115
+ }
116
+
117
+ const address = servers[0]!.address() as AddressInfo;
118
+ return {
119
+ address: `${address.address}:${address.port}`,
120
+ close: async () => {
121
+ if (sweepTimer) clearInterval(sweepTimer);
122
+ webSockets.close();
123
+ await Promise.all(servers.map((server) => closeServer(server)));
124
+ },
125
+ };
126
+ }
127
+
128
+ function createHttpServer(options: StartServerOptions, streamHub: StreamHub, webSockets: WebSocketServer): Server {
129
+ const server = createServer((request, response) => {
130
+ void handleHttpRequest(request, response, options, streamHub);
131
+ });
132
+ server.on("upgrade", (request, socket, head) => {
133
+ void handleUpgrade(request, socket, head, webSockets, options, streamHub);
134
+ });
135
+ return server;
136
+ }
137
+
138
+ async function listen(server: Server, host: string, port: number): Promise<void> {
139
+ await new Promise<void>((resolve, reject) => {
140
+ server.once("error", reject);
141
+ server.listen(port, host, () => {
142
+ server.off("error", reject);
143
+ resolve();
144
+ });
145
+ });
146
+ }
147
+
148
+ async function closeServer(server: Server): Promise<void> {
149
+ await new Promise<void>((resolve, reject) => {
150
+ server.close((error) => (error ? reject(error) : resolve()));
151
+ });
152
+ }
153
+
154
+ async function handleHttpRequest(
155
+ request: IncomingMessage,
156
+ response: ServerResponse,
157
+ options: StartServerOptions,
158
+ streamHub: StreamHub,
159
+ ): Promise<void> {
160
+ const url = new URL(request.url ?? "/", "http://localhost");
161
+ const pathname = url.pathname;
162
+
163
+ if (request.method === "GET" && pathname === "/v1/health") {
164
+ writeJson(response, 200, {
165
+ status: "ok",
166
+ piVersion: options.piVersion ?? "unknown",
167
+ daemonVersion: options.daemonVersion ?? "1.0.0",
168
+ });
169
+ return;
170
+ }
171
+
172
+ if (request.method === "POST" && pathname === "/v1/tui/sessions") {
173
+ if (!(await isTuiAuthorized(request, options))) {
174
+ writeJson(response, 401, { error: "unauthorized" });
175
+ return;
176
+ }
177
+ const body = (await readJsonBody(request)) as Parameters<ActiveSessionRegistry["registerSession"]>[0];
178
+ const session = options.activeSessions?.registerSession(body);
179
+ writeJson(response, session ? 200 : 503, session ? { session } : { error: "active_sessions_unavailable" });
180
+ return;
181
+ }
182
+
183
+ const tuiSessionMatch = pathname.match(/^\/v1\/tui\/sessions\/([^/]+)$/);
184
+ if (request.method === "DELETE" && tuiSessionMatch) {
185
+ if (!(await isTuiAuthorized(request, options))) {
186
+ writeJson(response, 401, { error: "unauthorized" });
187
+ return;
188
+ }
189
+ const sessionId = decodeURIComponent(tuiSessionMatch[1] ?? "");
190
+ const unregistered = options.activeSessions?.unregisterSession(sessionId) ?? false;
191
+ if (unregistered) closeSessions([sessionId], streamHub);
192
+ writeJson(response, 200, { unregistered });
193
+ return;
194
+ }
195
+
196
+ const tuiCommandsMatch = pathname.match(/^\/v1\/tui\/sessions\/([^/]+)\/commands$/);
197
+ if (request.method === "GET" && tuiCommandsMatch) {
198
+ if (!(await isTuiAuthorized(request, options))) {
199
+ writeJson(response, 401, { error: "unauthorized" });
200
+ return;
201
+ }
202
+ const sessionId = decodeURIComponent(tuiCommandsMatch[1] ?? "");
203
+ if (options.activeSessions && !options.activeSessions.touchSession(sessionId)) {
204
+ writeJson(response, 404, { error: "session_not_found" });
205
+ return;
206
+ }
207
+ writeJson(response, 200, { commands: options.activeSessions?.takeCommands(sessionId) ?? [] });
208
+ return;
209
+ }
210
+
211
+ const tuiEventMatch = pathname.match(/^\/v1\/tui\/sessions\/([^/]+)\/events$/);
212
+ if (request.method === "POST" && tuiEventMatch) {
213
+ if (!(await isTuiAuthorized(request, options))) {
214
+ writeJson(response, 401, { error: "unauthorized" });
215
+ return;
216
+ }
217
+ const sessionId = decodeURIComponent(tuiEventMatch[1] ?? "");
218
+ if (options.activeSessions && !options.activeSessions.touchSession(sessionId)) {
219
+ writeJson(response, 404, { error: "session_not_found" });
220
+ return;
221
+ }
222
+ const event = await readJsonBody(request);
223
+ if (isRuntimeStatusEvent(event)) {
224
+ const changed = options.activeSessions?.updateRuntimeStatus(sessionId, event.status) ?? false;
225
+ if (changed) streamHub.get(sessionId)?.forEach((webSocket) => sendWebSocketJson(webSocket, { type: "runtime_status", status: event.status }));
226
+ writeJson(response, 200, { accepted: true });
227
+ return;
228
+ }
229
+ if (isRemoteCompactResultEvent(event)) {
230
+ streamHub.get(sessionId)?.forEach((webSocket) => sendWebSocketJson(webSocket, event));
231
+ writeJson(response, 200, { accepted: true });
232
+ return;
233
+ }
234
+ const normalizedEvents = normalizeTuiEvent(event);
235
+ streamHub.get(sessionId)?.forEach((webSocket) => normalizedEvents.forEach((normalizedEvent) => sendWebSocketJson(webSocket, normalizedEvent)));
236
+ writeJson(response, 200, { accepted: true });
237
+ return;
238
+ }
239
+
240
+ if (request.method === "POST" && pathname === "/v1/pair/claim") {
241
+ try {
242
+ const body = (await readJsonBody(request)) as PairClaimRequest;
243
+ const result = await options.pairService?.claimPairingCode(body);
244
+ writeJson(response, 200, result ?? { error: "pairing_unavailable" });
245
+ } catch (error) {
246
+ if (error instanceof Error && error.message === "Invalid or expired pairing code") {
247
+ writeJson(response, 400, { error: "invalid_pairing_code" });
248
+ } else {
249
+ writeJson(response, 500, { error: "internal_error" });
250
+ }
251
+ }
252
+ return;
253
+ }
254
+
255
+ if (request.method === "GET" && pathname === "/v1/projects") {
256
+ if (!(await isAuthorized(request, options))) {
257
+ writeJson(response, 401, { error: "unauthorized" });
258
+ return;
259
+ }
260
+
261
+ writeJson(response, 200, { projects: options.activeSessions?.listProjects() ?? [] });
262
+ return;
263
+ }
264
+
265
+ const abortMatch = pathname.match(/^\/v1\/sessions\/([^/]+)\/abort$/);
266
+ if (request.method === "POST" && abortMatch) {
267
+ if (!(await isAuthorized(request, options))) {
268
+ writeJson(response, 401, { error: "unauthorized" });
269
+ return;
270
+ }
271
+
272
+ const sessionId = decodeURIComponent(abortMatch[1] ?? "");
273
+ if (options.activeSessions) {
274
+ if (!options.activeSessions.enqueueCommand(sessionId, { type: "remote_abort", requestId: nextRequestId() })) {
275
+ writeJson(response, 409, { error: "session_not_active" });
276
+ return;
277
+ }
278
+ } else {
279
+ await options.sessionService?.abortSession?.(sessionId);
280
+ }
281
+ writeJson(response, 200, { aborted: true });
282
+ return;
283
+ }
284
+
285
+ const compactMatch = pathname.match(/^\/v1\/sessions\/([^/]+)\/compact$/);
286
+ if (request.method === "POST" && compactMatch) {
287
+ if (!(await isAuthorized(request, options))) {
288
+ writeJson(response, 401, { error: "unauthorized" });
289
+ return;
290
+ }
291
+
292
+ const sessionId = decodeURIComponent(compactMatch[1] ?? "");
293
+ const requestId = nextRequestId();
294
+ if (options.activeSessions) {
295
+ if (!options.activeSessions.enqueueCommand(sessionId, { type: "remote_compact", requestId })) {
296
+ writeJson(response, 409, { error: "session_not_active" });
297
+ return;
298
+ }
299
+ } else {
300
+ await options.sessionService?.compactSession?.(sessionId);
301
+ }
302
+ writeJson(response, 200, { accepted: true, requestId });
303
+ return;
304
+ }
305
+
306
+ const promptMatch = pathname.match(/^\/v1\/sessions\/([^/]+)\/prompt$/);
307
+ if (request.method === "POST" && promptMatch) {
308
+ if (!(await isAuthorized(request, options))) {
309
+ writeJson(response, 401, { error: "unauthorized" });
310
+ return;
311
+ }
312
+
313
+ const sessionId = decodeURIComponent(promptMatch[1] ?? "");
314
+ const body = (await readJsonBody(request)) as PromptSessionRequest;
315
+ if (options.activeSessions) {
316
+ if (!options.activeSessions.enqueueCommand(sessionId, {
317
+ type: "remote_prompt",
318
+ requestId: nextRequestId(),
319
+ text: body.text,
320
+ streamingBehavior: body.streamingBehavior,
321
+ })) {
322
+ writeJson(response, 409, { error: "session_not_active" });
323
+ return;
324
+ }
325
+ } else {
326
+ await options.sessionService?.promptSession?.(sessionId, body);
327
+ }
328
+ writeJson(response, 200, { accepted: true });
329
+ return;
330
+ }
331
+
332
+ const sessionMessagesMatch = pathname.match(/^\/v1\/sessions\/([^/]+)\/messages$/);
333
+ if (request.method === "GET" && sessionMessagesMatch) {
334
+ if (!(await isAuthorized(request, options))) {
335
+ writeJson(response, 401, { error: "unauthorized" });
336
+ return;
337
+ }
338
+
339
+ const limit = parsePositiveLimit(url.searchParams.get("limit"));
340
+ if (!limit.valid) {
341
+ writeJson(response, 400, { error: "invalid_limit" });
342
+ return;
343
+ }
344
+ const before = url.searchParams.get("before");
345
+ if (!before) {
346
+ writeJson(response, 400, { error: "invalid_cursor" });
347
+ return;
348
+ }
349
+
350
+ try {
351
+ decodeTranscriptCursor(before);
352
+ const sessionId = decodeURIComponent(sessionMessagesMatch[1] ?? "");
353
+ const page = options.activeSessions?.getSessionMessages(sessionId, before, { limit: limit.value })
354
+ ?? (await options.sessionService?.getSessionMessages?.(sessionId, { before, limit: limit.value }));
355
+ if (!page) {
356
+ writeJson(response, 404, { error: "session_not_found" });
357
+ return;
358
+ }
359
+ writeJson(response, 200, page);
360
+ } catch (error) {
361
+ if (error instanceof InvalidTranscriptCursorError) writeJson(response, 400, { error: "invalid_cursor" });
362
+ else writeJson(response, 500, { error: "internal_error" });
363
+ }
364
+ return;
365
+ }
366
+
367
+ const sessionMatch = pathname.match(/^\/v1\/sessions\/([^/]+)$/);
368
+ if (request.method === "GET" && sessionMatch) {
369
+ if (!(await isAuthorized(request, options))) {
370
+ writeJson(response, 401, { error: "unauthorized" });
371
+ return;
372
+ }
373
+
374
+ const limit = parsePositiveLimit(url.searchParams.get("messageLimit"));
375
+ if (!limit.valid) {
376
+ writeJson(response, 400, { error: "invalid_limit" });
377
+ return;
378
+ }
379
+
380
+ const sessionId = decodeURIComponent(sessionMatch[1] ?? "");
381
+ const state = options.activeSessions?.getSessionState(sessionId, { messageLimit: limit.value })
382
+ ?? (await options.sessionService?.getSessionState?.(sessionId, { messageLimit: limit.value }));
383
+ if (!state) {
384
+ writeJson(response, 404, { error: "session_not_found" });
385
+ return;
386
+ }
387
+ writeJson(response, 200, state);
388
+ return;
389
+ }
390
+
391
+ const projectSessionsMatch = pathname.match(/^\/v1\/projects\/([^/]+)\/sessions$/);
392
+ if ((request.method === "GET" || request.method === "POST") && projectSessionsMatch) {
393
+ if (!(await isAuthorized(request, options))) {
394
+ writeJson(response, 401, { error: "unauthorized" });
395
+ return;
396
+ }
397
+
398
+ const projectId = decodeURIComponent(projectSessionsMatch[1] ?? "");
399
+ if (request.method === "POST") {
400
+ writeJson(response, 405, { error: "method_not_allowed" });
401
+ return;
402
+ }
403
+
404
+ const sessions = options.activeSessions?.listProjectSessions(projectId) ?? (await options.sessionService?.listProjectSessions?.(projectId));
405
+ writeJson(response, 200, { sessions: sessions ?? [] });
406
+ return;
407
+ }
408
+
409
+ writeJson(response, 404, { error: "not_found" });
410
+ }
411
+
412
+ async function handleUpgrade(
413
+ request: IncomingMessage,
414
+ socket: Duplex,
415
+ head: Buffer,
416
+ webSockets: WebSocketServer,
417
+ options: StartServerOptions,
418
+ streamHub: StreamHub,
419
+ ): Promise<void> {
420
+ const url = new URL(request.url ?? "/", "http://localhost");
421
+ const streamMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/stream$/);
422
+ if (!streamMatch) {
423
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
424
+ socket.destroy();
425
+ return;
426
+ }
427
+
428
+ if (!(await isAuthorized(request, options))) {
429
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
430
+ socket.destroy();
431
+ return;
432
+ }
433
+
434
+ const sessionId = decodeURIComponent(streamMatch[1] ?? "");
435
+ webSockets.handleUpgrade(request, socket, head, (webSocket) => {
436
+ webSockets.emit("connection", webSocket, request);
437
+ const subscribers = streamHub.get(sessionId) ?? new Set<WebSocket>();
438
+ subscribers.add(webSocket);
439
+ streamHub.set(sessionId, subscribers);
440
+ webSocket.once("close", () => subscribers.delete(webSocket));
441
+
442
+ const activeState = options.activeSessions?.getSessionState(sessionId, { messageLimit: INITIAL_WEBSOCKET_SESSION_MESSAGE_LIMIT });
443
+ if (activeState) sendWebSocketJson(webSocket, { type: "session_state", state: previewInitialSessionState(activeState) });
444
+
445
+ void options.sessionService?.streamSession?.(sessionId, (event) => {
446
+ sendWebSocketJson(webSocket, event);
447
+ });
448
+ });
449
+ }
450
+
451
+ async function isTuiAuthorized(request: IncomingMessage, options: StartServerOptions): Promise<boolean> {
452
+ if (isLoopbackRemoteAddress(request.socket.remoteAddress)) return true;
453
+ return isAuthorized(request, options);
454
+ }
455
+
456
+ async function isAuthorized(request: IncomingMessage, options: StartServerOptions): Promise<boolean> {
457
+ const authorization = request.headers.authorization;
458
+ if (!authorization?.startsWith("Bearer ")) return false;
459
+ const token = authorization.slice("Bearer ".length);
460
+ return options.authenticateToken ? Boolean(await options.authenticateToken(token)) : false;
461
+ }
462
+
463
+ function isLoopbackRemoteAddress(address: string | undefined): boolean {
464
+ return Boolean(address && (address === "::1" || address === "127.0.0.1" || address.startsWith("127.") || address.startsWith("::ffff:127.")));
465
+ }
466
+
467
+ function isRuntimeStatusEvent(event: unknown): event is { type: "runtime_status"; status: RuntimeStatus } {
468
+ return Boolean(event && typeof event === "object" && (event as { type?: unknown }).type === "runtime_status" && (event as { status?: unknown }).status && typeof (event as { status?: unknown }).status === "object");
469
+ }
470
+
471
+ function isRemoteCompactResultEvent(event: unknown): event is RemoteCompactResultEvent {
472
+ if (!event || typeof event !== "object") return false;
473
+ const record = event as Record<string, unknown>;
474
+ if (record.type !== "remote_compact_result" || typeof record.requestId !== "string") return false;
475
+ if (record.ok === true) {
476
+ return typeof record.summary === "string" && typeof record.firstKeptEntryId === "string" && typeof record.tokensBefore === "number";
477
+ }
478
+ return record.ok === false && typeof record.message === "string";
479
+ }
480
+
481
+ function nextRequestId(): string {
482
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
483
+ }
484
+
485
+ function parsePositiveLimit(rawLimit: string | null): { valid: true; value: number } | { valid: false } {
486
+ if (rawLimit === null) return { valid: true, value: DEFAULT_TRANSCRIPT_PAGE_LIMIT };
487
+ const parsed = Number(rawLimit);
488
+ if (!Number.isInteger(parsed) || parsed <= 0) return { valid: false };
489
+ return { valid: true, value: Math.min(parsed, MAX_TRANSCRIPT_PAGE_LIMIT) };
490
+ }
491
+
492
+ function parseBindAddress(bindAddress: string): { host: string; port: number } {
493
+ const index = bindAddress.lastIndexOf(":");
494
+ if (index === -1) throw new Error(`Invalid bind address: ${bindAddress}`);
495
+ return {
496
+ host: bindAddress.slice(0, index),
497
+ port: Number.parseInt(bindAddress.slice(index + 1), 10),
498
+ };
499
+ }
500
+
501
+ function isLoopbackHost(host: string): boolean {
502
+ return host === "localhost" || host === "::1" || host === "[::1]" || host === "127.0.0.1" || host.startsWith("127.");
503
+ }
504
+
505
+ function isWildcardHost(host: string): boolean {
506
+ return host === "0.0.0.0" || host === "::" || host === "[::]" || host === "";
507
+ }
508
+
509
+ async function readJsonBody(request: IncomingMessage): Promise<unknown> {
510
+ let body = "";
511
+ for await (const chunk of request) body += String(chunk);
512
+ return body ? JSON.parse(body) : {};
513
+ }
514
+
515
+ function writeJson(response: ServerResponse, status: number, body: unknown): void {
516
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
517
+ response.end(JSON.stringify(body));
518
+ }
519
+
520
+ function closeSessions(sessionIds: string[], streamHub: StreamHub): void {
521
+ for (const sessionId of sessionIds) {
522
+ streamHub.get(sessionId)?.forEach((webSocket) => sendWebSocketJson(webSocket, { type: "session_closed" }));
523
+ streamHub.delete(sessionId);
524
+ }
525
+ }
526
+
527
+ function sendWebSocketJson(webSocket: WebSocket, event: unknown): void {
528
+ if (webSocket.readyState === WebSocket.OPEN) webSocket.send(JSON.stringify(event));
529
+ }
@@ -0,0 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function projectIdForPath(projectPath: string): string {
4
+ return `proj_${stableHexId(projectPath)}`;
5
+ }
6
+
7
+ function stableHexId(input: string): string {
8
+ return createHash("sha256").update(input).digest("hex").slice(0, 16);
9
+ }
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { asRecord, readString, transcriptMessageFromPiMessage } from "./transcript-message.js";
3
+ import type { TranscriptMessage } from "./types.js";
4
+
5
+ export function readSessionTranscriptMessages(sessionFile: string): TranscriptMessage[] {
6
+ let text: string;
7
+ try {
8
+ text = readFileSync(sessionFile, "utf8");
9
+ } catch (error) {
10
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return [];
11
+ throw error;
12
+ }
13
+
14
+ return text.split(/\r?\n/u).flatMap((line) => {
15
+ const trimmed = line.trim();
16
+ if (!trimmed) return [];
17
+ try {
18
+ return messagesFromEntry(JSON.parse(trimmed));
19
+ } catch {
20
+ return [];
21
+ }
22
+ });
23
+ }
24
+
25
+ function messagesFromEntry(entry: unknown): TranscriptMessage[] {
26
+ const record = asRecord(entry);
27
+ if (record.type !== "message") return [];
28
+ return transcriptMessageFromPiMessage({
29
+ id: readString(record.id),
30
+ timestamp: readString(record.timestamp),
31
+ message: record.message,
32
+ isStreaming: false,
33
+ });
34
+ }