harnery 0.6.0 → 0.7.1

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 (137) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +19 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +51 -4
  7. package/dist/commands/deinit.d.ts.map +1 -1
  8. package/dist/commands/deinit.js +4 -0
  9. package/dist/commands/devtools.d.ts +4 -0
  10. package/dist/commands/devtools.d.ts.map +1 -0
  11. package/dist/commands/devtools.js +239 -0
  12. package/dist/commands/docs.d.ts.map +1 -1
  13. package/dist/commands/docs.js +69 -1
  14. package/dist/commands/doctor.js +12 -4
  15. package/dist/commands/env.d.ts.map +1 -1
  16. package/dist/commands/env.js +3 -63
  17. package/dist/commands/init.d.ts +1 -0
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +54 -14
  20. package/dist/commands/scratch.js +1 -1
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +273 -62
  23. package/dist/commands/web-fetch.js +1 -1
  24. package/dist/core/agents/cli.js +48 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/coord-client.js +32 -8
  27. package/dist/core/agents/events/emit.d.ts.map +1 -1
  28. package/dist/core/agents/events/emit.js +4 -0
  29. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  30. package/dist/core/agents/rules/claim-conflict.js +16 -5
  31. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  32. package/dist/core/agents/state/heartbeat-projector.js +10 -3
  33. package/dist/core/config.d.ts +10 -0
  34. package/dist/core/config.d.ts.map +1 -1
  35. package/dist/core/config.js +13 -0
  36. package/dist/core/hooks/cli.js +3 -3
  37. package/dist/core/hooks/effects/index.d.ts +11 -7
  38. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  39. package/dist/core/hooks/effects/index.js +15 -18
  40. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  41. package/dist/core/hooks/events/emit.js +4 -0
  42. package/dist/core/hooks/events/rotate.d.ts +43 -0
  43. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  44. package/dist/core/hooks/events/rotate.js +142 -0
  45. package/dist/core/hooks/harness/events.d.ts +11 -1
  46. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  47. package/dist/core/hooks/harness/events.js +22 -3
  48. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  49. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  50. package/dist/core/hooks/harness/wiring.js +34 -5
  51. package/dist/core/scratch/index.d.ts.map +1 -0
  52. package/dist/{lib → core}/scratch/index.js +2 -2
  53. package/dist/lib/devtools.d.ts +178 -0
  54. package/dist/lib/devtools.d.ts.map +1 -0
  55. package/dist/lib/devtools.js +1328 -0
  56. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  57. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  58. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  59. package/dist/lib/docs-frontmatter.d.ts +33 -0
  60. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  61. package/dist/lib/docs-frontmatter.js +130 -0
  62. package/dist/lib/docs-index.d.ts +1 -0
  63. package/dist/lib/docs-index.d.ts.map +1 -1
  64. package/dist/lib/docs-index.js +4 -5
  65. package/dist/lib/docs-lint.d.ts +2 -0
  66. package/dist/lib/docs-lint.d.ts.map +1 -1
  67. package/dist/lib/docs-lint.js +18 -12
  68. package/dist/lib/docs-meta.d.ts +14 -0
  69. package/dist/lib/docs-meta.d.ts.map +1 -0
  70. package/dist/lib/docs-meta.js +34 -0
  71. package/dist/lib/docs-sweep.d.ts +12 -0
  72. package/dist/lib/docs-sweep.d.ts.map +1 -1
  73. package/dist/lib/docs-sweep.js +98 -103
  74. package/dist/lib/format.js +2 -2
  75. package/dist/lib/http/index.d.ts +1 -0
  76. package/dist/lib/http/index.d.ts.map +1 -1
  77. package/dist/lib/http/index.js +1 -0
  78. package/dist/lib/http/request.d.ts +77 -0
  79. package/dist/lib/http/request.d.ts.map +1 -0
  80. package/dist/lib/http/request.js +105 -0
  81. package/dist/lib/instructions/apply.d.ts +63 -0
  82. package/dist/lib/instructions/apply.d.ts.map +1 -0
  83. package/dist/lib/instructions/apply.js +255 -0
  84. package/dist/lib/instructions/splice.d.ts +73 -0
  85. package/dist/lib/instructions/splice.d.ts.map +1 -0
  86. package/dist/lib/instructions/splice.js +118 -0
  87. package/dist/lib/instructions/templates.d.ts +45 -0
  88. package/dist/lib/instructions/templates.d.ts.map +1 -0
  89. package/dist/lib/instructions/templates.js +258 -0
  90. package/dist/lib/tunnel/gate.d.ts +1 -0
  91. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  92. package/dist/lib/tunnel/gate.js +14 -9
  93. package/dist/lib/tunnel/state.d.ts +11 -1
  94. package/dist/lib/tunnel/state.d.ts.map +1 -1
  95. package/dist/lib/tunnel/state.js +8 -3
  96. package/package.json +7 -6
  97. package/src/commander.ts +23 -0
  98. package/src/commands/agents.ts +50 -3
  99. package/src/commands/deinit.ts +5 -0
  100. package/src/commands/devtools.ts +284 -0
  101. package/src/commands/docs.ts +81 -1
  102. package/src/commands/doctor.ts +13 -4
  103. package/src/commands/env.ts +11 -77
  104. package/src/commands/init.ts +66 -15
  105. package/src/commands/scratch.ts +1 -1
  106. package/src/commands/tunnel.ts +316 -65
  107. package/src/commands/web-fetch.ts +1 -1
  108. package/src/core/agents/cli.ts +55 -0
  109. package/src/core/agents/coord-client.ts +34 -7
  110. package/src/core/agents/events/emit.ts +5 -0
  111. package/src/core/agents/rules/claim-conflict.ts +17 -6
  112. package/src/core/agents/state/heartbeat-projector.ts +11 -3
  113. package/src/core/config.ts +14 -0
  114. package/src/core/hooks/cli.ts +3 -3
  115. package/src/core/hooks/effects/index.ts +23 -17
  116. package/src/core/hooks/events/emit.ts +5 -0
  117. package/src/core/hooks/events/rotate.ts +151 -0
  118. package/src/core/hooks/harness/events.ts +30 -3
  119. package/src/core/hooks/harness/wiring.ts +46 -5
  120. package/src/{lib → core}/scratch/index.ts +2 -2
  121. package/src/lib/devtools.ts +1653 -0
  122. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  123. package/src/lib/docs-frontmatter.ts +151 -0
  124. package/src/lib/docs-index.ts +4 -5
  125. package/src/lib/docs-lint.ts +17 -11
  126. package/src/lib/docs-meta.ts +44 -0
  127. package/src/lib/docs-sweep.ts +104 -102
  128. package/src/lib/format.ts +2 -2
  129. package/src/lib/http/index.ts +1 -0
  130. package/src/lib/http/request.ts +154 -0
  131. package/src/lib/instructions/apply.ts +318 -0
  132. package/src/lib/instructions/splice.ts +148 -0
  133. package/src/lib/instructions/templates.ts +295 -0
  134. package/src/lib/tunnel/gate.ts +14 -9
  135. package/src/lib/tunnel/state.ts +19 -4
  136. package/dist/lib/scratch/index.d.ts.map +0 -1
  137. /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
  */
@@ -150,6 +150,32 @@ async function handleProject(root, rest) {
150
150
  }
151
151
  return 0;
152
152
  }
153
+ /**
154
+ * Append a canonical `claim.release` event for a path dropped from an owner's
155
+ * files_touched. The path is canonicalized to repo-relative (matching the
156
+ * projector's normalization) so the subtraction matches on replay regardless
157
+ * of the form the caller passed. Soft-fails: a failed emit must never break
158
+ * the release/kill flow — the file mutation already happened.
159
+ */
160
+ async function emitClaimRelease(root, owner, hb, path, reason) {
161
+ try {
162
+ const { emit } = await import("./events/emit.js");
163
+ const canonical = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
164
+ const platform = hb.platform;
165
+ const harness = platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code";
166
+ emit(root, {
167
+ event_type: "claim.release",
168
+ instance_id: owner,
169
+ session_id: hb.session_id ?? owner,
170
+ harness,
171
+ source: "agent-coord",
172
+ data: { path: canonical, reason },
173
+ });
174
+ }
175
+ catch {
176
+ /* soft-fail: never break the caller */
177
+ }
178
+ }
153
179
  async function handleStateAction(root, action, rest) {
154
180
  const writer = await import("./state/heartbeat-writer.js");
155
181
  const [owner, ...args] = rest;
@@ -189,14 +215,36 @@ async function handleStateAction(root, action, rest) {
189
215
  process.stderr.write("agent-coord release-claim: missing <path>\n");
190
216
  return 2;
191
217
  }
218
+ const before = writer.readHeartbeat(root, owner);
192
219
  const hb = writer.releaseClaim(root, owner, path);
193
220
  if (!hb)
194
221
  return 1;
222
+ // Durability: the projector rebuilds files_touched by replaying the
223
+ // permanent Edit/Write events, so a file-only release is silently
224
+ // reverted by the next full replay. Emitting claim.release puts the
225
+ // subtraction into the stream so every future replay honors it. Only
226
+ // emit when the release actually removed a held path (idempotent
227
+ // re-releases stay quiet).
228
+ const heldBefore = before?.files_touched?.length ?? 0;
229
+ const heldAfter = hb.files_touched?.length ?? 0;
230
+ if (heldBefore > heldAfter) {
231
+ await emitClaimRelease(root, owner, before ?? hb, path, "explicit");
232
+ }
195
233
  process.stdout.write(`${JSON.stringify({ instance_id: owner, files_touched: hb.files_touched })}\n`);
196
234
  return 0;
197
235
  }
198
236
  case "kill-heartbeat": {
237
+ // Read held claims BEFORE the unlink so they can be released durably —
238
+ // killing only the file leaves the claims resurrectable from the
239
+ // permanent Edit/Write events on the next full replay (observed: a
240
+ // 6-day-dead agent's claims returning after its heartbeat was killed).
241
+ const before = writer.readHeartbeat(root, owner);
199
242
  const ok = writer.killHeartbeat(root, owner);
243
+ if (ok && before) {
244
+ for (const held of before.files_touched ?? []) {
245
+ await emitClaimRelease(root, owner, before, held, "heal");
246
+ }
247
+ }
200
248
  process.stdout.write(`${JSON.stringify({ instance_id: owner, removed: ok })}\n`);
201
249
  return ok ? 0 : 1;
202
250
  }
@@ -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"}