@agent-idle/cli 0.1.2 → 0.1.3

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 (2) hide show
  1. package/index.js +136 -45
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -210,6 +210,54 @@ function clearToken() {
210
210
  }
211
211
  }
212
212
 
213
+ // src/daemonControl.ts
214
+ import { spawn } from "child_process";
215
+ import { statSync } from "fs";
216
+ import { fileURLToPath } from "url";
217
+ function buildFingerprint() {
218
+ const script = fileURLToPath(new URL("./index.js", import.meta.url));
219
+ try {
220
+ return { script, mtimeMs: statSync(script).mtimeMs };
221
+ } catch {
222
+ return { script, mtimeMs: 0 };
223
+ }
224
+ }
225
+ function isNewerBuild(callerMtimeMs, own) {
226
+ return own.mtimeMs > 0 && Number.isFinite(callerMtimeMs) && callerMtimeMs > own.mtimeMs + 1;
227
+ }
228
+ function spawnDaemon() {
229
+ const indexPath = fileURLToPath(new URL("./index.js", import.meta.url));
230
+ const child = spawn(process.execPath, [indexPath, "daemon"], {
231
+ detached: true,
232
+ stdio: "ignore"
233
+ });
234
+ child.unref();
235
+ }
236
+ async function pingDaemon() {
237
+ try {
238
+ const controller = new AbortController();
239
+ const timer = setTimeout(() => controller.abort(), 500);
240
+ const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
241
+ clearTimeout(timer);
242
+ return res.ok;
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+ async function daemonToken() {
248
+ try {
249
+ const controller = new AbortController();
250
+ const timer = setTimeout(() => controller.abort(), 800);
251
+ const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
252
+ clearTimeout(timer);
253
+ if (!res.ok) return null;
254
+ const { token } = await res.json();
255
+ return token ?? null;
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+
213
261
  // src/outbox.ts
214
262
  import { appendFileSync, chmodSync as chmodSync2, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
215
263
  function tighten() {
@@ -332,6 +380,7 @@ var RENEW_MS = 12e3;
332
380
  var HELPER_CREDIT_FLUSH_MS = 2e3;
333
381
  var HELPER_RETAIN_MS = 6e3;
334
382
  var LIVENESS_POLL_MS = 5e3;
383
+ var OWN_BUILD = buildFingerprint();
335
384
  var DEBUG = Boolean(process.env.AGENT_IDLE_DEBUG) || process.argv.includes("--debug");
336
385
  function debug(...args) {
337
386
  if (DEBUG) console.log("[agent-idle]", ...args);
@@ -492,8 +541,17 @@ var ALLOWED_ORIGINS = (() => {
492
541
  // Vite dev / Tauri dev webview
493
542
  "tauri://localhost",
494
543
  "http://tauri.localhost",
495
- "https://tauri.localhost"
544
+ "https://tauri.localhost",
496
545
  // Tauri production webview schemes (platform-dependent)
546
+ // ALL first-party hosted-app origins, unconditionally. The daemon that owns the port
547
+ // is not always the build that opened the auth page (a dev daemon vs the npm CLI's
548
+ // prod page, or an old install vs a newer canonical domain) — if it only trusted its
549
+ // OWN AUTH_URL origin, the page's token post would 403 and `login` would hang. These
550
+ // endpoints only RECEIVE a token / serve local display hints; the secret-reading
551
+ // /token stays browser-denied regardless of origin.
552
+ "https://agent-idle.com",
553
+ "https://www.agent-idle.com",
554
+ "https://agent-idle-app.vercel.app"
497
555
  ]);
498
556
  try {
499
557
  out.add(new URL(AUTH_URL).origin);
@@ -874,6 +932,15 @@ function startDaemon() {
874
932
  res.end(JSON.stringify({ token: readToken() }));
875
933
  return;
876
934
  }
935
+ if (req.method === "GET" && req.url === "/build") {
936
+ if (req.headers.origin) {
937
+ res.writeHead(403).end();
938
+ return;
939
+ }
940
+ res.writeHead(200, { "Content-Type": "application/json" });
941
+ res.end(JSON.stringify({ ...OWN_BUILD, pid: process.pid }));
942
+ return;
943
+ }
877
944
  if (req.method === "GET" && req.url === "/sessions") {
878
945
  res.writeHead(200, { ...cors, "Content-Type": "application/json" });
879
946
  res.end(JSON.stringify(Object.fromEntries(sessionMeta)));
@@ -899,6 +966,11 @@ function startDaemon() {
899
966
  setTimeout(() => process.exit(0), 50);
900
967
  return;
901
968
  }
969
+ const callerBuild = Number(req.headers["x-agent-idle-build"]);
970
+ if (isNewerBuild(callerBuild, OWN_BUILD)) {
971
+ debug(`caller build ${callerBuild} > own ${OWN_BUILD.mtimeMs} \u2014 retiring after this request`);
972
+ setTimeout(() => process.exit(0), 250);
973
+ }
902
974
  if (req.method !== "POST") {
903
975
  res.writeHead(404, cors).end();
904
976
  return;
@@ -920,7 +992,59 @@ function startDaemon() {
920
992
  res.writeHead(204, cors).end();
921
993
  });
922
994
  });
923
- server.on("error", () => process.exit(0));
995
+ async function isOurPreGuardDaemon() {
996
+ try {
997
+ const controller = new AbortController();
998
+ const timer = setTimeout(() => controller.abort(), 800);
999
+ const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
1000
+ clearTimeout(timer);
1001
+ if (!res.ok) return false;
1002
+ const body = await res.json();
1003
+ return "token" in body;
1004
+ } catch {
1005
+ return false;
1006
+ }
1007
+ }
1008
+ async function takeoverIfStale() {
1009
+ try {
1010
+ const controller = new AbortController();
1011
+ const timer = setTimeout(() => controller.abort(), 800);
1012
+ const res = await fetch(`${DAEMON_URL}/build`, { signal: controller.signal });
1013
+ clearTimeout(timer);
1014
+ if (res.ok) {
1015
+ const incumbent = await res.json();
1016
+ if (!isNewerBuild(OWN_BUILD.mtimeMs, { script: "", mtimeMs: incumbent.mtimeMs ?? 0 })) {
1017
+ process.exit(0);
1018
+ }
1019
+ debug(`incumbent build ${incumbent.mtimeMs} is stale \u2014 asking it to retire`);
1020
+ } else {
1021
+ if (!await isOurPreGuardDaemon()) process.exit(0);
1022
+ debug("incumbent predates the build guard \u2014 asking it to retire");
1023
+ }
1024
+ await fetch(`${DAEMON_URL}/shutdown`, { method: "POST" }).catch(() => {
1025
+ });
1026
+ for (let i = 0; i < 20; i++) {
1027
+ await new Promise((r) => setTimeout(r, 200));
1028
+ if (!await pingDaemon()) {
1029
+ server.listen(DAEMON_PORT, "127.0.0.1", () => {
1030
+ console.log(`agent-idle daemon took over port ${DAEMON_PORT} from a stale build`);
1031
+ });
1032
+ return;
1033
+ }
1034
+ }
1035
+ } catch {
1036
+ }
1037
+ process.exit(0);
1038
+ }
1039
+ let takeoverAttempted = false;
1040
+ server.on("error", (err) => {
1041
+ if (err.code === "EADDRINUSE" && !takeoverAttempted) {
1042
+ takeoverAttempted = true;
1043
+ void takeoverIfStale();
1044
+ return;
1045
+ }
1046
+ process.exit(0);
1047
+ });
924
1048
  server.listen(DAEMON_PORT, "127.0.0.1", () => {
925
1049
  console.log(`agent-idle daemon listening on http://127.0.0.1:${DAEMON_PORT}`);
926
1050
  console.log(
@@ -934,44 +1058,6 @@ function startDaemon() {
934
1058
 
935
1059
  // src/hook.ts
936
1060
  import { execFileSync as execFileSync2 } from "child_process";
937
-
938
- // src/daemonControl.ts
939
- import { spawn } from "child_process";
940
- import { fileURLToPath } from "url";
941
- function spawnDaemon() {
942
- const indexPath = fileURLToPath(new URL("./index.js", import.meta.url));
943
- const child = spawn(process.execPath, [indexPath, "daemon"], {
944
- detached: true,
945
- stdio: "ignore"
946
- });
947
- child.unref();
948
- }
949
- async function pingDaemon() {
950
- try {
951
- const controller = new AbortController();
952
- const timer = setTimeout(() => controller.abort(), 500);
953
- const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
954
- clearTimeout(timer);
955
- return res.ok;
956
- } catch {
957
- return false;
958
- }
959
- }
960
- async function daemonToken() {
961
- try {
962
- const controller = new AbortController();
963
- const timer = setTimeout(() => controller.abort(), 800);
964
- const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
965
- clearTimeout(timer);
966
- if (!res.ok) return null;
967
- const { token } = await res.json();
968
- return token ?? null;
969
- } catch {
970
- return null;
971
- }
972
- }
973
-
974
- // src/hook.ts
975
1061
  var SHELL_COMMS = /* @__PURE__ */ new Set([
976
1062
  "sh",
977
1063
  "bash",
@@ -1052,7 +1138,13 @@ async function postToDaemon(url, body) {
1052
1138
  const timer = setTimeout(() => controller.abort(), 800);
1053
1139
  await fetch(url, {
1054
1140
  method: "POST",
1055
- headers: { "Content-Type": "application/json" },
1141
+ headers: {
1142
+ "Content-Type": "application/json",
1143
+ // Stale-daemon guard: the hook is spawned fresh per event, so this mtime always
1144
+ // reflects the build ON DISK. A daemon seeing a newer caller retires itself and
1145
+ // the next hook respawns the new build — no manual `kill` after updates.
1146
+ "x-agent-idle-build": String(buildFingerprint().mtimeMs)
1147
+ },
1056
1148
  body,
1057
1149
  signal: controller.signal
1058
1150
  });
@@ -1205,10 +1297,9 @@ async function signIn() {
1205
1297
  );
1206
1298
  return;
1207
1299
  }
1208
- if (!await pingDaemon()) {
1209
- spawnDaemon();
1210
- await sleep3(700);
1211
- }
1300
+ spawnDaemon();
1301
+ const settleBy = Date.now() + 5e3;
1302
+ while (Date.now() < settleBy && !await pingDaemon()) await sleep3(250);
1212
1303
  console.log(`
1213
1304
  Opening ${AUTH_URL} to sign in (GitHub or email + password)\u2026`);
1214
1305
  console.log(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-idle/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "agent-idle CLI: setup, ui, and the headless sensor daemon.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",