pi-remote-control 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/docs/adr/0001-package-extension-as-control-shim.md +19 -0
- package/docs/adr/0002-use-sqlite-for-daemon-state.md +19 -0
- package/docs/adr/0003-use-lock-file-as-process-state.md +19 -0
- package/docs/adr/0004-allow-loopback-pair-code-without-token.md +19 -0
- package/docs/adr/0005-defer-os-service-installation.md +19 -0
- package/docs/adr/0006-use-tui-activated-remote-control-sessions.md +24 -0
- package/docs/adr/0007-require-tui-originated-pairing.md +19 -0
- package/docs/adr/0008-use-qr-pairing-links.md +21 -0
- package/docs/adr/0009-rename-package-to-remote-control.md +19 -0
- package/docs/adr/0010-clean-stale-lock-on-status.md +19 -0
- package/docs/adr/0011-use-loopback-tui-control.md +19 -0
- package/docs/adr/0012-use-paginated-session-transcript-loading.md +37 -0
- package/docs/adr/0013-require-manual-reactivation-after-tui-entry.md +31 -0
- package/docs/adr/0014-read-transcripts-from-session-files.md +33 -0
- package/docs/adr/0015-normalize-transcript-messages-and-stream-events.md +35 -0
- package/docs/adr/0016-expose-turn-lifecycle-events.md +31 -0
- package/docs/adr/0017-bound-initial-websocket-session-state.md +31 -0
- package/docs/adr/0018-reregister-active-tui-session-on-heartbeat-miss.md +33 -0
- package/docs/adr/0019-display-only-pairing-qr-and-expiry.md +25 -0
- package/docs/adr/0020-expose-session-status-snapshots.md +31 -0
- package/docs/adr/0021-support-remote-compact-action.md +31 -0
- package/docs/adr/0022-rename-session-status-to-runtime-status.md +27 -0
- package/docs/adr/0023-return-remote-compact-results.md +29 -0
- package/docs/architecture.md +96 -0
- package/docs/data-model.md +284 -0
- package/docs/interfaces.md +470 -0
- package/package.json +37 -0
- package/scripts/http-smoke-test.sh +100 -0
- package/src/active-session-registry.ts +205 -0
- package/src/auth/pairing.ts +30 -0
- package/src/auth/tokens.ts +30 -0
- package/src/cli-runner.cjs +15 -0
- package/src/cli.ts +254 -0
- package/src/config.ts +26 -0
- package/src/extension/index.ts +422 -0
- package/src/index.ts +16 -0
- package/src/lock.ts +26 -0
- package/src/pairing-link.ts +15 -0
- package/src/paths.ts +21 -0
- package/src/persistence/daemon-store.ts +56 -0
- package/src/persistence/schema.ts +21 -0
- package/src/qr.ts +23 -0
- package/src/runtime-status.ts +116 -0
- package/src/server/http.ts +529 -0
- package/src/session-index.ts +9 -0
- package/src/session-transcript.ts +34 -0
- package/src/transcript-message.ts +76 -0
- package/src/transcript-pagination.ts +68 -0
- package/src/transcript-preview.ts +102 -0
- package/src/transcript-stream.ts +89 -0
- package/src/types.ts +116 -0
- package/tests/active-session-registry.test.ts +170 -0
- package/tests/auth.test.ts +18 -0
- package/tests/cli.test.ts +361 -0
- package/tests/config.test.ts +35 -0
- package/tests/daemon-store.test.ts +54 -0
- package/tests/extension.test.ts +617 -0
- package/tests/lock.test.ts +36 -0
- package/tests/pairing-link.test.ts +26 -0
- package/tests/pairing.test.ts +26 -0
- package/tests/paths.test.ts +29 -0
- package/tests/qr.test.ts +25 -0
- package/tests/schema.test.ts +18 -0
- package/tests/server-http.test.ts +932 -0
- package/tests/session-index.test.ts +10 -0
- package/tests/session-transcript.test.ts +75 -0
- package/tests/transcript-pagination.test.ts +54 -0
- package/tests/transcript-preview.test.ts +64 -0
- package/tests/transcript-stream.test.ts +103 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { readSessionTranscriptMessages } from "./session-transcript.js";
|
|
2
|
+
import { DEFAULT_TRANSCRIPT_PAGE_LIMIT, olderTranscriptPage, recentTranscriptWindow, type TranscriptPage } from "./transcript-pagination.js";
|
|
3
|
+
import type { RuntimeStatus, ToolCallStatus } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type ActiveProject = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
path: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ActiveSessionRegistration = {
|
|
12
|
+
id: string;
|
|
13
|
+
piSessionId: string;
|
|
14
|
+
project: ActiveProject;
|
|
15
|
+
sessionFile: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
messageCount: number;
|
|
19
|
+
isStreaming: boolean;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
runtimeStatus?: RuntimeStatus;
|
|
22
|
+
entries?: unknown[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ActiveSessionSummary = {
|
|
26
|
+
id: string;
|
|
27
|
+
piSessionId: string;
|
|
28
|
+
projectId: string;
|
|
29
|
+
name: string | null;
|
|
30
|
+
path: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
messageCount: number;
|
|
33
|
+
isActive: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type RemoteTuiCommand =
|
|
37
|
+
| { type: "remote_prompt"; requestId: string; text: string; streamingBehavior?: "steer" | "followUp" | null }
|
|
38
|
+
| { type: "remote_abort"; requestId: string }
|
|
39
|
+
| { type: "remote_compact"; requestId: string };
|
|
40
|
+
|
|
41
|
+
export type ActiveSessionState = TranscriptPage & {
|
|
42
|
+
session: ActiveSessionSummary;
|
|
43
|
+
tools: ToolCallStatus[];
|
|
44
|
+
isStreaming: boolean;
|
|
45
|
+
pendingMessageCount: number;
|
|
46
|
+
runtimeStatus: RuntimeStatus | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ActiveSessionRegistry = {
|
|
50
|
+
registerSession(session: ActiveSessionRegistration): ActiveSessionSummary;
|
|
51
|
+
unregisterSession(sessionId: string): boolean;
|
|
52
|
+
touchSession(sessionId: string): boolean;
|
|
53
|
+
pruneInactiveSessions(): string[];
|
|
54
|
+
listProjects(): ActiveProject[];
|
|
55
|
+
listProjectSessions(projectId: string): ActiveSessionSummary[];
|
|
56
|
+
getSessionState(sessionId: string, options?: { messageLimit?: number }): ActiveSessionState | undefined;
|
|
57
|
+
getSessionMessages(sessionId: string, beforeCursor: string, options?: { limit?: number }): TranscriptPage | undefined;
|
|
58
|
+
updateRuntimeStatus(sessionId: string, status: RuntimeStatus): boolean;
|
|
59
|
+
enqueueCommand(sessionId: string, command: RemoteTuiCommand): boolean;
|
|
60
|
+
takeCommands(sessionId: string): RemoteTuiCommand[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type ActiveSessionRegistryOptions = {
|
|
64
|
+
now?: () => number;
|
|
65
|
+
staleSessionTimeoutMs?: number;
|
|
66
|
+
isProcessRunning?: (pid: number) => boolean;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const DEFAULT_ACTIVE_SESSION_STALE_TIMEOUT_MS = 5_000;
|
|
70
|
+
|
|
71
|
+
type StoredActiveSession = ActiveSessionRegistration & {
|
|
72
|
+
summary: ActiveSessionSummary;
|
|
73
|
+
tools: ToolCallStatus[];
|
|
74
|
+
pendingMessageCount: number;
|
|
75
|
+
commands: RemoteTuiCommand[];
|
|
76
|
+
lastSeenAtMs: number;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function createActiveSessionRegistry(options: ActiveSessionRegistryOptions = {}): ActiveSessionRegistry {
|
|
80
|
+
const sessions = new Map<string, StoredActiveSession>();
|
|
81
|
+
const now = options.now ?? Date.now;
|
|
82
|
+
const staleSessionTimeoutMs = options.staleSessionTimeoutMs ?? DEFAULT_ACTIVE_SESSION_STALE_TIMEOUT_MS;
|
|
83
|
+
const isProcessRunning = options.isProcessRunning;
|
|
84
|
+
|
|
85
|
+
const pruneInactiveSessions = (): string[] => {
|
|
86
|
+
const cutoff = now() - staleSessionTimeoutMs;
|
|
87
|
+
const removed: string[] = [];
|
|
88
|
+
for (const [sessionId, session] of sessions) {
|
|
89
|
+
if (session.lastSeenAtMs <= cutoff || (isProcessRunning ? !isProcessRunning(session.pid) : false)) {
|
|
90
|
+
sessions.delete(sessionId);
|
|
91
|
+
removed.push(sessionId);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return removed;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
registerSession(session) {
|
|
99
|
+
const summary = toSummary(session);
|
|
100
|
+
sessions.set(session.id, {
|
|
101
|
+
...session,
|
|
102
|
+
summary,
|
|
103
|
+
tools: [],
|
|
104
|
+
pendingMessageCount: 0,
|
|
105
|
+
commands: [],
|
|
106
|
+
lastSeenAtMs: now(),
|
|
107
|
+
});
|
|
108
|
+
return summary;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
unregisterSession(sessionId) {
|
|
112
|
+
return sessions.delete(sessionId);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
touchSession(sessionId) {
|
|
116
|
+
pruneInactiveSessions();
|
|
117
|
+
const session = sessions.get(sessionId);
|
|
118
|
+
if (!session) return false;
|
|
119
|
+
session.lastSeenAtMs = now();
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
pruneInactiveSessions,
|
|
124
|
+
|
|
125
|
+
listProjects() {
|
|
126
|
+
pruneInactiveSessions();
|
|
127
|
+
const projects = new Map<string, ActiveProject>();
|
|
128
|
+
for (const session of sessions.values()) projects.set(session.project.id, session.project);
|
|
129
|
+
return [...projects.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
listProjectSessions(projectId) {
|
|
133
|
+
pruneInactiveSessions();
|
|
134
|
+
return [...sessions.values()]
|
|
135
|
+
.filter((session) => session.project.id === projectId)
|
|
136
|
+
.map((session) => session.summary)
|
|
137
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
getSessionState(sessionId, options) {
|
|
141
|
+
pruneInactiveSessions();
|
|
142
|
+
const session = sessions.get(sessionId);
|
|
143
|
+
if (!session) return undefined;
|
|
144
|
+
const messages = readSessionTranscriptMessages(session.sessionFile);
|
|
145
|
+
return {
|
|
146
|
+
session: {
|
|
147
|
+
...session.summary,
|
|
148
|
+
messageCount: messages.length,
|
|
149
|
+
updatedAt: messages.at(-1)?.createdAt ?? session.summary.updatedAt,
|
|
150
|
+
},
|
|
151
|
+
...recentTranscriptWindow(messages, options?.messageLimit ?? DEFAULT_TRANSCRIPT_PAGE_LIMIT),
|
|
152
|
+
tools: session.tools,
|
|
153
|
+
isStreaming: session.isStreaming,
|
|
154
|
+
pendingMessageCount: session.pendingMessageCount,
|
|
155
|
+
runtimeStatus: session.runtimeStatus ?? null,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
getSessionMessages(sessionId, beforeCursor, options) {
|
|
160
|
+
pruneInactiveSessions();
|
|
161
|
+
const session = sessions.get(sessionId);
|
|
162
|
+
if (!session) return undefined;
|
|
163
|
+
return olderTranscriptPage(readSessionTranscriptMessages(session.sessionFile), beforeCursor, options?.limit ?? DEFAULT_TRANSCRIPT_PAGE_LIMIT);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
updateRuntimeStatus(sessionId, status) {
|
|
167
|
+
pruneInactiveSessions();
|
|
168
|
+
const session = sessions.get(sessionId);
|
|
169
|
+
if (!session) return false;
|
|
170
|
+
if (JSON.stringify(session.runtimeStatus ?? null) === JSON.stringify(status)) return false;
|
|
171
|
+
session.runtimeStatus = status;
|
|
172
|
+
return true;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
enqueueCommand(sessionId, command) {
|
|
176
|
+
pruneInactiveSessions();
|
|
177
|
+
const session = sessions.get(sessionId);
|
|
178
|
+
if (!session) return false;
|
|
179
|
+
session.commands.push(command);
|
|
180
|
+
return true;
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
takeCommands(sessionId) {
|
|
184
|
+
pruneInactiveSessions();
|
|
185
|
+
const session = sessions.get(sessionId);
|
|
186
|
+
if (!session) return [];
|
|
187
|
+
const commands = session.commands;
|
|
188
|
+
session.commands = [];
|
|
189
|
+
return commands;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function toSummary(session: ActiveSessionRegistration): ActiveSessionSummary {
|
|
195
|
+
return {
|
|
196
|
+
id: session.id,
|
|
197
|
+
piSessionId: session.piSessionId,
|
|
198
|
+
projectId: session.project.id,
|
|
199
|
+
name: session.name ?? null,
|
|
200
|
+
path: session.sessionFile,
|
|
201
|
+
updatedAt: session.updatedAt,
|
|
202
|
+
messageCount: session.messageCount,
|
|
203
|
+
isActive: true,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { randomInt, createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { PairingCode } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export type CreatedPairingCode = PairingCode & {
|
|
5
|
+
rawCode: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function createPairingCode(now: Date, ttlMs: number): CreatedPairingCode {
|
|
9
|
+
const rawCode = randomInt(0, 1_000_000).toString().padStart(6, "0");
|
|
10
|
+
return {
|
|
11
|
+
id: `pair_${createHash("sha256").update(`${rawCode}:${now.toISOString()}`).digest("hex").slice(0, 16)}`,
|
|
12
|
+
rawCode,
|
|
13
|
+
codeHash: createHash("sha256").update(rawCode).digest("hex"),
|
|
14
|
+
createdAt: now.toISOString(),
|
|
15
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hashPairingCode(rawCode: string): string {
|
|
20
|
+
return createHash("sha256").update(rawCode).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function canClaimPairingCode(pairingCode: PairingCode, rawCode: string, now: Date): boolean {
|
|
24
|
+
if (pairingCode.consumedAt) return false;
|
|
25
|
+
if (Date.parse(pairingCode.expiresAt) <= now.getTime()) return false;
|
|
26
|
+
|
|
27
|
+
const expected = Buffer.from(pairingCode.codeHash);
|
|
28
|
+
const actual = Buffer.from(hashPairingCode(rawCode));
|
|
29
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { randomBytes, scrypt, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
export type IssuedDeviceToken = {
|
|
5
|
+
rawToken: string;
|
|
6
|
+
tokenHash: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function issueDeviceToken(): IssuedDeviceToken {
|
|
10
|
+
const rawToken = `prd_${randomBytes(32).toString("base64url")}`;
|
|
11
|
+
const salt = randomBytes(16).toString("base64url");
|
|
12
|
+
const derived = scryptSync(rawToken, salt, 32).toString("base64url");
|
|
13
|
+
return { rawToken, tokenHash: `scrypt:${salt}:${derived}` };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function hashDeviceToken(rawToken: string, salt = randomBytes(16).toString("base64url")): Promise<string> {
|
|
17
|
+
const derive = promisify(scrypt) as (password: string, salt: string, keylen: number) => Promise<Buffer>;
|
|
18
|
+
const derived = await derive(rawToken, salt, 32);
|
|
19
|
+
return `scrypt:${salt}:${derived.toString("base64url")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function verifyDeviceToken(rawToken: string, encodedHash: string): Promise<boolean> {
|
|
23
|
+
const [algorithm, salt, expected] = encodedHash.split(":");
|
|
24
|
+
if (algorithm !== "scrypt" || !salt || !expected) return false;
|
|
25
|
+
|
|
26
|
+
const actual = await hashDeviceToken(rawToken, salt);
|
|
27
|
+
const actualKey = Buffer.from(actual.split(":")[2] ?? "");
|
|
28
|
+
const expectedKey = Buffer.from(expected);
|
|
29
|
+
return actualKey.length === expectedKey.length && timingSafeEqual(actualKey, expectedKey);
|
|
30
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { createJiti } = require("jiti");
|
|
3
|
+
|
|
4
|
+
const jiti = createJiti(__filename);
|
|
5
|
+
const { main } = jiti("./cli.ts");
|
|
6
|
+
|
|
7
|
+
main(process.argv.slice(2)).then(
|
|
8
|
+
(code) => {
|
|
9
|
+
process.exitCode = code;
|
|
10
|
+
},
|
|
11
|
+
(error) => {
|
|
12
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
},
|
|
15
|
+
);
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createActiveSessionRegistry } from "./active-session-registry.js";
|
|
6
|
+
import { defaultDaemonConfig, loadDaemonConfig, saveDaemonConfig } from "./config.js";
|
|
7
|
+
import { acquireDaemonLock, type DaemonLock } from "./lock.js";
|
|
8
|
+
import type { DaemonStore } from "./persistence/daemon-store.js";
|
|
9
|
+
import { ensureDaemonStateDir, getDaemonStateDir } from "./paths.js";
|
|
10
|
+
import { buildPairingLink } from "./pairing-link.js";
|
|
11
|
+
import { formatPairingDisplay } from "./qr.js";
|
|
12
|
+
import { startDaemonServer, type DaemonServer, type StartServerOptions } from "./server/http.js";
|
|
13
|
+
import type { DaemonConfig } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export type CliDependencies = {
|
|
16
|
+
getStateDir?: (options?: { env?: NodeJS.ProcessEnv }) => string;
|
|
17
|
+
ensureStateDir?: (stateDir: string) => Promise<void>;
|
|
18
|
+
loadConfig?: (stateDir: string) => Promise<DaemonConfig>;
|
|
19
|
+
saveConfig?: (stateDir: string, config: DaemonConfig) => Promise<void>;
|
|
20
|
+
startServer?: (options: StartServerOptions) => Promise<DaemonServer>;
|
|
21
|
+
openStore?: (stateDir: string) => DaemonStore | Promise<DaemonStore>;
|
|
22
|
+
acquireLock?: (stateDir: string) => Promise<DaemonLock | undefined>;
|
|
23
|
+
waitForShutdown?: () => Promise<void>;
|
|
24
|
+
readTextFile?: (path: string) => Promise<string>;
|
|
25
|
+
removeFile?: (path: string) => Promise<void>;
|
|
26
|
+
isProcessRunning?: (pid: number) => boolean;
|
|
27
|
+
sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
|
|
28
|
+
getPiVersion?: () => string | Promise<string>;
|
|
29
|
+
writeLine?: (line: string) => void;
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function main(argv = process.argv.slice(2), deps: CliDependencies = {}): Promise<number> {
|
|
34
|
+
const command = argv[0];
|
|
35
|
+
const env = deps.env ?? process.env;
|
|
36
|
+
if (command === "status") return statusCommand(argv.slice(1), deps, env);
|
|
37
|
+
if (command === "stop") return stopCommand(argv.slice(1), deps, env);
|
|
38
|
+
if (command === "pair") return pairCommand(argv.slice(1), deps, env);
|
|
39
|
+
if (command !== "start") {
|
|
40
|
+
(deps.writeLine ?? console.log)("Usage: pi-remote-control start|stop|status|pair [options]");
|
|
41
|
+
return command === "--help" || command === "-h" ? 0 : 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parsed = parseStartArgs(argv.slice(1));
|
|
45
|
+
const stateDir = parsed.stateDir ?? (deps.getStateDir ?? getDaemonStateDir)({ env });
|
|
46
|
+
await (deps.ensureStateDir ?? ensureDaemonStateDir)(stateDir);
|
|
47
|
+
|
|
48
|
+
const lock = await (deps.acquireLock ?? acquireDaemonLock)(stateDir);
|
|
49
|
+
if (!lock) {
|
|
50
|
+
(deps.writeLine ?? console.log)("pi-remote-control is already running");
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const loadedConfig = await (deps.loadConfig ?? loadDaemonConfig)(stateDir).catch(async (error) => {
|
|
55
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
56
|
+
const config = defaultDaemonConfig();
|
|
57
|
+
await (deps.saveConfig ?? saveDaemonConfig)(stateDir, config);
|
|
58
|
+
return config;
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
await (deps.saveConfig ?? saveDaemonConfig)(stateDir, loadedConfig);
|
|
63
|
+
const config = { ...loadedConfig, bindAddress: parsed.bindAddress ?? loadedConfig.bindAddress };
|
|
64
|
+
const devToken = env.PI_REMOTE_CONTROL_DEV_TOKEN;
|
|
65
|
+
const store = await (deps.openStore ?? openStore)(stateDir);
|
|
66
|
+
const piVersion = await (deps.getPiVersion ?? readInstalledPiVersion)();
|
|
67
|
+
const server = await (deps.startServer ?? startDaemonServer)({
|
|
68
|
+
stateDir,
|
|
69
|
+
config,
|
|
70
|
+
piVersion,
|
|
71
|
+
authenticateToken: devToken ? (token) => token === devToken || store.authenticateToken(token) : (token) => store.authenticateToken(token),
|
|
72
|
+
activeSessions: createActiveSessionRegistry({ isProcessRunning }),
|
|
73
|
+
pairService: {
|
|
74
|
+
createPairingCode: () => store.createPairingCode(new Date(), 5 * 60_000),
|
|
75
|
+
claimPairingCode: async (request) => {
|
|
76
|
+
const claimed = await store.claimPairingCode(request.pairCode, request.deviceName, new Date());
|
|
77
|
+
if (!claimed) throw new Error("Invalid or expired pairing code");
|
|
78
|
+
return claimed;
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
(deps.writeLine ?? console.log)(`pi-remote-control listening on http://${server.address}`);
|
|
83
|
+
if (devToken) (deps.writeLine ?? console.log)("dev token authentication is enabled");
|
|
84
|
+
|
|
85
|
+
await (deps.waitForShutdown ?? waitForInterrupt)();
|
|
86
|
+
await server.close();
|
|
87
|
+
store.close();
|
|
88
|
+
await lock.release();
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function pairCommand(args: string[], deps: CliDependencies, env: NodeJS.ProcessEnv): Promise<number> {
|
|
93
|
+
const writeLine = deps.writeLine ?? console.log;
|
|
94
|
+
const stateDir = parseStateDirArg(args) ?? (deps.getStateDir ?? getDaemonStateDir)({ env });
|
|
95
|
+
await (deps.ensureStateDir ?? ensureDaemonStateDir)(stateDir);
|
|
96
|
+
const config = await (deps.loadConfig ?? loadDaemonConfig)(stateDir);
|
|
97
|
+
const advertisedBaseUrl = parseAdvertisedBaseUrlArg(args) ?? env.PI_REMOTE_CONTROL_ADVERTISED_BASE_URL ?? config.advertisedBaseUrl ?? env.PI_REMOTE_CONTROL_URL;
|
|
98
|
+
if (!advertisedBaseUrl) {
|
|
99
|
+
writeLine("advertisedBaseUrl is required for QR pairing.");
|
|
100
|
+
writeLine("Set ~/.pi/remote-control/config.json or PI_REMOTE_CONTROL_ADVERTISED_BASE_URL to an iOS-reachable URL.");
|
|
101
|
+
writeLine("Example: https://macbook.tailnet.ts.net:17373");
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const store = await (deps.openStore ?? openStore)(stateDir);
|
|
106
|
+
try {
|
|
107
|
+
const result = await store.createPairingCode(new Date(), 5 * 60_000);
|
|
108
|
+
const pairingLink = buildPairingLink({
|
|
109
|
+
advertisedBaseUrl,
|
|
110
|
+
pairCode: result.pairCode,
|
|
111
|
+
expiresAt: result.expiresAt,
|
|
112
|
+
});
|
|
113
|
+
for (const line of formatPairingDisplay({ expiresAt: result.expiresAt, pairingLink })) {
|
|
114
|
+
writeLine(line);
|
|
115
|
+
}
|
|
116
|
+
return 0;
|
|
117
|
+
} finally {
|
|
118
|
+
store.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function stopCommand(args: string[], deps: CliDependencies, env: NodeJS.ProcessEnv): Promise<number> {
|
|
123
|
+
const stateDir = parseStateDirArg(args) ?? (deps.getStateDir ?? getDaemonStateDir)({ env });
|
|
124
|
+
const writeLine = deps.writeLine ?? console.log;
|
|
125
|
+
const readTextFile = deps.readTextFile ?? ((path: string) => readFile(path, "utf8"));
|
|
126
|
+
|
|
127
|
+
const lockFile = join(stateDir, "daemon.lock");
|
|
128
|
+
try {
|
|
129
|
+
const pid = Number.parseInt((await readTextFile(lockFile)).trim(), 10);
|
|
130
|
+
try {
|
|
131
|
+
(deps.sendSignal ?? process.kill)(pid, "SIGTERM");
|
|
132
|
+
writeLine(`pi-remote-control stop requested (pid ${pid})`);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ESRCH")) throw error;
|
|
135
|
+
writeLine(`pi-remote-control stale lock removed (pid ${pid})`);
|
|
136
|
+
}
|
|
137
|
+
await (deps.removeFile ?? ((path: string) => rm(path, { force: true })))(lockFile);
|
|
138
|
+
return 0;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
141
|
+
writeLine("pi-remote-control is not running");
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function statusCommand(args: string[], deps: CliDependencies, env: NodeJS.ProcessEnv): Promise<number> {
|
|
149
|
+
const stateDir = parseStateDirArg(args) ?? (deps.getStateDir ?? getDaemonStateDir)({ env });
|
|
150
|
+
const writeLine = deps.writeLine ?? console.log;
|
|
151
|
+
const readTextFile = deps.readTextFile ?? ((path: string) => readFile(path, "utf8"));
|
|
152
|
+
|
|
153
|
+
const lockFile = join(stateDir, "daemon.lock");
|
|
154
|
+
try {
|
|
155
|
+
const pid = Number.parseInt((await readTextFile(lockFile)).trim(), 10);
|
|
156
|
+
const running = (deps.isProcessRunning ?? isProcessRunning)(pid);
|
|
157
|
+
if (running) {
|
|
158
|
+
writeLine(`pi-remote-control is running (pid ${pid})`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await (deps.removeFile ?? ((path: string) => rm(path, { force: true })))(lockFile);
|
|
163
|
+
writeLine(`pi-remote-control is stopped (stale lock removed, pid ${pid})`);
|
|
164
|
+
return 1;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
167
|
+
writeLine("pi-remote-control is stopped");
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseAdvertisedBaseUrlArg(args: string[]): string | undefined {
|
|
175
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
176
|
+
if (args[index] === "--advertised-base-url") return args[index + 1];
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function openStore(stateDir: string): Promise<DaemonStore> {
|
|
182
|
+
const { openDaemonStore } = await import("./persistence/daemon-store.js");
|
|
183
|
+
return openDaemonStore(stateDir);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function readInstalledPiVersion(): Promise<string> {
|
|
187
|
+
try {
|
|
188
|
+
let directory = dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")));
|
|
189
|
+
while (true) {
|
|
190
|
+
const packageJsonPath = join(directory, "package.json");
|
|
191
|
+
try {
|
|
192
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { name?: unknown; version?: unknown };
|
|
193
|
+
if (packageJson.name === "@earendil-works/pi-coding-agent" && typeof packageJson.version === "string") return packageJson.version;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (!isNodeErrorCode(error, "ENOENT")) return "unknown";
|
|
196
|
+
}
|
|
197
|
+
const parent = dirname(directory);
|
|
198
|
+
if (parent === directory) return "unknown";
|
|
199
|
+
directory = parent;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
return "unknown";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseStateDirArg(args: string[]): string | undefined {
|
|
207
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
208
|
+
if (args[index] === "--state-dir") return args[index + 1];
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isProcessRunning(pid: number): boolean {
|
|
214
|
+
try {
|
|
215
|
+
process.kill(pid, 0);
|
|
216
|
+
return true;
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isNodeErrorCode(error: unknown, code: string): boolean {
|
|
223
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseStartArgs(args: string[]): { stateDir?: string; bindAddress?: string } {
|
|
227
|
+
const parsed: { stateDir?: string; bindAddress?: string } = {};
|
|
228
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
229
|
+
const arg = args[index];
|
|
230
|
+
if (arg === "--state-dir") parsed.stateDir = args[++index];
|
|
231
|
+
else if (arg === "--bind") parsed.bindAddress = args[++index];
|
|
232
|
+
else throw new Error(`Unknown start option: ${arg}`);
|
|
233
|
+
}
|
|
234
|
+
return parsed;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function waitForInterrupt(): Promise<void> {
|
|
238
|
+
await new Promise<void>((resolve) => {
|
|
239
|
+
process.once("SIGINT", resolve);
|
|
240
|
+
process.once("SIGTERM", resolve);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
245
|
+
main().then(
|
|
246
|
+
(code) => {
|
|
247
|
+
process.exitCode = code;
|
|
248
|
+
},
|
|
249
|
+
(error) => {
|
|
250
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
251
|
+
process.exitCode = 1;
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { DaemonConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_BIND_ADDRESS = "127.0.0.1:17373";
|
|
6
|
+
|
|
7
|
+
export function defaultDaemonConfig(): DaemonConfig {
|
|
8
|
+
return { bindAddress: DEFAULT_BIND_ADDRESS };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function loadDaemonConfig(stateDir: string): Promise<DaemonConfig> {
|
|
12
|
+
try {
|
|
13
|
+
const content = await readFile(join(stateDir, "config.json"), "utf8");
|
|
14
|
+
return JSON.parse(content) as DaemonConfig;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
17
|
+
return defaultDaemonConfig();
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function saveDaemonConfig(stateDir: string, config: DaemonConfig): Promise<void> {
|
|
24
|
+
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
25
|
+
await writeFile(join(stateDir, "config.json"), content, { mode: 0o600 });
|
|
26
|
+
}
|