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,76 @@
1
+ import type { TranscriptContentBlock, TranscriptMessage } from "./types.js";
2
+
3
+ export function transcriptMessageFromPiMessage(input: {
4
+ id?: unknown;
5
+ timestamp?: unknown;
6
+ message?: unknown;
7
+ isStreaming?: boolean;
8
+ }): TranscriptMessage[] {
9
+ const message = asRecord(input.message);
10
+ const role = messageRole(message.role);
11
+ if (!role) return [];
12
+ const content = transcriptContent(message.content);
13
+ const result: TranscriptMessage = {
14
+ id: readString(input.id) ?? readString(message.id) ?? `msg_${Math.random().toString(36).slice(2, 10)}`,
15
+ role,
16
+ content,
17
+ text: transcriptText(content),
18
+ createdAt: readTimestamp(input.timestamp) ?? readTimestamp(message.timestamp) ?? new Date().toISOString(),
19
+ isStreaming: input.isStreaming ?? false,
20
+ };
21
+ const toolCallId = readString(message.toolCallId);
22
+ if (toolCallId) result.toolCallId = toolCallId;
23
+ const toolName = readString(message.toolName);
24
+ if (toolName) result.toolName = toolName;
25
+ if (typeof message.isError === "boolean") result.isError = message.isError;
26
+ return [result];
27
+ }
28
+
29
+ export function transcriptContent(content: unknown): TranscriptContentBlock[] {
30
+ if (typeof content === "string") return [{ type: "text", text: content }];
31
+ if (!Array.isArray(content)) return [];
32
+ return content.flatMap<TranscriptContentBlock>((item) => {
33
+ const record = asRecord(item);
34
+ if (record.type === "text") {
35
+ const text = readString(record.text);
36
+ return text === undefined ? [] : [{ type: "text", text } satisfies TranscriptContentBlock];
37
+ }
38
+ if (record.type === "thinking") {
39
+ const thinking = readString(record.thinking);
40
+ return thinking === undefined ? [] : [{ type: "thinking", thinking } satisfies TranscriptContentBlock];
41
+ }
42
+ if (record.type === "toolCall") {
43
+ const id = readString(record.id);
44
+ const name = readString(record.name);
45
+ return id && name ? [{ type: "toolCall", id, name, arguments: record.arguments } satisfies TranscriptContentBlock] : [];
46
+ }
47
+ if (record.type === "image") {
48
+ const data = readString(record.data);
49
+ const mimeType = readString(record.mimeType);
50
+ return data && mimeType ? [{ type: "image", data, mimeType } satisfies TranscriptContentBlock] : [];
51
+ }
52
+ return [];
53
+ });
54
+ }
55
+
56
+ export function transcriptText(content: TranscriptContentBlock[]): string {
57
+ return content.flatMap((block) => block.type === "text" ? [block.text] : []).join("");
58
+ }
59
+
60
+ export function asRecord(value: unknown): Record<string, unknown> {
61
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
62
+ }
63
+
64
+ export function readString(value: unknown): string | undefined {
65
+ return typeof value === "string" ? value : undefined;
66
+ }
67
+
68
+ export function readTimestamp(value: unknown): string | undefined {
69
+ if (typeof value === "string") return value;
70
+ if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
71
+ return undefined;
72
+ }
73
+
74
+ function messageRole(value: unknown): TranscriptMessage["role"] | undefined {
75
+ return value === "user" || value === "assistant" || value === "toolResult" || value === "system" ? value : undefined;
76
+ }
@@ -0,0 +1,68 @@
1
+ import type { TranscriptMessage } from "./types.js";
2
+
3
+ export const DEFAULT_TRANSCRIPT_PAGE_LIMIT = 100;
4
+ export const MAX_TRANSCRIPT_PAGE_LIMIT = 200;
5
+
6
+ export type TranscriptPage = {
7
+ messages: TranscriptMessage[];
8
+ olderMessagesCursor: string | null;
9
+ hasOlderMessages: boolean;
10
+ };
11
+
12
+ export class InvalidTranscriptCursorError extends Error {
13
+ constructor() {
14
+ super("invalid_cursor");
15
+ }
16
+ }
17
+
18
+ export function encodeTranscriptCursor(createdAt: string): string {
19
+ return Buffer.from(createdAt, "utf8").toString("base64url");
20
+ }
21
+
22
+ export function decodeTranscriptCursor(cursor: string): string {
23
+ try {
24
+ if (!/^[A-Za-z0-9_-]+$/.test(cursor)) throw new InvalidTranscriptCursorError();
25
+ const decoded = Buffer.from(cursor, "base64url").toString("utf8");
26
+ if (!decoded || Number.isNaN(Date.parse(decoded)) || encodeTranscriptCursor(decoded) !== cursor) throw new InvalidTranscriptCursorError();
27
+ return decoded;
28
+ } catch (error) {
29
+ if (error instanceof InvalidTranscriptCursorError) throw error;
30
+ throw new InvalidTranscriptCursorError();
31
+ }
32
+ }
33
+
34
+ export function recentTranscriptWindow(messages: TranscriptMessage[], limit: number): TranscriptPage {
35
+ return pageFromCandidates(normalizeMessages(messages), limit);
36
+ }
37
+
38
+ export function olderTranscriptPage(messages: TranscriptMessage[], beforeCursor: string, limit: number): TranscriptPage {
39
+ const beforeCreatedAt = decodeTranscriptCursor(beforeCursor);
40
+ const normalizedMessages = normalizeMessages(messages);
41
+ if (!normalizedMessages.some((message) => message.createdAt === beforeCreatedAt)) throw new InvalidTranscriptCursorError();
42
+ return pageFromCandidates(
43
+ normalizedMessages.filter((message) => message.createdAt < beforeCreatedAt),
44
+ limit,
45
+ );
46
+ }
47
+
48
+ function pageFromCandidates(candidates: TranscriptMessage[], limit: number): TranscriptPage {
49
+ const start = Math.max(0, candidates.length - limit);
50
+ const pageMessages = candidates.slice(start);
51
+ const hasOlderMessages = start > 0;
52
+ return {
53
+ messages: pageMessages,
54
+ olderMessagesCursor: hasOlderMessages && pageMessages[0] ? encodeTranscriptCursor(pageMessages[0].createdAt) : null,
55
+ hasOlderMessages,
56
+ };
57
+ }
58
+
59
+ function normalizeMessages(messages: TranscriptMessage[]): TranscriptMessage[] {
60
+ const byId = new Map<string, TranscriptMessage>();
61
+ for (const message of messages) {
62
+ if (!byId.has(message.id)) byId.set(message.id, message);
63
+ }
64
+ return [...byId.values()].sort((left, right) => {
65
+ const byCreatedAt = left.createdAt.localeCompare(right.createdAt);
66
+ return byCreatedAt === 0 ? left.id.localeCompare(right.id) : byCreatedAt;
67
+ });
68
+ }
@@ -0,0 +1,102 @@
1
+ import type { TranscriptContentBlock, TranscriptMessage } from "./types.js";
2
+
3
+ export const INITIAL_WEBSOCKET_SESSION_MESSAGE_LIMIT = 20;
4
+ export const INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES = 10 * 1024;
5
+
6
+ type SessionStateWithMessages = {
7
+ messages?: TranscriptMessage[];
8
+ hasOlderMessages?: boolean;
9
+ };
10
+
11
+ export function previewInitialSessionState<T>(state: T): T {
12
+ if (!isRecord(state) || !Array.isArray((state as SessionStateWithMessages).messages)) return state;
13
+ const messages = (state as SessionStateWithMessages).messages ?? [];
14
+ const start = Math.max(0, messages.length - INITIAL_WEBSOCKET_SESSION_MESSAGE_LIMIT);
15
+ return {
16
+ ...state,
17
+ messages: messages.slice(start).map(previewTranscriptMessage),
18
+ hasOlderMessages: (state as SessionStateWithMessages).hasOlderMessages || start > 0,
19
+ };
20
+ }
21
+
22
+ function previewTranscriptMessage(message: TranscriptMessage): TranscriptMessage {
23
+ const text = previewString(message.text);
24
+ return {
25
+ ...message,
26
+ content: message.content.map(previewContentBlock),
27
+ text: text.value,
28
+ ...(text.truncated ? { textTruncated: true, textOriginalBytes: text.originalBytes } : {}),
29
+ };
30
+ }
31
+
32
+ function previewContentBlock(block: TranscriptContentBlock): TranscriptContentBlock {
33
+ if (block.type === "text") {
34
+ const text = previewString(block.text);
35
+ return text.truncated ? { ...block, text: text.value, truncated: true, originalBytes: text.originalBytes } : { ...block };
36
+ }
37
+ if (block.type === "thinking") {
38
+ const thinking = previewString(block.thinking);
39
+ return thinking.truncated ? { ...block, thinking: thinking.value, truncated: true, originalBytes: thinking.originalBytes } : { ...block };
40
+ }
41
+ if (block.type === "image") {
42
+ const data = previewString(block.data);
43
+ return data.truncated ? { ...block, data: data.value, truncated: true, originalBytes: data.originalBytes } : { ...block };
44
+ }
45
+ const originalBytes = jsonByteLength(block.arguments);
46
+ const { value, truncated } = previewUnknownStrings(block.arguments);
47
+ return truncated ? { ...block, arguments: value, argumentsTruncated: true, argumentsOriginalBytes: originalBytes } : { ...block, arguments: value };
48
+ }
49
+
50
+ function previewUnknownStrings(value: unknown): { value: unknown; truncated: boolean } {
51
+ if (typeof value === "string") {
52
+ const preview = previewString(value);
53
+ return { value: preview.value, truncated: preview.truncated };
54
+ }
55
+ if (Array.isArray(value)) {
56
+ let truncated = false;
57
+ const items = value.map((item) => {
58
+ const preview = previewUnknownStrings(item);
59
+ truncated ||= preview.truncated;
60
+ return preview.value;
61
+ });
62
+ return { value: items, truncated };
63
+ }
64
+ if (isRecord(value)) {
65
+ let truncated = false;
66
+ const result: Record<string, unknown> = {};
67
+ for (const [key, item] of Object.entries(value)) {
68
+ const preview = previewUnknownStrings(item);
69
+ truncated ||= preview.truncated;
70
+ result[key] = preview.value;
71
+ }
72
+ return { value: result, truncated };
73
+ }
74
+ return { value, truncated: false };
75
+ }
76
+
77
+ function previewString(value: string): { value: string; truncated: false } | { value: string; truncated: true; originalBytes: number } {
78
+ const originalBytes = Buffer.byteLength(value, "utf8");
79
+ if (originalBytes <= INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES) return { value, truncated: false };
80
+ return { value: truncateUtf8(value, INITIAL_WEBSOCKET_STRING_PREVIEW_BYTES), truncated: true, originalBytes };
81
+ }
82
+
83
+ function truncateUtf8(value: string, maxBytes: number): string {
84
+ const bytes = Buffer.from(value, "utf8");
85
+ for (let end = maxBytes; end >= 0; end -= 1) {
86
+ try {
87
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, end));
88
+ } catch {
89
+ // Try a smaller prefix when maxBytes cuts through a multi-byte sequence.
90
+ }
91
+ }
92
+ return "";
93
+ }
94
+
95
+ function jsonByteLength(value: unknown): number {
96
+ const serialized = JSON.stringify(value);
97
+ return serialized === undefined ? 0 : Buffer.byteLength(serialized, "utf8");
98
+ }
99
+
100
+ function isRecord(value: unknown): value is Record<string, unknown> {
101
+ return value !== null && typeof value === "object";
102
+ }
@@ -0,0 +1,89 @@
1
+ import { asRecord, readString, readTimestamp, transcriptMessageFromPiMessage } from "./transcript-message.js";
2
+ import type { TranscriptContentBlock, TranscriptStreamEvent } from "./types.js";
3
+
4
+ export function normalizeTuiEvent(event: unknown): TranscriptStreamEvent[] {
5
+ const record = asRecord(event);
6
+ switch (record.type) {
7
+ case "turn_start": {
8
+ const turnIndex = readTurnIndex(record.turnIndex);
9
+ if (turnIndex === undefined) return [];
10
+ const createdAt = readTimestamp(record.timestamp);
11
+ return createdAt ? [{ type: "turn_start", turnIndex, createdAt }] : [{ type: "turn_start", turnIndex }];
12
+ }
13
+ case "turn_end": {
14
+ const turnIndex = readTurnIndex(record.turnIndex);
15
+ return turnIndex === undefined ? [] : [{ type: "turn_end", turnIndex }];
16
+ }
17
+ case "message_start":
18
+ if (asRecord(record.message).role !== "assistant") return [];
19
+ return transcriptMessageFromPiMessage({ id: messageId(record), timestamp: record.timestamp, message: record.message, isStreaming: true })
20
+ .map((message) => ({ type: "transcript_message_start", message }));
21
+ case "message_end":
22
+ return transcriptMessageFromPiMessage({ id: messageId(record), timestamp: record.timestamp, message: record.message, isStreaming: false })
23
+ .map((message) => ({ type: "transcript_message_end", message }));
24
+ case "message_update":
25
+ return normalizeMessageUpdate(record);
26
+ case "tool_execution_start": {
27
+ const toolCallId = readString(record.toolCallId);
28
+ const toolName = readString(record.toolName);
29
+ return toolCallId && toolName ? [{ type: "tool_execution_start", toolCallId, toolName, args: record.args }] : [];
30
+ }
31
+ case "tool_execution_update": {
32
+ const toolCallId = readString(record.toolCallId);
33
+ const toolName = readString(record.toolName);
34
+ return toolCallId && toolName ? [{ type: "tool_execution_update", toolCallId, toolName, partialResult: record.partialResult }] : [];
35
+ }
36
+ case "tool_execution_end": {
37
+ const toolCallId = readString(record.toolCallId);
38
+ const toolName = readString(record.toolName);
39
+ if (!toolCallId || !toolName) return [];
40
+ return [{ type: "tool_execution_end", toolCallId, toolName, result: record.result, isError: record.isError === true }];
41
+ }
42
+ default:
43
+ return [];
44
+ }
45
+ }
46
+
47
+ function normalizeMessageUpdate(record: Record<string, unknown>): TranscriptStreamEvent[] {
48
+ const id = messageId(record);
49
+ if (!id) return [];
50
+ const assistantMessageEvent = asRecord(record.assistantMessageEvent);
51
+ const contentIndex = readContentIndex(assistantMessageEvent.contentIndex);
52
+ if (assistantMessageEvent.type === "text_delta") {
53
+ const delta = readString(assistantMessageEvent.delta) ?? readString(assistantMessageEvent.text);
54
+ return delta === undefined ? [] : [{ type: "transcript_message_patch", messageId: id, contentIndex, patch: { type: "text_delta", delta } }];
55
+ }
56
+ if (assistantMessageEvent.type === "thinking_delta") {
57
+ const delta = readString(assistantMessageEvent.delta) ?? readString(assistantMessageEvent.thinking);
58
+ return delta === undefined ? [] : [{ type: "transcript_message_patch", messageId: id, contentIndex, patch: { type: "thinking_delta", delta } }];
59
+ }
60
+ if (assistantMessageEvent.type === "toolcall_end") {
61
+ const toolCall = normalizeToolCall(assistantMessageEvent.toolCall);
62
+ return toolCall ? [{ type: "transcript_message_patch", messageId: id, contentIndex, patch: { type: "toolCall", toolCall } }] : [];
63
+ }
64
+ if (assistantMessageEvent.type === "done" || assistantMessageEvent.type === "error") {
65
+ return transcriptMessageFromPiMessage({ id, timestamp: record.timestamp, message: record.message, isStreaming: false })
66
+ .map((message) => ({ type: "transcript_message_end", message }));
67
+ }
68
+ return [];
69
+ }
70
+
71
+ function messageId(record: Record<string, unknown>): string | undefined {
72
+ return readString(record.id) ?? readString(asRecord(record.message).id);
73
+ }
74
+
75
+ function readContentIndex(value: unknown): number | undefined {
76
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
77
+ }
78
+
79
+ function readTurnIndex(value: unknown): number | undefined {
80
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
81
+ }
82
+
83
+ function normalizeToolCall(value: unknown): Extract<TranscriptContentBlock, { type: "toolCall" }> | undefined {
84
+ const record = asRecord(value);
85
+ if (record.type !== "toolCall") return undefined;
86
+ const id = readString(record.id);
87
+ const name = readString(record.name);
88
+ return id && name ? { type: "toolCall", id, name, arguments: record.arguments } : undefined;
89
+ }
package/src/types.ts ADDED
@@ -0,0 +1,116 @@
1
+ export type IsoTimestamp = string;
2
+
3
+ export type DaemonConfig = {
4
+ bindAddress: string;
5
+ advertisedBaseUrl?: string;
6
+ };
7
+
8
+ export type DaemonState = {
9
+ pid: number;
10
+ startedAt: IsoTimestamp;
11
+ version: string;
12
+ piVersion: string;
13
+ bindAddress: string;
14
+ stateDir: string;
15
+ };
16
+
17
+ export type PairedDevice = {
18
+ id: string;
19
+ name: string;
20
+ tokenHash: string;
21
+ createdAt: IsoTimestamp;
22
+ lastSeenAt?: IsoTimestamp;
23
+ revokedAt?: IsoTimestamp;
24
+ };
25
+
26
+ export type PairingCode = {
27
+ id: string;
28
+ codeHash: string;
29
+ createdAt: IsoTimestamp;
30
+ expiresAt: IsoTimestamp;
31
+ consumedAt?: IsoTimestamp;
32
+ };
33
+
34
+ export type TranscriptContentBlock =
35
+ | { type: "text"; text: string; truncated?: boolean; originalBytes?: number }
36
+ | { type: "thinking"; thinking: string; truncated?: boolean; originalBytes?: number }
37
+ | { type: "toolCall"; id: string; name: string; arguments: unknown; argumentsTruncated?: boolean; argumentsOriginalBytes?: number }
38
+ | { type: "image"; data: string; mimeType: string; truncated?: boolean; originalBytes?: number };
39
+
40
+ export type TranscriptMessage = {
41
+ id: string;
42
+ role: "user" | "assistant" | "toolResult" | "system";
43
+ content: TranscriptContentBlock[];
44
+ text: string;
45
+ textTruncated?: boolean;
46
+ textOriginalBytes?: number;
47
+ createdAt: IsoTimestamp;
48
+ toolCallId?: string;
49
+ toolName?: string;
50
+ isError?: boolean;
51
+ isStreaming: boolean;
52
+ };
53
+
54
+ export type TranscriptMessagePatch =
55
+ | { type: "text_delta"; delta: string }
56
+ | { type: "thinking_delta"; delta: string }
57
+ | { type: "toolCall"; toolCall: Extract<TranscriptContentBlock, { type: "toolCall" }> }
58
+ | { type: "replace"; message: TranscriptMessage };
59
+
60
+ export type RuntimeStatus = {
61
+ model: null | {
62
+ provider: string;
63
+ id: string;
64
+ name?: string;
65
+ contextWindow?: number;
66
+ maxTokens?: number;
67
+ reasoning?: boolean;
68
+ };
69
+ thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | null;
70
+ usage: {
71
+ input: number;
72
+ output: number;
73
+ cacheRead: number;
74
+ cacheWrite: number;
75
+ cost: {
76
+ input: number;
77
+ output: number;
78
+ cacheRead: number;
79
+ cacheWrite: number;
80
+ total: number;
81
+ };
82
+ };
83
+ context: null | {
84
+ tokens: number | null;
85
+ contextWindow: number;
86
+ percent: number | null;
87
+ };
88
+ updatedAt: IsoTimestamp;
89
+ };
90
+
91
+ export type RemoteCompactResultEvent =
92
+ | { type: "remote_compact_result"; requestId: string; ok: true; summary: string; firstKeptEntryId: string; tokensBefore: number }
93
+ | { type: "remote_compact_result"; requestId: string; ok: false; message: string };
94
+
95
+ export type TranscriptStreamEvent =
96
+ | { type: "session_state"; state: unknown }
97
+ | { type: "runtime_status"; status: RuntimeStatus }
98
+ | RemoteCompactResultEvent
99
+ | { type: "turn_start"; turnIndex: number; createdAt?: IsoTimestamp }
100
+ | { type: "turn_end"; turnIndex: number }
101
+ | { type: "transcript_message_start"; message: TranscriptMessage }
102
+ | { type: "transcript_message_patch"; messageId: string; contentIndex?: number; patch: TranscriptMessagePatch }
103
+ | { type: "transcript_message_end"; message: TranscriptMessage }
104
+ | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown }
105
+ | { type: "tool_execution_update"; toolCallId: string; toolName: string; partialResult: unknown }
106
+ | { type: "tool_execution_end"; toolCallId: string; toolName: string; result?: unknown; isError: boolean }
107
+ | { type: "session_closed" }
108
+ | { type: "error"; error: string };
109
+
110
+ export type ToolCallStatus = {
111
+ id: string;
112
+ name: string;
113
+ status: "pending" | "running" | "succeeded" | "failed" | "aborted";
114
+ summary?: string;
115
+ updatedAt: IsoTimestamp;
116
+ };
@@ -0,0 +1,170 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { describe, expect, it } from "vitest";
5
+ import { createActiveSessionRegistry } from "../src/active-session-registry.js";
6
+
7
+ const runtimeStatus = {
8
+ model: { provider: "anthropic", id: "claude-sonnet-4-5", contextWindow: 200000 },
9
+ thinkingLevel: "medium" as const,
10
+ 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 } },
11
+ context: { tokens: 65000, contextWindow: 200000, percent: 32.5 },
12
+ updatedAt: "2026-05-09T09:47:00.000Z",
13
+ };
14
+
15
+ describe("active TUI session registry", () => {
16
+ it("groups active sessions by project and removes inactive sessions", () => {
17
+ const registry = createActiveSessionRegistry();
18
+ registry.registerSession({
19
+ id: "sess_1",
20
+ piSessionId: "pi_1",
21
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
22
+ sessionFile: "/tmp/session.jsonl",
23
+ name: "Fix bug",
24
+ pid: 1234,
25
+ messageCount: 2,
26
+ isStreaming: false,
27
+ updatedAt: "2026-05-09T00:00:00.000Z",
28
+ });
29
+
30
+ expect(registry.listProjects()).toEqual([{ id: "proj_1", name: "Example", path: "/repo/example" }]);
31
+ expect(registry.listProjectSessions("proj_1")).toEqual([
32
+ {
33
+ id: "sess_1",
34
+ piSessionId: "pi_1",
35
+ projectId: "proj_1",
36
+ name: "Fix bug",
37
+ path: "/tmp/session.jsonl",
38
+ updatedAt: "2026-05-09T00:00:00.000Z",
39
+ messageCount: 2,
40
+ isActive: true,
41
+ },
42
+ ]);
43
+
44
+ expect(registry.unregisterSession("sess_1")).toBe(true);
45
+ expect(registry.listProjects()).toEqual([]);
46
+ expect(registry.listProjectSessions("proj_1")).toEqual([]);
47
+ });
48
+
49
+ it("prunes active TUI sessions when heartbeats stop", () => {
50
+ let now = 1_000;
51
+ const registry = createActiveSessionRegistry({ now: () => now, staleSessionTimeoutMs: 5_000 });
52
+ registry.registerSession({
53
+ id: "sess_1",
54
+ piSessionId: "pi_1",
55
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
56
+ sessionFile: "/tmp/session.jsonl",
57
+ pid: 1234,
58
+ messageCount: 0,
59
+ isStreaming: false,
60
+ updatedAt: "2026-05-09T00:00:00.000Z",
61
+ });
62
+
63
+ now = 5_000;
64
+ expect(registry.touchSession("sess_1")).toBe(true);
65
+ now = 9_999;
66
+ expect(registry.pruneInactiveSessions()).toEqual([]);
67
+ expect(registry.listProjects()).toEqual([{ id: "proj_1", name: "Example", path: "/repo/example" }]);
68
+
69
+ now = 10_001;
70
+ expect(registry.pruneInactiveSessions()).toEqual(["sess_1"]);
71
+ expect(registry.listProjects()).toEqual([]);
72
+ expect(registry.touchSession("sess_1")).toBe(false);
73
+ });
74
+
75
+ it("prunes active TUI sessions when the owning TUI process exits", () => {
76
+ const registry = createActiveSessionRegistry({ isProcessRunning: (pid) => pid !== 1234, staleSessionTimeoutMs: 60_000 });
77
+ registry.registerSession({
78
+ id: "sess_1",
79
+ piSessionId: "pi_1",
80
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
81
+ sessionFile: "/tmp/session.jsonl",
82
+ pid: 1234,
83
+ messageCount: 0,
84
+ isStreaming: false,
85
+ updatedAt: "2026-05-09T00:00:00.000Z",
86
+ });
87
+
88
+ expect(registry.pruneInactiveSessions()).toEqual(["sess_1"]);
89
+ expect(registry.listProjects()).toEqual([]);
90
+ });
91
+
92
+ it("queues remote commands for active TUI sessions", () => {
93
+ const registry = createActiveSessionRegistry();
94
+ registry.registerSession({
95
+ id: "sess_1",
96
+ piSessionId: "pi_1",
97
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
98
+ sessionFile: "/tmp/session.jsonl",
99
+ pid: 1234,
100
+ messageCount: 0,
101
+ isStreaming: false,
102
+ updatedAt: "2026-05-09T00:00:00.000Z",
103
+ });
104
+
105
+ expect(registry.enqueueCommand("sess_1", { type: "remote_abort", requestId: "req_1" })).toBe(true);
106
+ expect(registry.enqueueCommand("missing", { type: "remote_abort", requestId: "req_2" })).toBe(false);
107
+ expect(registry.takeCommands("sess_1")).toEqual([{ type: "remote_abort", requestId: "req_1" }]);
108
+ expect(registry.takeCommands("sess_1")).toEqual([]);
109
+ });
110
+
111
+ it("returns snapshots for active sessions", async () => {
112
+ const root = await mkdtemp(join(tmpdir(), "pi-remote-control-registry-"));
113
+ const sessionFile = join(root, "session.jsonl");
114
+ const registry = createActiveSessionRegistry();
115
+ try {
116
+ await writeFile(sessionFile, [
117
+ JSON.stringify({ type: "message", id: "msg_1", timestamp: "2026-05-09T00:00:00.000Z", message: { role: "user", content: "hello" } }),
118
+ JSON.stringify({ type: "message", id: "msg_2", timestamp: "2026-05-09T00:00:01.000Z", message: { role: "assistant", content: [{ type: "text", text: "hi" }] } }),
119
+ ].join("\n"));
120
+ registry.registerSession({
121
+ id: "sess_1",
122
+ piSessionId: "pi_1",
123
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
124
+ sessionFile,
125
+ pid: 1234,
126
+ messageCount: 0,
127
+ isStreaming: true,
128
+ updatedAt: "2026-05-09T00:00:00.000Z",
129
+ });
130
+
131
+ expect(registry.getSessionState("sess_1")).toMatchObject({
132
+ session: { id: "sess_1", projectId: "proj_1", isActive: true },
133
+ messages: [
134
+ { id: "msg_1", role: "user", text: "hello", createdAt: "2026-05-09T00:00:00.000Z", isStreaming: false },
135
+ { id: "msg_2", role: "assistant", text: "hi", createdAt: "2026-05-09T00:00:01.000Z", isStreaming: false },
136
+ ],
137
+ tools: [],
138
+ isStreaming: true,
139
+ pendingMessageCount: 0,
140
+ runtimeStatus: null,
141
+ });
142
+ expect(registry.getSessionState("missing")).toBeUndefined();
143
+ } finally {
144
+ await rm(root, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ it("stores and updates runtime status snapshots for active sessions", () => {
149
+ const registry = createActiveSessionRegistry();
150
+ registry.registerSession({
151
+ id: "sess_1",
152
+ piSessionId: "pi_1",
153
+ project: { id: "proj_1", name: "Example", path: "/repo/example" },
154
+ sessionFile: "/tmp/session.jsonl",
155
+ pid: 1234,
156
+ messageCount: 0,
157
+ isStreaming: false,
158
+ runtimeStatus,
159
+ updatedAt: "2026-05-09T00:00:00.000Z",
160
+ });
161
+
162
+ expect(registry.getSessionState("sess_1")?.runtimeStatus).toEqual(runtimeStatus);
163
+ expect(registry.updateRuntimeStatus("sess_1", runtimeStatus)).toBe(false);
164
+
165
+ const nextStatus = { ...runtimeStatus, thinkingLevel: "high" as const, updatedAt: "2026-05-09T09:48:00.000Z" };
166
+ expect(registry.updateRuntimeStatus("sess_1", nextStatus)).toBe(true);
167
+ expect(registry.getSessionState("sess_1")?.runtimeStatus).toEqual(nextStatus);
168
+ expect(registry.updateRuntimeStatus("missing", nextStatus)).toBe(false);
169
+ });
170
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hashDeviceToken, issueDeviceToken, verifyDeviceToken } from "../src/auth/tokens.js";
3
+
4
+ describe("device tokens", () => {
5
+ it("issues a raw token and a persistable hash", () => {
6
+ const issued = issueDeviceToken();
7
+
8
+ expect(issued.rawToken).toMatch(/^prd_[A-Za-z0-9_-]{32,}$/);
9
+ expect(issued.tokenHash).not.toContain(issued.rawToken);
10
+ });
11
+
12
+ it("verifies the original token", async () => {
13
+ const hash = await hashDeviceToken("prd_test_token", "fixed-salt");
14
+
15
+ await expect(verifyDeviceToken("prd_test_token", hash)).resolves.toBe(true);
16
+ await expect(verifyDeviceToken("wrong", hash)).resolves.toBe(false);
17
+ });
18
+ });