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,10 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { projectIdForPath } from "../src/session-index.js";
3
+
4
+ describe("session identifiers", () => {
5
+ it("creates stable project ids from paths", () => {
6
+ expect(projectIdForPath("/repo/example")).toBe(projectIdForPath("/repo/example"));
7
+ expect(projectIdForPath("/repo/example")).toMatch(/^proj_[0-9a-f]{16}$/);
8
+ expect(projectIdForPath("/repo/example")).not.toBe(projectIdForPath("/repo/other"));
9
+ });
10
+ });
@@ -0,0 +1,75 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { readSessionTranscriptMessages } from "../src/session-transcript.js";
6
+
7
+ let root: string;
8
+
9
+ beforeEach(async () => {
10
+ root = await mkdtemp(join(tmpdir(), "pi-remote-control-transcript-"));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await rm(root, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("session transcript files", () => {
18
+ it("reads message entries from Pi JSONL session files", async () => {
19
+ const sessionFile = join(root, "session.jsonl");
20
+ await writeFile(sessionFile, [
21
+ JSON.stringify({ type: "message", id: "msg_1", timestamp: "2026-05-09T00:00:01.000Z", message: { role: "user", content: "hello" } }),
22
+ JSON.stringify({ type: "tool_execution_start", toolCallId: "call_1" }),
23
+ JSON.stringify({ type: "message", id: "msg_2", timestamp: "2026-05-09T00:00:02.000Z", message: { role: "assistant", content: [{ type: "text", text: "hi" }] } }),
24
+ "not-json",
25
+ "",
26
+ ].join("\n"));
27
+
28
+ expect(readSessionTranscriptMessages(sessionFile)).toEqual([
29
+ { id: "msg_1", role: "user", content: [{ type: "text", text: "hello" }], text: "hello", createdAt: "2026-05-09T00:00:01.000Z", isStreaming: false },
30
+ { id: "msg_2", role: "assistant", content: [{ type: "text", text: "hi" }], text: "hi", createdAt: "2026-05-09T00:00:02.000Z", isStreaming: false },
31
+ ]);
32
+ });
33
+
34
+ it("preserves thinking, tool calls, and tool result metadata", async () => {
35
+ const sessionFile = join(root, "structured-session.jsonl");
36
+ await writeFile(sessionFile, [
37
+ JSON.stringify({ type: "message", id: "msg_1", timestamp: "2026-05-09T00:00:01.000Z", message: { role: "assistant", content: [
38
+ { type: "thinking", thinking: "checking" },
39
+ { type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
40
+ { type: "text", text: "done" },
41
+ ] } }),
42
+ JSON.stringify({ type: "message", id: "msg_2", timestamp: "2026-05-09T00:00:02.000Z", message: { role: "toolResult", toolCallId: "call_1", toolName: "bash", isError: false, content: [{ type: "text", text: "file.txt" }] } }),
43
+ ].join("\n"));
44
+
45
+ expect(readSessionTranscriptMessages(sessionFile)).toEqual([
46
+ {
47
+ id: "msg_1",
48
+ role: "assistant",
49
+ content: [
50
+ { type: "thinking", thinking: "checking" },
51
+ { type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
52
+ { type: "text", text: "done" },
53
+ ],
54
+ text: "done",
55
+ createdAt: "2026-05-09T00:00:01.000Z",
56
+ isStreaming: false,
57
+ },
58
+ {
59
+ id: "msg_2",
60
+ role: "toolResult",
61
+ content: [{ type: "text", text: "file.txt" }],
62
+ text: "file.txt",
63
+ createdAt: "2026-05-09T00:00:02.000Z",
64
+ toolCallId: "call_1",
65
+ toolName: "bash",
66
+ isError: false,
67
+ isStreaming: false,
68
+ },
69
+ ]);
70
+ });
71
+
72
+ it("returns an empty transcript when the session file is missing", () => {
73
+ expect(readSessionTranscriptMessages(join(root, "missing.jsonl"))).toEqual([]);
74
+ });
75
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TranscriptMessage } from "../src/types.js";
3
+ import { decodeTranscriptCursor, encodeTranscriptCursor, olderTranscriptPage, recentTranscriptWindow } from "../src/transcript-pagination.js";
4
+
5
+ function message(id: string, createdAt: string): TranscriptMessage {
6
+ return { id, role: "user", content: [{ type: "text", text: id }], text: id, createdAt, isStreaming: false };
7
+ }
8
+
9
+ describe("transcript pagination", () => {
10
+ const messages = [
11
+ message("msg_1", "2026-05-09T00:00:01.000Z"),
12
+ message("msg_2", "2026-05-09T00:00:02.000Z"),
13
+ message("msg_2", "2026-05-09T00:00:02.000Z"),
14
+ message("msg_3", "2026-05-09T00:00:03.000Z"),
15
+ message("msg_4", "2026-05-09T00:00:04.000Z"),
16
+ message("msg_5", "2026-05-09T00:00:05.000Z"),
17
+ ];
18
+
19
+ it("returns a bounded recent window with a timestamp-only older cursor", () => {
20
+ const page = recentTranscriptWindow(messages, 2);
21
+
22
+ expect(page.messages.map((item) => item.id)).toEqual(["msg_4", "msg_5"]);
23
+ expect(page.hasOlderMessages).toBe(true);
24
+ expect(decodeTranscriptCursor(page.olderMessagesCursor ?? "")).toBe("2026-05-09T00:00:04.000Z");
25
+ });
26
+
27
+ it("returns older pages before an exclusive timestamp cursor", () => {
28
+ const first = recentTranscriptWindow(messages, 2);
29
+ const older = olderTranscriptPage(messages, first.olderMessagesCursor ?? "", 2);
30
+ const oldest = olderTranscriptPage(messages, older.olderMessagesCursor ?? "", 2);
31
+
32
+ expect(older.messages.map((item) => item.id)).toEqual(["msg_2", "msg_3"]);
33
+ expect(older.hasOlderMessages).toBe(true);
34
+ expect(decodeTranscriptCursor(older.olderMessagesCursor ?? "")).toBe("2026-05-09T00:00:02.000Z");
35
+ expect(oldest.messages.map((item) => item.id)).toEqual(["msg_1"]);
36
+ expect(oldest.hasOlderMessages).toBe(false);
37
+ expect(oldest.olderMessagesCursor).toBeNull();
38
+ });
39
+
40
+ it("rejects malformed cursors", () => {
41
+ expect(() => olderTranscriptPage(messages, "not-a-cursor", 2)).toThrow("invalid_cursor");
42
+ });
43
+
44
+ it("rejects timestamp cursors that do not match the transcript", () => {
45
+ expect(() => olderTranscriptPage(messages, encodeTranscriptCursor("2026-05-09T00:00:30.000Z"), 2)).toThrow("invalid_cursor");
46
+ });
47
+
48
+ it("encodes cursors without message ids", () => {
49
+ const cursor = encodeTranscriptCursor("2026-05-09T00:00:04.000Z");
50
+
51
+ expect(decodeTranscriptCursor(cursor)).toBe("2026-05-09T00:00:04.000Z");
52
+ expect(Buffer.from(cursor, "base64url").toString("utf8")).toBe("2026-05-09T00:00:04.000Z");
53
+ });
54
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { INITIAL_WEBSOCKET_SESSION_MESSAGE_LIMIT, INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES, previewInitialSessionState } from "../src/transcript-preview.js";
3
+ import type { TranscriptMessage } from "../src/types.js";
4
+
5
+ function message(id: string, text = id): TranscriptMessage {
6
+ return {
7
+ id,
8
+ role: "assistant",
9
+ content: [{ type: "text", text }],
10
+ text,
11
+ createdAt: `2026-05-09T00:00:${id.padStart(2, "0")}.000Z`,
12
+ isStreaming: false,
13
+ };
14
+ }
15
+
16
+ describe("initial WebSocket session state preview", () => {
17
+ it("keeps at most the newest 20 messages", () => {
18
+ const state = { messages: Array.from({ length: 25 }, (_, index) => message(String(index + 1))) };
19
+
20
+ expect(previewInitialSessionState(state).messages.map((item) => item.id)).toEqual(
21
+ Array.from({ length: INITIAL_WEBSOCKET_SESSION_MESSAGE_LIMIT }, (_, index) => String(index + 6)),
22
+ );
23
+ });
24
+
25
+ it("truncates oversized transcript message strings to the first 10 KiB", () => {
26
+ const oversized = "a".repeat(INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES + 5);
27
+ const state = { messages: [{ ...message("1", oversized), content: [{ type: "text", text: oversized }, { type: "thinking", thinking: oversized }] }] };
28
+
29
+ expect(previewInitialSessionState(state).messages[0]).toEqual({
30
+ id: "1",
31
+ role: "assistant",
32
+ content: [
33
+ { type: "text", text: "a".repeat(INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES), truncated: true, originalBytes: INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES + 5 },
34
+ { type: "thinking", thinking: "a".repeat(INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES), truncated: true, originalBytes: INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES + 5 },
35
+ ],
36
+ text: "a".repeat(INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES),
37
+ textTruncated: true,
38
+ textOriginalBytes: INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES + 5,
39
+ createdAt: "2026-05-09T00:00:01.000Z",
40
+ isStreaming: false,
41
+ });
42
+ });
43
+
44
+ it("truncates oversized tool call argument strings and marks argument previews", () => {
45
+ const oversized = "x".repeat(INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES + 1);
46
+ const state = {
47
+ messages: [{
48
+ ...message("1", ""),
49
+ content: [{ type: "toolCall", id: "call_1", name: "write", arguments: { path: "file.ts", content: oversized } }],
50
+ }],
51
+ };
52
+
53
+ const result = previewInitialSessionState(state).messages[0]?.content[0];
54
+
55
+ expect(result).toEqual({
56
+ type: "toolCall",
57
+ id: "call_1",
58
+ name: "write",
59
+ arguments: { path: "file.ts", content: "x".repeat(INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES) },
60
+ argumentsTruncated: true,
61
+ argumentsOriginalBytes: Buffer.byteLength(JSON.stringify({ path: "file.ts", content: oversized }), "utf8"),
62
+ });
63
+ });
64
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeTuiEvent } from "../src/transcript-stream.js";
3
+
4
+ describe("TUI event transcript stream normalization", () => {
5
+ it("normalizes message lifecycle events to transcript message events", () => {
6
+ expect(normalizeTuiEvent({
7
+ type: "message_start",
8
+ message: { id: "msg_1", role: "assistant", timestamp: 1778284801000, content: [] },
9
+ })).toEqual([{
10
+ type: "transcript_message_start",
11
+ message: {
12
+ id: "msg_1",
13
+ role: "assistant",
14
+ content: [],
15
+ text: "",
16
+ createdAt: "2026-05-09T00:00:01.000Z",
17
+ isStreaming: true,
18
+ },
19
+ }]);
20
+
21
+ expect(normalizeTuiEvent({
22
+ type: "message_end",
23
+ message: { id: "msg_1", role: "assistant", timestamp: 1778284802000, content: [{ type: "text", text: "done" }] },
24
+ })).toEqual([{
25
+ type: "transcript_message_end",
26
+ message: {
27
+ id: "msg_1",
28
+ role: "assistant",
29
+ content: [{ type: "text", text: "done" }],
30
+ text: "done",
31
+ createdAt: "2026-05-09T00:00:02.000Z",
32
+ isStreaming: false,
33
+ },
34
+ }]);
35
+ });
36
+
37
+ it("does not emit message_start for non-streaming user messages", () => {
38
+ expect(normalizeTuiEvent({
39
+ type: "message_start",
40
+ message: { id: "msg_user_1", role: "user", timestamp: 1778284801000, content: "hello" },
41
+ })).toEqual([]);
42
+
43
+ expect(normalizeTuiEvent({
44
+ type: "message_end",
45
+ message: { id: "msg_user_1", role: "user", timestamp: 1778284801000, content: "hello" },
46
+ })).toEqual([{
47
+ type: "transcript_message_end",
48
+ message: {
49
+ id: "msg_user_1",
50
+ role: "user",
51
+ content: [{ type: "text", text: "hello" }],
52
+ text: "hello",
53
+ createdAt: "2026-05-09T00:00:01.000Z",
54
+ isStreaming: false,
55
+ },
56
+ }]);
57
+ });
58
+
59
+ it("normalizes assistant message deltas to transcript message patches", () => {
60
+ expect(normalizeTuiEvent({
61
+ type: "message_update",
62
+ message: { id: "msg_1", role: "assistant" },
63
+ assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "checking" },
64
+ })).toEqual([{ type: "transcript_message_patch", messageId: "msg_1", contentIndex: 0, patch: { type: "thinking_delta", delta: "checking" } }]);
65
+
66
+ expect(normalizeTuiEvent({
67
+ type: "message_update",
68
+ message: { id: "msg_1", role: "assistant" },
69
+ assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: "hello" },
70
+ })).toEqual([{ type: "transcript_message_patch", messageId: "msg_1", contentIndex: 1, patch: { type: "text_delta", delta: "hello" } }]);
71
+
72
+ expect(normalizeTuiEvent({
73
+ type: "message_update",
74
+ message: { id: "msg_1", role: "assistant" },
75
+ assistantMessageEvent: { type: "toolcall_end", contentIndex: 2, toolCall: { type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } } },
76
+ })).toEqual([{ type: "transcript_message_patch", messageId: "msg_1", contentIndex: 2, patch: { type: "toolCall", toolCall: { type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } } } }]);
77
+ });
78
+
79
+ it("normalizes turn lifecycle events", () => {
80
+ expect(normalizeTuiEvent({ type: "turn_start", turnIndex: 2, timestamp: 1778284801000 })).toEqual([
81
+ { type: "turn_start", turnIndex: 2, createdAt: "2026-05-09T00:00:01.000Z" },
82
+ ]);
83
+ expect(normalizeTuiEvent({ type: "turn_end", turnIndex: 2, message: { role: "assistant", content: [] }, toolResults: [] })).toEqual([
84
+ { type: "turn_end", turnIndex: 2 },
85
+ ]);
86
+ });
87
+
88
+ it("normalizes tool execution events", () => {
89
+ expect(normalizeTuiEvent({ type: "tool_execution_start", toolCallId: "call_1", toolName: "bash", args: { command: "ls" } })).toEqual([
90
+ { type: "tool_execution_start", toolCallId: "call_1", toolName: "bash", args: { command: "ls" } },
91
+ ]);
92
+ expect(normalizeTuiEvent({ type: "tool_execution_update", toolCallId: "call_1", toolName: "bash", partialResult: { content: [] } })).toEqual([
93
+ { type: "tool_execution_update", toolCallId: "call_1", toolName: "bash", partialResult: { content: [] } },
94
+ ]);
95
+ expect(normalizeTuiEvent({ type: "tool_execution_end", toolCallId: "call_1", toolName: "bash", result: { content: [] }, isError: false })).toEqual([
96
+ { type: "tool_execution_end", toolCallId: "call_1", toolName: "bash", result: { content: [] }, isError: false },
97
+ ]);
98
+ });
99
+
100
+ it("ignores events without public stream representation", () => {
101
+ expect(normalizeTuiEvent({ type: "agent_start" })).toEqual([]);
102
+ });
103
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "outDir": "dist",
12
+ "rootDir": "src",
13
+ "types": ["node"]
14
+ },
15
+ "include": ["src/**/*.ts"],
16
+ "exclude": ["dist", "node_modules", "tests"]
17
+ }