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,470 @@
1
+ # Interfaces
2
+
3
+ ## iOS app ↔ daemon
4
+
5
+ Remote endpoints require `Authorization: Bearer <device-token>` unless explicitly noted. Request and response bodies are JSON. Session streaming uses WebSocket JSON messages.
6
+
7
+ ### Pairing
8
+
9
+ Pair-code creation is not available through the remote iOS API. Codes are created only from the Pi TUI with `/remote-control-pair`.
10
+
11
+ `/remote-control-pair` displays a QR code that encodes a pairing link and prints the expiration time. The numeric pair code and raw pairing link are not printed as separate TUI text lines. `baseUrl` comes from daemon config `advertisedBaseUrl` and must be reachable from iOS.
12
+
13
+ `POST /v1/pair/claim` is unauthenticated because the short-lived pair code is the bootstrap proof.
14
+
15
+ Request:
16
+
17
+ ```json
18
+ {
19
+ "pairCode": "123456",
20
+ "deviceName": "Zerray iPhone"
21
+ }
22
+ ```
23
+
24
+ Response:
25
+
26
+ ```json
27
+ {
28
+ "deviceId": "dev_...",
29
+ "token": "...",
30
+ "daemonName": "macbook-pro"
31
+ }
32
+ ```
33
+
34
+ ### Health
35
+
36
+ `GET /v1/health`
37
+
38
+ Response:
39
+
40
+ ```json
41
+ {
42
+ "status": "ok",
43
+ "daemonVersion": "1.0.0"
44
+ }
45
+ ```
46
+
47
+ ### Projects
48
+
49
+ `GET /v1/projects`
50
+
51
+ Returns projects derived from active TUI sessions that enabled remote control.
52
+
53
+ Response:
54
+
55
+ ```json
56
+ {
57
+ "projects": [
58
+ {
59
+ "id": "proj_...",
60
+ "name": "pi-ios",
61
+ "path": "/Users/zerray/gitclone/pi-ios"
62
+ }
63
+ ]
64
+ }
65
+ ```
66
+
67
+ ### Sessions
68
+
69
+ `GET /v1/projects/{projectId}/sessions`
70
+
71
+ Returns only active remote-control TUI sessions for the project.
72
+
73
+ Response:
74
+
75
+ ```json
76
+ {
77
+ "sessions": [
78
+ {
79
+ "id": "sess_...",
80
+ "piSessionId": "019e0a73-...",
81
+ "projectId": "proj_...",
82
+ "name": "Refactor auth module",
83
+ "path": "/Users/zerray/.pi/agent/sessions/...jsonl",
84
+ "updatedAt": "2026-05-09T09:47:00.000Z",
85
+ "messageCount": 42,
86
+ "isActive": true
87
+ }
88
+ ]
89
+ }
90
+ ```
91
+
92
+ `POST /v1/projects/{projectId}/sessions` returns `405 method_not_allowed`. New sessions are created in the Pi TUI, then made visible by running `/remote-control`.
93
+
94
+ `GET /v1/sessions/{sessionId}?messageLimit={limit}`
95
+
96
+ Returns the daemon's current state for an active remote-control TUI session with a bounded recent transcript window read from the session's Pi JSONL `sessionFile`. If `messageLimit` is absent, the daemon uses its default recent-message limit. The daemon enforces a maximum page size. Invalid non-positive limits return `400` with `invalid_limit`.
97
+
98
+ Response:
99
+
100
+ ```json
101
+ {
102
+ "session": {
103
+ "id": "sess_...",
104
+ "piSessionId": "019e0a73-...",
105
+ "projectId": "proj_...",
106
+ "name": "Refactor auth module",
107
+ "path": "/Users/zerray/.pi/agent/sessions/...jsonl",
108
+ "updatedAt": "2026-05-09T09:47:00.000Z",
109
+ "messageCount": 4200,
110
+ "isActive": true
111
+ },
112
+ "messages": [
113
+ {
114
+ "id": "msg_...",
115
+ "role": "assistant",
116
+ "content": [
117
+ { "type": "thinking", "thinking": "Checking the project structure..." },
118
+ { "type": "toolCall", "id": "call_...", "name": "bash", "arguments": { "command": "ls" } },
119
+ { "type": "text", "text": "Recent answer text" }
120
+ ],
121
+ "text": "Recent answer text",
122
+ "createdAt": "2026-05-09T09:47:00.000Z",
123
+ "isStreaming": false
124
+ }
125
+ ],
126
+ "olderMessagesCursor": "opaque-cursor-or-null",
127
+ "hasOlderMessages": true,
128
+ "tools": [],
129
+ "isStreaming": false,
130
+ "pendingMessageCount": 0,
131
+ "runtimeStatus": {
132
+ "model": {
133
+ "provider": "anthropic",
134
+ "id": "claude-sonnet-4-5",
135
+ "name": "Claude Sonnet 4.5",
136
+ "contextWindow": 200000,
137
+ "maxTokens": 8192,
138
+ "reasoning": true
139
+ },
140
+ "thinkingLevel": "medium",
141
+ "usage": {
142
+ "input": 12000,
143
+ "output": 3000,
144
+ "cacheRead": 50000,
145
+ "cacheWrite": 10000,
146
+ "cost": { "input": 0.036, "output": 0.045, "cacheRead": 0.015, "cacheWrite": 0.0375, "total": 0.1335 }
147
+ },
148
+ "context": { "tokens": 65000, "contextWindow": 200000, "percent": 32.5 },
149
+ "updatedAt": "2026-05-09T09:47:00.000Z"
150
+ }
151
+ }
152
+ ```
153
+
154
+ `messages` are ordered oldest-to-newest within the returned window and represent transcript data persisted in the Pi session file at request time. Each item uses the same `TranscriptMessage` shape as live stream message events. `olderMessagesCursor` is `null` when there are no older messages. When present, the cursor is generated from the oldest returned message's `createdAt` timestamp. The cursor is encoded by the daemon and treated as opaque by clients.
155
+
156
+ `GET /v1/sessions/{sessionId}/messages?before={cursor}&limit={limit}`
157
+
158
+ Returns the next older transcript page from the session's Pi JSONL `sessionFile` before `cursor`. The `before` value must be a cursor previously returned by the daemon. It represents an exclusive timestamp upper bound, so returned messages satisfy `createdAt < cursor.createdAt`. Invalid cursors return `400` with `invalid_cursor`. Invalid non-positive limits return `400` with `invalid_limit`.
159
+
160
+ Response:
161
+
162
+ ```json
163
+ {
164
+ "messages": [
165
+ {
166
+ "id": "msg_older",
167
+ "role": "user",
168
+ "content": [{ "type": "text", "text": "Older prompt text" }],
169
+ "text": "Older prompt text",
170
+ "createdAt": "2026-05-09T09:30:00.000Z",
171
+ "isStreaming": false
172
+ }
173
+ ],
174
+ "olderMessagesCursor": "next-older-cursor-or-null",
175
+ "hasOlderMessages": true
176
+ }
177
+ ```
178
+
179
+ `messages` are ordered oldest-to-newest within the returned page so the app can prepend the page while preserving transcript order. The daemon must not include duplicate message IDs within a single response. Pi Relay must still de-duplicate merged pages and live stream updates by message `id` because page requests and live events can overlap.
180
+
181
+ Public transcript messages use this shape:
182
+
183
+ ```ts
184
+ type TranscriptMessage = {
185
+ id: string;
186
+ role: "user" | "assistant" | "toolResult" | "system";
187
+ content: Array<
188
+ | { type: "text"; text: string; truncated?: boolean; originalBytes?: number }
189
+ | { type: "thinking"; thinking: string; truncated?: boolean; originalBytes?: number }
190
+ | { type: "toolCall"; id: string; name: string; arguments: unknown; argumentsTruncated?: boolean; argumentsOriginalBytes?: number }
191
+ | { type: "image"; data: string; mimeType: string; truncated?: boolean; originalBytes?: number }
192
+ >;
193
+ text: string;
194
+ textTruncated?: boolean;
195
+ textOriginalBytes?: number;
196
+ createdAt: string;
197
+ toolCallId?: string;
198
+ toolName?: string;
199
+ isError?: boolean;
200
+ isStreaming: boolean;
201
+ };
202
+ ```
203
+
204
+ `content` preserves Pi message blocks. `text` is a simple display summary. Tool-result messages include `toolCallId`, `toolName`, and `isError` when available. Truncation metadata is present only when the daemon intentionally sends a preview.
205
+
206
+ Runtime status snapshots use this shape and may be `null` when the TUI extension has not reported one yet:
207
+
208
+ ```ts
209
+ type RuntimeStatus = {
210
+ model: null | {
211
+ provider: string;
212
+ id: string;
213
+ name?: string;
214
+ contextWindow?: number;
215
+ maxTokens?: number;
216
+ reasoning?: boolean;
217
+ };
218
+ thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | null;
219
+ usage: {
220
+ input: number;
221
+ output: number;
222
+ cacheRead: number;
223
+ cacheWrite: number;
224
+ cost: {
225
+ input: number;
226
+ output: number;
227
+ cacheRead: number;
228
+ cacheWrite: number;
229
+ total: number;
230
+ };
231
+ };
232
+ context: null | {
233
+ tokens: number | null;
234
+ contextWindow: number;
235
+ percent: number | null;
236
+ };
237
+ updatedAt: string;
238
+ };
239
+ ```
240
+
241
+ `POST /v1/sessions/{sessionId}/prompt`
242
+
243
+ Forwards a prompt to the TUI extension that owns the active session.
244
+
245
+ Request:
246
+
247
+ ```json
248
+ {
249
+ "text": "请解释这个项目结构",
250
+ "streamingBehavior": null
251
+ }
252
+ ```
253
+
254
+ `streamingBehavior` is `null`, `"steer"`, or `"followUp"`. `null` sends immediately when the TUI is idle; if the TUI is already busy, the extension delivers it as `"followUp"` so Pi does not reject the remote prompt for missing streaming delivery mode.
255
+
256
+ Response:
257
+
258
+ ```json
259
+ {
260
+ "accepted": true
261
+ }
262
+ ```
263
+
264
+ If the session has no active TUI owner, the daemon returns `409`:
265
+
266
+ ```json
267
+ {
268
+ "error": "session_not_active"
269
+ }
270
+ ```
271
+
272
+ `POST /v1/sessions/{sessionId}/abort`
273
+
274
+ Forwards abort to the TUI extension that owns the active session.
275
+
276
+ Response:
277
+
278
+ ```json
279
+ {
280
+ "aborted": true
281
+ }
282
+ ```
283
+
284
+ `POST /v1/sessions/{sessionId}/compact`
285
+
286
+ Forwards a compact request to the TUI extension that owns the active session. This is the remote-control equivalent of running `/compact` in the Pi TUI. It is an explicit allowlisted action, not generic slash-command passthrough. The HTTP response only confirms that the daemon accepted the command for delivery; the asynchronous compaction outcome is reported later on the session WebSocket as `remote_compact_result`.
287
+
288
+ Response:
289
+
290
+ ```json
291
+ {
292
+ "accepted": true,
293
+ "requestId": "req_..."
294
+ }
295
+ ```
296
+
297
+ If the session has no active TUI owner, the daemon returns `409`:
298
+
299
+ ```json
300
+ {
301
+ "error": "session_not_active"
302
+ }
303
+ ```
304
+
305
+ ### Session stream
306
+
307
+ `GET /v1/sessions/{sessionId}/stream` upgrades to WebSocket.
308
+
309
+ Server messages are daemon-normalized transcript stream events. The stream sends a bounded initial `session_state`, turn lifecycle events, live `TranscriptMessage` lifecycle events, normalized tool execution events, `runtime_status`, `remote_compact_result`, `session_closed`, and errors. It must not send raw Pi TUI extension events or full historical transcript payloads. Full or older persisted history is loaded only through the HTTP session snapshot and transcript-page endpoints. In-progress events that are not yet persisted may be visible only on the stream.
310
+
311
+ The initial `session_state` contains at most 20 recent messages regardless of the HTTP transcript default. Before the initial `session_state` is sent, oversized string payloads inside those messages are truncated to their first 10 KiB of UTF-8 data and marked with truncation metadata. This preview truncation applies to initial WebSocket state only; HTTP transcript endpoints keep their requested transcript windows, and live incremental events are not changed by this rule.
312
+
313
+ Initial state:
314
+
315
+ ```json
316
+ {
317
+ "type": "session_state",
318
+ "state": {
319
+ "session": { "id": "sess_...", "isActive": true },
320
+ "messages": [
321
+ {
322
+ "id": "msg_...",
323
+ "role": "toolResult",
324
+ "content": [{ "type": "text", "text": "first 10 KiB preview...", "truncated": true, "originalBytes": 1048576 }],
325
+ "text": "first 10 KiB preview...",
326
+ "textTruncated": true,
327
+ "textOriginalBytes": 1048576,
328
+ "createdAt": "2026-05-09T09:47:00.000Z",
329
+ "toolCallId": "call_...",
330
+ "toolName": "bash",
331
+ "isStreaming": false
332
+ }
333
+ ],
334
+ "olderMessagesCursor": null,
335
+ "hasOlderMessages": false,
336
+ "tools": [],
337
+ "isStreaming": false,
338
+ "pendingMessageCount": 0,
339
+ "runtimeStatus": null
340
+ }
341
+ }
342
+ ```
343
+
344
+ Turn lifecycle events:
345
+
346
+ ```json
347
+ { "type": "turn_start", "turnIndex": 0, "createdAt": "2026-05-09T09:47:00.000Z" }
348
+ { "type": "turn_end", "turnIndex": 0 }
349
+ ```
350
+
351
+ `turn_start` marks an active model/tool turn. `turn_end` marks that the turn is complete. Transcript content is still delivered through message and tool events.
352
+
353
+ Message lifecycle events. `transcript_message_start` is emitted for assistant streaming messages; non-streaming user messages are emitted once as `transcript_message_end` to avoid duplicate client display.
354
+
355
+ ```json
356
+ { "type": "transcript_message_start", "message": { "id": "msg_...", "role": "assistant", "content": [], "text": "", "createdAt": "2026-05-09T09:47:00.000Z", "isStreaming": true } }
357
+ { "type": "transcript_message_patch", "messageId": "msg_...", "contentIndex": 0, "patch": { "type": "thinking_delta", "delta": "Checking..." } }
358
+ { "type": "transcript_message_patch", "messageId": "msg_...", "contentIndex": 1, "patch": { "type": "toolCall", "toolCall": { "type": "toolCall", "id": "call_...", "name": "bash", "arguments": { "command": "ls" } } } }
359
+ { "type": "transcript_message_patch", "messageId": "msg_...", "contentIndex": 2, "patch": { "type": "text_delta", "delta": "Done." } }
360
+ { "type": "transcript_message_end", "message": { "id": "msg_...", "role": "assistant", "content": [{ "type": "text", "text": "Done." }], "text": "Done.", "createdAt": "2026-05-09T09:47:02.000Z", "isStreaming": false } }
361
+ ```
362
+
363
+ Tool execution events:
364
+
365
+ ```json
366
+ { "type": "tool_execution_start", "toolCallId": "call_...", "toolName": "bash", "args": { "command": "ls" } }
367
+ { "type": "tool_execution_update", "toolCallId": "call_...", "toolName": "bash", "partialResult": { "content": [{ "type": "text", "text": "partial output" }] } }
368
+ { "type": "tool_execution_end", "toolCallId": "call_...", "toolName": "bash", "result": { "content": [{ "type": "text", "text": "final output" }] }, "isError": false }
369
+ ```
370
+
371
+ Runtime status events:
372
+
373
+ ```json
374
+ { "type": "runtime_status", "status": { "model": { "provider": "anthropic", "id": "claude-sonnet-4-5", "contextWindow": 200000 }, "thinkingLevel": "medium", "usage": { "input": 12000, "output": 3000, "cacheRead": 50000, "cacheWrite": 10000, "cost": { "input": 0.036, "output": 0.045, "cacheRead": 0.015, "cacheWrite": 0.0375, "total": 0.1335 } }, "context": { "tokens": 65000, "contextWindow": 200000, "percent": 32.5 }, "updatedAt": "2026-05-09T09:47:00.000Z" } }
375
+ ```
376
+
377
+ The daemon sends `runtime_status` when the owning TUI extension reports a changed runtime-status snapshot. Clients should treat the event as replacing the previous runtime status for that session.
378
+
379
+ Remote compact result events:
380
+
381
+ ```json
382
+ { "type": "remote_compact_result", "requestId": "req_...", "ok": true, "summary": "Conversation summary...", "firstKeptEntryId": "entry_...", "tokensBefore": 12345 }
383
+ { "type": "remote_compact_result", "requestId": "req_...", "ok": false, "message": "Compaction failed: ..." }
384
+ ```
385
+
386
+ The daemon sends `remote_compact_result` when the owning TUI extension reports completion or failure for a prior `remote_compact` command. Clients correlate the result with the `requestId` returned by `POST /v1/sessions/{sessionId}/compact`. Compact results are live stream events and are not included in HTTP session snapshots.
387
+
388
+ ## Pi TUI extension ↔ daemon
389
+
390
+ The TUI control interface is package-internal and used by the Pi extension, not by iOS clients. Loopback TUI requests are accepted without a bearer token; non-loopback callers must provide a valid bearer token. The extension normally calls `127.0.0.1:<configured-port>` even when iOS uses `advertisedBaseUrl` over Tailscale.
391
+
392
+ ### Pair code creation
393
+
394
+ `/remote-control-pair` asks the daemon to create one short-lived pair code and displays it in the TUI as a QR code plus expiration time.
395
+
396
+ Response payload:
397
+
398
+ ```json
399
+ {
400
+ "pairCode": "123456",
401
+ "expiresAt": "2026-05-09T09:52:00.000Z",
402
+ "advertisedBaseUrl": "https://macbook.tailnet.ts.net:17373",
403
+ "pairingLink": "pi-remote://pair?baseUrl=https%3A%2F%2Fmacbook.tailnet.ts.net%3A17373&code=123456&expiresAt=2026-05-09T09%3A52%3A00.000Z"
404
+ }
405
+ ```
406
+
407
+ ### Session registration
408
+
409
+ When `/remote-control` enables a session, the extension registers the current TUI session:
410
+
411
+ ```json
412
+ {
413
+ "type": "register_session",
414
+ "session": {
415
+ "id": "sess_...",
416
+ "piSessionId": "019e0a73-...",
417
+ "sessionFile": "/Users/zerray/.pi/agent/sessions/...jsonl",
418
+ "name": "Refactor auth module",
419
+ "project": {
420
+ "id": "proj_...",
421
+ "name": "pi-ios",
422
+ "path": "/Users/zerray/gitclone/pi-ios"
423
+ },
424
+ "pid": 12345,
425
+ "messageCount": 42,
426
+ "isStreaming": false,
427
+ "runtimeStatus": {
428
+ "model": { "provider": "anthropic", "id": "claude-sonnet-4-5", "contextWindow": 200000 },
429
+ "thinkingLevel": "medium",
430
+ "usage": { "input": 12000, "output": 3000, "cacheRead": 50000, "cacheWrite": 10000, "cost": { "input": 0.036, "output": 0.045, "cacheRead": 0.015, "cacheWrite": 0.0375, "total": 0.1335 } },
431
+ "context": { "tokens": 65000, "contextWindow": 200000, "percent": 32.5 },
432
+ "updatedAt": "2026-05-09T09:47:00.000Z"
433
+ }
434
+ }
435
+ }
436
+ ```
437
+
438
+ `GET /v1/tui/sessions/{sessionId}/commands` also acts as the TUI heartbeat while remote control is active. The daemon removes active-session registrations when the owning TUI PID exits or when heartbeats stop, then broadcasts `session_closed` to iOS subscribers. If this heartbeat returns `404 { "error": "session_not_found" }` while the TUI extension still has local remote-control state active, the extension re-registers the current session by posting the same registration payload to `/v1/tui/sessions`. If re-registration fails, the extension clears local active state and notifies the user. Entering or resuming a TUI session does not automatically enable remote control; the user must run `/remote-control` each time.
439
+
440
+ ### TUI-to-daemon events
441
+
442
+ While active, the extension forwards Pi extension events and runtime-status snapshots to the daemon over the package-internal control interface. Raw Pi event payloads are internal inputs only. The daemon normalizes them before sending any WebSocket messages to iOS.
443
+
444
+ Accepted internal event kinds include turn lifecycle, message lifecycle, assistant message updates, tool execution lifecycle, agent lifecycle, queue, status, and session lifecycle events emitted or computed by the Pi extension. Runtime-status snapshots are posted as `{ "type": "runtime_status", "status": RuntimeStatus }`; the daemon stores the snapshot and broadcasts the public `runtime_status` WebSocket event when it changes. Remote compact results are posted as `{ "type": "remote_compact_result", "requestId": "req_...", "ok": true, "summary": "...", "firstKeptEntryId": "entry_...", "tokensBefore": 12345 }` or `{ "type": "remote_compact_result", "requestId": "req_...", "ok": false, "message": "..." }`; the daemon broadcasts the public `remote_compact_result` WebSocket event without storing it durably.
445
+
446
+ ### Daemon-to-TUI commands
447
+
448
+ The daemon forwards iOS requests to the owning TUI extension:
449
+
450
+ ```json
451
+ { "type": "remote_prompt", "requestId": "req_...", "text": "...", "streamingBehavior": null }
452
+ { "type": "remote_abort", "requestId": "req_..." }
453
+ { "type": "remote_compact", "requestId": "req_..." }
454
+ ```
455
+
456
+ Prompt and abort command acknowledgements are not part of the current MVP protocol. Compact completion is reported asynchronously through `remote_compact_result`.
457
+
458
+ ## Pi integration boundary
459
+
460
+ The daemon keeps Pi-specific transport details inside the package. Pi SDK/RPC is not used by the daemon to operate sessions in the MVP; those calls are made by the live TUI process through the extension API.
461
+
462
+ | Action | Owner |
463
+ | --- | --- |
464
+ | Create pair code | TUI command `/remote-control-pair` asks daemon locally. |
465
+ | Enable remote visibility | TUI command `/remote-control`. |
466
+ | List remote sessions | Daemon active TUI session registry. |
467
+ | Prompt | iOS → daemon → owning TUI extension → Pi extension API. |
468
+ | Abort | iOS → daemon → owning TUI extension → Pi extension API. |
469
+ | Compact | iOS → daemon → owning TUI extension → Pi extension API `ctx.compact()` → TUI-reported `remote_compact_result` → iOS WebSocket. |
470
+ | Stream events | Raw Pi event/status snapshot or remote-action result → TUI extension → daemon normalization/storage/forwarding → iOS WebSocket normalized transcript, status, or result event. |
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-remote-control",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Authenticated remote control for Pi sessions.",
6
+ "keywords": ["pi-package"],
7
+ "bin": {
8
+ "pi-remote-control": "./src/cli-runner.cjs"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json",
12
+ "lint": "tsc -p tsconfig.json --noEmit",
13
+ "test": "vitest run"
14
+ },
15
+ "dependencies": {
16
+ "@earendil-works/pi-coding-agent": "*",
17
+ "@types/qrcode-terminal": "^0.12.2",
18
+ "jiti": "^2.6.1",
19
+ "qrcode-terminal": "^0.12.0",
20
+ "ws": "^8.20.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^24.0.0",
24
+ "@types/ws": "^8.18.1",
25
+ "typescript": "^5.9.0",
26
+ "vitest": "^4.0.0"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": "*",
30
+ "typebox": "*"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./src/extension/index.ts"
35
+ ]
36
+ }
37
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ BASE_URL="${BASE_URL:-http://127.0.0.1:17373}"
5
+ TOKEN="${TOKEN:-${PI_REMOTE_CONTROL_DEV_TOKEN:-}}"
6
+ PROJECT_ID="${PROJECT_ID:-}"
7
+ SESSION_ID="${SESSION_ID:-}"
8
+ PAIR_CODE="${PAIR_CODE:-}"
9
+ DEVICE_NAME="${DEVICE_NAME:-Smoke Test Device}"
10
+ PROMPT="${PROMPT:-Hello from http-smoke-test.sh}"
11
+
12
+ json() {
13
+ if command -v jq >/dev/null 2>&1; then jq .; else cat; fi
14
+ }
15
+
16
+ request() {
17
+ local method="$1"
18
+ local path="$2"
19
+ local body="${3:-}"
20
+ shift 3 || true
21
+
22
+ echo
23
+ echo "### ${method} ${path}"
24
+ if [[ -n "${body}" ]]; then
25
+ curl -sS -X "${method}" "${BASE_URL}${path}" \
26
+ -H "content-type: application/json" "$@" \
27
+ --data "${body}" | json
28
+ else
29
+ curl -sS -X "${method}" "${BASE_URL}${path}" "$@" | json
30
+ fi
31
+ }
32
+
33
+ auth_headers=()
34
+ if [[ -n "${TOKEN}" ]]; then
35
+ auth_headers=(-H "authorization: Bearer ${TOKEN}")
36
+ fi
37
+
38
+ request GET /v1/health ""
39
+
40
+ if [[ -n "${PAIR_CODE}" ]]; then
41
+ request POST /v1/pair/claim \
42
+ "$(printf '{"pairCode":"%s","deviceName":"%s"}' "${PAIR_CODE}" "${DEVICE_NAME}")"
43
+ fi
44
+
45
+ if [[ -z "${TOKEN}" ]]; then
46
+ echo
47
+ echo "TOKEN is not set; skipping authenticated endpoints."
48
+ echo "For local dev, start the server with PI_REMOTE_CONTROL_DEV_TOKEN=test-token and run TOKEN=test-token $0"
49
+ exit 0
50
+ fi
51
+
52
+ request POST /v1/pair/code "" "${auth_headers[@]}"
53
+
54
+ request GET /v1/projects "" "${auth_headers[@]}"
55
+
56
+ if [[ -n "${PROJECT_ID}" ]]; then
57
+ request GET "/v1/projects/${PROJECT_ID}/sessions" "" "${auth_headers[@]}"
58
+ request POST "/v1/projects/${PROJECT_ID}/sessions" "" "${auth_headers[@]}"
59
+ else
60
+ echo
61
+ echo "PROJECT_ID is not set; skipping project session endpoints."
62
+ fi
63
+
64
+ if [[ -n "${SESSION_ID}" ]]; then
65
+ request GET "/v1/sessions/${SESSION_ID}" "" "${auth_headers[@]}"
66
+ request POST "/v1/sessions/${SESSION_ID}/prompt" \
67
+ "$(printf '{"text":"%s","streamingBehavior":null}' "${PROMPT}")" \
68
+ "${auth_headers[@]}"
69
+ request POST "/v1/sessions/${SESSION_ID}/abort" "" "${auth_headers[@]}"
70
+
71
+ if [[ -d node_modules/ws ]]; then
72
+ echo
73
+ echo "### WS /v1/sessions/${SESSION_ID}/stream"
74
+ BASE_URL="${BASE_URL}" TOKEN="${TOKEN}" SESSION_ID="${SESSION_ID}" node --input-type=module <<'NODE'
75
+ import WebSocket from "ws";
76
+ const wsUrl = process.env.BASE_URL.replace(/^http:/, "ws:").replace(/^https:/, "wss:") + `/v1/sessions/${process.env.SESSION_ID}/stream`;
77
+ const socket = new WebSocket(wsUrl, { headers: { authorization: `Bearer ${process.env.TOKEN}` } });
78
+ const timeout = setTimeout(() => {
79
+ console.log("(no websocket message within 2s)");
80
+ socket.close();
81
+ }, 2000);
82
+ socket.on("message", (data) => {
83
+ clearTimeout(timeout);
84
+ console.log(String(data));
85
+ socket.close();
86
+ });
87
+ socket.on("error", (error) => {
88
+ clearTimeout(timeout);
89
+ console.error(error.message);
90
+ process.exitCode = 1;
91
+ });
92
+ NODE
93
+ else
94
+ echo
95
+ echo "node_modules/ws is missing; run npm install to test WebSocket stream."
96
+ fi
97
+ else
98
+ echo
99
+ echo "SESSION_ID is not set; skipping session state/prompt/abort/stream endpoints."
100
+ fi