@yefengr/remote-pi 0.7.4 → 0.7.6

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 (165) hide show
  1. package/README.md +81 -586
  2. package/dist/bin/supervisord.js +4 -5
  3. package/dist/bin/supervisord.js.map +1 -1
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.js +2 -2
  6. package/dist/daemon/cli-links.d.ts +30 -0
  7. package/dist/daemon/cli-links.js +134 -0
  8. package/dist/daemon/cli-links.js.map +1 -0
  9. package/dist/daemon/commands.d.ts +27 -0
  10. package/dist/daemon/commands.js +295 -0
  11. package/dist/daemon/commands.js.map +1 -0
  12. package/dist/daemon/control_protocol.d.ts +53 -31
  13. package/dist/daemon/control_protocol.js +8 -31
  14. package/dist/daemon/control_protocol.js.map +1 -1
  15. package/dist/daemon/cron_log.d.ts +2 -23
  16. package/dist/daemon/cron_log.js +10 -25
  17. package/dist/daemon/cron_log.js.map +1 -1
  18. package/dist/daemon/cron_registry.d.ts +3 -40
  19. package/dist/daemon/cron_registry.js +58 -103
  20. package/dist/daemon/cron_registry.js.map +1 -1
  21. package/dist/daemon/install-runtime.d.ts +10 -0
  22. package/dist/daemon/install-runtime.js +81 -0
  23. package/dist/daemon/install-runtime.js.map +1 -0
  24. package/dist/daemon/install.d.ts +3 -63
  25. package/dist/daemon/install.js +5 -289
  26. package/dist/daemon/install.js.map +1 -1
  27. package/dist/daemon/preflight.d.ts +51 -0
  28. package/dist/daemon/preflight.js +136 -0
  29. package/dist/daemon/preflight.js.map +1 -0
  30. package/dist/daemon/registry.d.ts +33 -45
  31. package/dist/daemon/registry.js +171 -117
  32. package/dist/daemon/registry.js.map +1 -1
  33. package/dist/daemon/rpc_child.d.ts +92 -146
  34. package/dist/daemon/rpc_child.js +336 -263
  35. package/dist/daemon/rpc_child.js.map +1 -1
  36. package/dist/daemon/status.d.ts +45 -0
  37. package/dist/daemon/status.js +87 -0
  38. package/dist/daemon/status.js.map +1 -0
  39. package/dist/daemon/supervisor-ipc.d.ts +2 -0
  40. package/dist/daemon/supervisor-ipc.js +20 -0
  41. package/dist/daemon/supervisor-ipc.js.map +1 -0
  42. package/dist/daemon/supervisor.d.ts +48 -71
  43. package/dist/daemon/supervisor.js +430 -420
  44. package/dist/daemon/supervisor.js.map +1 -1
  45. package/dist/extension_ui_bridge.d.ts +4 -3
  46. package/dist/extension_ui_bridge.js +10 -0
  47. package/dist/extension_ui_bridge.js.map +1 -1
  48. package/dist/index.d.ts +25 -230
  49. package/dist/index.js +515 -5553
  50. package/dist/index.js.map +1 -1
  51. package/dist/pairing/crypto.d.ts +8 -1
  52. package/dist/pairing/crypto.js +56 -6
  53. package/dist/pairing/crypto.js.map +1 -1
  54. package/dist/pairing/owner_storage.d.ts +42 -0
  55. package/dist/pairing/owner_storage.js +207 -0
  56. package/dist/pairing/owner_storage.js.map +1 -0
  57. package/dist/pairing/qr.d.ts +3 -42
  58. package/dist/pairing/qr.js +9 -59
  59. package/dist/pairing/qr.js.map +1 -1
  60. package/dist/pairing/storage.d.ts +3 -45
  61. package/dist/pairing/storage.js +10 -223
  62. package/dist/pairing/storage.js.map +1 -1
  63. package/dist/protocol/types.d.ts +1 -1
  64. package/dist/protocol/v2/schemas.d.ts +1 -1
  65. package/dist/protocol/v2/schemas.js +1 -1
  66. package/dist/protocol/v2/schemas.js.map +1 -1
  67. package/dist/runtime/owner_router.d.ts +16 -0
  68. package/dist/runtime/owner_router.js +37 -0
  69. package/dist/runtime/owner_router.js.map +1 -0
  70. package/dist/session/ipc.d.ts +1 -22
  71. package/dist/session/ipc.js +4 -11
  72. package/dist/session/ipc.js.map +1 -1
  73. package/dist/session/local_config.d.ts +4 -47
  74. package/dist/session/local_config.js +19 -101
  75. package/dist/session/local_config.js.map +1 -1
  76. package/dist/transport/peer_channel.d.ts +25 -39
  77. package/dist/transport/peer_channel.js +74 -131
  78. package/dist/transport/peer_channel.js.map +1 -1
  79. package/dist/transport/relay_client.d.ts +26 -70
  80. package/dist/transport/relay_client.js +37 -106
  81. package/dist/transport/relay_client.js.map +1 -1
  82. package/docs/daemon.md +91 -207
  83. package/package.json +21 -17
  84. package/dist/daemon/id.d.ts +0 -24
  85. package/dist/daemon/id.js +0 -36
  86. package/dist/daemon/id.js.map +0 -1
  87. package/dist/mcp/mesh_result.d.ts +0 -10
  88. package/dist/mcp/mesh_result.js +0 -42
  89. package/dist/mcp/mesh_result.js.map +0 -1
  90. package/dist/mcp/mesh_server.d.ts +0 -16
  91. package/dist/mcp/mesh_server.js +0 -327
  92. package/dist/mcp/mesh_server.js.map +0 -1
  93. package/dist/mesh/canonical.d.ts +0 -30
  94. package/dist/mesh/canonical.js +0 -61
  95. package/dist/mesh/canonical.js.map +0 -1
  96. package/dist/mesh/client.d.ts +0 -18
  97. package/dist/mesh/client.js +0 -125
  98. package/dist/mesh/client.js.map +0 -1
  99. package/dist/mesh/encoding.d.ts +0 -38
  100. package/dist/mesh/encoding.js +0 -216
  101. package/dist/mesh/encoding.js.map +0 -1
  102. package/dist/mesh/self_revoke.d.ts +0 -73
  103. package/dist/mesh/self_revoke.js +0 -401
  104. package/dist/mesh/self_revoke.js.map +0 -1
  105. package/dist/mesh/siblings.d.ts +0 -49
  106. package/dist/mesh/siblings.js +0 -159
  107. package/dist/mesh/siblings.js.map +0 -1
  108. package/dist/mesh/types.d.ts +0 -34
  109. package/dist/mesh/types.js +0 -11
  110. package/dist/mesh/types.js.map +0 -1
  111. package/dist/mesh/verify.d.ts +0 -17
  112. package/dist/mesh/verify.js +0 -86
  113. package/dist/mesh/verify.js.map +0 -1
  114. package/dist/rooms.d.ts +0 -29
  115. package/dist/rooms.js +0 -57
  116. package/dist/rooms.js.map +0 -1
  117. package/dist/session/bridge.d.ts +0 -37
  118. package/dist/session/bridge.js +0 -130
  119. package/dist/session/bridge.js.map +0 -1
  120. package/dist/session/broker.d.ts +0 -189
  121. package/dist/session/broker.js +0 -507
  122. package/dist/session/broker.js.map +0 -1
  123. package/dist/session/broker_remote.d.ts +0 -74
  124. package/dist/session/broker_remote.js +0 -583
  125. package/dist/session/broker_remote.js.map +0 -1
  126. package/dist/session/cwd_lock.d.ts +0 -33
  127. package/dist/session/cwd_lock.js +0 -126
  128. package/dist/session/cwd_lock.js.map +0 -1
  129. package/dist/session/envelope.d.ts +0 -33
  130. package/dist/session/envelope.js +0 -117
  131. package/dist/session/envelope.js.map +0 -1
  132. package/dist/session/global_config.d.ts +0 -33
  133. package/dist/session/global_config.js +0 -73
  134. package/dist/session/global_config.js.map +0 -1
  135. package/dist/session/leader_election.d.ts +0 -32
  136. package/dist/session/leader_election.js +0 -106
  137. package/dist/session/leader_election.js.map +0 -1
  138. package/dist/session/mesh_node.d.ts +0 -116
  139. package/dist/session/mesh_node.js +0 -573
  140. package/dist/session/mesh_node.js.map +0 -1
  141. package/dist/session/peer.d.ts +0 -139
  142. package/dist/session/peer.js +0 -451
  143. package/dist/session/peer.js.map +0 -1
  144. package/dist/session/peer_inventory.d.ts +0 -13
  145. package/dist/session/peer_inventory.js +0 -48
  146. package/dist/session/peer_inventory.js.map +0 -1
  147. package/dist/session/peer_limits.d.ts +0 -9
  148. package/dist/session/peer_limits.js +0 -21
  149. package/dist/session/peer_limits.js.map +0 -1
  150. package/dist/session/setup_wizard.d.ts +0 -39
  151. package/dist/session/setup_wizard.js +0 -63
  152. package/dist/session/setup_wizard.js.map +0 -1
  153. package/dist/session/tools.d.ts +0 -24
  154. package/dist/session/tools.js +0 -252
  155. package/dist/session/tools.js.map +0 -1
  156. package/dist/session/wizard.d.ts +0 -13
  157. package/dist/session/wizard.js +0 -20
  158. package/dist/session/wizard.js.map +0 -1
  159. package/dist/transport/pi_forward_client.d.ts +0 -29
  160. package/dist/transport/pi_forward_client.js +0 -62
  161. package/dist/transport/pi_forward_client.js.map +0 -1
  162. package/dist/ui/footer.d.ts +0 -32
  163. package/dist/ui/footer.js +0 -34
  164. package/dist/ui/footer.js.map +0 -1
  165. package/skills/agent-network/SKILL.md +0 -310
@@ -1,27 +1,13 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { EventEmitter } from "node:events";
3
4
  import { existsSync, readFileSync } from "node:fs";
4
5
  import { dirname, join } from "node:path";
5
6
  import { defaultAgentName, loadLocalConfig } from "../session/local_config.js";
6
7
  export const EXIT_DAEMON_FRESH_SESSION = 42;
7
- /** Windows extensions `spawn` (without `shell`) can actually launch, in
8
- * preference order: native `.exe` first, then the `.cmd`/`.bat` shims (run via
9
- * cmd.exe). An extensionless `pi` is a Unix shell script Node CANNOT spawn on
10
- * Windows — picking it yields `spawn … ENOENT` and the daemon shows "crashed". */
8
+ export const DEFAULT_READINESS_TIMEOUT_MS = 10_000;
9
+ export const DEFAULT_PROMPT_TIMEOUT_MS = 5_000;
11
10
  const WIN_EXECUTABLE_EXTS = [".exe", ".cmd", ".bat", ".com"];
12
- /**
13
- * Resolve the `pi` executable for `spawn` (plan/40, decision C). On Windows a
14
- * bare `pi` is actually `pi.cmd`/`pi.ps1`, and `spawn` won't find it without an
15
- * extension → resolve the real path via `where` (rather than `shell:true`,
16
- * which risks shell injection). An explicit path or an already-suffixed name is
17
- * used as-is. POSIX returns the name unchanged. Best-effort: if `where` fails,
18
- * fall back to the bare name (spawn will surface ENOENT honestly).
19
- *
20
- * `where` lists every match in PATH order, which on pi-node installs puts the
21
- * extensionless `pi` (a shell script) BEFORE `pi.cmd`. Naively taking the first
22
- * line spawns the unrunnable script → ENOENT → "crashed". So we prefer a result
23
- * with a real Windows executable extension and only fall back to the first line.
24
- */
25
11
  export function resolvePiBin(piBin, plat = process.platform) {
26
12
  if (plat !== "win32")
27
13
  return piBin;
@@ -29,29 +15,13 @@ export function resolvePiBin(piBin, plat = process.platform) {
29
15
  return piBin;
30
16
  try {
31
17
  const out = execFileSync("where", [piBin], { encoding: "utf8" });
32
- const lines = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
33
- const runnable = lines.find((l) => WIN_EXECUTABLE_EXTS.some((ext) => l.toLowerCase().endsWith(ext)));
34
- const chosen = runnable ?? lines[0];
35
- if (chosen)
36
- return chosen;
18
+ const lines = out.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
19
+ return lines.find((line) => WIN_EXECUTABLE_EXTS.some((ext) => line.toLowerCase().endsWith(ext))) ?? lines[0] ?? piBin;
37
20
  }
38
21
  catch {
39
- /* `where` unavailable or `pi` not found — fall back to the bare name */
22
+ return piBin;
40
23
  }
41
- return piBin;
42
24
  }
43
- /**
44
- * Resolve `pi` to a directly-spawnable target (plan/40). On Windows `pi` is an
45
- * npm `.cmd` shim, which modern Node refuses to `spawn` without `shell:true`
46
- * (EINVAL, post CVE-2024-27980). Wrapping it in `cmd.exe` would (a) add a layer
47
- * that orphans the real `node` on kill and (b) complicate stdio — both fatal for
48
- * a long-lived RPC daemon the supervisor must stop/restart cleanly. Instead we
49
- * parse the shim to recover the underlying `node <cli.js>` and spawn that
50
- * directly: one process, clean signals, clean stdio.
51
- *
52
- * Falls back to the bare resolved path when not a parseable shim (e.g. a real
53
- * `pi.exe`, or POSIX) so `spawn` surfaces any real error honestly.
54
- */
55
25
  export function resolvePiSpawn(piBin, plat = process.platform, nodeExe = process.execPath) {
56
26
  const resolved = resolvePiBin(piBin, plat);
57
27
  if (plat !== "win32")
@@ -63,12 +33,6 @@ export function resolvePiSpawn(piBin, plat = process.platform, nodeExe = process
63
33
  }
64
34
  return { command: resolved, prefixArgs: [] };
65
35
  }
66
- /**
67
- * Extract the JS entry an npm `.cmd` shim launches. npm shims invoke
68
- * `"%_prog%" "%dp0%\<relative>\entry.js" %*`; we recover `<dir>\<relative>\entry.js`
69
- * (dir = the shim's own folder, i.e. `%dp0%`). Returns null when the shim
70
- * doesn't match the expected shape or the target is missing.
71
- */
72
36
  export function _npmShimTarget(cmdPath) {
73
37
  let content;
74
38
  try {
@@ -77,313 +41,422 @@ export function _npmShimTarget(cmdPath) {
77
41
  catch {
78
42
  return null;
79
43
  }
80
- const m = content.match(/"%dp0%\\([^"]+\.[cm]?js)"/i);
81
- if (!m)
44
+ const match = content.match(/"%dp0%\\([^\"]+\.[cm]?js)"/i);
45
+ if (!match)
82
46
  return null;
83
- const target = join(dirname(cmdPath), m[1]);
47
+ const target = join(dirname(cmdPath), match[1]);
84
48
  return existsSync(target) ? target : null;
85
49
  }
86
- /**
87
- * Maps an RPC stdout line to a busy-state transition: `message_start` → true
88
- * (a message is streaming), `message_end` → false. Other lines → null (no
89
- * change). Pure + exported for tests.
90
- *
91
- * NOTE (plan/39 detail 1): the Pi RPC stream has NO turn-level event — only
92
- * per-message start/end (and `response{command:"prompt"}` is emitted at
93
- * PREFLIGHT, i.e. turn START, not end). So this passive flag reflects
94
- * "a message is being produced right now"; the authoritative turn-busy signal
95
- * is `get_state.isStreaming` (see `RpcChild.refreshBusy`).
96
- */
97
50
  export function busyTransition(line) {
98
- let obj;
51
+ let value;
99
52
  try {
100
- obj = JSON.parse(line);
53
+ value = JSON.parse(line);
101
54
  }
102
55
  catch {
103
56
  return null;
104
57
  }
105
- const t = obj?.type;
106
- if (t === "message_start")
58
+ const type = value?.type;
59
+ if (type === "message_start")
107
60
  return true;
108
- if (t === "message_end")
61
+ if (type === "message_end")
109
62
  return false;
110
63
  return null;
111
64
  }
112
- /** Parses a `get_state` RPC response line, returning its id + isStreaming. */
113
- function parseGetStateResponse(line) {
114
- let obj;
115
- try {
116
- obj = JSON.parse(line);
117
- }
118
- catch {
119
- return null;
120
- }
121
- const o = obj;
122
- if (o.type !== "response" || o.command !== "get_state")
123
- return null;
124
- const data = o.data;
125
- return {
126
- id: typeof o.id === "string" ? o.id : undefined,
127
- isStreaming: typeof data?.isStreaming === "boolean" ? data.isStreaming : undefined,
128
- };
129
- }
130
65
  /**
131
- * CLI args for the daemon's `pi --mode rpc` child.
132
- *
133
- * `--continue` is the key bit: it resumes the **most recent** session for the
134
- * cwd (`SessionManager.continueRecent`, non-interactive — unlike `--resume`
135
- * which opens a picker). Without it every supervisor restart spun up a brand
136
- * new session file, piling up thousands of JSONLs per folder. With it, a
137
- * restart REUSES the one session; the app's `/new` (session_new) still rolls
138
- * it over to a fresh one, which the next restart then continues. First boot
139
- * (no prior session) just creates the first one.
140
- *
141
- * `--name <sessionName>`, when given, pins the session's display name to the
142
- * daemon's identity (its `agent_name`) so every restart shows up under the
143
- * same stable name in the picker/app instead of an auto-generated one. The
144
- * daemon's name is set at registration (`remote-pi create <cwd> --name "…"`).
145
- * Omitted when no name resolves, so the arg list stays minimal.
146
- *
147
- * `--approve` is mandatory for a daemon (pi ≥0.79 project trust): RPC mode is
148
- * non-interactive, so without an override Pi resolves an untrusted project
149
- * folder (any folder with `.pi/` or CLAUDE.md/AGENTS.md) to NOT trusted and
150
- * silently skips its `.pi/settings.json` (model/provider/keys), instructions,
151
- * resources and project extensions — the daemon then comes up with no model
152
- * and fails on the first turn. The operator already authorized this folder by
153
- * registering/launching a daemon in it, so `--approve` (trust-for-this-run) is
154
- * the correct non-interactive stance. (Does NOT affect the separate "extension
155
- * loaded twice" conflict, which comes from the extension being BOTH installed
156
- * in ~/.pi/agent/extensions or cwd/.pi/extensions AND passed via `-e`.)
66
+ * The installed Pi RPC starts configured extensions itself. In particular this
67
+ * must not attach Remote Pi with `-e`, which could load it a second time.
157
68
  */
158
- export function rpcSpawnArgs(extensionPath, sessionName, useContinue = true) {
69
+ export function rpcSpawnArgs(_extensionPath, sessionName, useContinue = true) {
159
70
  return [
160
71
  "--mode", "rpc",
161
72
  "--approve",
162
73
  ...(useContinue ? ["--continue"] : []),
163
74
  ...(sessionName ? ["--name", sessionName] : []),
164
- "-e", extensionPath,
165
75
  ];
166
76
  }
77
+ /**
78
+ * Parses direct daemon-control events and the current extension's custom
79
+ * relay-state message shape. Natural-language stderr is intentionally absent.
80
+ */
81
+ export function parseRuntimeEvent(line) {
82
+ let raw;
83
+ try {
84
+ raw = JSON.parse(line);
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ if (!raw || typeof raw !== "object")
90
+ return null;
91
+ const obj = raw;
92
+ let type = obj["type"];
93
+ let payload = obj;
94
+ if (type === "entry_appended" && obj["entry"] && typeof obj["entry"] === "object") {
95
+ const entry = obj["entry"];
96
+ const customType = entry["customType"];
97
+ const details = entry["details"];
98
+ if (typeof customType === "string" && details && typeof details === "object") {
99
+ type = customType.replace(/^remote-pi:/, "").replace(/-/g, "_");
100
+ payload = details;
101
+ }
102
+ }
103
+ if (type === "runtime_ready") {
104
+ return {
105
+ type,
106
+ event: {
107
+ control_protocol_version: typeof payload["control_protocol_version"] === "number" ? payload["control_protocol_version"] : undefined,
108
+ extension_version: typeof payload["extension_version"] === "string" ? payload["extension_version"] : undefined,
109
+ endpoint_id: typeof payload["endpoint_id"] === "string" ? payload["endpoint_id"] : undefined,
110
+ runtime_instance_id: typeof payload["runtime_instance_id"] === "string" ? payload["runtime_instance_id"] : undefined,
111
+ session_id: typeof payload["session_id"] === "string" ? payload["session_id"] : undefined,
112
+ },
113
+ };
114
+ }
115
+ if (type === "runtime_failed") {
116
+ if (typeof payload["code"] !== "string" || typeof payload["retryable"] !== "boolean")
117
+ return null;
118
+ return { type, event: {
119
+ stage: typeof payload["stage"] === "string" ? payload["stage"] : undefined,
120
+ code: payload["code"],
121
+ message: typeof payload["message"] === "string" ? payload["message"] : undefined,
122
+ retryable: payload["retryable"],
123
+ } };
124
+ }
125
+ if (type === "relay_state_changed" || type === "relay_state") {
126
+ const state = payload["state"] ?? payload["status"];
127
+ if (state === "disconnected" || state === "connecting" || state === "connected" || state === "reconnecting") {
128
+ return { type: "relay_state_changed", event: { state } };
129
+ }
130
+ }
131
+ if (type === "session_changed") {
132
+ return { type, event: { session_id: typeof payload["session_id"] === "string" ? payload["session_id"] : undefined } };
133
+ }
134
+ return null;
135
+ }
167
136
  export class RpcChild extends EventEmitter {
168
137
  opts;
169
138
  child = null;
170
139
  _state = "stopped";
171
- _startedAt = null;
140
+ _process = "absent";
141
+ _runtime = "pending";
142
+ _relay = "disconnected";
143
+ _startedAt;
144
+ _runtimeInstanceId;
145
+ _sessionId;
172
146
  _restartCount = 0;
173
- /** True while a deliberate `stop()` is in flight. The process dies by
174
- * signal (SIGTERM/SIGKILL), which would otherwise look like a crash and
175
- * trip the supervisor's auto-restart — re-spawning a daemon the operator
176
- * just stopped/removed. Gating `isCrash` on this makes a deliberate stop a
177
- * clean exit (no restart). Reset on every `spawn()`. */
178
147
  _stopping = false;
179
- /** Next spawn should create a fresh session instead of --continue. */
180
148
  forceFreshSessionOnNextSpawn = false;
181
- /** Accumulates partial stdout lines while waiting for `\n`. */
182
149
  stdoutBuf = "";
183
- /** Passive busy flag derived from the RPC stream (message_start/end). Hint
184
- * only; `refreshBusy` syncs it authoritatively via get_state.isStreaming. */
185
150
  _busy = false;
186
- /** In-flight `get_state` requests, keyed by request id. */
187
- _statePending = new Map();
151
+ rpcReady = false;
152
+ extensionReady = false;
153
+ extensionReadyEvent = null;
154
+ readinessTimer = null;
155
+ readyPromise = Promise.resolve(false);
156
+ readyResolve = null;
157
+ statePending = new Map();
158
+ promptPending = new Map();
188
159
  constructor(opts) {
189
160
  super();
190
161
  this.opts = opts;
191
162
  }
192
163
  get state() { return this._state; }
193
- /** Passive busy hint from the stream. Prefer `refreshBusy()` for an
194
- * authoritative check before acting on it (cron skip_if_busy). */
164
+ get processState() { return this._process; }
165
+ get runtimeState() { return this._runtime; }
166
+ get relayState() { return this._relay; }
195
167
  get isBusy() { return this._busy; }
196
168
  get pid() { return this.child?.pid; }
197
169
  get restartCount() { return this._restartCount; }
198
- get uptimeMs() {
199
- return this._startedAt !== null ? Date.now() - this._startedAt : undefined;
200
- }
201
- /**
202
- * Spawn the child process. Idempotent for the same instance: a second
203
- * call while already running is a no-op.
204
- */
170
+ get startedAt() { return this._startedAt; }
171
+ get runtimeInstanceId() { return this._runtimeInstanceId; }
172
+ get sessionId() { return this._sessionId; }
173
+ get uptimeMs() { return this._startedAt === undefined ? undefined : Date.now() - this._startedAt; }
174
+ /** Resolves true only after the readiness probe/event, false on timeout/exit. */
175
+ ready() { return this.readyPromise; }
205
176
  spawn() {
206
177
  if (this.child)
207
178
  return;
208
- this._stopping = false; // fresh start — a later signal IS a real crash
179
+ this._stopping = false;
209
180
  this._busy = false;
210
181
  this._state = "starting";
211
- const piTarget = resolvePiSpawn(this.opts.piBin ?? "pi");
212
- // Name the (single) daemon session after the daemon's configured identity,
213
- // so it shows up stably instead of an auto-generated name on each restart.
214
- // Prefer the supervisor-injected config; fall back to the on-disk file.
215
- const cfg = this.opts.config ?? loadLocalConfig(this.opts.cwd);
216
- const sessionName = cfg.agent_name ?? defaultAgentName(this.opts.cwd);
182
+ this._process = "spawning";
183
+ this._runtime = "pending";
184
+ this._relay = "connecting";
185
+ this._sessionId = undefined;
186
+ this.rpcReady = false;
187
+ this.extensionReady = false;
188
+ this.extensionReadyEvent = null;
189
+ this._runtimeInstanceId = randomUUID();
190
+ this.readyPromise = new Promise((resolve) => { this.readyResolve = resolve; });
191
+ const target = resolvePiSpawn(this.opts.piBin ?? "pi");
192
+ const config = this.opts.config ?? loadLocalConfig(this.opts.cwd);
193
+ const sessionName = config.agent_name ?? defaultAgentName(this.opts.cwd);
217
194
  const useContinue = !this.forceFreshSessionOnNextSpawn;
218
195
  this.forceFreshSessionOnNextSpawn = false;
219
- // On Windows `prefixArgs` carries pi's cli.js (we spawn node directly); on
220
- // POSIX it's empty and `command` is `pi` itself.
221
- const args = [...piTarget.prefixArgs, ...rpcSpawnArgs(this.opts.extensionPath, sessionName, useContinue)];
196
+ const args = [...target.prefixArgs, ...rpcSpawnArgs(this.opts.extensionPath, sessionName, useContinue)];
222
197
  const env = {
223
198
  ...process.env,
224
199
  ...this.opts.env,
225
- // Mandatory daemon marker — `_cmdRoot` in index.ts can use this to
226
- // bail early if local config is missing (no wizard in RPC mode).
227
200
  REMOTE_PI_DAEMON: "1",
228
- // Inject the daemon config inline so the child needs no config file.
201
+ REMOTE_PI_ENDPOINT_ID: this.opts.endpointId ?? "",
202
+ REMOTE_PI_RUNTIME_INSTANCE_ID: this._runtimeInstanceId,
229
203
  ...(this.opts.config ? { REMOTE_PI_DIRECT_CONFIG: JSON.stringify(this.opts.config) } : {}),
230
204
  };
231
- const child = spawn(piTarget.command, args, {
232
- cwd: this.opts.cwd,
233
- env,
234
- stdio: ["pipe", "pipe", "pipe"],
235
- // Windows: suppress any console window the child might allocate. With the
236
- // node+cli.js path (resolvePiSpawn) there's no cmd.exe layer, but keep this
237
- // as belt-and-suspenders alongside the supervisor's hidden wscript launcher
238
- // (service-templates/task-launcher.vbs.template). No-op on POSIX.
239
- windowsHide: process.platform === "win32",
240
- });
205
+ const child = spawn(target.command, args, { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: process.platform === "win32" });
241
206
  this.child = child;
242
207
  this._startedAt = Date.now();
243
- this._state = "running";
244
- child.stdout?.on("data", (chunk) => this._onStdout(chunk));
245
- child.stderr?.on("data", (chunk) => {
246
- // Forward stderr to our own stderr so `journalctl --user -u remote-pi-supervisord`
247
- // sees daemon logs (with cwd prefix for disambiguation).
248
- process.stderr.write(`[${this.opts.cwd}] ${chunk.toString()}`);
208
+ // A spawned ChildProcess object is not evidence its executable completed
209
+ // startup. Preserve `spawning` until Node reports the OS spawn event.
210
+ child.once("spawn", () => {
211
+ if (this.child === child && this._process === "spawning")
212
+ this._process = "running";
249
213
  });
250
- child.on("exit", (code, signal) => this._onExit(code, signal));
251
- child.on("error", (err) => {
252
- // spawn() itself failed (e.g. `pi` binary not found).
253
- this._state = "crashed";
254
- process.stderr.write(`[remote-pi-supervisord] spawn failed for ${this.opts.cwd}: ${String(err)}\n`);
255
- this.emit("exit", { code: null, signal: null, isCrash: true });
214
+ child.stdout?.on("data", (chunk) => this.onStdout(chunk));
215
+ child.stderr?.on("data", (chunk) => process.stderr.write(`[${this.opts.cwd}] ${chunk.toString()}`));
216
+ child.on("exit", (code, signal) => this.onExit(code, signal));
217
+ child.on("error", (error) => {
218
+ this.failRuntime({ stage: "spawn", code: "spawn_failed", message: String(error), retryable: true });
219
+ this._process = "exited";
256
220
  });
257
- this.emit("spawn", { pid: child.pid });
221
+ this.startReadinessProbe();
222
+ this.emit("spawn", { pid: child.pid, runtime_instance_id: this._runtimeInstanceId });
258
223
  }
259
- /**
260
- * Sends a Pi RPC `prompt` command to the child's stdin. Fire-and-forget
261
- * we don't wait for the response (response would be the success ack;
262
- * the actual agent output streams via the relay/UDS, not back through
263
- * stdout).
264
- *
265
- * Returns false if the child isn't running (caller decides how to report).
266
- */
267
- sendPrompt(text, requestId) {
268
- if (!this.child || !this.child.stdin || this._state !== "running")
269
- return false;
270
- const cmd = { id: requestId ?? `sv-${Date.now()}`, type: "prompt", message: text };
271
- try {
272
- this.child.stdin.write(JSON.stringify(cmd) + "\n");
273
- return true;
274
- }
275
- catch {
276
- return false;
224
+ /** Waits for Pi's correlated prompt preflight response, not merely stdin write. */
225
+ sendPrompt(text, requestId = `sv-${randomUUID()}`, timeoutMs = this.opts.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS) {
226
+ if (!this.child?.stdin || this._process !== "running" || this._runtime !== "ready") {
227
+ return Promise.resolve({ accepted: false, code: "runtime_not_ready", error: "daemon RPC runtime is not ready" });
277
228
  }
229
+ return new Promise((resolve) => {
230
+ const timer = setTimeout(() => {
231
+ this.promptPending.delete(requestId);
232
+ resolve({ accepted: false, code: "prompt_response_timeout", error: "Pi did not accept the prompt before timeout" });
233
+ }, timeoutMs);
234
+ this.promptPending.set(requestId, { resolve, timer });
235
+ try {
236
+ this.child.stdin.write(JSON.stringify({ id: requestId, type: "prompt", message: text }) + "\n");
237
+ }
238
+ catch (error) {
239
+ clearTimeout(timer);
240
+ this.promptPending.delete(requestId);
241
+ resolve({ accepted: false, code: "stdin_write_failed", error: String(error) });
242
+ }
243
+ });
278
244
  }
279
- /**
280
- * Asks the child to exit gracefully. Sends SIGTERM; if the child doesn't
281
- * exit within `timeoutMs`, escalates to SIGKILL. Resolves when the
282
- * `exit` event fires.
283
- */
284
- async stop(timeoutMs = 5000) {
245
+ async stop(timeoutMs = 5_000) {
285
246
  if (!this.child)
286
247
  return;
287
- this._stopping = true; // deliberate — the upcoming signal-exit is NOT a crash
248
+ this._stopping = true;
288
249
  const child = this.child;
289
- return new Promise((resolve) => {
290
- const onExit = () => { resolve(); };
291
- this.once("exit", onExit);
250
+ await new Promise((resolve) => {
251
+ this.once("exit", resolve);
292
252
  try {
293
253
  child.kill("SIGTERM");
294
254
  }
295
- catch { /* already dead */ }
296
- const t = setTimeout(() => {
297
- try {
298
- child.kill("SIGKILL");
299
- }
300
- catch { /* race — already dead */ }
301
- }, timeoutMs);
302
- this.once("exit", () => clearTimeout(t));
303
- });
304
- }
305
- _onStdout(chunk) {
306
- this.stdoutBuf += chunk.toString();
307
- let nl;
308
- while ((nl = this.stdoutBuf.indexOf("\n")) >= 0) {
309
- const line = this.stdoutBuf.slice(0, nl);
310
- this.stdoutBuf = this.stdoutBuf.slice(nl + 1);
311
- if (!line.trim())
312
- continue;
313
- this._handleStdoutLine(line);
314
- }
315
- }
316
- _handleStdoutLine(line) {
317
- const t = busyTransition(line);
318
- if (t !== null)
319
- this._busy = t;
320
- const gs = parseGetStateResponse(line);
321
- if (gs && gs.id) {
322
- const pending = this._statePending.get(gs.id);
323
- if (pending) {
324
- clearTimeout(pending.timer);
325
- this._statePending.delete(gs.id);
326
- if (typeof gs.isStreaming === "boolean")
327
- this._busy = gs.isStreaming;
328
- pending.resolve(this._busy);
255
+ catch {
256
+ resolve();
329
257
  }
330
- }
331
- this.emit("stdout", line);
258
+ const timer = setTimeout(() => { try {
259
+ child.kill("SIGKILL");
260
+ }
261
+ catch { /* process already gone */ } }, timeoutMs);
262
+ this.once("exit", () => clearTimeout(timer));
263
+ });
332
264
  }
333
- /**
334
- * Authoritative busy check: queries the child's `get_state` and syncs
335
- * `_busy` to `isStreaming`. Resolves the passive flag on timeout (no
336
- * response) or when the child isn't running. Used by the cron `skip_if_busy`
337
- * gate, where a false "not busy" would pile a prompt onto a live turn.
338
- */
339
- async refreshBusy(timeoutMs = 1500) {
340
- if (this._state !== "running" || !this.child?.stdin)
265
+ async refreshBusy(timeoutMs = 1_500) {
266
+ if (this._process !== "running" || !this.child?.stdin)
341
267
  return this._busy;
342
- const id = `gs-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
268
+ const id = `gs-${randomUUID()}`;
343
269
  return new Promise((resolve) => {
344
- const timer = setTimeout(() => {
345
- this._statePending.delete(id);
346
- resolve(this._busy);
347
- }, timeoutMs);
348
- this._statePending.set(id, { resolve, timer });
270
+ const timer = setTimeout(() => { this.statePending.delete(id); resolve(this._busy); }, timeoutMs);
271
+ this.statePending.set(id, { resolve, timer });
349
272
  try {
350
273
  this.child.stdin.write(JSON.stringify({ id, type: "get_state" }) + "\n");
351
274
  }
352
275
  catch {
353
276
  clearTimeout(timer);
354
- this._statePending.delete(id);
277
+ this.statePending.delete(id);
355
278
  resolve(this._busy);
356
279
  }
357
280
  });
358
281
  }
359
- /** Test-only: feed a raw stdout line through the same handler. */
360
- _ingestStdoutForTest(line) {
361
- this._handleStdoutLine(line);
282
+ /** Test injection point for deterministic RPC event/response coverage. */
283
+ _ingestStdoutForTest(line) { this.handleStdoutLine(line); }
284
+ startReadinessProbe() {
285
+ const timeoutMs = this.opts.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS;
286
+ this.readinessTimer = setTimeout(() => {
287
+ if (this.rpcReady && !this.extensionReady) {
288
+ this.failRuntime({ stage: "extension", code: "extension_not_ready", message: "Pi RPC is ready but the configured Remote Pi extension did not report readiness", retryable: false });
289
+ return;
290
+ }
291
+ this.failRuntime({ stage: "rpc_probe", code: "readiness_timeout", message: "Pi RPC get_state did not respond before timeout", retryable: true });
292
+ }, timeoutMs);
293
+ const id = `ready-${randomUUID()}`;
294
+ try {
295
+ this.child?.stdin?.write(JSON.stringify({ id, type: "get_state" }) + "\n");
296
+ }
297
+ catch (error) {
298
+ this.failRuntime({ stage: "rpc_probe", code: "readiness_probe_write_failed", message: String(error), retryable: true });
299
+ }
300
+ }
301
+ onStdout(chunk) {
302
+ this.stdoutBuf += chunk.toString();
303
+ let newline;
304
+ while ((newline = this.stdoutBuf.indexOf("\n")) >= 0) {
305
+ const line = this.stdoutBuf.slice(0, newline);
306
+ this.stdoutBuf = this.stdoutBuf.slice(newline + 1);
307
+ if (line.trim())
308
+ this.handleStdoutLine(line);
309
+ }
310
+ }
311
+ handleStdoutLine(line) {
312
+ const busy = busyTransition(line);
313
+ if (busy !== null)
314
+ this._busy = busy;
315
+ const response = this.parseResponse(line);
316
+ if (response)
317
+ this.handleResponse(response);
318
+ const event = parseRuntimeEvent(line);
319
+ if (event)
320
+ this.handleRuntimeEvent(event);
321
+ this.emit("stdout", line);
322
+ }
323
+ parseResponse(line) {
324
+ try {
325
+ const value = JSON.parse(line);
326
+ return value.type === "response" ? value : null;
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ }
332
+ handleResponse(response) {
333
+ if (typeof response.id !== "string" || typeof response.command !== "string")
334
+ return;
335
+ if (response.command === "get_state") {
336
+ const pending = this.statePending.get(response.id);
337
+ if (pending) {
338
+ clearTimeout(pending.timer);
339
+ this.statePending.delete(response.id);
340
+ const streaming = response.data?.isStreaming;
341
+ if (typeof streaming === "boolean")
342
+ this._busy = streaming;
343
+ pending.resolve(this._busy);
344
+ }
345
+ if (response.success === true) {
346
+ const data = response.data;
347
+ this.rpcReady = true;
348
+ if (typeof data?.sessionId === "string")
349
+ this._sessionId = data.sessionId;
350
+ this.maybeMarkReady();
351
+ }
352
+ else {
353
+ this.failRuntime({ stage: "rpc_probe", code: "readiness_probe_rejected", message: typeof response.error === "string" ? response.error : "Pi rejected get_state", retryable: true });
354
+ }
355
+ return;
356
+ }
357
+ if (response.command === "prompt") {
358
+ const pending = this.promptPending.get(response.id);
359
+ if (!pending)
360
+ return;
361
+ clearTimeout(pending.timer);
362
+ this.promptPending.delete(response.id);
363
+ pending.resolve(response.success === true
364
+ ? { accepted: true }
365
+ : { accepted: false, code: "prompt_rejected", error: typeof response.error === "string" ? response.error : "Pi rejected prompt" });
366
+ }
367
+ }
368
+ handleRuntimeEvent(event) {
369
+ switch (event.type) {
370
+ case "runtime_ready":
371
+ this.acceptExtensionReady(event.event);
372
+ break;
373
+ case "runtime_failed":
374
+ this.failRuntime(event.event);
375
+ break;
376
+ case "relay_state_changed":
377
+ this._relay = event.event.state;
378
+ this.emit("relay_state_changed", event.event);
379
+ break;
380
+ case "session_changed":
381
+ this._sessionId = event.event.session_id;
382
+ this.emit("session_changed", event.event);
383
+ break;
384
+ }
362
385
  }
363
- _onExit(code, signal) {
364
- // Daemon app-action `/new`: child exits with a private code, supervisor
365
- // restarts it, and the next spawn omits --continue once to create a fresh
366
- // session. Later restarts go back to --continue.
386
+ acceptExtensionReady(event) {
387
+ if (this._runtime === "failed" || !this.child)
388
+ return;
389
+ if (event.control_protocol_version !== 2) {
390
+ this.failRuntime({ stage: "extension", code: "control_protocol_mismatch", message: "Remote Pi extension control protocol is incompatible", retryable: false });
391
+ return;
392
+ }
393
+ if (event.endpoint_id !== this.opts.endpointId || event.runtime_instance_id !== this._runtimeInstanceId) {
394
+ this.failRuntime({ stage: "extension", code: "runtime_identity_mismatch", message: "Remote Pi extension reported a different endpoint runtime identity", retryable: false });
395
+ return;
396
+ }
397
+ this.extensionReady = true;
398
+ this.extensionReadyEvent = event;
399
+ if (event.session_id)
400
+ this._sessionId = event.session_id;
401
+ this.maybeMarkReady();
402
+ }
403
+ maybeMarkReady() {
404
+ if (!this.rpcReady || !this.extensionReady || this._runtime === "failed" || !this.child)
405
+ return;
406
+ this._process = "running";
407
+ if (this.readinessTimer) {
408
+ clearTimeout(this.readinessTimer);
409
+ this.readinessTimer = null;
410
+ }
411
+ this._runtime = "ready";
412
+ this._state = "running";
413
+ this.readyResolve?.(true);
414
+ this.readyResolve = null;
415
+ this.emit("runtime_ready", { ...this.extensionReadyEvent, session_id: this._sessionId, endpoint_id: this.opts.endpointId, runtime_instance_id: this._runtimeInstanceId });
416
+ }
417
+ failRuntime(event) {
418
+ if (this._runtime === "failed")
419
+ return;
420
+ if (this.readinessTimer) {
421
+ clearTimeout(this.readinessTimer);
422
+ this.readinessTimer = null;
423
+ }
424
+ this._runtime = "failed";
425
+ this._state = event.retryable ? "crashed" : "blocked";
426
+ this.readyResolve?.(false);
427
+ this.readyResolve = null;
428
+ this.emit("runtime_failed", event);
429
+ }
430
+ onExit(code, signal) {
367
431
  if (code === EXIT_DAEMON_FRESH_SESSION)
368
432
  this.forceFreshSessionOnNextSpawn = true;
369
- // A deliberate stop() kills by signal — not a crash, so the supervisor
370
- // must NOT auto-restart it. Only an UNexpected exit counts as a crash.
371
- const isCrash = !this._stopping && (code !== 0 || signal !== null);
433
+ if (this.readinessTimer) {
434
+ clearTimeout(this.readinessTimer);
435
+ this.readinessTimer = null;
436
+ }
437
+ const isCrash = !this._stopping;
438
+ this._process = "exited";
439
+ if (this._runtime !== "failed")
440
+ this._runtime = "pending";
372
441
  this._state = isCrash ? "crashed" : "stopped";
373
442
  this.child = null;
374
- this._startedAt = null;
443
+ this._startedAt = undefined;
375
444
  this._busy = false;
376
- for (const p of this._statePending.values()) {
377
- clearTimeout(p.timer);
378
- p.resolve(false);
445
+ this._relay = "disconnected";
446
+ this.readyResolve?.(false);
447
+ this.readyResolve = null;
448
+ for (const pending of this.statePending.values()) {
449
+ clearTimeout(pending.timer);
450
+ pending.resolve(false);
451
+ }
452
+ this.statePending.clear();
453
+ for (const pending of this.promptPending.values()) {
454
+ clearTimeout(pending.timer);
455
+ pending.resolve({ accepted: false, code: "child_exited", error: "daemon exited before Pi accepted the prompt" });
379
456
  }
380
- this._statePending.clear();
457
+ this.promptPending.clear();
381
458
  this.emit("exit", { code, signal, isCrash });
382
459
  }
383
- /** Bumps the restart counter — called by the supervisor when it
384
- * decides to re-spawn after a crash. Exposed so tests can drive it. */
385
- noteRestart() {
386
- this._restartCount += 1;
387
- }
460
+ noteRestart() { this._restartCount += 1; }
388
461
  }
389
462
  //# sourceMappingURL=rpc_child.js.map