@hua-labs/tap 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,45 +1,116 @@
1
1
  // src/state.ts
2
- import * as fs2 from "fs";
3
- import * as path2 from "path";
2
+ import * as fs3 from "fs";
3
+ import * as path3 from "path";
4
4
  import * as crypto from "crypto";
5
5
 
6
6
  // src/config/resolve.ts
7
+ import * as fs2 from "fs";
8
+ import * as path2 from "path";
9
+
10
+ // src/utils.ts
7
11
  import * as fs from "fs";
8
12
  import * as path from "path";
9
- var SHARED_CONFIG_FILE = "tap-config.json";
10
- var LOCAL_CONFIG_FILE = "tap-config.local.json";
11
- var DEFAULT_RUNTIME_COMMAND = "node";
12
- var DEFAULT_APP_SERVER_URL = "ws://127.0.0.1:4501";
13
+ var _noGitWarned = false;
14
+ function _setNoGitWarned() {
15
+ _noGitWarned = true;
16
+ }
13
17
  function findRepoRoot(startDir = process.cwd()) {
14
18
  let dir = path.resolve(startDir);
15
19
  while (true) {
16
20
  if (fs.existsSync(path.join(dir, ".git"))) return dir;
17
- if (fs.existsSync(path.join(dir, "package.json"))) return dir;
21
+ if (fs.existsSync(path.join(dir, "package.json"))) {
22
+ if (!_noGitWarned) {
23
+ _setNoGitWarned();
24
+ logWarn(
25
+ "No .git directory found. Resolved repo root via package.json \u2014 comms directory may be created in an unexpected location. Use --comms-dir to specify explicitly."
26
+ );
27
+ }
28
+ return dir;
29
+ }
18
30
  const parent = path.dirname(dir);
19
31
  if (parent === dir) break;
20
32
  dir = parent;
21
33
  }
34
+ if (!_noGitWarned) {
35
+ _setNoGitWarned();
36
+ logWarn(
37
+ "No git repository or package.json found. Using current directory as root. Run 'git init' first, or use --comms-dir to specify the comms path."
38
+ );
39
+ }
40
+ return process.cwd();
41
+ }
42
+ var _jsonMode = false;
43
+ function logWarn(message) {
44
+ if (!_jsonMode) console.log(` ! ${message}`);
45
+ }
46
+
47
+ // src/config/resolve.ts
48
+ var SHARED_CONFIG_FILE = "tap-config.json";
49
+ var LOCAL_CONFIG_FILE = "tap-config.local.json";
50
+ var LEGACY_CONFIG_FILE = ".tap-config";
51
+ var DEFAULT_RUNTIME_COMMAND = "node";
52
+ var DEFAULT_APP_SERVER_URL = "ws://127.0.0.1:4501";
53
+ function findRepoRoot2(startDir = process.cwd()) {
54
+ let dir = path2.resolve(startDir);
55
+ while (true) {
56
+ if (fs2.existsSync(path2.join(dir, ".git"))) return dir;
57
+ if (fs2.existsSync(path2.join(dir, "package.json"))) {
58
+ if (!_noGitWarned) {
59
+ _setNoGitWarned();
60
+ console.error(
61
+ "[tap] warning: No .git directory found. Resolved via package.json. Use --comms-dir to specify explicitly."
62
+ );
63
+ }
64
+ return dir;
65
+ }
66
+ const parent = path2.dirname(dir);
67
+ if (parent === dir) break;
68
+ dir = parent;
69
+ }
70
+ if (!_noGitWarned) {
71
+ _setNoGitWarned();
72
+ console.error(
73
+ "[tap] warning: No git repository found. Using cwd as root. Run 'git init' or use --comms-dir."
74
+ );
75
+ }
22
76
  return process.cwd();
23
77
  }
24
78
  function loadJsonFile(filePath) {
25
- if (!fs.existsSync(filePath)) return null;
79
+ if (!fs2.existsSync(filePath)) return null;
26
80
  try {
27
- const raw = fs.readFileSync(filePath, "utf-8");
81
+ const raw = fs2.readFileSync(filePath, "utf-8");
28
82
  return JSON.parse(raw);
29
83
  } catch {
30
84
  return null;
31
85
  }
32
86
  }
33
87
  function loadSharedConfig(repoRoot) {
34
- return loadJsonFile(path.join(repoRoot, SHARED_CONFIG_FILE));
88
+ return loadJsonFile(path2.join(repoRoot, SHARED_CONFIG_FILE));
35
89
  }
36
90
  function loadLocalConfig(repoRoot) {
37
- return loadJsonFile(path.join(repoRoot, LOCAL_CONFIG_FILE));
91
+ return loadJsonFile(path2.join(repoRoot, LOCAL_CONFIG_FILE));
92
+ }
93
+ function readLegacyShellValue(configText, key) {
94
+ const match = configText.match(new RegExp(`^${key}="?(.+?)"?$`, "m"));
95
+ return match?.[1]?.trim() || null;
96
+ }
97
+ function loadLegacyShellConfig(repoRoot) {
98
+ const filePath = path2.join(repoRoot, LEGACY_CONFIG_FILE);
99
+ if (!fs2.existsSync(filePath)) return null;
100
+ try {
101
+ const raw = fs2.readFileSync(filePath, "utf-8");
102
+ const commsDir = readLegacyShellValue(raw, "TAP_COMMS_DIR");
103
+ if (!commsDir) return null;
104
+ return { commsDir };
105
+ } catch {
106
+ return null;
107
+ }
38
108
  }
39
109
  function resolveConfig(overrides = {}, startDir) {
40
- const repoRoot = findRepoRoot(startDir);
110
+ const repoRoot = findRepoRoot2(startDir);
41
111
  const shared = loadSharedConfig(repoRoot) ?? {};
42
112
  const local = loadLocalConfig(repoRoot) ?? {};
113
+ const legacy = loadLegacyShellConfig(repoRoot) ?? {};
43
114
  const sources = {
44
115
  repoRoot: "auto",
45
116
  commsDir: "auto",
@@ -49,10 +120,10 @@ function resolveConfig(overrides = {}, startDir) {
49
120
  };
50
121
  let commsDir;
51
122
  if (overrides.commsDir) {
52
- commsDir = path.resolve(overrides.commsDir);
123
+ commsDir = resolvePath(repoRoot, overrides.commsDir);
53
124
  sources.commsDir = "cli-flag";
54
125
  } else if (process.env.TAP_COMMS_DIR) {
55
- commsDir = path.resolve(process.env.TAP_COMMS_DIR);
126
+ commsDir = resolvePath(repoRoot, process.env.TAP_COMMS_DIR);
56
127
  sources.commsDir = "env";
57
128
  } else if (local.commsDir) {
58
129
  commsDir = resolvePath(repoRoot, local.commsDir);
@@ -60,15 +131,18 @@ function resolveConfig(overrides = {}, startDir) {
60
131
  } else if (shared.commsDir) {
61
132
  commsDir = resolvePath(repoRoot, shared.commsDir);
62
133
  sources.commsDir = "shared-config";
134
+ } else if (legacy.commsDir) {
135
+ commsDir = resolvePath(repoRoot, legacy.commsDir);
136
+ sources.commsDir = "legacy-shell-config";
63
137
  } else {
64
- commsDir = path.join(path.dirname(repoRoot), "tap-comms");
138
+ commsDir = path2.join(repoRoot, "tap-comms");
65
139
  }
66
140
  let stateDir;
67
141
  if (overrides.stateDir) {
68
- stateDir = path.resolve(overrides.stateDir);
142
+ stateDir = resolvePath(repoRoot, overrides.stateDir);
69
143
  sources.stateDir = "cli-flag";
70
144
  } else if (process.env.TAP_STATE_DIR) {
71
- stateDir = path.resolve(process.env.TAP_STATE_DIR);
145
+ stateDir = resolvePath(repoRoot, process.env.TAP_STATE_DIR);
72
146
  sources.stateDir = "env";
73
147
  } else if (local.stateDir) {
74
148
  stateDir = resolvePath(repoRoot, local.stateDir);
@@ -77,7 +151,7 @@ function resolveConfig(overrides = {}, startDir) {
77
151
  stateDir = resolvePath(repoRoot, shared.stateDir);
78
152
  sources.stateDir = "shared-config";
79
153
  } else {
80
- stateDir = path.join(repoRoot, ".tap-comms");
154
+ stateDir = path2.join(repoRoot, ".tap-comms");
81
155
  }
82
156
  let runtimeCommand;
83
157
  if (overrides.runtimeCommand) {
@@ -117,19 +191,33 @@ function resolveConfig(overrides = {}, startDir) {
117
191
  };
118
192
  }
119
193
  function saveSharedConfig(repoRoot, config) {
120
- const filePath = path.join(repoRoot, SHARED_CONFIG_FILE);
194
+ const filePath = path2.join(repoRoot, SHARED_CONFIG_FILE);
121
195
  const tmp = `${filePath}.tmp.${process.pid}`;
122
- fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
123
- fs.renameSync(tmp, filePath);
196
+ fs2.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
197
+ fs2.renameSync(tmp, filePath);
124
198
  }
125
199
  function saveLocalConfig(repoRoot, config) {
126
- const filePath = path.join(repoRoot, LOCAL_CONFIG_FILE);
200
+ const filePath = path2.join(repoRoot, LOCAL_CONFIG_FILE);
127
201
  const tmp = `${filePath}.tmp.${process.pid}`;
128
- fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
129
- fs.renameSync(tmp, filePath);
202
+ fs2.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
203
+ fs2.renameSync(tmp, filePath);
130
204
  }
131
205
  function resolvePath(repoRoot, p) {
132
- return path.isAbsolute(p) ? p : path.resolve(repoRoot, p);
206
+ const normalized = normalizeTapPath(p);
207
+ return path2.isAbsolute(normalized) ? normalized : path2.resolve(repoRoot, normalized);
208
+ }
209
+ function normalizeTapPath(input) {
210
+ const trimmed = input.trim().replace(/^["'`]+|["'`]+$/g, "");
211
+ if (/^[A-Za-z]:[\\/]/.test(trimmed)) {
212
+ return trimmed;
213
+ }
214
+ if (process.platform === "win32") {
215
+ const match = trimmed.match(/^\/([A-Za-z])\/(.*)$/);
216
+ if (match) {
217
+ return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, "\\")}`;
218
+ }
219
+ }
220
+ return trimmed;
133
221
  }
134
222
 
135
223
  // src/state.ts
@@ -140,10 +228,10 @@ function getStateDir(repoRoot) {
140
228
  return config.stateDir;
141
229
  }
142
230
  function getStatePath(repoRoot) {
143
- return path2.join(getStateDir(repoRoot), STATE_FILE);
231
+ return path3.join(getStateDir(repoRoot), STATE_FILE);
144
232
  }
145
233
  function stateExists(repoRoot) {
146
- return fs2.existsSync(getStatePath(repoRoot));
234
+ return fs3.existsSync(getStatePath(repoRoot));
147
235
  }
148
236
  function migrateStateV1toV2(v1) {
149
237
  const instances = {};
@@ -171,8 +259,8 @@ function migrateStateV1toV2(v1) {
171
259
  }
172
260
  function loadState(repoRoot) {
173
261
  const statePath = getStatePath(repoRoot);
174
- if (!fs2.existsSync(statePath)) return null;
175
- const raw = fs2.readFileSync(statePath, "utf-8");
262
+ if (!fs3.existsSync(statePath)) return null;
263
+ const raw = fs3.readFileSync(statePath, "utf-8");
176
264
  const parsed = JSON.parse(raw);
177
265
  if (parsed.schemaVersion === 1 || parsed.runtimes) {
178
266
  const migrated = migrateStateV1toV2(parsed);
@@ -183,11 +271,11 @@ function loadState(repoRoot) {
183
271
  }
184
272
  function saveState(repoRoot, state) {
185
273
  const stateDir = getStateDir(repoRoot);
186
- fs2.mkdirSync(stateDir, { recursive: true });
274
+ fs3.mkdirSync(stateDir, { recursive: true });
187
275
  const statePath = getStatePath(repoRoot);
188
276
  const tmp = `${statePath}.tmp.${process.pid}`;
189
- fs2.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
190
- fs2.renameSync(tmp, statePath);
277
+ fs3.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
278
+ fs3.renameSync(tmp, statePath);
191
279
  }
192
280
  function createInitialState(commsDir, repoRoot, packageVersion) {
193
281
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -195,30 +283,49 @@ function createInitialState(commsDir, repoRoot, packageVersion) {
195
283
  schemaVersion: SCHEMA_VERSION,
196
284
  createdAt: now,
197
285
  updatedAt: now,
198
- commsDir: path2.resolve(commsDir),
199
- repoRoot: path2.resolve(repoRoot),
286
+ commsDir: path3.resolve(commsDir),
287
+ repoRoot: path3.resolve(repoRoot),
200
288
  packageVersion,
201
289
  instances: {}
202
290
  };
203
291
  }
204
292
 
205
293
  // src/version.ts
206
- var version = "0.1.0";
207
-
208
- // src/engine/bridge.ts
209
294
  import * as fs4 from "fs";
210
295
  import * as path4 from "path";
211
- import { spawn, execSync as execSync2 } from "child_process";
296
+ import { fileURLToPath } from "url";
297
+ var FALLBACK_VERSION = "0.0.0";
298
+ function resolvePackageVersion(metaUrl = import.meta.url) {
299
+ const moduleDir = path4.dirname(fileURLToPath(metaUrl));
300
+ const packageJsonPath = path4.join(moduleDir, "..", "package.json");
301
+ try {
302
+ const parsed = JSON.parse(fs4.readFileSync(packageJsonPath, "utf-8"));
303
+ if (typeof parsed.version === "string" && parsed.version.trim()) {
304
+ return parsed.version;
305
+ }
306
+ } catch {
307
+ }
308
+ return FALLBACK_VERSION;
309
+ }
310
+ var version = resolvePackageVersion();
311
+
312
+ // src/engine/bridge.ts
313
+ import * as fs6 from "fs";
314
+ import * as net from "net";
315
+ import * as path6 from "path";
316
+ import { randomBytes } from "crypto";
317
+ import { spawn, spawnSync, execSync as execSync2 } from "child_process";
318
+ import { fileURLToPath as fileURLToPath2 } from "url";
212
319
 
213
320
  // src/runtime/resolve-node.ts
214
- import * as fs3 from "fs";
215
- import * as path3 from "path";
321
+ import * as fs5 from "fs";
322
+ import * as path5 from "path";
216
323
  import { execSync } from "child_process";
217
324
  function readNodeVersion(repoRoot) {
218
- const nvFile = path3.join(repoRoot, ".node-version");
219
- if (!fs3.existsSync(nvFile)) return null;
325
+ const nvFile = path5.join(repoRoot, ".node-version");
326
+ if (!fs5.existsSync(nvFile)) return null;
220
327
  try {
221
- const raw = fs3.readFileSync(nvFile, "utf-8").trim();
328
+ const raw = fs5.readFileSync(nvFile, "utf-8").trim();
222
329
  return raw.length > 0 ? raw.replace(/^v/, "") : null;
223
330
  } catch {
224
331
  return null;
@@ -228,16 +335,16 @@ function fnmCandidateDirs() {
228
335
  if (process.platform === "win32") {
229
336
  return [
230
337
  process.env.FNM_DIR,
231
- process.env.APPDATA ? path3.join(process.env.APPDATA, "fnm") : null,
232
- process.env.LOCALAPPDATA ? path3.join(process.env.LOCALAPPDATA, "fnm") : null,
233
- process.env.USERPROFILE ? path3.join(process.env.USERPROFILE, "scoop", "persist", "fnm") : null
338
+ process.env.APPDATA ? path5.join(process.env.APPDATA, "fnm") : null,
339
+ process.env.LOCALAPPDATA ? path5.join(process.env.LOCALAPPDATA, "fnm") : null,
340
+ process.env.USERPROFILE ? path5.join(process.env.USERPROFILE, "scoop", "persist", "fnm") : null
234
341
  ].filter(Boolean);
235
342
  }
236
343
  return [
237
344
  process.env.FNM_DIR,
238
- process.env.HOME ? path3.join(process.env.HOME, ".local", "share", "fnm") : null,
239
- process.env.HOME ? path3.join(process.env.HOME, ".fnm") : null,
240
- process.env.XDG_DATA_HOME ? path3.join(process.env.XDG_DATA_HOME, "fnm") : null
345
+ process.env.HOME ? path5.join(process.env.HOME, ".local", "share", "fnm") : null,
346
+ process.env.HOME ? path5.join(process.env.HOME, ".fnm") : null,
347
+ process.env.XDG_DATA_HOME ? path5.join(process.env.XDG_DATA_HOME, "fnm") : null
241
348
  ].filter(Boolean);
242
349
  }
243
350
  function nodeExecutableName() {
@@ -247,14 +354,14 @@ function probeFnmNode(desiredVersion) {
247
354
  const dirs = fnmCandidateDirs();
248
355
  const exe = nodeExecutableName();
249
356
  for (const baseDir of dirs) {
250
- const candidate = path3.join(
357
+ const candidate = path5.join(
251
358
  baseDir,
252
359
  "node-versions",
253
360
  `v${desiredVersion}`,
254
361
  "installation",
255
362
  exe
256
363
  );
257
- if (!fs3.existsSync(candidate)) continue;
364
+ if (!fs5.existsSync(candidate)) continue;
258
365
  try {
259
366
  const v = execSync(`"${candidate}" --version`, {
260
367
  encoding: "utf-8",
@@ -295,12 +402,12 @@ function checkStripTypesSupport(command) {
295
402
  }
296
403
  function findTsxFallback(repoRoot) {
297
404
  const candidates = [
298
- path3.join(repoRoot, "node_modules", ".bin", "tsx.exe"),
299
- path3.join(repoRoot, "node_modules", ".bin", "tsx.CMD"),
300
- path3.join(repoRoot, "node_modules", ".bin", "tsx")
405
+ path5.join(repoRoot, "node_modules", ".bin", "tsx.exe"),
406
+ path5.join(repoRoot, "node_modules", ".bin", "tsx.CMD"),
407
+ path5.join(repoRoot, "node_modules", ".bin", "tsx")
301
408
  ];
302
409
  for (const c of candidates) {
303
- if (fs3.existsSync(c)) return c;
410
+ if (fs5.existsSync(c)) return c;
304
411
  }
305
412
  return null;
306
413
  }
@@ -309,7 +416,7 @@ function getFnmBinDir(repoRoot) {
309
416
  if (!desiredVersion) return null;
310
417
  const nodePath = probeFnmNode(desiredVersion);
311
418
  if (!nodePath) return null;
312
- return path3.dirname(nodePath);
419
+ return path5.dirname(nodePath);
313
420
  }
314
421
  function resolveNodeRuntime(configCommand, repoRoot) {
315
422
  if (configCommand === "bun" || configCommand.endsWith("bun.exe")) {
@@ -365,19 +472,53 @@ function buildRuntimeEnv(repoRoot, baseEnv = process.env) {
365
472
  const currentPath = baseEnv[pathKey] ?? baseEnv.PATH ?? "";
366
473
  return {
367
474
  ...baseEnv,
368
- [pathKey]: `${fnmBin}${path3.delimiter}${currentPath}`
475
+ [pathKey]: `${fnmBin}${path5.delimiter}${currentPath}`
369
476
  };
370
477
  }
371
478
 
372
479
  // src/engine/bridge.ts
480
+ var APP_SERVER_AUTH_FILE_MODE = 384;
481
+ function writeProtectedTextFile(filePath, content) {
482
+ fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
483
+ const tmp = `${filePath}.tmp.${process.pid}`;
484
+ fs6.writeFileSync(tmp, content, {
485
+ encoding: "utf-8",
486
+ mode: APP_SERVER_AUTH_FILE_MODE
487
+ });
488
+ fs6.chmodSync(tmp, APP_SERVER_AUTH_FILE_MODE);
489
+ fs6.renameSync(tmp, filePath);
490
+ fs6.chmodSync(filePath, APP_SERVER_AUTH_FILE_MODE);
491
+ }
373
492
  function pidFilePath(stateDir, instanceId) {
374
- return path4.join(stateDir, "pids", `bridge-${instanceId}.json`);
493
+ return path6.join(stateDir, "pids", `bridge-${instanceId}.json`);
494
+ }
495
+ function runtimeHeartbeatFilePath(runtimeStateDir) {
496
+ return path6.join(runtimeStateDir, "heartbeat.json");
497
+ }
498
+ function loadRuntimeHeartbeatTimestamp(runtimeStateDir) {
499
+ if (!runtimeStateDir) {
500
+ return null;
501
+ }
502
+ const heartbeatPath = runtimeHeartbeatFilePath(runtimeStateDir);
503
+ if (!fs6.existsSync(heartbeatPath)) {
504
+ return null;
505
+ }
506
+ try {
507
+ const raw = fs6.readFileSync(heartbeatPath, "utf-8");
508
+ const parsed = JSON.parse(raw);
509
+ return typeof parsed.updatedAt === "string" ? parsed.updatedAt : null;
510
+ } catch {
511
+ return null;
512
+ }
513
+ }
514
+ function resolveHeartbeatTimestamp(state) {
515
+ return loadRuntimeHeartbeatTimestamp(state?.runtimeStateDir) ?? state?.lastHeartbeat ?? null;
375
516
  }
376
517
  function loadBridgeState(stateDir, instanceId) {
377
518
  const pidPath = pidFilePath(stateDir, instanceId);
378
- if (!fs4.existsSync(pidPath)) return null;
519
+ if (!fs6.existsSync(pidPath)) return null;
379
520
  try {
380
- const raw = fs4.readFileSync(pidPath, "utf-8");
521
+ const raw = fs6.readFileSync(pidPath, "utf-8");
381
522
  return JSON.parse(raw);
382
523
  } catch {
383
524
  return null;
@@ -385,18 +526,33 @@ function loadBridgeState(stateDir, instanceId) {
385
526
  }
386
527
  function saveBridgeState(stateDir, instanceId, state) {
387
528
  const pidPath = pidFilePath(stateDir, instanceId);
388
- fs4.mkdirSync(path4.dirname(pidPath), { recursive: true });
389
- const tmp = `${pidPath}.tmp.${process.pid}`;
390
- fs4.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
391
- fs4.renameSync(tmp, pidPath);
529
+ const serializable = JSON.parse(JSON.stringify(state));
530
+ if (serializable.appServer?.auth) {
531
+ delete serializable.appServer.auth.token;
532
+ }
533
+ writeProtectedTextFile(pidPath, JSON.stringify(serializable, null, 2));
534
+ }
535
+ function clearBridgeState(stateDir, instanceId) {
536
+ const pidPath = pidFilePath(stateDir, instanceId);
537
+ if (fs6.existsSync(pidPath)) {
538
+ fs6.unlinkSync(pidPath);
539
+ }
540
+ }
541
+ function isProcessAlive(pid) {
542
+ try {
543
+ process.kill(pid, 0);
544
+ return true;
545
+ } catch {
546
+ return false;
547
+ }
392
548
  }
393
549
  function rotateLog(logPath) {
394
- if (!fs4.existsSync(logPath)) return;
550
+ if (!fs6.existsSync(logPath)) return;
395
551
  try {
396
- const stats = fs4.statSync(logPath);
552
+ const stats = fs6.statSync(logPath);
397
553
  if (stats.size === 0) return;
398
554
  const prevPath = `${logPath}.prev`;
399
- fs4.renameSync(logPath, prevPath);
555
+ fs6.renameSync(logPath, prevPath);
400
556
  } catch {
401
557
  }
402
558
  }
@@ -409,16 +565,317 @@ function updateBridgeHeartbeat(stateDir, instanceId) {
409
565
  }
410
566
  function getHeartbeatAge(stateDir, instanceId) {
411
567
  const state = loadBridgeState(stateDir, instanceId);
412
- if (!state?.lastHeartbeat) return null;
413
- const heartbeatTime = new Date(state.lastHeartbeat).getTime();
568
+ const heartbeat = resolveHeartbeatTimestamp(state);
569
+ if (!heartbeat) return null;
570
+ const heartbeatTime = new Date(heartbeat).getTime();
414
571
  if (isNaN(heartbeatTime)) return null;
415
572
  return Math.floor((Date.now() - heartbeatTime) / 1e3);
416
573
  }
574
+ function getBridgeStatus(stateDir, instanceId) {
575
+ const state = loadBridgeState(stateDir, instanceId);
576
+ if (!state) return "stopped";
577
+ if (!isProcessAlive(state.pid)) {
578
+ clearBridgeState(stateDir, instanceId);
579
+ return "stale";
580
+ }
581
+ return "running";
582
+ }
583
+
584
+ // src/engine/dashboard.ts
585
+ import * as fs7 from "fs";
586
+ import * as path7 from "path";
587
+ import { execSync as execSync3 } from "child_process";
588
+ function collectAgents(commsDir) {
589
+ const heartbeatsPath = path7.join(commsDir, "heartbeats.json");
590
+ if (!fs7.existsSync(heartbeatsPath)) return [];
591
+ try {
592
+ const raw = fs7.readFileSync(heartbeatsPath, "utf-8");
593
+ const data = JSON.parse(raw);
594
+ return Object.entries(data).map(([name, info]) => ({
595
+ name: info.agent ?? name,
596
+ status: info.status ?? null,
597
+ lastActivity: info.lastActivity ?? info.timestamp ?? null,
598
+ joinedAt: info.joinedAt ?? null
599
+ }));
600
+ } catch {
601
+ return [];
602
+ }
603
+ }
604
+ function collectBridges(repoRoot) {
605
+ const state = loadState(repoRoot);
606
+ const { config } = resolveConfig({}, repoRoot);
607
+ const stateDir = config.stateDir;
608
+ const bridges = [];
609
+ if (state) {
610
+ for (const [id, inst] of Object.entries(state.instances)) {
611
+ if (!inst?.installed) continue;
612
+ if (inst.bridgeMode !== "app-server") continue;
613
+ const instanceId = id;
614
+ const status = getBridgeStatus(stateDir, instanceId);
615
+ const bridgeState = loadBridgeState(stateDir, instanceId);
616
+ const age = getHeartbeatAge(stateDir, instanceId);
617
+ bridges.push({
618
+ instanceId: id,
619
+ runtime: inst.runtime,
620
+ status,
621
+ pid: bridgeState?.pid ?? null,
622
+ port: inst.port ?? null,
623
+ heartbeatAge: age,
624
+ headless: inst.headless?.enabled ?? false
625
+ });
626
+ }
627
+ }
628
+ const tmpDir = path7.join(repoRoot, ".tmp");
629
+ if (fs7.existsSync(tmpDir)) {
630
+ try {
631
+ const dirs = fs7.readdirSync(tmpDir).filter((d) => d.startsWith("codex-app-server-bridge"));
632
+ for (const dir of dirs) {
633
+ const daemonPath = path7.join(tmpDir, dir, "bridge-daemon.json");
634
+ if (!fs7.existsSync(daemonPath)) continue;
635
+ try {
636
+ const raw = fs7.readFileSync(daemonPath, "utf-8");
637
+ const daemon = JSON.parse(raw);
638
+ const alreadyCovered = bridges.some(
639
+ (b) => b.pid === daemon.pid && b.pid !== null
640
+ );
641
+ if (alreadyCovered) continue;
642
+ const agentFile = path7.join(tmpDir, dir, "agent-name.txt");
643
+ const agentName = fs7.existsSync(agentFile) ? fs7.readFileSync(agentFile, "utf-8").trim() : dir;
644
+ const running = daemon.pid ? isProcessAlive(daemon.pid) : false;
645
+ const portMatch = daemon.appServerUrl?.match(/:(\d+)/);
646
+ const port = portMatch ? parseInt(portMatch[1], 10) : null;
647
+ bridges.push({
648
+ instanceId: agentName,
649
+ runtime: "codex",
650
+ status: running ? "running" : "stale",
651
+ pid: daemon.pid ?? null,
652
+ port,
653
+ heartbeatAge: null,
654
+ headless: false
655
+ });
656
+ } catch {
657
+ }
658
+ }
659
+ } catch {
660
+ }
661
+ }
662
+ return bridges;
663
+ }
664
+ function collectPRs() {
665
+ try {
666
+ const output = execSync3(
667
+ "gh pr list --state all --limit 10 --json number,title,author,state,url",
668
+ { encoding: "utf-8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] }
669
+ );
670
+ const prs = JSON.parse(output);
671
+ return prs.map((pr) => ({
672
+ number: pr.number,
673
+ title: pr.title,
674
+ author: pr.author.login,
675
+ state: pr.state,
676
+ url: pr.url
677
+ }));
678
+ } catch {
679
+ return [];
680
+ }
681
+ }
682
+ function collectWarnings(bridges, agents) {
683
+ const warnings = [];
684
+ for (const bridge of bridges) {
685
+ if (bridge.status === "stale") {
686
+ warnings.push({
687
+ level: "warn",
688
+ message: `Bridge ${bridge.instanceId} is stale (PID ${bridge.pid} dead)`
689
+ });
690
+ }
691
+ if (bridge.status === "running" && bridge.heartbeatAge !== null && bridge.heartbeatAge > 60) {
692
+ warnings.push({
693
+ level: "warn",
694
+ message: `Bridge ${bridge.instanceId} heartbeat stale (${bridge.heartbeatAge}s ago)`
695
+ });
696
+ }
697
+ }
698
+ if (bridges.length === 0) {
699
+ warnings.push({
700
+ level: "warn",
701
+ message: "No bridges configured"
702
+ });
703
+ }
704
+ if (agents.length === 0) {
705
+ warnings.push({
706
+ level: "warn",
707
+ message: "No agent heartbeats found"
708
+ });
709
+ }
710
+ return warnings;
711
+ }
712
+ function collectDashboardSnapshot(repoRoot, commsDirOverride) {
713
+ const { config } = resolveConfig(
714
+ commsDirOverride ? { commsDir: commsDirOverride } : {},
715
+ repoRoot
716
+ );
717
+ const resolved = config;
718
+ const agents = collectAgents(resolved.commsDir);
719
+ const bridges = collectBridges(resolved.repoRoot);
720
+ const prs = collectPRs();
721
+ const warnings = collectWarnings(bridges, agents);
722
+ return {
723
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
724
+ repoRoot: resolved.repoRoot,
725
+ commsDir: resolved.commsDir,
726
+ agents,
727
+ bridges,
728
+ prs,
729
+ warnings
730
+ };
731
+ }
732
+
733
+ // src/api/state.ts
734
+ function getDashboardSnapshot(options) {
735
+ const repoRoot = options?.repoRoot ?? findRepoRoot();
736
+ return collectDashboardSnapshot(repoRoot, options?.commsDir);
737
+ }
738
+ async function* streamEvents(options) {
739
+ const intervalMs = options?.intervalMs ?? 2e3;
740
+ const repoRoot = options?.repoRoot ?? findRepoRoot();
741
+ while (!options?.signal?.aborted) {
742
+ yield collectDashboardSnapshot(repoRoot, options?.commsDir);
743
+ await new Promise((resolve5) => {
744
+ const onAbort = () => {
745
+ clearTimeout(timer);
746
+ resolve5();
747
+ };
748
+ const timer = setTimeout(() => {
749
+ options?.signal?.removeEventListener("abort", onAbort);
750
+ resolve5();
751
+ }, intervalMs);
752
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
753
+ });
754
+ }
755
+ }
756
+ function getConfig(options) {
757
+ const repoRoot = options?.repoRoot ?? findRepoRoot();
758
+ const { config } = resolveConfig({}, repoRoot);
759
+ return {
760
+ repoRoot,
761
+ commsDir: options?.commsDir ?? config.commsDir,
762
+ stateDir: config.stateDir,
763
+ appServerUrl: config.appServerUrl
764
+ };
765
+ }
766
+
767
+ // src/api/http.ts
768
+ import {
769
+ createServer as createServer2
770
+ } from "http";
771
+ var CORS_HEADERS = {
772
+ "Access-Control-Allow-Origin": "http://localhost:3000",
773
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
774
+ "Access-Control-Allow-Headers": "Content-Type"
775
+ };
776
+ function jsonResponse(res, data, status = 200) {
777
+ res.writeHead(status, {
778
+ "Content-Type": "application/json",
779
+ ...CORS_HEADERS
780
+ });
781
+ res.end(JSON.stringify(data));
782
+ }
783
+ function handleSnapshot(res, apiOptions) {
784
+ const snapshot = getDashboardSnapshot(apiOptions);
785
+ jsonResponse(res, snapshot);
786
+ }
787
+ function handleConfig(res, apiOptions) {
788
+ const config = getConfig(apiOptions);
789
+ jsonResponse(res, config);
790
+ }
791
+ async function handleEvents(req, res, apiOptions) {
792
+ res.writeHead(200, {
793
+ "Content-Type": "text/event-stream",
794
+ "Cache-Control": "no-cache",
795
+ Connection: "keep-alive",
796
+ ...CORS_HEADERS
797
+ });
798
+ const controller = new AbortController();
799
+ req.on("close", () => controller.abort());
800
+ for await (const snapshot of streamEvents({
801
+ ...apiOptions,
802
+ signal: controller.signal
803
+ })) {
804
+ if (controller.signal.aborted) break;
805
+ res.write(`data: ${JSON.stringify(snapshot)}
806
+
807
+ `);
808
+ }
809
+ res.end();
810
+ }
811
+ function handleHealth(res) {
812
+ jsonResponse(res, { ok: true, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
813
+ }
814
+ async function startHttpServer(options) {
815
+ const port = options?.port ?? 4580;
816
+ const host = "127.0.0.1";
817
+ const apiOptions = {
818
+ repoRoot: options?.repoRoot,
819
+ commsDir: options?.commsDir
820
+ };
821
+ const server = createServer2(
822
+ async (req, res) => {
823
+ const url = new URL(req.url ?? "/", `http://${host}:${port}`);
824
+ const pathname = url.pathname;
825
+ if (req.method === "OPTIONS") {
826
+ res.writeHead(204, CORS_HEADERS);
827
+ res.end();
828
+ return;
829
+ }
830
+ if (req.method !== "GET") {
831
+ jsonResponse(res, { error: "Method not allowed" }, 405);
832
+ return;
833
+ }
834
+ try {
835
+ switch (pathname) {
836
+ case "/api/snapshot":
837
+ handleSnapshot(res, apiOptions);
838
+ break;
839
+ case "/api/events":
840
+ await handleEvents(req, res, apiOptions);
841
+ break;
842
+ case "/api/config":
843
+ handleConfig(res, apiOptions);
844
+ break;
845
+ case "/health":
846
+ handleHealth(res);
847
+ break;
848
+ default:
849
+ jsonResponse(res, { error: "Not found" }, 404);
850
+ }
851
+ } catch (err) {
852
+ const message = err instanceof Error ? err.message : String(err);
853
+ jsonResponse(res, { error: message }, 500);
854
+ }
855
+ }
856
+ );
857
+ await new Promise((resolve5, reject) => {
858
+ server.once("error", reject);
859
+ server.listen(port, host, () => {
860
+ server.removeListener("error", reject);
861
+ resolve5();
862
+ });
863
+ });
864
+ return {
865
+ port,
866
+ close: () => new Promise((resolve5, reject) => {
867
+ server.close((err) => err ? reject(err) : resolve5());
868
+ })
869
+ };
870
+ }
417
871
  export {
418
872
  LOCAL_CONFIG_FILE,
419
873
  SHARED_CONFIG_FILE,
420
874
  buildRuntimeEnv,
875
+ collectDashboardSnapshot,
421
876
  createInitialState,
877
+ getConfig,
878
+ getDashboardSnapshot,
422
879
  getFnmBinDir,
423
880
  getHeartbeatAge,
424
881
  loadLocalConfig,
@@ -432,7 +889,9 @@ export {
432
889
  saveLocalConfig,
433
890
  saveSharedConfig,
434
891
  saveState,
892
+ startHttpServer,
435
893
  stateExists,
894
+ streamEvents,
436
895
  updateBridgeHeartbeat,
437
896
  version
438
897
  };