harnery 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +29 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +4 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +94 -33
  7. package/dist/commands/browse-ai.js +1 -1
  8. package/dist/commands/browse.d.ts.map +1 -1
  9. package/dist/commands/browse.js +41 -9
  10. package/dist/commands/cookies.js +1 -1
  11. package/dist/commands/decision.d.ts +4 -0
  12. package/dist/commands/decision.d.ts.map +1 -0
  13. package/dist/commands/decision.js +354 -0
  14. package/dist/commands/deinit.d.ts.map +1 -1
  15. package/dist/commands/deinit.js +4 -0
  16. package/dist/commands/devtools.d.ts +4 -0
  17. package/dist/commands/devtools.d.ts.map +1 -0
  18. package/dist/commands/devtools.js +239 -0
  19. package/dist/commands/docs.d.ts.map +1 -1
  20. package/dist/commands/docs.js +74 -2
  21. package/dist/commands/doctor.js +12 -4
  22. package/dist/commands/env.d.ts.map +1 -1
  23. package/dist/commands/env.js +3 -63
  24. package/dist/commands/fetch.js +1 -1
  25. package/dist/commands/init.d.ts +1 -0
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +54 -14
  28. package/dist/commands/scratch.js +1 -1
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +273 -62
  31. package/dist/commands/web-fetch.js +1 -1
  32. package/dist/core/agents/coord-client.d.ts.map +1 -1
  33. package/dist/core/agents/coord-client.js +32 -8
  34. package/dist/core/agents/events/consume.d.ts +25 -2
  35. package/dist/core/agents/events/consume.d.ts.map +1 -1
  36. package/dist/core/agents/events/consume.js +55 -7
  37. package/dist/core/agents/events/emit.d.ts +2 -1
  38. package/dist/core/agents/events/emit.d.ts.map +1 -1
  39. package/dist/core/agents/events/emit.js +6 -1
  40. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  41. package/dist/core/agents/rules/claim-conflict.js +16 -5
  42. package/dist/core/agents/state/scratch.d.ts +1 -1
  43. package/dist/core/agents/state/scratch.js +2 -2
  44. package/dist/core/config.d.ts +10 -0
  45. package/dist/core/config.d.ts.map +1 -1
  46. package/dist/core/config.js +13 -0
  47. package/dist/core/hooks/cli.js +3 -3
  48. package/dist/core/hooks/effects/index.d.ts +11 -7
  49. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  50. package/dist/core/hooks/effects/index.js +15 -17
  51. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  52. package/dist/core/hooks/events/emit.js +4 -0
  53. package/dist/core/hooks/events/rotate.d.ts +43 -0
  54. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  55. package/dist/core/hooks/events/rotate.js +142 -0
  56. package/dist/core/hooks/harness/events.d.ts +11 -1
  57. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  58. package/dist/core/hooks/harness/events.js +22 -3
  59. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  60. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  61. package/dist/core/hooks/harness/wiring.js +34 -5
  62. package/dist/core/scratch/index.d.ts.map +1 -0
  63. package/dist/{lib → core}/scratch/index.js +2 -2
  64. package/dist/lib/agent-browser/client.js +1 -1
  65. package/dist/lib/browser/client.d.ts +14 -0
  66. package/dist/lib/browser/client.d.ts.map +1 -1
  67. package/dist/lib/browser/client.js +20 -0
  68. package/dist/lib/browser/index.d.ts +1 -0
  69. package/dist/lib/browser/index.d.ts.map +1 -1
  70. package/dist/lib/browser/runts.d.ts +44 -0
  71. package/dist/lib/browser/runts.d.ts.map +1 -0
  72. package/dist/lib/browser/runts.js +193 -0
  73. package/dist/lib/completion/walk.js +1 -1
  74. package/dist/lib/cookies/client.d.ts +1 -1
  75. package/dist/lib/cookies/client.d.ts.map +1 -1
  76. package/dist/lib/cookies/client.js +1 -1
  77. package/dist/lib/decision/index.d.ts +212 -0
  78. package/dist/lib/decision/index.d.ts.map +1 -0
  79. package/dist/lib/decision/index.js +523 -0
  80. package/dist/lib/devtools.d.ts +178 -0
  81. package/dist/lib/devtools.d.ts.map +1 -0
  82. package/dist/lib/devtools.js +1328 -0
  83. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  84. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  85. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  86. package/dist/lib/docs-frontmatter.d.ts +33 -0
  87. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  88. package/dist/lib/docs-frontmatter.js +130 -0
  89. package/dist/lib/docs-index.d.ts +1 -0
  90. package/dist/lib/docs-index.d.ts.map +1 -1
  91. package/dist/lib/docs-index.js +4 -5
  92. package/dist/lib/docs-lint.d.ts +3 -0
  93. package/dist/lib/docs-lint.d.ts.map +1 -1
  94. package/dist/lib/docs-lint.js +67 -12
  95. package/dist/lib/docs-meta.d.ts +14 -0
  96. package/dist/lib/docs-meta.d.ts.map +1 -0
  97. package/dist/lib/docs-meta.js +34 -0
  98. package/dist/lib/docs-sweep.d.ts +12 -0
  99. package/dist/lib/docs-sweep.d.ts.map +1 -1
  100. package/dist/lib/docs-sweep.js +98 -103
  101. package/dist/lib/format.js +2 -2
  102. package/dist/lib/http/index.d.ts +1 -0
  103. package/dist/lib/http/index.d.ts.map +1 -1
  104. package/dist/lib/http/index.js +1 -0
  105. package/dist/lib/http/request.d.ts +77 -0
  106. package/dist/lib/http/request.d.ts.map +1 -0
  107. package/dist/lib/http/request.js +105 -0
  108. package/dist/lib/instructions/apply.d.ts +63 -0
  109. package/dist/lib/instructions/apply.d.ts.map +1 -0
  110. package/dist/lib/instructions/apply.js +255 -0
  111. package/dist/lib/instructions/splice.d.ts +73 -0
  112. package/dist/lib/instructions/splice.d.ts.map +1 -0
  113. package/dist/lib/instructions/splice.js +118 -0
  114. package/dist/lib/instructions/templates.d.ts +45 -0
  115. package/dist/lib/instructions/templates.d.ts.map +1 -0
  116. package/dist/lib/instructions/templates.js +258 -0
  117. package/dist/lib/tunnel/gate.d.ts +1 -0
  118. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  119. package/dist/lib/tunnel/gate.js +15 -10
  120. package/dist/lib/tunnel/state.d.ts +11 -1
  121. package/dist/lib/tunnel/state.d.ts.map +1 -1
  122. package/dist/lib/tunnel/state.js +8 -3
  123. package/package.json +9 -6
  124. package/src/commander.ts +35 -0
  125. package/src/commands/agents.ts +97 -29
  126. package/src/commands/browse-ai.ts +1 -1
  127. package/src/commands/browse.ts +63 -8
  128. package/src/commands/cookies.ts +1 -1
  129. package/src/commands/decision.ts +438 -0
  130. package/src/commands/deinit.ts +5 -0
  131. package/src/commands/devtools.ts +284 -0
  132. package/src/commands/docs.ts +86 -2
  133. package/src/commands/doctor.ts +13 -4
  134. package/src/commands/env.ts +11 -77
  135. package/src/commands/fetch.ts +1 -1
  136. package/src/commands/init.ts +66 -15
  137. package/src/commands/scratch.ts +1 -1
  138. package/src/commands/tunnel.ts +316 -65
  139. package/src/commands/web-fetch.ts +1 -1
  140. package/src/core/agents/coord-client.ts +34 -7
  141. package/src/core/agents/events/consume.ts +65 -7
  142. package/src/core/agents/events/emit.ts +7 -1
  143. package/src/core/agents/rules/claim-conflict.ts +17 -6
  144. package/src/core/agents/state/scratch.ts +2 -2
  145. package/src/core/config.ts +15 -1
  146. package/src/core/hooks/cli.ts +3 -3
  147. package/src/core/hooks/effects/index.ts +23 -16
  148. package/src/core/hooks/events/emit.ts +5 -0
  149. package/src/core/hooks/events/rotate.ts +151 -0
  150. package/src/core/hooks/harness/events.ts +30 -3
  151. package/src/core/hooks/harness/wiring.ts +46 -5
  152. package/src/{lib → core}/scratch/index.ts +2 -2
  153. package/src/lib/agent-browser/client.ts +1 -1
  154. package/src/lib/browser/client.ts +28 -0
  155. package/src/lib/browser/index.ts +4 -0
  156. package/src/lib/browser/runts.ts +218 -0
  157. package/src/lib/completion/walk.ts +1 -1
  158. package/src/lib/cookies/client.ts +2 -2
  159. package/src/lib/decision/index.ts +685 -0
  160. package/src/lib/devtools.ts +1653 -0
  161. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  162. package/src/lib/docs-frontmatter.ts +151 -0
  163. package/src/lib/docs-index.ts +4 -5
  164. package/src/lib/docs-lint.ts +61 -11
  165. package/src/lib/docs-meta.ts +44 -0
  166. package/src/lib/docs-sweep.ts +104 -102
  167. package/src/lib/format.ts +2 -2
  168. package/src/lib/http/index.ts +1 -0
  169. package/src/lib/http/request.ts +154 -0
  170. package/src/lib/instructions/apply.ts +318 -0
  171. package/src/lib/instructions/splice.ts +148 -0
  172. package/src/lib/instructions/templates.ts +295 -0
  173. package/src/lib/tunnel/gate.ts +15 -10
  174. package/src/lib/tunnel/state.ts +19 -4
  175. package/dist/lib/scratch/index.d.ts.map +0 -1
  176. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -6,17 +6,18 @@ function cachePath(tool, filename) {
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { existsSync, openSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
- import { cfdLogFile, clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, listStates, readConfig, readState, writeConfig, writeState, } from "../lib/tunnel/state.js";
9
+ import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, listStates, providerLogFile, readConfig, readState, writeConfig, writeState, } from "../lib/tunnel/state.js";
10
10
  /**
11
- * `tunnel`: IP-gated Cloudflare quick tunnel in front of a local upstream.
11
+ * `tunnel`: provider-backed tunnel in front of a local upstream.
12
12
  *
13
- * Two-process design: a Bun reverse-proxy worker (lib/tunnel/gate.ts) checks
14
- * the Cloudflare-set `CF-Connecting-IP` header against an allowlist and
15
- * rewrites Host for the upstream; cloudflared --url then exposes the gate
16
- * as a random `<words>.trycloudflare.com` hostname.
13
+ * A Bun reverse-proxy worker (lib/tunnel/gate.ts) rewrites Host for the
14
+ * upstream. Cloudflare quick tunnels add an IP allowlist at the gate via the
15
+ * Cloudflare-set `CF-Connecting-IP` header; Tailscale Serve/Funnel exposes the
16
+ * same gate through tailscaled, with Tailscale owning the access boundary.
17
17
  *
18
18
  * State + config persisted under `.cache/tunnel/`. cloudflared auto-installs
19
- * to ~/.local/bin/ on first run (Linux only; macOS users `brew install`).
19
+ * to ~/.local/bin/ on first run (Linux only; macOS users `brew install`);
20
+ * Tailscale requires an installed/authenticated `tailscale` CLI.
20
21
  */
21
22
  const DEFAULT_TARGET = "127.0.0.1:8001";
22
23
  const DEFAULT_VHOST = "localhost";
@@ -38,6 +39,54 @@ function resolveName(raw) {
38
39
  }
39
40
  return name;
40
41
  }
42
+ function resolveProvider(raw) {
43
+ const provider = (raw ?? "cloudflare").trim().toLowerCase();
44
+ if (provider === "cloudflare" || provider === "cf")
45
+ return "cloudflare";
46
+ if (provider === "tailscale" || provider === "ts")
47
+ return "tailscale";
48
+ emit.error({
49
+ code: "tunnel_bad_provider",
50
+ message: `Invalid provider "${raw}". Use cloudflare or tailscale.`,
51
+ });
52
+ process.exit(1);
53
+ }
54
+ function resolveTailscaleMode(raw) {
55
+ const visibility = (raw ?? "tailnet").trim().toLowerCase();
56
+ if (visibility === "tailnet" || visibility === "serve")
57
+ return "serve";
58
+ if (visibility === "public" || visibility === "internet" || visibility === "funnel") {
59
+ return "funnel";
60
+ }
61
+ emit.error({
62
+ code: "tunnel_bad_visibility",
63
+ message: `Invalid Tailscale visibility "${raw}". Use tailnet or public.`,
64
+ });
65
+ process.exit(1);
66
+ }
67
+ function resolveTailscalePath(raw, name) {
68
+ const fallback = name === DEFAULT_INSTANCE ? "/" : `/${name}`;
69
+ const path = (raw ?? fallback).trim();
70
+ if (!path.startsWith("/")) {
71
+ emit.error({
72
+ code: "tunnel_bad_path",
73
+ message: `Tailscale path "${path}" must start with /.`,
74
+ });
75
+ process.exit(1);
76
+ }
77
+ return path === "" ? "/" : path;
78
+ }
79
+ function resolveHttpsPort(raw) {
80
+ const port = raw === undefined ? 443 : Number(raw);
81
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
82
+ emit.error({
83
+ code: "tunnel_bad_https_port",
84
+ message: `Invalid HTTPS port "${raw}". Use a number from 1 to 65535.`,
85
+ });
86
+ process.exit(1);
87
+ }
88
+ return port;
89
+ }
41
90
  function gateScriptPath() {
42
91
  return resolve(import.meta.dirname, "..", "lib", "tunnel", "gate.ts");
43
92
  }
@@ -51,6 +100,9 @@ function gateScriptPath() {
51
100
  function bunAvailable() {
52
101
  return spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0;
53
102
  }
103
+ function tailscaleAvailable() {
104
+ return spawnSync("tailscale", ["version"], { stdio: "ignore" }).status === 0;
105
+ }
54
106
  function sleep(ms) {
55
107
  return new Promise((r) => setTimeout(r, ms));
56
108
  }
@@ -169,12 +221,98 @@ async function waitForReady(logPath, timeoutMs) {
169
221
  }
170
222
  return { url, registered: false };
171
223
  }
224
+ function tailscaleDnsName() {
225
+ const r = spawnSync("tailscale", ["status", "--json"], { encoding: "utf-8" });
226
+ if (r.status !== 0) {
227
+ emit.error({
228
+ code: "tunnel_tailscale_status_failed",
229
+ message: "Failed to read `tailscale status --json`. Install Tailscale and connect this machine to a tailnet first.",
230
+ });
231
+ process.exit(1);
232
+ }
233
+ try {
234
+ const parsed = JSON.parse(r.stdout);
235
+ const dns = parsed.Self?.DNSName?.replace(/\.$/, "");
236
+ if (dns)
237
+ return dns;
238
+ }
239
+ catch {
240
+ /* handled below */
241
+ }
242
+ emit.error({
243
+ code: "tunnel_tailscale_no_dns",
244
+ message: "Tailscale status did not report a MagicDNS name. Enable MagicDNS for the tailnet, then retry.",
245
+ });
246
+ process.exit(1);
247
+ }
248
+ function tailscaleUrl(path, httpsPort) {
249
+ const base = `https://${tailscaleDnsName()}${httpsPort === 443 ? "" : `:${httpsPort}`}`;
250
+ return path === "/" ? `${base}/` : `${base}${path}`;
251
+ }
252
+ function tailscaleCommand(mode) {
253
+ return mode === "funnel" ? "funnel" : "serve";
254
+ }
255
+ function runTailscaleShare(mode, targetUrl, path, httpsPort, logPath) {
256
+ const fd = openSync(logPath, "a");
257
+ const args = [
258
+ tailscaleCommand(mode),
259
+ "--bg",
260
+ "--yes",
261
+ `--https=${httpsPort}`,
262
+ `--set-path=${path}`,
263
+ targetUrl,
264
+ ];
265
+ const r = spawnSync("tailscale", args, {
266
+ stdio: ["ignore", fd, fd],
267
+ });
268
+ if (r.status !== 0) {
269
+ emit.error({
270
+ code: "tunnel_tailscale_failed",
271
+ message: `tailscale ${tailscaleCommand(mode)} failed. Check log: ${logPath}`,
272
+ });
273
+ process.exit(r.status ?? 1);
274
+ }
275
+ }
276
+ function stopTailscaleShare(state) {
277
+ if (state.provider !== "tailscale" || !state.tailscale_mode)
278
+ return true;
279
+ const cmd = tailscaleCommand(state.tailscale_mode);
280
+ const logPath = cachePath("tunnel", providerLogFile(state.name, "tailscale"));
281
+ const fd = openSync(logPath, "a");
282
+ const args = [
283
+ cmd,
284
+ `--https=${state.tailscale_https_port ?? 443}`,
285
+ `--set-path=${state.tailscale_path ?? "/"}`,
286
+ "off",
287
+ ];
288
+ const r = spawnSync("tailscale", args, { stdio: ["ignore", fd, fd] });
289
+ if (r.status !== 0) {
290
+ // The gate is torn down and its port is freed for reuse regardless, so a
291
+ // surviving serve/funnel mapping could later re-expose whatever next binds
292
+ // that port. Surface it loudly so the operator can clear it by hand.
293
+ emit.text(`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path ?? "/"}).\n` +
294
+ ` It may still be exposing this machine. Clear it with:\n` +
295
+ ` tailscale ${cmd} --https=${state.tailscale_https_port ?? 443} --set-path=${state.tailscale_path ?? "/"} off\n` +
296
+ ` Log: ${logPath}\n\n`);
297
+ return false;
298
+ }
299
+ return true;
300
+ }
172
301
  /** Resolve the context-supplied default vhost (literal or lazy resolver). */
173
302
  function contextVhost() {
174
303
  const v = context?.tunnelDefaultVhost;
175
304
  const resolved = typeof v === "function" ? v() : v;
176
305
  return resolved ?? null;
177
306
  }
307
+ function providerIsAlive(state) {
308
+ if (state.provider === "tailscale")
309
+ return true;
310
+ const pid = state.cloudflared_pid ?? state.provider_pid;
311
+ return typeof pid === "number" && isProcessAlive(pid);
312
+ }
313
+ function tunnelIsAlive(state) {
314
+ return isProcessAlive(state.gate_pid) && providerIsAlive(state);
315
+ }
178
316
  async function up(opts) {
179
317
  if (!bunAvailable()) {
180
318
  emit.error({
@@ -185,18 +323,30 @@ async function up(opts) {
185
323
  process.exit(1);
186
324
  }
187
325
  const name = resolveName(opts.name);
326
+ const provider = resolveProvider(opts.provider);
188
327
  const target = opts.target ?? DEFAULT_TARGET;
189
328
  // Precedence: explicit --vhost > the consumer's configured default (via
190
329
  // context.tunnelDefaultVhost) > "localhost".
191
330
  const vhost = opts.vhost ?? contextVhost() ?? DEFAULT_VHOST;
331
+ const tailscaleMode = provider === "tailscale" ? resolveTailscaleMode(opts.visibility) : undefined;
332
+ const tailscalePath = provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
333
+ const tailscaleHttpsPort = provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
192
334
  const existing = readState(name);
193
- if (existing && isProcessAlive(existing.gate_pid) && isProcessAlive(existing.cloudflared_pid)) {
335
+ if (existing && tunnelIsAlive(existing)) {
336
+ if (existing.provider !== provider) {
337
+ emit.error({
338
+ code: "tunnel_instance_in_use",
339
+ message: `Tunnel [${name}] is already up with provider ${existing.provider}. Stop it before starting ${provider}.`,
340
+ });
341
+ process.exit(1);
342
+ }
194
343
  emit.text(`Already up [${name}]: ${existing.url}\n`);
344
+ emit.text(` Provider: ${existing.provider}\n`);
195
345
  emit.text(` Forwarding: ${existing.target} (Host: ${existing.vhost})\n`);
196
346
  return;
197
347
  }
198
348
  if (existing)
199
- clearState(name);
349
+ downOne(name, new Set());
200
350
  // Allocate the gate port (after clearing dead state so its old port frees up
201
351
  // for reuse). Explicit --gate-port is validated; otherwise auto-scan.
202
352
  const gatePort = allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
@@ -205,18 +355,25 @@ async function up(opts) {
205
355
  if (sweepStrays(gatePort, new Set()))
206
356
  await sleep(500);
207
357
  const cfg = readConfig();
208
- if (cfg.allowed_ips.length === 0) {
358
+ if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
209
359
  emit.error({
210
360
  code: "tunnel_allowlist_empty",
211
361
  message: "Allowlist is empty; refusing to start. Add an IP first: harn tunnel allow add <ip>",
212
362
  });
213
363
  process.exit(1);
214
364
  }
215
- const cloudflaredBin = ensureCloudflared();
365
+ const cloudflaredBin = provider === "cloudflare" ? ensureCloudflared() : null;
366
+ if (provider === "tailscale" && !tailscaleAvailable()) {
367
+ emit.error({
368
+ code: "tunnel_tailscale_missing",
369
+ message: "tailscale CLI not found or unavailable. Install Tailscale and sign in, then retry.",
370
+ });
371
+ process.exit(1);
372
+ }
216
373
  const gateLogPath = cachePath("tunnel", gateLogFile(name));
217
- const cfdLogPath = cachePath("tunnel", cfdLogFile(name));
374
+ const providerLogPath = cachePath("tunnel", providerLogFile(name, provider));
218
375
  writeFileSync(gateLogPath, "");
219
- writeFileSync(cfdLogPath, "");
376
+ writeFileSync(providerLogPath, "");
220
377
  const gateFd = openSync(gateLogPath, "a");
221
378
  // `--name`/`--port` on argv mirror the env vars; they're what makes the gate
222
379
  // process distinguishable per-instance in `pgrep -f` (see sweepStrays).
@@ -226,6 +383,7 @@ async function up(opts) {
226
383
  env: {
227
384
  ...process.env,
228
385
  HARNERY_TUNNEL_ALLOW: cfg.allowed_ips.join(","),
386
+ HARNERY_TUNNEL_ACCESS: provider === "cloudflare" ? "cloudflare-allowlist" : "trusted-local-proxy",
229
387
  HARNERY_TUNNEL_TARGET: target,
230
388
  HARNERY_TUNNEL_VHOST: vhost,
231
389
  HARNERY_TUNNEL_PORT: String(gatePort),
@@ -240,54 +398,85 @@ async function up(opts) {
240
398
  });
241
399
  process.exit(1);
242
400
  }
243
- const cfdFd = openSync(cfdLogPath, "a");
244
- // Force HTTP/2 transport. The default QUIC transport wedges at precheck on
245
- // constrained hosts (e.g. WSL, where UDP receive buffers can't grow and ICMP
246
- // is restricted): the URL prints but the edge never registers. HTTP/2 is
247
- // marginally higher-latency but registers reliably, which is what a dev
248
- // tunnel needs.
249
- const cfdProc = spawn(cloudflaredBin, ["tunnel", "--protocol", "http2", "--url", `http://localhost:${gatePort}`], {
250
- detached: true,
251
- stdio: ["ignore", cfdFd, cfdFd],
252
- });
253
- cfdProc.unref();
254
- const { url, registered } = await waitForReady(cfdLogPath, 30_000);
255
- if (!url) {
256
- try {
257
- process.kill(gateProc.pid);
258
- }
259
- catch {
260
- /* already dead */
261
- }
262
- try {
263
- process.kill(cfdProc.pid);
264
- }
265
- catch {
266
- /* already dead */
267
- }
268
- emit.error({
269
- code: "tunnel_url_timeout",
270
- message: `Failed to obtain tunnel URL within 30s. Check log: ${cfdLogPath}`,
401
+ let url;
402
+ let registered = true;
403
+ let cloudflaredPid;
404
+ if (provider === "cloudflare") {
405
+ const cfdFd = openSync(providerLogPath, "a");
406
+ // Force HTTP/2 transport. The default QUIC transport wedges at precheck on
407
+ // constrained hosts (e.g. WSL, where UDP receive buffers can't grow and ICMP
408
+ // is restricted): the URL prints but the edge never registers. HTTP/2 is
409
+ // marginally higher-latency but registers reliably, which is what a dev
410
+ // tunnel needs.
411
+ const cfdProc = spawn(cloudflaredBin, ["tunnel", "--protocol", "http2", "--url", `http://localhost:${gatePort}`], {
412
+ detached: true,
413
+ stdio: ["ignore", cfdFd, cfdFd],
271
414
  });
272
- process.exit(1);
415
+ cfdProc.unref();
416
+ cloudflaredPid = cfdProc.pid;
417
+ const ready = await waitForReady(providerLogPath, 30_000);
418
+ registered = ready.registered;
419
+ if (!ready.url) {
420
+ try {
421
+ process.kill(gateProc.pid);
422
+ }
423
+ catch {
424
+ /* already dead */
425
+ }
426
+ try {
427
+ process.kill(cloudflaredPid);
428
+ }
429
+ catch {
430
+ /* already dead */
431
+ }
432
+ emit.error({
433
+ code: "tunnel_url_timeout",
434
+ message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
435
+ });
436
+ process.exit(1);
437
+ }
438
+ url = ready.url;
439
+ }
440
+ else {
441
+ // Resolve the public URL (MagicDNS name) BEFORE starting the share.
442
+ // tailscaleUrl -> tailscaleDnsName() hard-exits when MagicDNS is
443
+ // unavailable; doing it first means we fail cleanly rather than leaving a
444
+ // live serve/funnel exposure with no state file — which `down`/`status`/
445
+ // `heal` would then be unable to see or clean up.
446
+ url = tailscaleUrl(tailscalePath, tailscaleHttpsPort);
447
+ const gateTarget = `http://127.0.0.1:${gatePort}`;
448
+ runTailscaleShare(tailscaleMode, gateTarget, tailscalePath, tailscaleHttpsPort, providerLogPath);
273
449
  }
274
450
  const state = {
275
451
  name,
452
+ provider,
276
453
  url,
277
454
  gate_pid: gateProc.pid,
278
- cloudflared_pid: cfdProc.pid,
455
+ cloudflared_pid: cloudflaredPid,
279
456
  started_at: new Date().toISOString(),
280
457
  target,
281
458
  vhost,
282
459
  gate_port: gatePort,
460
+ tailscale_mode: tailscaleMode,
461
+ tailscale_path: tailscalePath,
462
+ tailscale_https_port: tailscaleHttpsPort,
283
463
  };
284
464
  writeState(state);
285
465
  const stopHint = name === DEFAULT_INSTANCE ? "harn tunnel down" : `harn tunnel down --name ${name}`;
286
466
  emit.text(`\n Instance: ${name}\n`);
467
+ emit.text(` Provider: ${provider}${tailscaleMode ? ` (${tailscaleMode})` : ""}\n`);
287
468
  emit.text(` URL: ${url}\n\n`);
288
469
  emit.text(` Forwarding: ${target} (Host: ${vhost})\n`);
289
470
  emit.text(` Gate port: ${gatePort}\n`);
290
- emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n\n`);
471
+ if (provider === "cloudflare") {
472
+ emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n\n`);
473
+ }
474
+ else {
475
+ emit.text(` Tailscale path: ${tailscalePath}\n`);
476
+ emit.text(tailscaleMode === "funnel"
477
+ ? " Visibility: public internet via Tailscale Funnel\n\n"
478
+ : " Visibility: tailnet only via Tailscale Serve\n\n");
479
+ }
291
480
  if (!registered) {
292
481
  emit.text(` ⚠ Edge connection didn't register within 30s (QUIC can wedge on a cold\n start). If the URL 404s or times out, bounce it: ${stopHint} && harn tunnel up\n\n`);
293
482
  }
@@ -299,7 +488,11 @@ function downOne(name, killed) {
299
488
  const before = killed.size;
300
489
  const state = readState(name);
301
490
  if (state) {
302
- for (const pid of [state.gate_pid, state.cloudflared_pid]) {
491
+ if (state.provider === "tailscale")
492
+ stopTailscaleShare(state);
493
+ for (const pid of [state.gate_pid, state.cloudflared_pid, state.provider_pid]) {
494
+ if (typeof pid !== "number")
495
+ continue;
303
496
  if (isProcessAlive(pid)) {
304
497
  try {
305
498
  process.kill(pid);
@@ -346,7 +539,7 @@ function down(opts) {
346
539
  : `Stopped ${killed.size} process(es). Tunnel [${name}] down.\n`);
347
540
  }
348
541
  function instanceState(state) {
349
- return isProcessAlive(state.gate_pid) && isProcessAlive(state.cloudflared_pid) ? "up" : "stale";
542
+ return tunnelIsAlive(state) ? "up" : "stale";
350
543
  }
351
544
  function fmtUptime(startedAt) {
352
545
  const secs = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
@@ -361,15 +554,24 @@ function fmtUptime(startedAt) {
361
554
  /** Detailed single-instance block (the pre-multi-instance format). */
362
555
  function statusDetail(state) {
363
556
  const gateAlive = isProcessAlive(state.gate_pid);
364
- const cfdAlive = isProcessAlive(state.cloudflared_pid);
557
+ const providerAlive = providerIsAlive(state);
558
+ const providerPid = state.cloudflared_pid ?? state.provider_pid;
365
559
  const cfg = readConfig();
366
- emit.text(`${gateAlive && cfdAlive ? "up" : "stale"} [${state.name}]\n`);
560
+ emit.text(`${gateAlive && providerAlive ? "up" : "stale"} [${state.name}]\n`);
561
+ emit.text(` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`);
367
562
  emit.text(` URL: ${state.url}\n`);
368
563
  emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
369
564
  emit.text(` Gate port: ${state.gate_port}\n`);
370
- emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n`);
565
+ if (state.provider === "cloudflare") {
566
+ emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n`);
567
+ }
568
+ else {
569
+ emit.text(` TS path: ${state.tailscale_path ?? "/"}\n`);
570
+ }
371
571
  emit.text(` Gate PID: ${state.gate_pid}${gateAlive ? "" : " (DEAD)"}\n`);
372
- emit.text(` CFD PID: ${state.cloudflared_pid}${cfdAlive ? "" : " (DEAD)"}\n`);
572
+ if (state.provider === "cloudflare") {
573
+ emit.text(` CFD PID: ${providerPid ?? "?"}${providerAlive ? "" : " (DEAD)"}\n`);
574
+ }
373
575
  emit.text(` Uptime: ${fmtUptime(state.started_at)}\n`);
374
576
  }
375
577
  function status(opts) {
@@ -396,6 +598,7 @@ function status(opts) {
396
598
  }
397
599
  const rows = states.map((s) => ({
398
600
  name: s.name,
601
+ provider: s.provider,
399
602
  state: instanceState(s),
400
603
  url: s.url,
401
604
  fwd: `${s.target} (${s.vhost})`,
@@ -404,20 +607,23 @@ function status(opts) {
404
607
  }));
405
608
  const w = {
406
609
  name: Math.max(4, ...rows.map((r) => r.name.length)),
610
+ provider: Math.max(8, ...rows.map((r) => r.provider.length)),
407
611
  state: 5,
408
612
  url: Math.max(3, ...rows.map((r) => r.url.length)),
409
613
  fwd: Math.max(10, ...rows.map((r) => r.fwd.length)),
410
614
  port: 4,
411
615
  };
412
616
  const pad = (s, n) => s.padEnd(n);
413
- emit.text(`${pad("NAME", w.name)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PORT", w.port)} UPTIME\n`);
617
+ emit.text(`${pad("NAME", w.name)} ${pad("PROVIDER", w.provider)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PORT", w.port)} UPTIME\n`);
414
618
  for (const r of rows) {
415
- emit.text(`${pad(r.name, w.name)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.port, w.port)} ${r.up}\n`);
619
+ emit.text(`${pad(r.name, w.name)} ${pad(r.provider, w.provider)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.port, w.port)} ${r.up}\n`);
416
620
  }
417
621
  }
418
622
  function logs(opts) {
419
623
  const name = resolveName(opts.name);
420
- const which = opts.cloudflared ? cfdLogFile(name) : gateLogFile(name);
624
+ const state = readState(name);
625
+ const provider = state?.provider ?? "cloudflare";
626
+ const which = opts.cloudflared || opts.provider ? providerLogFile(name, provider) : gateLogFile(name);
421
627
  const path = cachePath("tunnel", which);
422
628
  if (!existsSync(path)) {
423
629
  emit.error({ code: "tunnel_no_log", message: `No log file at ${path}` });
@@ -446,9 +652,9 @@ function allowAdd(ip) {
446
652
  cfg.allowed_ips.push(ip);
447
653
  writeConfig(cfg);
448
654
  emit.text(`Added ${ip}.\n`);
449
- const up = listStates();
655
+ const up = listStates().filter((s) => s.provider === "cloudflare");
450
656
  if (up.length > 0) {
451
- emit.text(`Allowlist is shared across all tunnels; restart each to apply (${up.map((s) => s.name).join(", ")}).\n`);
657
+ emit.text(`Cloudflare allowlist is shared across Cloudflare tunnels; restart each to apply (${up.map((s) => s.name).join(", ")}).\n`);
452
658
  }
453
659
  }
454
660
  function allowRm(ip) {
@@ -461,9 +667,9 @@ function allowRm(ip) {
461
667
  cfg.allowed_ips.splice(idx, 1);
462
668
  writeConfig(cfg);
463
669
  emit.text(`Removed ${ip}.\n`);
464
- const up = listStates();
670
+ const up = listStates().filter((s) => s.provider === "cloudflare");
465
671
  if (up.length > 0) {
466
- emit.text(`Allowlist is shared across all tunnels; restart each to apply (${up.map((s) => s.name).join(", ")}).\n`);
672
+ emit.text(`Cloudflare allowlist is shared across Cloudflare tunnels; restart each to apply (${up.map((s) => s.name).join(", ")}).\n`);
467
673
  }
468
674
  }
469
675
  let emit;
@@ -473,16 +679,20 @@ export function registerTunnelCommand(program, emitParam, contextParam) {
473
679
  context = contextParam;
474
680
  const cmd = program
475
681
  .command("tunnel")
476
- .description("IP-gated Cloudflare quick tunnel(s) in front of a local upstream (default upstream: 127.0.0.1:8001). " +
682
+ .description("Provider-backed tunnel(s) in front of a local upstream (default upstream: 127.0.0.1:8001). " +
477
683
  "Run several at once with --name <instance>.");
478
684
  cmd
479
685
  .command("up")
480
- .description("Start a gate + cloudflared tunnel (one per --name instance)")
686
+ .description("Start a gate + provider tunnel (one per --name instance)")
481
687
  .option("--name <name>", "instance name; run multiple tunnels side by side", DEFAULT_INSTANCE)
688
+ .option("--provider <provider>", "provider: cloudflare (default) or tailscale", "cloudflare")
482
689
  .option("--target <addr>", "upstream to forward to", DEFAULT_TARGET)
483
690
  .option("--vhost <host>", "Host header sent to the upstream (default: the consumer's configured " +
484
691
  "default, else localhost).")
485
692
  .option("--gate-port <port>", "local port the gate binds to (default: auto-allocate the first free port from 9001)")
693
+ .option("--visibility <visibility>", "Tailscale only: tailnet (Serve, default) or public (Funnel)", "tailnet")
694
+ .option("--path <path>", "Tailscale only: URL path mount (default: / for default, /<name> for named instances)")
695
+ .option("--https-port <port>", "Tailscale only: HTTPS listen port (default: 443)", "443")
486
696
  .action(up);
487
697
  cmd
488
698
  .command("down")
@@ -497,15 +707,16 @@ export function registerTunnelCommand(program, emitParam, contextParam) {
497
707
  .action(status);
498
708
  cmd
499
709
  .command("logs")
500
- .description("Tail the gate log (default) or cloudflared log for an instance")
710
+ .description("Tail the gate log (default) or provider log for an instance")
501
711
  .option("--name <name>", "instance whose log to tail", DEFAULT_INSTANCE)
502
712
  .option("-f, --follow", "follow the log")
503
713
  .option("--gate", "tail the gate log (default)")
504
- .option("--cloudflared", "tail the cloudflared log instead")
714
+ .option("--provider", "tail the provider log instead")
715
+ .option("--cloudflared", "tail the Cloudflare provider log instead (legacy alias)")
505
716
  .action(logs);
506
717
  const allow = cmd
507
718
  .command("allow")
508
- .description("Manage the CF-Connecting-IP allowlist")
719
+ .description("Manage the Cloudflare CF-Connecting-IP allowlist")
509
720
  .action(allowList);
510
721
  allow.command("add <ip>").description("Add an IP to the allowlist").action(allowAdd);
511
722
  allow.command("rm <ip>").description("Remove an IP from the allowlist").action(allowRm);
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  * harnery repo at the *matching version tag* into a cache dir and install the
15
15
  * web app's deps. Subsequent runs reuse the cache.
16
16
  *
17
- * The only thing `web/` pulls from `src/` is `harnery/lib/scratch` (a single,
17
+ * The only thing `web/` pulls from `src/` is `harnery/core/scratch` (a single,
18
18
  * dependency-free module), so the install is `web/` deps only: no root install,
19
19
  * no Playwright browser download.
20
20
  */
@@ -1 +1 @@
1
- {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0IAA0I;IAC1I,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CA0C5C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,aAAa,GAAG,kBAAkB,GAAG,MAAM,CAAC;CAC5F,CAuDA;AA4BD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA2BpE;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyBpE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKlE;AAgBD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAIxC"}
1
+ {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0IAA0I;IAC1I,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CA0C5C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,aAAa,GAAG,kBAAkB,GAAG,MAAM,CAAC;CAC5F,CAkEA;AA4CD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA2BpE;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyBpE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKlE;AAgBD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAIxC"}
@@ -112,6 +112,15 @@ export function resolveOwnerWithSource() {
112
112
  const root = monorepoRoot();
113
113
  if (!root)
114
114
  return { owner: null, source: "none" };
115
+ // Cursor's Glass/Agents UI can run several chats under one long-lived node
116
+ // process, so the pid-map row for that shared ancestor is last-writer-wins.
117
+ // Prefer the per-chat session id when Cursor exposes one in the tool env.
118
+ if (shouldPreferSessionEnv()) {
119
+ const bySession = resolveOwnerBySessionEnv(root);
120
+ if (bySession) {
121
+ return { owner: bySession, source: "session_env" };
122
+ }
123
+ }
115
124
  const pidmapDir = resolve(root, ".harnery", "pid-map");
116
125
  if (!existsSync(pidmapDir))
117
126
  return { owner: null, source: "none" };
@@ -172,16 +181,31 @@ const SESSION_ID_ENV_VARS = [
172
181
  "HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
173
182
  "CLAUDE_CODE_SESSION_ID",
174
183
  "CURSOR_SESSION_ID",
184
+ "CURSOR_CONVERSATION_ID",
175
185
  "CODEX_SESSION_ID",
176
186
  ];
177
- /** Read the first non-empty harness session-id env var, or null. */
178
- function sessionIdFromEnv() {
187
+ /** Read normalized candidates from the first non-empty harness session-id env var. */
188
+ function sessionIdsFromEnv() {
179
189
  for (const key of SESSION_ID_ENV_VARS) {
180
190
  const v = process.env[key]?.trim();
181
- if (v)
182
- return v;
191
+ if (!v)
192
+ continue;
193
+ if (key === "CURSOR_CONVERSATION_ID" && v.startsWith("bc-") && v.length > 3) {
194
+ return [v.slice(3), v];
195
+ }
196
+ return [v];
183
197
  }
184
- return null;
198
+ return [];
199
+ }
200
+ /** Read the first non-empty harness session-id env var, or null. */
201
+ function sessionIdFromEnv() {
202
+ return sessionIdsFromEnv()[0] ?? null;
203
+ }
204
+ function shouldPreferSessionEnv() {
205
+ if (!sessionIdFromEnv())
206
+ return false;
207
+ const platform = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim();
208
+ return process.env.CURSOR_AGENT === "1" || platform === "cursor";
185
209
  }
186
210
  /**
187
211
  * Resolve the owner by matching the harness session-id env var against the
@@ -194,8 +218,8 @@ function sessionIdFromEnv() {
194
218
  * Exported for unit testing with an injectable root.
195
219
  */
196
220
  export function resolveOwnerBySessionEnv(root) {
197
- const sessionId = sessionIdFromEnv();
198
- if (!sessionId)
221
+ const sessionIds = sessionIdsFromEnv();
222
+ if (sessionIds.length === 0)
199
223
  return null;
200
224
  const activeDir = resolve(root, ".harnery", "active");
201
225
  if (!existsSync(activeDir))
@@ -214,7 +238,7 @@ export function resolveOwnerBySessionEnv(root) {
214
238
  continue;
215
239
  try {
216
240
  const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
217
- if (!parsed || parsed.session_id !== sessionId)
241
+ if (!parsed || !sessionIds.includes(parsed.session_id))
218
242
  continue;
219
243
  if (typeof parsed.instance_id !== "string")
220
244
  continue;
@@ -11,8 +11,12 @@
11
11
  * with a bounded **tail read** (reading only the last `tailBytes` of the file
12
12
  * and locating the cursor there) which turns an O(file-size) read into
13
13
  * O(window). If the cursor is older than the window (a long-idle system, or a
14
- * cursor that's been rotated out) we fall back to a full read. The fallback is
15
- * always correct, just slower, so undersizing the window can never lose events.
14
+ * cursor that's been rotated out) we fall back to a wider read that is itself
15
+ * capped (`fallbackCapBytes`): the stream is an append-only ledger that grows
16
+ * without bound, so a whole-file `readFileSync` throws V8's max-string-length
17
+ * error ("Cannot create a string longer than 0x1fffffe8 characters") once it
18
+ * passes ~512MB, which would abort the projection. Events older than the cap are
19
+ * stale for coord-state purposes, so a bounded replay is the correct fallback.
16
20
  *
17
21
  * Idempotency: projectors are idempotent by `event_id`. The
18
22
  * cursor file makes this cheap (we never replay an already-projected event by
@@ -49,11 +53,30 @@ export interface ConsumeOpts {
49
53
  * callers should leave it unset.
50
54
  */
51
55
  tailBytes?: number;
56
+ /**
57
+ * Cap in bytes for the fall-through read (cursor missed the tail window).
58
+ * Defaults to `HARNERY_AGENT_COORD_FALLBACK_CAP_BYTES` env or 64 MiB. Bounds
59
+ * the read so the unbounded ledger can never overflow V8's max string length.
60
+ * Mainly a test seam; production callers should leave it unset.
61
+ */
62
+ fallbackCapBytes?: number;
52
63
  }
53
64
  /**
54
65
  * Read all events after the persisted cursor, in chronological (file) order.
55
66
  */
56
67
  export declare function consumeSince(coordRoot: string, opts?: ConsumeOpts): ConsumeResult;
68
+ /**
69
+ * Read at most `capBytes` from the tail of the event stream as UTF-8, dropping
70
+ * the (partial) leading line when the file is larger than the cap. Shared
71
+ * bounded reader for CLI consumers (`agents trace` / `agents health`) that must
72
+ * never `readFileSync` the whole unbounded ledger — a >512MB read throws V8's
73
+ * max-string-length error. `truncated` is true when older bytes were skipped, so
74
+ * callers can surface the cap rather than silently under-reporting.
75
+ */
76
+ export declare function readStreamTailBounded(streamPath: string, capBytes: number): {
77
+ text: string;
78
+ truncated: boolean;
79
+ };
57
80
  export declare function readCursor(coordRoot: string): string | null;
58
81
  export declare function writeCursor(coordRoot: string, eventId: string): void;
59
82
  //# sourceMappingURL=consume.d.ts.map