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,932 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import WebSocket from "ws";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { createActiveSessionRegistry } from "../src/active-session-registry.js";
7
+ import { openDaemonStore } from "../src/persistence/daemon-store.js";
8
+ import { bindAddressesForConfig, startDaemonServer, type StartServerOptions } from "../src/server/http.js";
9
+
10
+ const runtimeStatus = {
11
+ model: { provider: "anthropic", id: "claude-sonnet-4-5", contextWindow: 200000 },
12
+ thinkingLevel: "medium" as const,
13
+ usage: { input: 12, output: 3, cacheRead: 50, cacheWrite: 10, cost: { input: 0.036, output: 0.045, cacheRead: 0.015, cacheWrite: 0.0375, total: 0.1335 } },
14
+ context: { tokens: 65000, contextWindow: 200000, percent: 32.5 },
15
+ updatedAt: "2026-05-09T09:47:00.000Z",
16
+ };
17
+
18
+ async function withServer<T>(
19
+ fn: (baseUrl: string) => Promise<T>,
20
+ overrides: Partial<StartServerOptions> = {},
21
+ ): Promise<T> {
22
+ const server = await startDaemonServer({
23
+ stateDir: "/tmp/pi-remote-control-test",
24
+ config: { bindAddress: "127.0.0.1:0" },
25
+ piVersion: "pi-test",
26
+ daemonVersion: "daemon-test",
27
+ ...overrides,
28
+ });
29
+
30
+ try {
31
+ return await fn(`http://${server.address}`);
32
+ } finally {
33
+ await server.close();
34
+ }
35
+ }
36
+
37
+ describe("daemon HTTP server", () => {
38
+ it("serves health", async () => {
39
+ await withServer(async (baseUrl) => {
40
+ const response = await fetch(`${baseUrl}/v1/health`);
41
+
42
+ expect(response.status).toBe(200);
43
+ await expect(response.json()).resolves.toEqual({
44
+ status: "ok",
45
+ piVersion: "pi-test",
46
+ daemonVersion: "daemon-test",
47
+ });
48
+ });
49
+ });
50
+
51
+ it("rejects unauthenticated project requests", async () => {
52
+ await withServer(async (baseUrl) => {
53
+ const response = await fetch(`${baseUrl}/v1/projects`);
54
+
55
+ expect(response.status).toBe(401);
56
+ await expect(response.json()).resolves.toEqual({ error: "unauthorized" });
57
+ });
58
+ });
59
+
60
+ it("binds an additional loopback listener when the configured bind address is not local", () => {
61
+ expect(bindAddressesForConfig("100.86.12.34:17373")).toEqual(["100.86.12.34:17373", "127.0.0.1:17373"]);
62
+ expect(bindAddressesForConfig("127.0.0.1:17373")).toEqual(["127.0.0.1:17373"]);
63
+ expect(bindAddressesForConfig("0.0.0.0:17373")).toEqual(["0.0.0.0:17373"]);
64
+ });
65
+
66
+ it("registers and unregisters active TUI sessions from package-internal endpoints", async () => {
67
+ const activeSessions = createActiveSessionRegistry();
68
+
69
+ await withServer(
70
+ async (baseUrl) => {
71
+ const registerWithoutToken = await fetch(`${baseUrl}/v1/tui/sessions`, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json" },
74
+ body: JSON.stringify({
75
+ id: "sess_1",
76
+ piSessionId: "pi_1",
77
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
78
+ sessionFile: "/tmp/session.jsonl",
79
+ pid: 1234,
80
+ messageCount: 0,
81
+ isStreaming: false,
82
+ updatedAt: "2026-05-09T00:00:00.000Z",
83
+ }),
84
+ });
85
+ expect(registerWithoutToken.status).toBe(200);
86
+
87
+ const registerResponse = await fetch(`${baseUrl}/v1/tui/sessions`, {
88
+ method: "POST",
89
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
90
+ body: JSON.stringify({
91
+ id: "sess_1",
92
+ piSessionId: "pi_1",
93
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
94
+ sessionFile: "/tmp/session.jsonl",
95
+ pid: 1234,
96
+ messageCount: 0,
97
+ isStreaming: false,
98
+ updatedAt: "2026-05-09T00:00:00.000Z",
99
+ }),
100
+ });
101
+ expect(registerResponse.status).toBe(200);
102
+ await expect(registerResponse.json()).resolves.toMatchObject({ session: { id: "sess_1", projectId: "proj_1" } });
103
+ expect(activeSessions.listProjects()).toEqual([{ id: "proj_1", name: "Example", path: "/repo/example" }]);
104
+
105
+ const unregisterResponse = await fetch(`${baseUrl}/v1/tui/sessions/sess_1`, {
106
+ method: "DELETE",
107
+ headers: { authorization: "Bearer test-token" },
108
+ });
109
+ expect(unregisterResponse.status).toBe(200);
110
+ await expect(unregisterResponse.json()).resolves.toEqual({ unregistered: true });
111
+ expect(activeSessions.listProjects()).toEqual([]);
112
+ },
113
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
114
+ );
115
+ });
116
+
117
+ it("does not expose TUI session resume synchronization", async () => {
118
+ const activeSessions = createActiveSessionRegistry();
119
+ activeSessions.registerSession({
120
+ id: "sess_1",
121
+ piSessionId: "pi_1",
122
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
123
+ sessionFile: "/tmp/session.jsonl",
124
+ pid: 1234,
125
+ messageCount: 0,
126
+ isStreaming: false,
127
+ updatedAt: "2026-05-09T00:00:00.000Z",
128
+ });
129
+
130
+ await withServer(
131
+ async (baseUrl) => {
132
+ const response = await fetch(`${baseUrl}/v1/tui/sessions/sess_1`, {
133
+ headers: { authorization: "Bearer test-token" },
134
+ });
135
+
136
+ expect(response.status).toBe(404);
137
+ await expect(response.json()).resolves.toEqual({ error: "not_found" });
138
+ },
139
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
140
+ );
141
+ });
142
+
143
+ it("broadcasts session closure when TUI heartbeats expire", async () => {
144
+ const activeSessions = createActiveSessionRegistry({ staleSessionTimeoutMs: 40 });
145
+ activeSessions.registerSession({
146
+ id: "sess_1",
147
+ piSessionId: "pi_1",
148
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
149
+ sessionFile: "/tmp/session.jsonl",
150
+ pid: 1234,
151
+ messageCount: 0,
152
+ isStreaming: false,
153
+ updatedAt: "2026-05-09T00:00:00.000Z",
154
+ });
155
+
156
+ await withServer(
157
+ async (baseUrl) => {
158
+ const wsUrl = baseUrl.replace("http://", "ws://");
159
+ const webSocket = new WebSocket(`${wsUrl}/v1/sessions/sess_1/stream`, {
160
+ headers: { authorization: "Bearer test-token" },
161
+ });
162
+ const messages: unknown[] = [];
163
+ webSocket.on("message", (data) => messages.push(JSON.parse(String(data))));
164
+ await new Promise<void>((resolve) => webSocket.once("open", resolve));
165
+
166
+ await vi.waitFor(() => expect(messages).toContainEqual({ type: "session_closed" }), { timeout: 300 });
167
+ expect(activeSessions.listProjects()).toEqual([]);
168
+ webSocket.close();
169
+ },
170
+ { activeSessions, authenticateToken: (token) => token === "test-token", sessionSweepIntervalMs: 10 } as Partial<StartServerOptions>,
171
+ );
172
+ });
173
+
174
+ it("sends bounded preview session state to iOS WebSocket subscribers", async () => {
175
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-ws-preview-"));
176
+ const sessionFile = join(root, "session.jsonl");
177
+ const activeSessions = createActiveSessionRegistry();
178
+ try {
179
+ await writeFile(sessionFile, Array.from({ length: 25 }, (_, index) => JSON.stringify({
180
+ type: "message",
181
+ id: `msg_${index + 1}`,
182
+ timestamp: `2026-05-09T00:00:${String(index + 1).padStart(2, "0")}.000Z`,
183
+ message: { role: "assistant", content: [{ type: "text", text: index === 24 ? "z".repeat(11 * 1024) : `message ${index + 1}` }] },
184
+ })).join("\n"));
185
+ activeSessions.registerSession({
186
+ id: "sess_1",
187
+ piSessionId: "pi_1",
188
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
189
+ sessionFile,
190
+ pid: 1234,
191
+ messageCount: 25,
192
+ isStreaming: false,
193
+ updatedAt: "2026-05-09T00:00:25.000Z",
194
+ });
195
+
196
+ await withServer(
197
+ async (baseUrl) => {
198
+ const wsUrl = baseUrl.replace("http://", "ws://");
199
+ const message = await new Promise<{ type: string; state: { messages: Array<{ id: string; text: string; textTruncated?: boolean; content: unknown[] }> } }>((resolve, reject) => {
200
+ const webSocket = new WebSocket(`${wsUrl}/v1/sessions/sess_1/stream`, {
201
+ headers: { authorization: "Bearer test-token" },
202
+ });
203
+ webSocket.once("message", (data) => {
204
+ resolve(JSON.parse(String(data)));
205
+ webSocket.close();
206
+ });
207
+ webSocket.once("error", reject);
208
+ });
209
+
210
+ expect(message.type).toBe("session_state");
211
+ expect(message.state.messages).toHaveLength(20);
212
+ expect(message.state.messages[0]?.id).toBe("msg_6");
213
+ expect(message.state.messages.at(-1)).toMatchObject({ id: "msg_25", textTruncated: true });
214
+ expect(message.state.messages.at(-1)?.text).toHaveLength(10 * 1024);
215
+ },
216
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
217
+ );
218
+ } finally {
219
+ await rm(root, { recursive: true, force: true });
220
+ }
221
+ });
222
+
223
+ it("stores and broadcasts runtime status events from active TUI sessions", async () => {
224
+ const activeSessions = createActiveSessionRegistry();
225
+ activeSessions.registerSession({
226
+ id: "sess_1",
227
+ piSessionId: "pi_1",
228
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
229
+ sessionFile: "/tmp/session.jsonl",
230
+ pid: 1234,
231
+ messageCount: 0,
232
+ isStreaming: false,
233
+ updatedAt: "2026-05-09T00:00:00.000Z",
234
+ });
235
+
236
+ await withServer(
237
+ async (baseUrl) => {
238
+ const wsUrl = baseUrl.replace("http://", "ws://");
239
+ const webSocket = new WebSocket(`${wsUrl}/v1/sessions/sess_1/stream`, {
240
+ headers: { authorization: "Bearer test-token" },
241
+ });
242
+ const messages: unknown[] = [];
243
+ webSocket.on("message", (data) => messages.push(JSON.parse(String(data))));
244
+ await new Promise<void>((resolve) => webSocket.once("open", resolve));
245
+ await vi.waitFor(() => expect(messages).toContainEqual(expect.objectContaining({ type: "session_state", state: expect.objectContaining({ runtimeStatus: null }) })));
246
+
247
+ const response = await fetch(`${baseUrl}/v1/tui/sessions/sess_1/events`, {
248
+ method: "POST",
249
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
250
+ body: JSON.stringify({ type: "runtime_status", status: runtimeStatus }),
251
+ });
252
+ expect(response.status).toBe(200);
253
+ await vi.waitFor(() => expect(messages).toContainEqual({ type: "runtime_status", status: runtimeStatus }));
254
+
255
+ const snapshotResponse = await fetch(`${baseUrl}/v1/sessions/sess_1`, {
256
+ headers: { authorization: "Bearer test-token" },
257
+ });
258
+ expect(snapshotResponse.status).toBe(200);
259
+ await expect(snapshotResponse.json()).resolves.toMatchObject({ runtimeStatus });
260
+ webSocket.close();
261
+ },
262
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
263
+ );
264
+ });
265
+
266
+ it("broadcasts remote compact results to iOS WebSocket subscribers", async () => {
267
+ const activeSessions = createActiveSessionRegistry();
268
+ activeSessions.registerSession({
269
+ id: "sess_1",
270
+ piSessionId: "pi_1",
271
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
272
+ sessionFile: "/tmp/session.jsonl",
273
+ pid: 1234,
274
+ messageCount: 0,
275
+ isStreaming: false,
276
+ updatedAt: "2026-05-09T00:00:00.000Z",
277
+ });
278
+
279
+ await withServer(
280
+ async (baseUrl) => {
281
+ const wsUrl = baseUrl.replace("http://", "ws://");
282
+ const webSocket = new WebSocket(`${wsUrl}/v1/sessions/sess_1/stream`, {
283
+ headers: { authorization: "Bearer test-token" },
284
+ });
285
+ const messages: unknown[] = [];
286
+ webSocket.on("message", (data) => messages.push(JSON.parse(String(data))));
287
+ await new Promise<void>((resolve) => webSocket.once("open", resolve));
288
+ await vi.waitFor(() => expect(messages).toContainEqual(expect.objectContaining({ type: "session_state", state: expect.any(Object) })));
289
+
290
+ const event = { type: "remote_compact_result", requestId: "req_1", ok: true, summary: "Summary", firstKeptEntryId: "entry_1", tokensBefore: 12345 };
291
+ const response = await fetch(`${baseUrl}/v1/tui/sessions/sess_1/events`, {
292
+ method: "POST",
293
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
294
+ body: JSON.stringify(event),
295
+ });
296
+
297
+ expect(response.status).toBe(200);
298
+ await vi.waitFor(() => expect(messages).toContainEqual(event));
299
+ webSocket.close();
300
+ },
301
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
302
+ );
303
+ });
304
+
305
+ it("broadcasts normalized TUI session events to iOS WebSocket subscribers", async () => {
306
+ const activeSessions = createActiveSessionRegistry();
307
+ activeSessions.registerSession({
308
+ id: "sess_1",
309
+ piSessionId: "pi_1",
310
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
311
+ sessionFile: "/tmp/session.jsonl",
312
+ pid: 1234,
313
+ messageCount: 0,
314
+ isStreaming: false,
315
+ updatedAt: "2026-05-09T00:00:00.000Z",
316
+ });
317
+
318
+ await withServer(
319
+ async (baseUrl) => {
320
+ const wsUrl = baseUrl.replace("http://", "ws://");
321
+ const webSocket = new WebSocket(`${wsUrl}/v1/sessions/sess_1/stream`, {
322
+ headers: { authorization: "Bearer test-token" },
323
+ });
324
+ const messages: unknown[] = [];
325
+ webSocket.on("message", (data) => messages.push(JSON.parse(String(data))));
326
+ await new Promise<void>((resolve) => webSocket.once("open", resolve));
327
+
328
+ await vi.waitFor(() => expect(messages).toContainEqual(expect.objectContaining({ type: "session_state", state: expect.any(Object) })));
329
+ const event = { type: "message_update", message: { id: "msg_1", role: "assistant" }, assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "hello" } };
330
+ const response = await fetch(`${baseUrl}/v1/tui/sessions/sess_1/events`, {
331
+ method: "POST",
332
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
333
+ body: JSON.stringify(event),
334
+ });
335
+ expect(response.status).toBe(200);
336
+ await new Promise<void>((resolve) => setTimeout(resolve, 20));
337
+ expect(messages).toContainEqual({ type: "transcript_message_patch", messageId: "msg_1", contentIndex: 0, patch: { type: "text_delta", delta: "hello" } });
338
+ expect(messages).not.toContainEqual(event);
339
+ webSocket.close();
340
+ },
341
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
342
+ );
343
+ });
344
+
345
+ it("lists active TUI projects for authenticated devices", async () => {
346
+ const activeSessions = createActiveSessionRegistry();
347
+ activeSessions.registerSession({
348
+ id: "sess_1",
349
+ piSessionId: "pi_1",
350
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
351
+ sessionFile: "/tmp/session.jsonl",
352
+ pid: 1234,
353
+ messageCount: 0,
354
+ isStreaming: false,
355
+ updatedAt: "2026-05-09T00:00:00.000Z",
356
+ });
357
+
358
+ await withServer(
359
+ async (baseUrl) => {
360
+ const response = await fetch(`${baseUrl}/v1/projects`, {
361
+ headers: { authorization: "Bearer test-token" },
362
+ });
363
+
364
+ expect(response.status).toBe(200);
365
+ await expect(response.json()).resolves.toEqual({
366
+ projects: [{ id: "proj_1", name: "Example", path: "/repo/example" }],
367
+ });
368
+ },
369
+ {
370
+ activeSessions,
371
+ authenticateToken: (token) => token === "test-token",
372
+ },
373
+ );
374
+ });
375
+
376
+ it("lists project sessions for authenticated devices", async () => {
377
+ await withServer(
378
+ async (baseUrl) => {
379
+ const response = await fetch(`${baseUrl}/v1/projects/proj_1/sessions`, {
380
+ headers: { authorization: "Bearer test-token" },
381
+ });
382
+
383
+ expect(response.status).toBe(200);
384
+ await expect(response.json()).resolves.toEqual({
385
+ sessions: [
386
+ {
387
+ id: "sess_1",
388
+ piSessionId: "pi_1",
389
+ projectId: "proj_1",
390
+ name: "Work",
391
+ path: "/sessions/work.jsonl",
392
+ updatedAt: "2026-05-09T00:00:00.000Z",
393
+ messageCount: 2,
394
+ },
395
+ ],
396
+ });
397
+ },
398
+ {
399
+ authenticateToken: (token) => token === "test-token",
400
+ sessionService: {
401
+ listProjectSessions: async (projectId) => [
402
+ {
403
+ id: "sess_1",
404
+ piSessionId: "pi_1",
405
+ projectId,
406
+ name: "Work",
407
+ path: "/sessions/work.jsonl",
408
+ updatedAt: "2026-05-09T00:00:00.000Z",
409
+ messageCount: 2,
410
+ },
411
+ ],
412
+ createProjectSession: async () => {
413
+ throw new Error("not used");
414
+ },
415
+ },
416
+ },
417
+ );
418
+ });
419
+
420
+ it("does not create project sessions from the daemon", async () => {
421
+ await withServer(
422
+ async (baseUrl) => {
423
+ const response = await fetch(`${baseUrl}/v1/projects/proj_1/sessions`, {
424
+ method: "POST",
425
+ headers: { authorization: "Bearer test-token" },
426
+ });
427
+
428
+ expect(response.status).toBe(405);
429
+ await expect(response.json()).resolves.toEqual({ error: "method_not_allowed" });
430
+ },
431
+ { authenticateToken: (token) => token === "test-token" },
432
+ );
433
+ });
434
+
435
+ it("returns 404 for inactive sessions", async () => {
436
+ await withServer(
437
+ async (baseUrl) => {
438
+ const response = await fetch(`${baseUrl}/v1/sessions/missing`, {
439
+ headers: { authorization: "Bearer test-token" },
440
+ });
441
+
442
+ expect(response.status).toBe(404);
443
+ await expect(response.json()).resolves.toEqual({ error: "session_not_found" });
444
+ },
445
+ { activeSessions: createActiveSessionRegistry(), authenticateToken: (token) => token === "test-token" },
446
+ );
447
+ });
448
+
449
+ it("returns an authenticated session snapshot", async () => {
450
+ await withServer(
451
+ async (baseUrl) => {
452
+ const response = await fetch(`${baseUrl}/v1/sessions/sess_1`, {
453
+ headers: { authorization: "Bearer test-token" },
454
+ });
455
+
456
+ expect(response.status).toBe(200);
457
+ await expect(response.json()).resolves.toEqual({
458
+ session: { id: "sess_1" },
459
+ messages: [],
460
+ olderMessagesCursor: null,
461
+ hasOlderMessages: false,
462
+ tools: [],
463
+ isStreaming: false,
464
+ pendingMessageCount: 0,
465
+ });
466
+ },
467
+ {
468
+ authenticateToken: (token) => token === "test-token",
469
+ sessionService: {
470
+ listProjectSessions: async () => [],
471
+ createProjectSession: async () => {
472
+ throw new Error("not used");
473
+ },
474
+ getSessionState: async (sessionId) => ({
475
+ session: { id: sessionId },
476
+ messages: [],
477
+ olderMessagesCursor: null,
478
+ hasOlderMessages: false,
479
+ tools: [],
480
+ isStreaming: false,
481
+ pendingMessageCount: 0,
482
+ }),
483
+ },
484
+ },
485
+ );
486
+ });
487
+
488
+ it("reads session snapshots from the latest session file contents", async () => {
489
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-session-file-http-"));
490
+ const sessionFile = join(root, "session.jsonl");
491
+ const activeSessions = createActiveSessionRegistry();
492
+ activeSessions.registerSession({
493
+ id: "sess_1",
494
+ piSessionId: "pi_1",
495
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
496
+ sessionFile,
497
+ pid: 1234,
498
+ messageCount: 1,
499
+ isStreaming: false,
500
+ updatedAt: "2026-05-09T00:00:01.000Z",
501
+ entries: [
502
+ { type: "message", id: "stale_msg", timestamp: "2026-05-09T00:00:00.000Z", message: { role: "user", content: "stale" } },
503
+ ],
504
+ });
505
+ await writeFile(sessionFile, [
506
+ JSON.stringify({ type: "message", id: "msg_1", timestamp: "2026-05-09T00:00:01.000Z", message: { role: "user", content: "one" } }),
507
+ JSON.stringify({ type: "message", id: "msg_2", timestamp: "2026-05-09T00:00:02.000Z", message: { role: "assistant", content: "two" } }),
508
+ ].join("\n"));
509
+
510
+ try {
511
+ await withServer(
512
+ async (baseUrl) => {
513
+ await writeFile(sessionFile, [
514
+ JSON.stringify({ type: "message", id: "msg_1", timestamp: "2026-05-09T00:00:01.000Z", message: { role: "user", content: "one" } }),
515
+ JSON.stringify({ type: "message", id: "msg_2", timestamp: "2026-05-09T00:00:02.000Z", message: { role: "assistant", content: "two" } }),
516
+ JSON.stringify({ type: "message", id: "msg_3", timestamp: "2026-05-09T00:00:03.000Z", message: { role: "user", content: "three" } }),
517
+ ].join("\n"));
518
+
519
+ const response = await fetch(`${baseUrl}/v1/sessions/sess_1?messageLimit=2`, {
520
+ headers: { authorization: "Bearer test-token" },
521
+ });
522
+ const body = (await response.json()) as { session: { messageCount: number; updatedAt: string }; messages: Array<{ id: string; text: string }>; hasOlderMessages: boolean };
523
+
524
+ expect(response.status).toBe(200);
525
+ expect(body.messages).toEqual([
526
+ expect.objectContaining({ id: "msg_2", text: "two" }),
527
+ expect.objectContaining({ id: "msg_3", text: "three" }),
528
+ ]);
529
+ expect(body.hasOlderMessages).toBe(true);
530
+ expect(body.session.messageCount).toBe(3);
531
+ expect(body.session.updatedAt).toBe("2026-05-09T00:00:03.000Z");
532
+ },
533
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
534
+ );
535
+ } finally {
536
+ await rm(root, { recursive: true, force: true });
537
+ }
538
+ });
539
+
540
+ it("returns bounded session snapshots with older message cursors", async () => {
541
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-bounded-http-"));
542
+ const sessionFile = join(root, "session.jsonl");
543
+ const activeSessions = createActiveSessionRegistry();
544
+ try {
545
+ await writeFile(sessionFile, [
546
+ JSON.stringify({ type: "message", id: "msg_1", timestamp: "2026-05-09T00:00:01.000Z", message: { role: "user", content: "one" } }),
547
+ JSON.stringify({ type: "message", id: "msg_2", timestamp: "2026-05-09T00:00:02.000Z", message: { role: "assistant", content: "two" } }),
548
+ JSON.stringify({ type: "message", id: "msg_3", timestamp: "2026-05-09T00:00:03.000Z", message: { role: "user", content: "three" } }),
549
+ ].join("\n"));
550
+ activeSessions.registerSession({
551
+ id: "sess_1",
552
+ piSessionId: "pi_1",
553
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
554
+ sessionFile,
555
+ pid: 1234,
556
+ messageCount: 3,
557
+ isStreaming: false,
558
+ updatedAt: "2026-05-09T00:00:03.000Z",
559
+ });
560
+
561
+ await withServer(
562
+ async (baseUrl) => {
563
+ const response = await fetch(`${baseUrl}/v1/sessions/sess_1?messageLimit=2`, {
564
+ headers: { authorization: "Bearer test-token" },
565
+ });
566
+ const body = (await response.json()) as { messages: Array<{ id: string }>; olderMessagesCursor: string | null; hasOlderMessages: boolean };
567
+
568
+ expect(response.status).toBe(200);
569
+ expect(body.messages.map((message) => message.id)).toEqual(["msg_2", "msg_3"]);
570
+ expect(body.hasOlderMessages).toBe(true);
571
+ expect(typeof body.olderMessagesCursor).toBe("string");
572
+ },
573
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
574
+ );
575
+ } finally {
576
+ await rm(root, { recursive: true, force: true });
577
+ }
578
+ });
579
+
580
+ it("returns older transcript pages before a cursor", async () => {
581
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-page-http-"));
582
+ const sessionFile = join(root, "session.jsonl");
583
+ const activeSessions = createActiveSessionRegistry();
584
+ try {
585
+ await writeFile(sessionFile, [
586
+ JSON.stringify({ type: "message", id: "msg_1", timestamp: "2026-05-09T00:00:01.000Z", message: { role: "user", content: "one" } }),
587
+ JSON.stringify({ type: "message", id: "msg_2", timestamp: "2026-05-09T00:00:02.000Z", message: { role: "assistant", content: "two" } }),
588
+ JSON.stringify({ type: "message", id: "msg_3", timestamp: "2026-05-09T00:00:03.000Z", message: { role: "user", content: "three" } }),
589
+ JSON.stringify({ type: "message", id: "msg_4", timestamp: "2026-05-09T00:00:04.000Z", message: { role: "assistant", content: "four" } }),
590
+ ].join("\n"));
591
+ activeSessions.registerSession({
592
+ id: "sess_1",
593
+ piSessionId: "pi_1",
594
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
595
+ sessionFile,
596
+ pid: 1234,
597
+ messageCount: 4,
598
+ isStreaming: false,
599
+ updatedAt: "2026-05-09T00:00:04.000Z",
600
+ });
601
+
602
+ await withServer(
603
+ async (baseUrl) => {
604
+ const snapshotResponse = await fetch(`${baseUrl}/v1/sessions/sess_1?messageLimit=2`, {
605
+ headers: { authorization: "Bearer test-token" },
606
+ });
607
+ const snapshot = (await snapshotResponse.json()) as { olderMessagesCursor: string };
608
+
609
+ const pageResponse = await fetch(`${baseUrl}/v1/sessions/sess_1/messages?before=${encodeURIComponent(snapshot.olderMessagesCursor)}&limit=2`, {
610
+ headers: { authorization: "Bearer test-token" },
611
+ });
612
+ const page = (await pageResponse.json()) as { messages: Array<{ id: string }>; olderMessagesCursor: string | null; hasOlderMessages: boolean };
613
+
614
+ expect(pageResponse.status).toBe(200);
615
+ expect(page.messages.map((message) => message.id)).toEqual(["msg_1", "msg_2"]);
616
+ expect(page.hasOlderMessages).toBe(false);
617
+ expect(page.olderMessagesCursor).toBeNull();
618
+ },
619
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
620
+ );
621
+ } finally {
622
+ await rm(root, { recursive: true, force: true });
623
+ }
624
+ });
625
+
626
+ it("rejects invalid transcript page parameters", async () => {
627
+ const activeSessions = createActiveSessionRegistry();
628
+ activeSessions.registerSession({
629
+ id: "sess_1",
630
+ piSessionId: "pi_1",
631
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
632
+ sessionFile: "/tmp/session.jsonl",
633
+ pid: 1234,
634
+ messageCount: 0,
635
+ isStreaming: false,
636
+ updatedAt: "2026-05-09T00:00:00.000Z",
637
+ });
638
+
639
+ await withServer(
640
+ async (baseUrl) => {
641
+ const badLimit = await fetch(`${baseUrl}/v1/sessions/sess_1?messageLimit=0`, {
642
+ headers: { authorization: "Bearer test-token" },
643
+ });
644
+ const badCursor = await fetch(`${baseUrl}/v1/sessions/sess_1/messages?before=not-a-cursor&limit=1`, {
645
+ headers: { authorization: "Bearer test-token" },
646
+ });
647
+
648
+ expect(badLimit.status).toBe(400);
649
+ await expect(badLimit.json()).resolves.toEqual({ error: "invalid_limit" });
650
+ expect(badCursor.status).toBe(400);
651
+ await expect(badCursor.json()).resolves.toEqual({ error: "invalid_cursor" });
652
+ },
653
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
654
+ );
655
+ });
656
+
657
+ it("lets TUI extensions take queued remote commands", async () => {
658
+ const activeSessions = createActiveSessionRegistry();
659
+ activeSessions.registerSession({
660
+ id: "sess_1",
661
+ piSessionId: "pi_1",
662
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
663
+ sessionFile: "/tmp/session.jsonl",
664
+ pid: 1234,
665
+ messageCount: 0,
666
+ isStreaming: false,
667
+ updatedAt: "2026-05-09T00:00:00.000Z",
668
+ });
669
+ activeSessions.enqueueCommand("sess_1", { type: "remote_abort", requestId: "req_1" });
670
+
671
+ await withServer(
672
+ async (baseUrl) => {
673
+ const response = await fetch(`${baseUrl}/v1/tui/sessions/sess_1/commands`, {
674
+ headers: { authorization: "Bearer test-token" },
675
+ });
676
+ expect(response.status).toBe(200);
677
+ await expect(response.json()).resolves.toEqual({ commands: [{ type: "remote_abort", requestId: "req_1" }] });
678
+
679
+ const emptyResponse = await fetch(`${baseUrl}/v1/tui/sessions/sess_1/commands`, {
680
+ headers: { authorization: "Bearer test-token" },
681
+ });
682
+ await expect(emptyResponse.json()).resolves.toEqual({ commands: [] });
683
+ },
684
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
685
+ );
686
+ });
687
+
688
+ it("queues prompts for active TUI sessions", async () => {
689
+ const activeSessions = createActiveSessionRegistry();
690
+ activeSessions.registerSession({
691
+ id: "sess_1",
692
+ piSessionId: "pi_1",
693
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
694
+ sessionFile: "/tmp/session.jsonl",
695
+ pid: 1234,
696
+ messageCount: 0,
697
+ isStreaming: false,
698
+ updatedAt: "2026-05-09T00:00:00.000Z",
699
+ });
700
+
701
+ await withServer(
702
+ async (baseUrl) => {
703
+ const response = await fetch(`${baseUrl}/v1/sessions/sess_1/prompt`, {
704
+ method: "POST",
705
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
706
+ body: JSON.stringify({ text: "hello", streamingBehavior: "followUp" }),
707
+ });
708
+
709
+ expect(response.status).toBe(200);
710
+ await expect(response.json()).resolves.toEqual({ accepted: true });
711
+ expect(activeSessions.takeCommands("sess_1")).toEqual([
712
+ { type: "remote_prompt", requestId: expect.stringMatching(/^req_/), text: "hello", streamingBehavior: "followUp" },
713
+ ]);
714
+ },
715
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
716
+ );
717
+ });
718
+
719
+ it("rejects prompts for inactive TUI sessions", async () => {
720
+ await withServer(
721
+ async (baseUrl) => {
722
+ const response = await fetch(`${baseUrl}/v1/sessions/missing/prompt`, {
723
+ method: "POST",
724
+ headers: { authorization: "Bearer test-token", "content-type": "application/json" },
725
+ body: JSON.stringify({ text: "hello" }),
726
+ });
727
+
728
+ expect(response.status).toBe(409);
729
+ await expect(response.json()).resolves.toEqual({ error: "session_not_active" });
730
+ },
731
+ { activeSessions: createActiveSessionRegistry(), authenticateToken: (token) => token === "test-token" },
732
+ );
733
+ });
734
+
735
+ it("queues compact for active TUI sessions", async () => {
736
+ const activeSessions = createActiveSessionRegistry();
737
+ activeSessions.registerSession({
738
+ id: "sess_1",
739
+ piSessionId: "pi_1",
740
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
741
+ sessionFile: "/tmp/session.jsonl",
742
+ pid: 1234,
743
+ messageCount: 0,
744
+ isStreaming: false,
745
+ updatedAt: "2026-05-09T00:00:00.000Z",
746
+ });
747
+
748
+ await withServer(
749
+ async (baseUrl) => {
750
+ const response = await fetch(`${baseUrl}/v1/sessions/sess_1/compact`, {
751
+ method: "POST",
752
+ headers: { authorization: "Bearer test-token" },
753
+ });
754
+
755
+ expect(response.status).toBe(200);
756
+ const body = (await response.json()) as { accepted: boolean; requestId: string };
757
+ expect(body).toEqual({ accepted: true, requestId: expect.stringMatching(/^req_/) });
758
+ expect(activeSessions.takeCommands("sess_1")).toEqual([{ type: "remote_compact", requestId: body.requestId }]);
759
+ },
760
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
761
+ );
762
+ });
763
+
764
+ it("rejects compact for inactive TUI sessions", async () => {
765
+ await withServer(
766
+ async (baseUrl) => {
767
+ const response = await fetch(`${baseUrl}/v1/sessions/missing/compact`, {
768
+ method: "POST",
769
+ headers: { authorization: "Bearer test-token" },
770
+ });
771
+
772
+ expect(response.status).toBe(409);
773
+ await expect(response.json()).resolves.toEqual({ error: "session_not_active" });
774
+ },
775
+ { activeSessions: createActiveSessionRegistry(), authenticateToken: (token) => token === "test-token" },
776
+ );
777
+ });
778
+
779
+ it("queues abort for active TUI sessions", async () => {
780
+ const activeSessions = createActiveSessionRegistry();
781
+ activeSessions.registerSession({
782
+ id: "sess_1",
783
+ piSessionId: "pi_1",
784
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
785
+ sessionFile: "/tmp/session.jsonl",
786
+ pid: 1234,
787
+ messageCount: 0,
788
+ isStreaming: true,
789
+ updatedAt: "2026-05-09T00:00:00.000Z",
790
+ });
791
+
792
+ await withServer(
793
+ async (baseUrl) => {
794
+ const response = await fetch(`${baseUrl}/v1/sessions/sess_1/abort`, {
795
+ method: "POST",
796
+ headers: { authorization: "Bearer test-token" },
797
+ });
798
+
799
+ expect(response.status).toBe(200);
800
+ await expect(response.json()).resolves.toEqual({ aborted: true });
801
+ expect(activeSessions.takeCommands("sess_1")).toEqual([{ type: "remote_abort", requestId: expect.stringMatching(/^req_/) }]);
802
+ },
803
+ { activeSessions, authenticateToken: (token) => token === "test-token" },
804
+ );
805
+ });
806
+
807
+ it("does not expose remote pairing code creation", async () => {
808
+ await withServer(async (baseUrl) => {
809
+ const response = await fetch(`${baseUrl}/v1/pair/code`, { method: "POST" });
810
+
811
+ expect(response.status).toBe(404);
812
+ await expect(response.json()).resolves.toEqual({ error: "not_found" });
813
+ });
814
+ });
815
+
816
+ it("returns 400 for invalid pairing claims", async () => {
817
+ await withServer(
818
+ async (baseUrl) => {
819
+ const response = await fetch(`${baseUrl}/v1/pair/claim`, {
820
+ method: "POST",
821
+ headers: { "content-type": "application/json" },
822
+ body: JSON.stringify({ pairCode: "000000", deviceName: "iPhone" }),
823
+ });
824
+
825
+ expect(response.status).toBe(400);
826
+ await expect(response.json()).resolves.toEqual({ error: "invalid_pairing_code" });
827
+ },
828
+ {
829
+ pairService: {
830
+ claimPairingCode: async () => {
831
+ throw new Error("Invalid or expired pairing code");
832
+ },
833
+ },
834
+ },
835
+ );
836
+ });
837
+
838
+ it("issues usable bearer tokens through the pair flow", async () => {
839
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-pair-http-"));
840
+ const store = openDaemonStore(root);
841
+ try {
842
+ await withServer(
843
+ async (baseUrl) => {
844
+ const codeBody = await store.createPairingCode(new Date("2026-05-09T00:00:00.000Z"), 60_000);
845
+
846
+ const claimResponse = await fetch(`${baseUrl}/v1/pair/claim`, {
847
+ method: "POST",
848
+ headers: { "content-type": "application/json" },
849
+ body: JSON.stringify({ pairCode: codeBody.pairCode, deviceName: "iPhone" }),
850
+ });
851
+ const claimBody = (await claimResponse.json()) as { token: string };
852
+
853
+ const projectsResponse = await fetch(`${baseUrl}/v1/projects`, {
854
+ headers: { authorization: `Bearer ${claimBody.token}` },
855
+ });
856
+
857
+ expect(claimResponse.status).toBe(200);
858
+ expect(projectsResponse.status).toBe(200);
859
+ },
860
+ {
861
+ authenticateToken: (token) => store.authenticateToken(token),
862
+ pairService: {
863
+ claimPairingCode: async (request) => {
864
+ const claimed = await store.claimPairingCode(request.pairCode, request.deviceName, new Date("2026-05-09T00:00:30.000Z"));
865
+ if (!claimed) throw new Error("invalid pair code");
866
+ return claimed;
867
+ },
868
+ },
869
+ },
870
+ );
871
+ } finally {
872
+ store.close();
873
+ await rm(root, { recursive: true, force: true });
874
+ }
875
+ });
876
+
877
+ it("claims pairing codes without bearer auth", async () => {
878
+ await withServer(
879
+ async (baseUrl) => {
880
+ const response = await fetch(`${baseUrl}/v1/pair/claim`, {
881
+ method: "POST",
882
+ headers: { "content-type": "application/json" },
883
+ body: JSON.stringify({ pairCode: "123456", deviceName: "iPhone" }),
884
+ });
885
+
886
+ expect(response.status).toBe(200);
887
+ await expect(response.json()).resolves.toEqual({
888
+ deviceId: "dev_1",
889
+ token: "prd_token",
890
+ daemonName: "test-daemon",
891
+ });
892
+ },
893
+ {
894
+ pairService: {
895
+ claimPairingCode: async (request) => {
896
+ expect(request).toEqual({ pairCode: "123456", deviceName: "iPhone" });
897
+ return { deviceId: "dev_1", token: "prd_token", daemonName: "test-daemon" };
898
+ },
899
+ },
900
+ },
901
+ );
902
+ });
903
+
904
+ it("streams session events over authenticated WebSocket", async () => {
905
+ await withServer(
906
+ async (baseUrl) => {
907
+ const wsUrl = baseUrl.replace(/^http:/, "ws:");
908
+ const message = await new Promise<unknown>((resolve, reject) => {
909
+ const socket = new WebSocket(`${wsUrl}/v1/sessions/sess_1/stream`, {
910
+ headers: { authorization: "Bearer test-token" },
911
+ });
912
+ socket.once("message", (data) => {
913
+ resolve(JSON.parse(String(data)));
914
+ socket.close();
915
+ });
916
+ socket.once("error", reject);
917
+ });
918
+
919
+ expect(message).toEqual({ type: "agent_done" });
920
+ },
921
+ {
922
+ authenticateToken: (token) => token === "test-token",
923
+ sessionService: {
924
+ streamSession: async (sessionId, send) => {
925
+ expect(sessionId).toBe("sess_1");
926
+ send({ type: "agent_done" });
927
+ },
928
+ },
929
+ },
930
+ );
931
+ });
932
+ });