@yefengr/remote-pi 0.7.3 → 0.7.5

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 (171) hide show
  1. package/README.md +81 -586
  2. package/dist/actions/handlers.d.ts +1 -1
  3. package/dist/actions/handlers.js +2 -1
  4. package/dist/actions/handlers.js.map +1 -1
  5. package/dist/bin/supervisord.js +4 -5
  6. package/dist/bin/supervisord.js.map +1 -1
  7. package/dist/config.d.ts +1 -1
  8. package/dist/config.js +2 -2
  9. package/dist/daemon/cli-links.d.ts +30 -0
  10. package/dist/daemon/cli-links.js +134 -0
  11. package/dist/daemon/cli-links.js.map +1 -0
  12. package/dist/daemon/commands.d.ts +27 -0
  13. package/dist/daemon/commands.js +295 -0
  14. package/dist/daemon/commands.js.map +1 -0
  15. package/dist/daemon/control_protocol.d.ts +53 -31
  16. package/dist/daemon/control_protocol.js +8 -31
  17. package/dist/daemon/control_protocol.js.map +1 -1
  18. package/dist/daemon/cron_log.d.ts +2 -23
  19. package/dist/daemon/cron_log.js +10 -25
  20. package/dist/daemon/cron_log.js.map +1 -1
  21. package/dist/daemon/cron_registry.d.ts +3 -40
  22. package/dist/daemon/cron_registry.js +58 -103
  23. package/dist/daemon/cron_registry.js.map +1 -1
  24. package/dist/daemon/install-runtime.d.ts +10 -0
  25. package/dist/daemon/install-runtime.js +81 -0
  26. package/dist/daemon/install-runtime.js.map +1 -0
  27. package/dist/daemon/install.d.ts +3 -63
  28. package/dist/daemon/install.js +5 -289
  29. package/dist/daemon/install.js.map +1 -1
  30. package/dist/daemon/preflight.d.ts +51 -0
  31. package/dist/daemon/preflight.js +136 -0
  32. package/dist/daemon/preflight.js.map +1 -0
  33. package/dist/daemon/registry.d.ts +33 -45
  34. package/dist/daemon/registry.js +171 -117
  35. package/dist/daemon/registry.js.map +1 -1
  36. package/dist/daemon/rpc_child.d.ts +92 -146
  37. package/dist/daemon/rpc_child.js +336 -263
  38. package/dist/daemon/rpc_child.js.map +1 -1
  39. package/dist/daemon/status.d.ts +45 -0
  40. package/dist/daemon/status.js +87 -0
  41. package/dist/daemon/status.js.map +1 -0
  42. package/dist/daemon/supervisor-ipc.d.ts +2 -0
  43. package/dist/daemon/supervisor-ipc.js +20 -0
  44. package/dist/daemon/supervisor-ipc.js.map +1 -0
  45. package/dist/daemon/supervisor.d.ts +48 -71
  46. package/dist/daemon/supervisor.js +430 -420
  47. package/dist/daemon/supervisor.js.map +1 -1
  48. package/dist/extension_ui_bridge.d.ts +4 -3
  49. package/dist/extension_ui_bridge.js +10 -0
  50. package/dist/extension_ui_bridge.js.map +1 -1
  51. package/dist/index.d.ts +25 -217
  52. package/dist/index.js +527 -5504
  53. package/dist/index.js.map +1 -1
  54. package/dist/pairing/crypto.d.ts +8 -1
  55. package/dist/pairing/crypto.js +56 -6
  56. package/dist/pairing/crypto.js.map +1 -1
  57. package/dist/pairing/owner_storage.d.ts +42 -0
  58. package/dist/pairing/owner_storage.js +207 -0
  59. package/dist/pairing/owner_storage.js.map +1 -0
  60. package/dist/pairing/qr.d.ts +3 -42
  61. package/dist/pairing/qr.js +9 -59
  62. package/dist/pairing/qr.js.map +1 -1
  63. package/dist/pairing/storage.d.ts +3 -45
  64. package/dist/pairing/storage.js +10 -223
  65. package/dist/pairing/storage.js.map +1 -1
  66. package/dist/protocol/types.d.ts +1 -1
  67. package/dist/protocol/v2/schemas.d.ts +1 -1
  68. package/dist/protocol/v2/schemas.js +1 -1
  69. package/dist/protocol/v2/schemas.js.map +1 -1
  70. package/dist/runtime/owner_router.d.ts +16 -0
  71. package/dist/runtime/owner_router.js +37 -0
  72. package/dist/runtime/owner_router.js.map +1 -0
  73. package/dist/session/ipc.d.ts +1 -22
  74. package/dist/session/ipc.js +4 -11
  75. package/dist/session/ipc.js.map +1 -1
  76. package/dist/session/local_config.d.ts +4 -47
  77. package/dist/session/local_config.js +19 -101
  78. package/dist/session/local_config.js.map +1 -1
  79. package/dist/timeline/v2_service.d.ts +6 -0
  80. package/dist/timeline/v2_service.js +20 -2
  81. package/dist/timeline/v2_service.js.map +1 -1
  82. package/dist/transport/peer_channel.d.ts +25 -39
  83. package/dist/transport/peer_channel.js +74 -131
  84. package/dist/transport/peer_channel.js.map +1 -1
  85. package/dist/transport/relay_client.d.ts +26 -70
  86. package/dist/transport/relay_client.js +37 -106
  87. package/dist/transport/relay_client.js.map +1 -1
  88. package/docs/daemon.md +91 -207
  89. package/package.json +21 -17
  90. package/dist/daemon/id.d.ts +0 -24
  91. package/dist/daemon/id.js +0 -36
  92. package/dist/daemon/id.js.map +0 -1
  93. package/dist/mcp/mesh_result.d.ts +0 -10
  94. package/dist/mcp/mesh_result.js +0 -42
  95. package/dist/mcp/mesh_result.js.map +0 -1
  96. package/dist/mcp/mesh_server.d.ts +0 -16
  97. package/dist/mcp/mesh_server.js +0 -327
  98. package/dist/mcp/mesh_server.js.map +0 -1
  99. package/dist/mesh/canonical.d.ts +0 -30
  100. package/dist/mesh/canonical.js +0 -61
  101. package/dist/mesh/canonical.js.map +0 -1
  102. package/dist/mesh/client.d.ts +0 -18
  103. package/dist/mesh/client.js +0 -125
  104. package/dist/mesh/client.js.map +0 -1
  105. package/dist/mesh/encoding.d.ts +0 -38
  106. package/dist/mesh/encoding.js +0 -216
  107. package/dist/mesh/encoding.js.map +0 -1
  108. package/dist/mesh/self_revoke.d.ts +0 -73
  109. package/dist/mesh/self_revoke.js +0 -401
  110. package/dist/mesh/self_revoke.js.map +0 -1
  111. package/dist/mesh/siblings.d.ts +0 -49
  112. package/dist/mesh/siblings.js +0 -159
  113. package/dist/mesh/siblings.js.map +0 -1
  114. package/dist/mesh/types.d.ts +0 -34
  115. package/dist/mesh/types.js +0 -11
  116. package/dist/mesh/types.js.map +0 -1
  117. package/dist/mesh/verify.d.ts +0 -17
  118. package/dist/mesh/verify.js +0 -86
  119. package/dist/mesh/verify.js.map +0 -1
  120. package/dist/rooms.d.ts +0 -29
  121. package/dist/rooms.js +0 -57
  122. package/dist/rooms.js.map +0 -1
  123. package/dist/session/bridge.d.ts +0 -37
  124. package/dist/session/bridge.js +0 -130
  125. package/dist/session/bridge.js.map +0 -1
  126. package/dist/session/broker.d.ts +0 -189
  127. package/dist/session/broker.js +0 -507
  128. package/dist/session/broker.js.map +0 -1
  129. package/dist/session/broker_remote.d.ts +0 -74
  130. package/dist/session/broker_remote.js +0 -583
  131. package/dist/session/broker_remote.js.map +0 -1
  132. package/dist/session/cwd_lock.d.ts +0 -33
  133. package/dist/session/cwd_lock.js +0 -126
  134. package/dist/session/cwd_lock.js.map +0 -1
  135. package/dist/session/envelope.d.ts +0 -33
  136. package/dist/session/envelope.js +0 -117
  137. package/dist/session/envelope.js.map +0 -1
  138. package/dist/session/global_config.d.ts +0 -33
  139. package/dist/session/global_config.js +0 -73
  140. package/dist/session/global_config.js.map +0 -1
  141. package/dist/session/leader_election.d.ts +0 -32
  142. package/dist/session/leader_election.js +0 -106
  143. package/dist/session/leader_election.js.map +0 -1
  144. package/dist/session/mesh_node.d.ts +0 -116
  145. package/dist/session/mesh_node.js +0 -573
  146. package/dist/session/mesh_node.js.map +0 -1
  147. package/dist/session/peer.d.ts +0 -139
  148. package/dist/session/peer.js +0 -451
  149. package/dist/session/peer.js.map +0 -1
  150. package/dist/session/peer_inventory.d.ts +0 -13
  151. package/dist/session/peer_inventory.js +0 -48
  152. package/dist/session/peer_inventory.js.map +0 -1
  153. package/dist/session/peer_limits.d.ts +0 -9
  154. package/dist/session/peer_limits.js +0 -21
  155. package/dist/session/peer_limits.js.map +0 -1
  156. package/dist/session/setup_wizard.d.ts +0 -39
  157. package/dist/session/setup_wizard.js +0 -63
  158. package/dist/session/setup_wizard.js.map +0 -1
  159. package/dist/session/tools.d.ts +0 -24
  160. package/dist/session/tools.js +0 -252
  161. package/dist/session/tools.js.map +0 -1
  162. package/dist/session/wizard.d.ts +0 -13
  163. package/dist/session/wizard.js +0 -20
  164. package/dist/session/wizard.js.map +0 -1
  165. package/dist/transport/pi_forward_client.d.ts +0 -29
  166. package/dist/transport/pi_forward_client.js +0 -62
  167. package/dist/transport/pi_forward_client.js.map +0 -1
  168. package/dist/ui/footer.d.ts +0 -32
  169. package/dist/ui/footer.js +0 -34
  170. package/dist/ui/footer.js.map +0 -1
  171. package/skills/agent-network/SKILL.md +0 -310
@@ -1,515 +1,426 @@
1
1
  import { existsSync, mkdirSync, unlinkSync } from "node:fs";
2
- import { createConnection, createServer } from "node:net";
2
+ import { createServer } from "node:net";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import { addDaemon, listDaemons, migrateRegistryNames, removeDaemon } from "./registry.js";
6
- import { daemonIdForCwd } from "./id.js";
5
+ import { Cron } from "croner";
6
+ import { addDaemon, findDaemonByCwd, listDaemons, removeDaemon, setDaemonDesiredState, } from "./registry.js";
7
7
  import { defaultAgentName } from "../session/local_config.js";
8
8
  import { ipcAddress, usesNamedPipe } from "../session/ipc.js";
9
- import { EXIT_DAEMON_FRESH_SESSION, RpcChild } from "./rpc_child.js";
9
+ import { EXIT_DAEMON_FRESH_SESSION, RpcChild, } from "./rpc_child.js";
10
10
  import { encodeReply, parseRequest, } from "./control_protocol.js";
11
- import { Cron } from "croner";
12
11
  import { addJob as addCronJob, getJob as getCronJob, listJobs as listCronJobs, nextRunFor, recordRun, removeJob as removeCronJob, setJobEnabled, validateSchedule, } from "./cron_registry.js";
13
12
  import { appendCronLog, readCronLog } from "./cron_log.js";
14
- /**
15
- * Central process that owns the daemon fleet (plan/26).
16
- *
17
- * Responsibilities:
18
- * - Spawn one `pi --mode rpc` child per registry entry. Track them in
19
- * `_children: Map<id, RpcChild>`.
20
- * - Auto-restart crashed children with exponential backoff
21
- * (1s, 5s, 30s, 5min). Give up after 4 attempts to avoid log spam
22
- * when the agent is misconfigured.
23
- * - Listen on `~/.pi/remote/supervisor.sock` for `ControlRequest`s from
24
- * the `remote-pi` CLI. Each connection: 1 request → 1 reply → close.
25
- * - Graceful shutdown on SIGTERM/SIGINT: stop all children + unlink
26
- * the UDS file so a next supervisor can bind cleanly.
27
- *
28
- * The supervisor itself is the only long-running process the user
29
- * installs as a system service (plan/26 W3 will generate the unit/plist).
30
- * If it crashes, systemd/launchd restarts it; on restart it re-reads
31
- * the registry and re-spawns everything.
32
- */
13
+ import { decideFireAction, infoFor } from "./status.js";
14
+ import { runDaemonPreflight } from "./preflight.js";
15
+ import { probeSupervisor } from "./supervisor-ipc.js";
33
16
  const SUPERVISOR_SOCK_NAME = "supervisor.sock";
34
- /** Backoff schedule for auto-restart after a crash. After exhausting, the
35
- * child stays in `crashed` state until manual `restart_all` or fresh
36
- * registry add. Keeps logs sane when the agent dies on every boot. */
37
- const RESTART_BACKOFFS_MS = [1_000, 5_000, 30_000, 5 * 60_000];
17
+ const RESTART_BACKOFFS_MS = [1_000, 5_000, 30_000, 5 * 60_000], RESTART_STABILITY_MS = 30_000, RECONCILE_INTERVAL_MS = 15_000, MISSING_CWD_CONFIRMATIONS = 2;
38
18
  function supervisorSockPath() {
39
19
  const root = process.env["REMOTE_PI_HOME"] || homedir();
40
- // POSIX → ~/.pi/remote/supervisor.sock; Windows → per-user named pipe (plan/40).
41
20
  return ipcAddress("supervisor", join(root, ".pi", "remote", SUPERVISOR_SOCK_NAME));
42
21
  }
43
- /** Thrown by `start()` when another live supervisor already holds the UDS.
44
- * Prevents a second supervisor from orphaning the first's children. */
45
22
  export class SupervisorAlreadyRunningError extends Error {
46
23
  sockPath;
47
24
  constructor(sockPath) {
48
- super(`Another pi-supervisord is already running (UDS held at ${sockPath}). ` +
49
- "Refusing to start a second instance. Use `remote-pi daemon …` to control it, " +
50
- "or stop the running one first.");
25
+ super(`Another pi-supervisord is already running (UDS held at ${sockPath}).`);
51
26
  this.sockPath = sockPath;
52
27
  this.name = "SupervisorAlreadyRunningError";
53
28
  }
54
29
  }
55
- /** Probes whether a live supervisor is accepting connections on `path`.
56
- * Resolves true if the connect succeeds (a listener is there), false on
57
- * ECONNREFUSED / ENOENT (stale socket file from a crashed supervisor). */
58
- function _probeSupervisor(path) {
59
- return new Promise((resolve) => {
60
- const sock = createConnection({ path });
61
- const done = (alive) => {
62
- sock.removeAllListeners();
63
- sock.destroy();
64
- resolve(alive);
65
- };
66
- const timer = setTimeout(() => done(false), 1_000);
67
- sock.once("connect", () => { clearTimeout(timer); done(true); });
68
- sock.once("error", () => { clearTimeout(timer); done(false); });
69
- });
70
- }
71
- export function decideFireAction(o) {
72
- if (!o.running)
73
- return o.wake ? "wake_and_send" : "skip_down";
74
- if (o.skipIfBusy && o.busy)
75
- return "skip_busy";
76
- return "send";
77
- }
30
+ export { decideFireAction } from "./status.js";
78
31
  export class Supervisor {
79
32
  opts;
80
33
  server = null;
81
34
  children = new Map();
82
- /** Live croner schedules, keyed by cron job id (plan/39). */
83
35
  cronJobs = new Map();
84
36
  shuttingDown = false;
37
+ reconcileTimer = null;
38
+ reconcileRunning = false;
39
+ preflightFor;
85
40
  constructor(opts) {
86
41
  this.opts = opts;
42
+ this.preflightFor = opts.preflight ?? ((entry) => runDaemonPreflight({ cwd: entry.cwd }));
87
43
  }
88
- /** Bind the control UDS + spawn all registered daemons. */
89
44
  async start() {
90
- this._mkdirParent();
91
- // Backfill folder-derived names into legacy registry entries (pre-name
92
- // field) so every daemon has a stable name to inject via env.
93
- migrateRegistryNames();
94
- await this._bindUds();
95
- this._spawnAllFromRegistry();
96
- // Cron (plan/39): schedule all enabled jobs, then run any missed catchup.
97
- this._reconcileCron();
98
- this._runCatchup();
99
- }
100
- /** Graceful shutdown: stop all children, close UDS. */
45
+ this.mkdirParent();
46
+ await this.bindUds();
47
+ await this.reconcileRegistryCwds();
48
+ this.spawnDesiredEntries();
49
+ this.reconcileCron();
50
+ this.runCatchup();
51
+ const interval = this.opts.reconcileIntervalMs ?? RECONCILE_INTERVAL_MS;
52
+ this.reconcileTimer = setInterval(() => { void this.reconcileRegistryCwds(); }, interval);
53
+ this.reconcileTimer.unref();
54
+ }
101
55
  async stop() {
102
56
  this.shuttingDown = true;
103
- // Stop all cron schedules (plan/39) so no fire races with teardown.
104
- for (const c of this.cronJobs.values())
105
- c.stop();
57
+ if (this.reconcileTimer)
58
+ clearInterval(this.reconcileTimer);
59
+ this.reconcileTimer = null;
60
+ for (const cron of this.cronJobs.values())
61
+ cron.stop();
106
62
  this.cronJobs.clear();
107
- // Cancel pending restart timers first so they don't race with stop().
108
- for (const slot of this.children.values()) {
109
- if (slot.restartTimer !== null) {
110
- clearTimeout(slot.restartTimer);
111
- slot.restartTimer = null;
112
- }
113
- }
114
- await Promise.all([...this.children.values()].map((s) => s.child.stop()));
63
+ for (const slot of this.children.values())
64
+ this.cancelRetry(slot);
65
+ await Promise.all([...this.children.values()].map(async (slot) => slot.child.stop()));
115
66
  this.children.clear();
116
- await new Promise((resolve) => {
117
- if (!this.server)
118
- return resolve();
119
- this.server.close(() => resolve());
120
- });
67
+ await new Promise((resolve) => this.server ? this.server.close(() => resolve()) : resolve());
121
68
  this.server = null;
122
- // Best-effort: clear the socket file so a next supervisor bind succeeds.
123
- // Windows named pipes have no file (auto-removed on exit) → nothing to do.
124
69
  if (!usesNamedPipe()) {
125
70
  try {
126
71
  unlinkSync(supervisorSockPath());
127
72
  }
128
- catch { /* ignored */ }
73
+ catch { /* socket already absent */ }
129
74
  }
130
75
  }
131
- // ── UDS binding ──────────────────────────────────────────────────────────
132
- _mkdirParent() {
133
- // A named pipe has no parent directory to create (the addr is `\\.\pipe\…`).
134
- if (usesNamedPipe())
135
- return;
136
- mkdirSync(dirname(supervisorSockPath()), { recursive: true });
76
+ mkdirParent() {
77
+ if (!usesNamedPipe())
78
+ mkdirSync(dirname(supervisorSockPath()), { recursive: true });
137
79
  }
138
- async _bindUds() {
80
+ async bindUds() {
139
81
  const path = supervisorSockPath();
140
- const pipe = usesNamedPipe();
141
- // Single-instance guard. PROBE first: a live supervisor answering the
142
- // connect means we must NOT start a second one. Stealing the socket
143
- // (unlink + bind) would orphan the running supervisor's children — they'd
144
- // keep running, unreachable by the CLI. Only on POSIX, when the probe
145
- // fails (stale socket from a crash), do we unlink + bind. On Windows there
146
- // is no file: always probe, never unlink (the pipe self-cleans on exit).
147
- if (pipe || existsSync(path)) {
148
- const alive = await _probeSupervisor(path);
149
- if (alive) {
82
+ const namedPipe = usesNamedPipe();
83
+ if (namedPipe || existsSync(path)) {
84
+ if (await probeSupervisor(path))
150
85
  throw new SupervisorAlreadyRunningError(path);
151
- }
152
- if (!pipe) {
86
+ if (!namedPipe) {
153
87
  try {
154
88
  unlinkSync(path);
155
89
  }
156
- catch { /* will throw on bind if still held */ }
90
+ catch { /* bind reports an actual race */ }
157
91
  }
158
92
  }
159
- const server = createServer((socket) => this._onConnection(socket));
93
+ const server = createServer((socket) => this.onConnection(socket));
160
94
  await new Promise((resolve, reject) => {
161
95
  server.once("error", reject);
162
96
  server.listen(path, () => resolve());
163
97
  });
164
98
  this.server = server;
165
99
  }
166
- _onConnection(socket) {
167
- let buf = "";
100
+ onConnection(socket) {
101
+ let buffer = "";
168
102
  socket.setEncoding("utf8");
169
103
  socket.on("data", (chunk) => {
170
- buf += chunk;
171
- const nl = buf.indexOf("\n");
172
- if (nl < 0)
104
+ buffer += chunk;
105
+ const newline = buffer.indexOf("\n");
106
+ if (newline < 0)
173
107
  return;
174
- const line = buf.slice(0, nl);
175
- // Single request per connection; ignore anything past the newline.
176
- void this._handleRequest(line)
177
- .then((reply) => socket.end(encodeReply(reply)))
178
- .catch((err) => socket.end(encodeReply({ ok: false, error: String(err) })));
108
+ void this.handleRequest(buffer.slice(0, newline)).then((reply) => socket.end(encodeReply(reply)), (error) => socket.end(encodeReply({ ok: false, error: String(error) })));
179
109
  });
180
110
  socket.on("error", () => { });
181
111
  }
182
- // ── Request dispatch ─────────────────────────────────────────────────────
183
- async _handleRequest(line) {
184
- let req;
112
+ async handleRequest(line) {
113
+ let request;
185
114
  try {
186
- req = parseRequest(line);
115
+ request = parseRequest(line);
187
116
  }
188
- catch (e) {
189
- return { ok: false, error: e.message };
117
+ catch (error) {
118
+ return { ok: false, error: error.message };
190
119
  }
191
- switch (req.op) {
192
- case "list": return { ok: true, data: { daemons: this._listInfo() } };
193
- case "status": return { ok: true, data: { daemons: this._listInfo() } };
194
- case "start_all": return this._opStartAll();
195
- case "start": return this._opStart(req.id);
196
- case "stop_all": return this._opStopAll();
197
- case "stop": return this._opStop(req.id);
198
- case "restart_all": return this._opRestartAll();
199
- case "restart": return this._opRestart(req.id);
200
- case "send": return this._opSend(req.id, req.text);
201
- case "register": return this._opRegister(req.cwd);
202
- case "unregister": return this._opUnregister(req.id);
203
- case "cron_add": return this._opCronAdd(req);
204
- case "cron_list": return this._opCronList();
205
- case "cron_remove": return this._opCronRemove(req.job_id);
206
- case "cron_enable": return this._opCronEnable(req.job_id, req.enabled);
207
- case "cron_run": return this._opCronRun(req.job_id);
208
- case "cron_log": return this._opCronLog(req.job_id, req.tail);
209
- default: {
210
- const unknown = req.op;
211
- return { ok: false, error: `unknown op: ${unknown}` };
212
- }
120
+ await this.reconcileRegistryCwds();
121
+ switch (request.op) {
122
+ case "list": return { ok: true, data: { daemons: this.listInfo() } };
123
+ case "status": return { ok: true, data: { daemons: this.listInfo() } };
124
+ case "start_all": return this.startAll();
125
+ case "start": return this.startOne(request.id);
126
+ case "stop_all": return this.stopAll();
127
+ case "stop": return this.stopOne(request.id);
128
+ case "restart_all": return this.restartAll();
129
+ case "restart": return this.restartOne(request.id);
130
+ case "send": return this.send(request.id, request.text);
131
+ case "register": return this.register(request.cwd);
132
+ case "unregister": return this.unregister(request.id);
133
+ case "unregister_cwd": return this.unregisterCwd(request.cwd);
134
+ case "cron_add": return this.cronAdd(request);
135
+ case "cron_list": return { ok: true, data: { jobs: listCronJobs().map((job) => this.jobView(job)) } };
136
+ case "cron_remove": return this.cronRemove(request.job_id);
137
+ case "cron_enable": return this.cronEnable(request.job_id, request.enabled);
138
+ case "cron_run": return this.cronRun(request.job_id);
139
+ case "cron_log": return { ok: true, data: { entries: readCronLog({ jobId: request.job_id, tail: request.tail }) } };
140
+ default: return { ok: false, error: `unknown op: ${request.op}` };
213
141
  }
214
142
  }
215
- // ── Op handlers ──────────────────────────────────────────────────────────
216
- _listInfo() {
217
- const registry = listDaemons();
218
- return registry.map((entry) => {
219
- const slot = this.children.get(entry.id);
220
- const name = entry.name ?? defaultAgentName(entry.cwd);
221
- const info = {
222
- id: entry.id,
223
- cwd: entry.cwd,
224
- name,
225
- state: slot?.child.state ?? "stopped",
226
- };
227
- if (slot) {
228
- if (slot.child.pid !== undefined)
229
- info.pid = slot.child.pid;
230
- if (slot.child.uptimeMs !== undefined)
231
- info.uptime_s = Math.floor(slot.child.uptimeMs / 1000);
232
- info.restart_count = slot.child.restartCount;
233
- }
234
- return info;
235
- });
143
+ listInfo() {
144
+ return listDaemons().map((entry) => this.infoFor(entry));
145
+ }
146
+ infoFor(entry) {
147
+ const slot = this.children.get(entry.id);
148
+ const snapshot = {
149
+ id: entry.id,
150
+ cwd: entry.cwd,
151
+ name: entry.name,
152
+ registration: existsSync(entry.cwd) ? "registered" : "missing",
153
+ desired: entry.desired_state,
154
+ process: slot?.child.processState ?? "absent",
155
+ runtime: slot?.child.runtimeState ?? "pending",
156
+ relay: slot?.child.relayState ?? "disconnected",
157
+ ...(slot?.child.runtimeInstanceId ? { runtimeInstanceId: slot.child.runtimeInstanceId } : {}),
158
+ ...(slot?.child.pid !== undefined ? { pid: slot.child.pid } : {}),
159
+ ...(slot?.child.startedAt !== undefined ? { startedAt: slot.child.startedAt } : {}),
160
+ ...(slot?.child.uptimeMs !== undefined ? { uptimeMs: slot.child.uptimeMs } : {}),
161
+ restartCount: slot?.child.restartCount ?? 0,
162
+ retrying: !!slot?.restartTimer,
163
+ ...(slot?.nextRetryAt ? { nextRetryAt: slot.nextRetryAt } : {}),
164
+ blocked: !!slot?.blocked || slot?.child.state === "blocked",
165
+ ...(slot?.error ? { error: slot.error } : {}),
166
+ };
167
+ return infoFor(snapshot);
236
168
  }
237
- _opStartAll() {
169
+ startAll() {
238
170
  const started = [];
239
- const already = [];
171
+ const alreadyRunning = [];
240
172
  for (const entry of listDaemons()) {
241
- const slot = this.children.get(entry.id);
242
- if (slot && slot.child.state === "running") {
243
- already.push(entry.id);
244
- continue;
245
- }
246
- this._spawnEntry(entry.id, entry.cwd);
247
- started.push(entry.id);
173
+ const result = this.startEntry(entry);
174
+ (result ? alreadyRunning : started).push(entry.id);
248
175
  }
249
- return { ok: true, data: { started, already_running: already } };
250
- }
251
- /** Spawn a single registered daemon by id. Idempotent: a daemon already
252
- * running returns `started: false`. Unknown id → ok:false. This is what
253
- * `/remote-pi create` calls so a freshly-registered folder boots its Pi
254
- * immediately instead of waiting for the next supervisor restart. */
255
- _opStart(id) {
256
- const entry = listDaemons().find((d) => d.id === id);
176
+ return { ok: true, data: { started, already_running: alreadyRunning } };
177
+ }
178
+ startOne(id) {
179
+ const entry = listDaemons().find((item) => item.id === id);
257
180
  if (!entry)
258
181
  return { ok: false, error: `no daemon with id ${id}` };
259
- const slot = this.children.get(id);
260
- if (slot && slot.child.state === "running") {
261
- return { ok: true, data: { id, state: slot.child.state, started: false } };
262
- }
263
- this._spawnEntry(entry.id, entry.cwd, entry.name);
264
- const state = this.children.get(id)?.child.state ?? "starting";
265
- return { ok: true, data: { id, state, started: true } };
266
- }
267
- async _opStopAll() {
268
- const stopped = [];
269
- const already = [];
270
- for (const [id, slot] of this.children) {
271
- if (slot.child.state !== "running") {
272
- already.push(id);
273
- continue;
274
- }
275
- if (slot.restartTimer !== null) {
276
- clearTimeout(slot.restartTimer);
277
- slot.restartTimer = null;
182
+ const alreadyRunning = this.startEntry(entry);
183
+ const info = this.infoFor(setDaemonDesiredState(id, "running"));
184
+ return { ok: true, data: { id, state: info.state, started: !alreadyRunning, desired_state: "running" } };
185
+ }
186
+ startEntry(entry) {
187
+ const updated = setDaemonDesiredState(entry.id, "running");
188
+ const slot = this.children.get(entry.id);
189
+ if (slot)
190
+ slot.entry = updated;
191
+ if (slot && (slot.child.processState === "running" || slot.child.processState === "spawning") && !slot.blocked)
192
+ return true;
193
+ if (slot) {
194
+ this.cancelRetry(slot);
195
+ slot.blocked = false;
196
+ slot.error = undefined;
197
+ slot.entry = updated;
198
+ if (slot.child.processState === "exited" || slot.child.pid === undefined) {
199
+ void this.preflightAndSpawn(slot);
200
+ return false;
278
201
  }
279
- await slot.child.stop();
280
- stopped.push(id);
202
+ void slot.child.stop().then(() => this.preflightAndSpawn(slot));
203
+ return false;
281
204
  }
282
- return { ok: true, data: { stopped, already_stopped: already } };
283
- }
284
- /** Stop a single registered daemon by id. Idempotent: a daemon that isn't
285
- * running returns `stopped: false`. Unknown id → ok:false. Mirrors the
286
- * per-id semantics of `_opStart`. Cancels any pending restart backoff so a
287
- * deliberate stop stays stopped. */
288
- async _opStop(id) {
289
- const entry = listDaemons().find((d) => d.id === id);
205
+ this.createSlot(updated);
206
+ return false;
207
+ }
208
+ async stopAll() {
209
+ const stopped = [], alreadyStopped = [];
210
+ for (const entry of listDaemons())
211
+ (await this.stopEntry(entry) ? stopped : alreadyStopped).push(entry.id);
212
+ return { ok: true, data: { stopped, already_stopped: alreadyStopped } };
213
+ }
214
+ async stopOne(id) {
215
+ const entry = listDaemons().find((item) => item.id === id);
290
216
  if (!entry)
291
217
  return { ok: false, error: `no daemon with id ${id}` };
292
- const slot = this.children.get(id);
293
- if (!slot || slot.child.state !== "running") {
294
- return { ok: true, data: { id, state: slot?.child.state ?? "stopped", stopped: false } };
295
- }
296
- if (slot.restartTimer !== null) {
297
- clearTimeout(slot.restartTimer);
298
- slot.restartTimer = null;
299
- }
218
+ const stopped = await this.stopEntry(entry);
219
+ const info = this.infoFor(setDaemonDesiredState(id, "stopped"));
220
+ return { ok: true, data: { id, state: info.state, stopped, desired_state: "stopped" } };
221
+ }
222
+ async stopEntry(entry) {
223
+ const updated = setDaemonDesiredState(entry.id, "stopped");
224
+ const slot = this.children.get(entry.id);
225
+ if (slot)
226
+ slot.entry = updated;
227
+ if (!slot)
228
+ return false;
229
+ this.cancelRetry(slot);
230
+ this.cancelStabilityReset(slot);
231
+ const wasLive = slot.child.processState === "running" || slot.child.processState === "spawning";
300
232
  await slot.child.stop();
301
- return { ok: true, data: { id, state: slot.child.state, stopped: true } };
233
+ return wasLive;
302
234
  }
303
- /** Restart a single registered daemon by id (stop-if-running, then spawn).
304
- * Unknown id → ok:false. Resets the crash backoff. */
305
- async _opRestart(id) {
306
- const entry = listDaemons().find((d) => d.id === id);
235
+ async restartOne(id) {
236
+ const entry = listDaemons().find((item) => item.id === id);
307
237
  if (!entry)
308
238
  return { ok: false, error: `no daemon with id ${id}` };
309
- const slot = this.children.get(id);
310
- if (slot && slot.child.state === "running") {
311
- if (slot.restartTimer !== null) {
312
- clearTimeout(slot.restartTimer);
313
- slot.restartTimer = null;
314
- }
315
- await slot.child.stop();
239
+ await this.restartEntry(entry);
240
+ const info = this.infoFor(setDaemonDesiredState(id, "running"));
241
+ return { ok: true, data: { id, state: info.state, restarted: true, desired_state: "running" } };
242
+ }
243
+ async restartAll() {
244
+ const restarted = [];
245
+ for (const entry of listDaemons()) {
246
+ await this.restartEntry(entry);
247
+ restarted.push(entry.id);
316
248
  }
317
- this._spawnEntry(entry.id, entry.cwd, entry.name);
318
- const state = this.children.get(id)?.child.state ?? "starting";
319
- return { ok: true, data: { id, state, restarted: true } };
320
- }
321
- async _opRestartAll() {
322
- const stopReply = await this._opStopAll();
323
- if (!stopReply.ok)
324
- return stopReply;
325
- const startReply = this._opStartAll();
326
- if (!startReply.ok)
327
- return startReply;
328
- const restarted = startReply.data.started;
329
249
  return { ok: true, data: { restarted } };
330
250
  }
331
- _opSend(id, text) {
332
- const slot = this.children.get(id);
333
- if (!slot)
334
- return { ok: false, error: `daemon ${id} not running` };
335
- if (slot.child.state !== "running") {
336
- return { ok: false, error: `daemon ${id} state is ${slot.child.state}` };
251
+ async restartEntry(entry) {
252
+ const updated = setDaemonDesiredState(entry.id, "running");
253
+ const existing = this.children.get(entry.id);
254
+ if (existing) {
255
+ this.cancelRetry(existing);
256
+ this.cancelStabilityReset(existing);
257
+ existing.blocked = false;
258
+ existing.error = undefined;
259
+ await existing.child.stop();
260
+ this.children.delete(entry.id);
337
261
  }
338
- const ok = slot.child.sendPrompt(text);
339
- return { ok: true, data: { id, delivered: ok } };
262
+ this.createSlot(updated);
340
263
  }
341
- _opRegister(rawCwd) {
264
+ async send(id, text) {
265
+ const entry = listDaemons().find((item) => item.id === id);
266
+ if (!entry)
267
+ return { ok: false, error: `no daemon with id ${id}` };
268
+ if (!existsSync(entry.cwd)) {
269
+ return { ok: true, data: { id, accepted: false, delivered: false, code: "cwd_missing", error: "daemon cwd no longer exists" } };
270
+ }
271
+ const info = this.infoFor(entry);
272
+ if (info.desired !== "running" || info.runtime !== "ready") {
273
+ return { ok: true, data: { id, accepted: false, delivered: false, code: "runtime_not_ready", error: `daemon is ${info.health}` } };
274
+ }
275
+ const slot = this.children.get(id);
276
+ if (!slot)
277
+ return { ok: true, data: { id, accepted: false, delivered: false, code: "runtime_missing" } };
278
+ const acceptance = await slot.child.sendPrompt(text);
279
+ return { ok: true, data: {
280
+ id,
281
+ accepted: acceptance.accepted,
282
+ delivered: acceptance.accepted,
283
+ ...(acceptance.code ? { code: acceptance.code } : {}),
284
+ ...(acceptance.error ? { error: acceptance.error } : {}),
285
+ } };
286
+ }
287
+ register(cwd) {
342
288
  try {
343
- const { id, cwd } = addDaemon(rawCwd);
344
- return { ok: true, data: { id, cwd } };
289
+ const entry = addDaemon(cwd);
290
+ this.startEntry(entry);
291
+ return { ok: true, data: { id: entry.id, cwd: entry.cwd, name: entry.name, desired_state: entry.desired_state } };
345
292
  }
346
- catch (e) {
347
- return { ok: false, error: e.message };
293
+ catch (error) {
294
+ return { ok: false, error: error.message };
348
295
  }
349
296
  }
350
- async _opUnregister(id) {
351
- // Stop the child first so we don't leave an orphan when the registry
352
- // entry is gone.
297
+ async unregister(id) {
353
298
  const slot = this.children.get(id);
354
299
  if (slot) {
355
- if (slot.restartTimer !== null) {
356
- clearTimeout(slot.restartTimer);
357
- slot.restartTimer = null;
358
- }
300
+ this.cancelRetry(slot);
359
301
  await slot.child.stop();
360
302
  this.children.delete(id);
361
303
  }
362
304
  try {
363
- const result = removeDaemon(id);
364
- return { ok: true, data: result };
305
+ return { ok: true, data: removeDaemon(id) };
365
306
  }
366
- catch (e) {
367
- return { ok: false, error: e.message };
307
+ catch (error) {
308
+ return { ok: false, error: error.message };
368
309
  }
369
310
  }
370
- // ── Cron ops + engine (plan/39) ────────────────────────────────────────────
371
- _opCronAdd(req) {
372
- const v = validateSchedule(req.schedule, req.tz);
373
- if (!v.ok)
374
- return { ok: false, error: v.error ?? "invalid schedule" };
375
- const input = { daemon_id: req.daemon_id, schedule: req.schedule, prompt: req.prompt };
376
- if (req.tz !== undefined)
377
- input.tz = req.tz;
378
- if (req.skip_if_busy !== undefined)
379
- input.skip_if_busy = req.skip_if_busy;
380
- if (req.wake !== undefined)
381
- input.wake = req.wake;
382
- if (req.catchup !== undefined)
383
- input.catchup = req.catchup;
311
+ async unregisterCwd(cwd) {
312
+ const entry = findDaemonByCwd(cwd);
313
+ if (!entry)
314
+ return { ok: true, data: { removed: false } };
315
+ return this.unregister(entry.id);
316
+ }
317
+ cronAdd(request) {
318
+ const validation = validateSchedule(request.schedule, request.tz);
319
+ if (!validation.ok)
320
+ return { ok: false, error: validation.error ?? "invalid schedule" };
321
+ const input = { daemon_id: request.daemon_id, schedule: request.schedule, prompt: request.prompt };
322
+ if (request.tz !== undefined)
323
+ input.tz = request.tz;
324
+ if (request.skip_if_busy !== undefined)
325
+ input.skip_if_busy = request.skip_if_busy;
326
+ if (request.catchup !== undefined)
327
+ input.catchup = request.catchup;
384
328
  const job = addCronJob(input);
385
- this._scheduleCron(job);
386
- return { ok: true, data: { job: this._jobView(job) } };
387
- }
388
- _opCronList() {
389
- const jobs = listCronJobs().map((j) => this._jobView(j));
390
- return { ok: true, data: { jobs } };
329
+ this.scheduleCron(job);
330
+ return { ok: true, data: { job: this.jobView(job) } };
391
331
  }
392
- _opCronRemove(jobId) {
332
+ cronRemove(jobId) {
393
333
  const removed = removeCronJob(jobId);
394
- this._stopCron(jobId);
334
+ this.stopCron(jobId);
395
335
  return { ok: true, data: { removed } };
396
336
  }
397
- _opCronEnable(jobId, enabled) {
337
+ cronEnable(jobId, enabled) {
398
338
  const updated = setJobEnabled(jobId, enabled);
399
339
  if (updated) {
400
- this._stopCron(jobId);
340
+ this.stopCron(jobId);
401
341
  const job = getCronJob(jobId);
402
342
  if (enabled && job)
403
- this._scheduleCron(job);
343
+ this.scheduleCron(job);
404
344
  }
405
345
  return { ok: true, data: { job_id: jobId, enabled, updated } };
406
346
  }
407
- async _opCronRun(jobId) {
347
+ async cronRun(jobId) {
408
348
  if (!getCronJob(jobId))
409
349
  return { ok: false, error: `no cron job with id ${jobId}` };
410
- const result = await this.fireJob(jobId, { manual: true });
411
- return { ok: true, data: { job_id: jobId, result } };
412
- }
413
- _opCronLog(jobId, tail) {
414
- const opts = {};
415
- if (jobId !== undefined)
416
- opts.jobId = jobId;
417
- if (tail !== undefined)
418
- opts.tail = tail;
419
- return { ok: true, data: { entries: readCronLog(opts) } };
420
- }
421
- _jobView(job) {
350
+ return { ok: true, data: { job_id: jobId, result: await this.fireJob(jobId, { manual: true }) } };
351
+ }
352
+ jobView(job) {
422
353
  const next = nextRunFor(job);
423
- return { ...job, next_run: next ? next.toISOString() : null };
354
+ return { ...job, next_run: next?.toISOString() ?? null };
424
355
  }
425
- /** Rebuild all live `Cron` schedules from the registry (enabled jobs only).
426
- * Called on start; mutations reconcile incrementally via _scheduleCron/_stopCron. */
427
- _reconcileCron() {
428
- for (const c of this.cronJobs.values())
429
- c.stop();
356
+ reconcileCron() {
357
+ for (const cron of this.cronJobs.values())
358
+ cron.stop();
430
359
  this.cronJobs.clear();
431
- for (const job of listCronJobs()) {
360
+ for (const job of listCronJobs())
432
361
  if (job.enabled)
433
- this._scheduleCron(job);
434
- }
362
+ this.scheduleCron(job);
435
363
  }
436
- _scheduleCron(job) {
437
- this._stopCron(job.id);
364
+ scheduleCron(job) {
365
+ this.stopCron(job.id);
438
366
  try {
439
- const opts = job.tz ? { timezone: job.tz, name: job.id } : { name: job.id };
440
- const cron = new Cron(job.schedule, opts, () => { void this.fireJob(job.id); });
441
- this.cronJobs.set(job.id, cron);
367
+ const options = job.tz ? { timezone: job.tz, name: job.id } : { name: job.id };
368
+ this.cronJobs.set(job.id, new Cron(job.schedule, options, () => { void this.fireJob(job.id); }));
442
369
  }
443
- catch (e) {
444
- process.stderr.write(`[remote-pi-supervisord] cron schedule failed for ${job.id}: ${String(e)}\n`);
370
+ catch (error) {
371
+ process.stderr.write(`[remote-pi-supervisord] cron schedule failed for ${job.id}: ${String(error)}\n`);
445
372
  }
446
373
  }
447
- _stopCron(jobId) {
448
- const c = this.cronJobs.get(jobId);
449
- if (c) {
450
- c.stop();
374
+ stopCron(jobId) {
375
+ const cron = this.cronJobs.get(jobId);
376
+ if (cron) {
377
+ cron.stop();
451
378
  this.cronJobs.delete(jobId);
452
379
  }
453
380
  }
454
- /** Detail 2: on start, run a catchup job once if its previous scheduled run
455
- * was missed while the supervisor was down. Opt-in (`catchup`), at most 1×. */
456
- _runCatchup() {
381
+ runCatchup() {
457
382
  for (const job of listCronJobs()) {
458
383
  if (!job.enabled || !job.catchup)
459
384
  continue;
460
385
  try {
461
386
  const cron = new Cron(job.schedule, job.tz ? { timezone: job.tz } : {});
462
- const prev = cron.previousRun();
387
+ const previous = cron.previousRun();
463
388
  cron.stop();
464
- if (!prev)
465
- continue;
466
- const lastRunMs = job.last_run ? Date.parse(job.last_run) : 0;
467
- if (prev.getTime() > lastRunMs)
389
+ if (previous && previous.getTime() > (job.last_run ? Date.parse(job.last_run) : 0)) {
468
390
  void this.fireJob(job.id, { manual: true });
391
+ }
469
392
  }
470
- catch { /* skip a malformed schedule */ }
393
+ catch { /* schedule was checked when added; corrupt persisted data simply skips */ }
471
394
  }
472
395
  }
473
- /**
474
- * Fires a cron job: resolves the daemon, decides the action (decideFireAction),
475
- * acts, and records the outcome — ALWAYS one `last_status` update + one JSONL
476
- * line, for both fires and skips. Returns the result. `manual` bypasses the
477
- * disabled-skip (used by `cron run` + catchup).
478
- */
479
- async fireJob(jobId, opts = {}) {
396
+ async fireJob(jobId, options = {}) {
480
397
  const job = getCronJob(jobId);
481
398
  if (!job)
482
399
  return "missing";
483
400
  let result;
484
- if (!job.enabled && !opts.manual) {
401
+ if (!job.enabled && !options.manual) {
485
402
  result = "skipped_disabled";
486
403
  }
487
404
  else {
488
- const slot = this.children.get(job.daemon_id);
489
- const running = !!slot && slot.child.state === "running";
490
- let busy = false;
491
- if (running && job.skip_if_busy)
492
- busy = await slot.child.refreshBusy();
493
- const action = decideFireAction({ running, busy, wake: job.wake, skipIfBusy: job.skip_if_busy });
494
- if (action === "skip_down") {
495
- result = "skipped_down";
496
- }
497
- else if (action === "skip_busy") {
498
- result = "skipped_busy";
499
- }
500
- else if (action === "wake_and_send") {
501
- const entry = listDaemons().find((d) => d.id === job.daemon_id);
502
- if (!entry) {
503
- result = "skipped_down";
504
- }
505
- else {
506
- this._spawnEntry(entry.id, entry.cwd, entry.name);
507
- const woke = this.children.get(job.daemon_id);
508
- result = woke && woke.child.sendPrompt(job.prompt) ? "woke_and_delivered" : "deliver_failed";
509
- }
405
+ const entry = listDaemons().find((item) => item.id === job.daemon_id);
406
+ const cwdExists = !!entry && existsSync(entry.cwd);
407
+ const slot = cwdExists && entry ? this.children.get(entry.id) : undefined;
408
+ const info = cwdExists && entry ? this.infoFor(entry) : undefined;
409
+ const busy = !!slot && info?.runtime === "ready" && job.skip_if_busy ? await slot.child.refreshBusy() : false;
410
+ const action = decideFireAction({
411
+ exists: cwdExists,
412
+ desired: entry?.desired_state ?? "stopped",
413
+ runtime: info?.runtime ?? "pending",
414
+ health: info?.health ?? "failed",
415
+ busy,
416
+ skipIfBusy: job.skip_if_busy,
417
+ retrying: info?.retrying ?? false,
418
+ });
419
+ if (action === "send") {
420
+ result = slot && (await slot.child.sendPrompt(job.prompt)).accepted ? "accepted" : "rejected";
510
421
  }
511
422
  else {
512
- result = slot.child.sendPrompt(job.prompt) ? "delivered" : "deliver_failed";
423
+ result = this.cronResultFor(action);
513
424
  }
514
425
  }
515
426
  const at = new Date().toISOString();
@@ -517,80 +428,179 @@ export class Supervisor {
517
428
  appendCronLog({ job_id: job.id, daemon_id: job.daemon_id, schedule: job.schedule, result, prompt: job.prompt });
518
429
  return result;
519
430
  }
520
- // ── Child lifecycle ──────────────────────────────────────────────────────
521
- _spawnAllFromRegistry() {
522
- for (const entry of listDaemons()) {
523
- this._spawnEntry(entry.id, entry.cwd, entry.name);
431
+ cronResultFor(action) {
432
+ switch (action) {
433
+ case "skip_busy": return "skipped_busy";
434
+ case "skip_desired_stopped": return "skipped_desired_stopped";
435
+ case "skip_starting": return "skipped_starting";
436
+ case "skip_retrying": return "skipped_retrying";
437
+ case "skip_failed": return "skipped_failed";
438
+ case "skip_blocked": return "skipped_blocked";
439
+ case "skip_missing": return "skipped_missing";
524
440
  }
525
441
  }
526
- _spawnEntry(id, cwd, name) {
527
- // Clean up any prior slot (e.g. crashed + waiting for backoff).
528
- const existing = this.children.get(id);
529
- if (existing) {
530
- if (existing.restartTimer !== null)
531
- clearTimeout(existing.restartTimer);
532
- // If somehow the child is still alive, stop it first so we don't
533
- // leak. Fire-and-forget — caller doesn't await.
534
- if (existing.child.state === "running")
535
- void existing.child.stop();
536
- }
537
- // Build the daemon's config and inject it via REMOTE_PI_DIRECT_CONFIG —
538
- // no per-cwd config file needed. The daemon scopes by (cwd, name) like any
539
- // agent (plan/38); relay on.
540
- const config = {
541
- agent_name: name ?? defaultAgentName(cwd),
542
- auto_start_relay: true,
442
+ spawnDesiredEntries() {
443
+ for (const entry of listDaemons())
444
+ if (entry.desired_state === "running")
445
+ this.createSlot(entry);
446
+ }
447
+ createSlot(entry) {
448
+ const config = { agent_name: entry.name || defaultAgentName(entry.cwd), auto_start_relay: true };
449
+ const childOptions = { endpointId: entry.id, extensionPath: this.opts.extensionPath, cwd: entry.cwd, config };
450
+ if (this.opts.piBin)
451
+ childOptions.piBin = this.opts.piBin;
452
+ const child = new RpcChild(childOptions);
453
+ const slot = {
454
+ entry,
455
+ child,
456
+ restartTimer: null,
457
+ stabilityTimer: null,
458
+ restartAttempt: 0,
459
+ blocked: false,
460
+ missingCwdObservations: 0,
461
+ reconcileInProgress: false,
462
+ preflight: null,
543
463
  };
544
- const childOpts = {
545
- extensionPath: this.opts.extensionPath,
546
- cwd,
547
- config,
464
+ this.children.set(entry.id, slot);
465
+ child.on("exit", (event) => this.onChildExit(entry.id, event));
466
+ child.on("runtime_ready", () => this.onRuntimeReady(entry.id));
467
+ child.on("runtime_failed", (event) => this.onRuntimeFailed(entry.id, event));
468
+ void this.preflightAndSpawn(slot);
469
+ }
470
+ preflightAndSpawn(slot) {
471
+ if (slot.preflight)
472
+ return slot.preflight;
473
+ const run = async () => {
474
+ const result = await this.preflightFor(slot.entry);
475
+ if (this.children.get(slot.entry.id) !== slot || this.shuttingDown || slot.entry.desired_state !== "running")
476
+ return;
477
+ if (!result.ok) {
478
+ slot.blocked = true;
479
+ slot.error = { code: result.code, message: result.message, at: Date.now(), retryable: false, stage: "preflight" };
480
+ return;
481
+ }
482
+ slot.blocked = false;
483
+ slot.error = undefined;
484
+ slot.child.spawn();
548
485
  };
549
- if (this.opts.piBin !== undefined)
550
- childOpts.piBin = this.opts.piBin;
551
- const child = new RpcChild(childOpts);
552
- const slot = { id, cwd, child, restartTimer: null, restartAttempt: 0 };
553
- this.children.set(id, slot);
554
- child.on("exit", (evt) => this._onChildExit(id, evt));
555
- child.spawn();
556
- }
557
- _onChildExit(id, evt) {
558
- if (this.shuttingDown)
486
+ const pending = run().catch(() => {
487
+ if (this.children.get(slot.entry.id) !== slot || this.shuttingDown)
488
+ return;
489
+ slot.blocked = true;
490
+ slot.error = { code: "preflight_failed", message: "Pi SDK resource discovery failed", at: Date.now(), retryable: false, stage: "preflight" };
491
+ });
492
+ let tracked;
493
+ tracked = pending.finally(() => {
494
+ if (slot.preflight === tracked)
495
+ slot.preflight = null;
496
+ });
497
+ slot.preflight = tracked;
498
+ return tracked;
499
+ }
500
+ onRuntimeReady(id) {
501
+ const slot = this.children.get(id);
502
+ if (!slot || this.shuttingDown || slot.entry.desired_state !== "running")
559
503
  return;
504
+ if (slot.stabilityTimer)
505
+ clearTimeout(slot.stabilityTimer);
506
+ slot.stabilityTimer = setTimeout(() => {
507
+ slot.stabilityTimer = null;
508
+ if (this.children.get(id) === slot && slot.child.runtimeState === "ready" && !slot.blocked)
509
+ slot.restartAttempt = 0;
510
+ }, RESTART_STABILITY_MS);
511
+ slot.stabilityTimer.unref();
512
+ }
513
+ onRuntimeFailed(id, event) {
560
514
  const slot = this.children.get(id);
561
- if (!slot)
515
+ if (!slot || this.shuttingDown || slot.entry.desired_state === "stopped")
562
516
  return;
563
- if (!evt.isCrash) {
564
- // Clean shutdown (e.g. via `stop_all`). Don't auto-restart.
517
+ slot.error = { code: event.code, message: event.message, at: Date.now(), retryable: event.retryable, stage: event.stage ?? "runtime" };
518
+ if (!event.retryable) {
519
+ slot.blocked = true;
520
+ this.cancelRetry(slot);
521
+ this.cancelStabilityReset(slot);
522
+ void slot.child.stop();
565
523
  return;
566
524
  }
567
- if (evt.code === EXIT_DAEMON_FRESH_SESSION) {
568
- // App-triggered daemon `/new`: this is an intentional recycle, not a
569
- // crash. Restart immediately and don't burn the crash backoff budget.
525
+ this.scheduleRetry(slot, "runtime failure");
526
+ void slot.child.stop();
527
+ }
528
+ onChildExit(id, event) {
529
+ const slot = this.children.get(id);
530
+ if (!slot || this.shuttingDown || slot.entry.desired_state === "stopped" || slot.blocked)
531
+ return;
532
+ this.cancelStabilityReset(slot);
533
+ if (event.code === EXIT_DAEMON_FRESH_SESSION) {
570
534
  slot.restartAttempt = 0;
571
535
  slot.child.noteRestart();
572
- slot.child.spawn();
536
+ void this.preflightAndSpawn(slot);
573
537
  return;
574
538
  }
575
- // Crash: schedule restart with backoff. After exhausting the schedule
576
- // we give up and stay in `crashed`.
539
+ if (!event.isCrash)
540
+ return;
541
+ slot.error ??= { code: "child_exited", message: `child exited (${String(event.code ?? event.signal)})`, at: Date.now(), retryable: true, stage: "process" };
542
+ this.scheduleRetry(slot, "child exit");
543
+ }
544
+ scheduleRetry(slot, _reason) {
545
+ if (slot.restartTimer || slot.blocked || slot.entry.desired_state !== "running")
546
+ return;
577
547
  if (slot.restartAttempt >= RESTART_BACKOFFS_MS.length) {
578
- process.stderr.write(`[remote-pi-supervisord] giving up restart for ${id} after ${slot.restartAttempt} attempts\n`);
548
+ slot.error = { code: "retry_exhausted", message: "transient restart budget exhausted", at: Date.now(), retryable: false, stage: "retry" };
579
549
  return;
580
550
  }
581
551
  const delay = RESTART_BACKOFFS_MS[slot.restartAttempt];
582
- process.stderr.write(`[remote-pi-supervisord] scheduling restart of ${id} in ${delay}ms (attempt ${slot.restartAttempt + 1})\n`);
552
+ slot.nextRetryAt = Date.now() + delay;
583
553
  slot.restartTimer = setTimeout(() => {
584
554
  slot.restartTimer = null;
555
+ slot.nextRetryAt = undefined;
556
+ if (this.shuttingDown || slot.blocked || slot.entry.desired_state !== "running")
557
+ return;
585
558
  slot.restartAttempt += 1;
586
559
  slot.child.noteRestart();
587
- slot.child.spawn();
560
+ void this.preflightAndSpawn(slot);
588
561
  }, delay);
589
562
  }
563
+ cancelRetry(slot) {
564
+ if (slot.restartTimer)
565
+ clearTimeout(slot.restartTimer);
566
+ slot.restartTimer = null;
567
+ slot.nextRetryAt = undefined;
568
+ }
569
+ cancelStabilityReset(slot) {
570
+ if (slot.stabilityTimer)
571
+ clearTimeout(slot.stabilityTimer);
572
+ slot.stabilityTimer = null;
573
+ }
574
+ async reconcileRegistryCwds() {
575
+ if (this.reconcileRunning)
576
+ return;
577
+ this.reconcileRunning = true;
578
+ try {
579
+ for (const entry of listDaemons()) {
580
+ const slot = this.children.get(entry.id);
581
+ if (existsSync(entry.cwd)) {
582
+ if (slot)
583
+ slot.missingCwdObservations = 0;
584
+ continue;
585
+ }
586
+ if (!slot) {
587
+ removeDaemon(entry.id);
588
+ continue;
589
+ }
590
+ slot.missingCwdObservations += 1;
591
+ if (slot.missingCwdObservations < MISSING_CWD_CONFIRMATIONS || slot.reconcileInProgress)
592
+ continue;
593
+ slot.reconcileInProgress = true;
594
+ this.cancelRetry(slot);
595
+ await slot.child.stop();
596
+ this.children.delete(entry.id);
597
+ removeDaemon(entry.id);
598
+ }
599
+ }
600
+ finally {
601
+ this.reconcileRunning = false;
602
+ }
603
+ }
590
604
  }
591
- /** Test helper: derive id from cwd without going through the registry. */
592
- export function _idForCwdForTest(cwd) { return daemonIdForCwd(cwd); }
593
- /** Exported for the bin/supervisord entry + tests to know where the
594
- * supervisor will bind. */
595
605
  export function getSupervisorSockPath() { return supervisorSockPath(); }
596
606
  //# sourceMappingURL=supervisor.js.map