codehost 0.19.0 → 0.20.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.20.1](https://github.com/snomiao/codehost/compare/v0.20.0...v0.20.1) (2026-06-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **signaling:** tame the room-DO request storm; live agent titles on the sidepanel ([d39dceb](https://github.com/snomiao/codehost/commit/d39dceb2c7e3cd47dcb17f099aa8e0d37d3a7e13))
7
+
8
+ # [0.20.0](https://github.com/snomiao/codehost/compare/v0.19.0...v0.20.0) (2026-06-11)
9
+
10
+
11
+ ### Features
12
+
13
+ * **web:** edit a host's .codehost config from the site — advertised as a ⚙ workspace entry ([7c97b87](https://github.com/snomiao/codehost/commit/7c97b870715d6879cee39dab286cc7b8d45f08a6))
14
+
1
15
  # [0.19.0](https://github.com/snomiao/codehost/compare/v0.18.2...v0.19.0) (2026-06-11)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,6 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { homedir, hostname } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import type { CommandModule } from "yargs";
5
5
  import type { PeerMeta } from "../../shared/signaling";
6
6
  import { DEFAULT_LAYOUT, GITHUB_HOST, toPosixPath } from "../../shared/repo";
@@ -146,10 +146,26 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
146
146
  // so the advertised list and the provisioned paths agree.
147
147
  const layout = readCodehostConfig(dir).workspace || DEFAULT_LAYOUT;
148
148
  const plugins = [agentYesPlugin()].filter((p) => p != null);
149
+ // buildMeta runs every AGENTS_META_POLL_MS so live agent titles propagate
150
+ // (the room only sees a push when something changed). The filesystem walk
151
+ // for checkouts is the expensive part — memoize it; registered workspaces
152
+ // and agents are cheap reads and stay fresh on every call.
153
+ const WORKSPACE_WALK_TTL_MS = 30_000;
154
+ let wsWalk: { at: number; list: ReturnType<typeof enumerateWorkspaces> } | null = null;
149
155
  const buildMeta = (): PeerMeta => {
150
156
  // Layout-enumerated checkouts plus directories other `codehost dev` runs
151
157
  // registered with this host daemon (git-identified best-effort).
152
- const workspaces = enumerateWorkspaces(dir, layout);
158
+ if (!wsWalk || Date.now() - wsWalk.at > WORKSPACE_WALK_TTL_MS) {
159
+ const list = enumerateWorkspaces(dir, layout);
160
+ // The config dir itself is editable from the site (rendered as ⚙, opens
161
+ // in the editor) — advertised so its /host/<host>/<path> link resolves.
162
+ const configDir = join(dir, ".codehost");
163
+ if (existsSync(configDir)) {
164
+ list.push({ path: toPosixPath(configDir), config: true });
165
+ }
166
+ wsWalk = { at: Date.now(), list };
167
+ }
168
+ const workspaces = [...wsWalk.list];
153
169
  for (const w of readRegisteredWorkspaces()) {
154
170
  const path = toPosixPath(w.path);
155
171
  if (workspaces.some((x) => x.path === path)) continue;
@@ -187,6 +203,9 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
187
203
  signal: argv.signal,
188
204
  meta: buildMeta(),
189
205
  refreshMeta: buildMeta,
206
+ // Fast poll so agents' self-set titles go live on the site sidepanel;
207
+ // per tick it's pid-liveness checks + log-tail stat()s (see liveTitle).
208
+ metaRefreshMs: 3_000,
190
209
  watchFiles: [workspacesFile()],
191
210
  plugins,
192
211
  label: `serving workspace root ${dir}`,
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { agentYesPlugin, readAgents } from "./agent-yes";
5
+ import { agentYesPlugin, liveTitle, readAgents } from "./agent-yes";
6
6
  import { routePlugins, withPluginMeta } from "./types";
7
7
 
8
8
  function makeAyDir(lines: object[]): string {
@@ -34,6 +34,42 @@ describe("readAgents", () => {
34
34
  test("missing registry -> empty", () => {
35
35
  expect(readAgents(mkdtempSync(join(tmpdir(), "codehost-ay-empty-")))).toEqual([]);
36
36
  });
37
+
38
+ test("live OSC title from the log tail beats the launch prompt", () => {
39
+ const dir = mkdtempSync(join(tmpdir(), "codehost-ay-title-"));
40
+ const log = join(dir, "agent.raw.log");
41
+ writeFileSync(log, "boot\x1b]2;first title\x07work work\x1b]0;renamed by agent\x07tail");
42
+ writeFileSync(
43
+ join(dir, "pids.jsonl"),
44
+ JSON.stringify({
45
+ pid: process.pid,
46
+ cli: "claude",
47
+ prompt: "the launch prompt",
48
+ cwd: "/tmp/x",
49
+ log_file: log,
50
+ status: "active",
51
+ }) + "\n",
52
+ );
53
+ expect(readAgents(dir)[0].title).toBe("renamed by agent");
54
+ });
55
+ });
56
+
57
+ describe("liveTitle", () => {
58
+ test("returns the LAST OSC 0/2 title, cached by (size, mtime)", () => {
59
+ const dir = mkdtempSync(join(tmpdir(), "codehost-ay-osc-"));
60
+ const log = join(dir, "x.log");
61
+ writeFileSync(log, "\x1b]0;one\x07...\x1b]2;two\x1b\\rest");
62
+ expect(liveTitle(log)).toBe("two");
63
+ expect(liveTitle(log)).toBe("two"); // cache hit path
64
+ });
65
+
66
+ test("missing file / no title -> null", () => {
67
+ expect(liveTitle("/nonexistent/x.log")).toBeNull();
68
+ const dir = mkdtempSync(join(tmpdir(), "codehost-ay-osc2-"));
69
+ const log = join(dir, "plain.log");
70
+ writeFileSync(log, "no escapes here");
71
+ expect(liveTitle(log)).toBeNull();
72
+ });
37
73
  });
38
74
 
39
75
  describe("plugin routing + meta", () => {
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { toPosixPath } from "../../shared/repo";
@@ -22,10 +22,47 @@ interface AyRecord {
22
22
  cli?: string;
23
23
  prompt?: string | null;
24
24
  cwd?: string;
25
+ log_file?: string | null;
25
26
  status?: "active" | "idle" | "exited";
26
27
  started_at?: number;
27
28
  }
28
29
 
30
+ // Agents retitle their terminal by writing OSC 0/2 (\x1b]2;name\x07) into the
31
+ // PTY stream agent-yes logs; the most recent one is the live title (same trick
32
+ // `ay serve` uses for /api/ls). Read straight from the log tail so it works
33
+ // even when ay serve is down. Cached per (size, mtime): only logs that grew
34
+ // since the last refresh are re-read, so a frequent meta poll stays cheap.
35
+ const TITLE_TAIL_BYTES = 65536;
36
+ const titleCache = new Map<string, { size: number; mtimeMs: number; title: string | null }>();
37
+
38
+ export function liveTitle(logFile: string | null | undefined): string | null {
39
+ if (!logFile) return null;
40
+ try {
41
+ const fd = openSync(logFile, "r");
42
+ try {
43
+ const { size, mtimeMs } = fstatSync(fd);
44
+ const hit = titleCache.get(logFile);
45
+ if (hit && hit.size === size && hit.mtimeMs === mtimeMs) return hit.title;
46
+ const len = Math.min(size, TITLE_TAIL_BYTES);
47
+ const buf = Buffer.allocUnsafe(len);
48
+ const bytesRead = readSync(fd, buf, 0, len, size - len);
49
+ const text = buf.toString("utf-8", 0, bytesRead);
50
+ // eslint-disable-next-line no-control-regex
51
+ const oscTitleRe = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
52
+ let title: string | null = null;
53
+ for (let m: RegExpExecArray | null; (m = oscTitleRe.exec(text)); ) {
54
+ if (m[1].trim()) title = m[1].trim();
55
+ }
56
+ titleCache.set(logFile, { size, mtimeMs, title });
57
+ return title;
58
+ } finally {
59
+ closeSync(fd);
60
+ }
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
29
66
  /** agent-yes's global registry: JSONL, last line per pid wins. */
30
67
  export function readAgents(dir: string = AY_DIR): AgentInfo[] {
31
68
  let raw: string;
@@ -48,10 +85,12 @@ export function readAgents(dir: string = AY_DIR): AgentInfo[] {
48
85
  for (const rec of [...byPid.values()].sort((a, b) => (b.started_at ?? 0) - (a.started_at ?? 0))) {
49
86
  if (out.length >= MAX_AGENTS) break;
50
87
  if (rec.status === "exited" || !alive(rec.pid)) continue;
88
+ // Live self-set title from the PTY log beats the static launch prompt.
89
+ const title = liveTitle(rec.log_file) ?? rec.prompt ?? null;
51
90
  out.push({
52
91
  pid: rec.pid,
53
92
  tool: rec.cli || "agent",
54
- ...(rec.prompt ? { title: rec.prompt.slice(0, 120) } : {}),
93
+ ...(title ? { title: title.slice(0, 120) } : {}),
55
94
  cwd: toPosixPath(rec.cwd ?? ""),
56
95
  state: rec.status === "active" ? "active" : "idle",
57
96
  ...(rec.started_at ? { startedAt: rec.started_at } : {}),
@@ -31,10 +31,14 @@ export interface RunServerOptions {
31
31
  /** Enables `/__codehost/provision` on the tunnel (serve only — runs the home's
32
32
  * setup.sh). Omitted by `expose`, which has no home/workspace. */
33
33
  provision?: ProvisionDeps;
34
- /** Recompute the advertised meta (e.g. re-enumerate workspaces). Polled on a
35
- * slow interval and right after each provision; pushed to the room only when
34
+ /** Recompute the advertised meta (e.g. re-enumerate workspaces). Polled on
35
+ * an interval and right after each provision; pushed to the room only when
36
36
  * it actually changed. */
37
37
  refreshMeta?: () => PeerMeta;
38
+ /** Meta poll cadence (default META_REFRESH_MS). `serve` polls fast so live
39
+ * agent titles propagate — refreshMeta must then be cheap per call. The
40
+ * room only sees a message when the meta actually changed. */
41
+ metaRefreshMs?: number;
38
42
  /** Daemon plugins: tunneled routes under /__codehost/<name>/ (their meta
39
43
  * contributions are the caller's job, inside `meta`/`refreshMeta`). */
40
44
  plugins?: DaemonPlugin[];
@@ -113,16 +117,24 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
113
117
 
114
118
  client.connect();
115
119
  if (opts.refreshMeta) {
116
- setInterval(refreshMeta, META_REFRESH_MS);
117
- // Instant re-advertise when a watched file changes (debounced — editors
118
- // and fs.watch both fire in bursts).
120
+ setInterval(refreshMeta, opts.metaRefreshMs ?? META_REFRESH_MS);
121
+ // Near-instant re-advertise when a watched file changes. Throttled, not
122
+ // debounced: fs.watch fires in bursts, but a file that changes CONSTANTLY
123
+ // (a busy registry) would keep resetting a debounce forever — a trailing
124
+ // throttle guarantees a refresh at most/at least every 300ms.
119
125
  let pending: ReturnType<typeof setTimeout> | null = null;
126
+ const requestRefresh = () => {
127
+ if (pending) return;
128
+ pending = setTimeout(() => {
129
+ pending = null;
130
+ refreshMeta();
131
+ }, 300);
132
+ };
120
133
  for (const file of opts.watchFiles ?? []) {
121
134
  try {
122
135
  watch(dirname(file), (_event, filename) => {
123
136
  if (filename && filename !== basename(file)) return;
124
- if (pending) clearTimeout(pending);
125
- pending = setTimeout(refreshMeta, 300);
137
+ requestRefresh();
126
138
  });
127
139
  } catch {
128
140
  // missing dir / unsupported platform — the interval still covers it
@@ -34,8 +34,10 @@ export interface CloseInfo {
34
34
  * connection that completes the handshake then drops within seconds (a
35
35
  * middlebox that accepts the WebSocket upgrade but kills the socket, seen on
36
36
  * some field networks) must keep backing off — otherwise every reset-to-1s
37
- * open/close cycle becomes a sub-second reconnect storm. */
38
- const STABLE_MS = 10_000;
37
+ * open/close cycle becomes a sub-second reconnect storm. A server that drops
38
+ * sockets every few tens of seconds (room DO redeploys, sweep evictions) must
39
+ * also keep backing off, so this sits above any such churn period. */
40
+ const STABLE_MS = 60_000;
39
41
 
40
42
  /** Abort a connect attempt that hasn't opened by this deadline. Observed in the
41
43
  * field (Chrome, page-load burst): a socket can sit in CONNECTING for minutes
@@ -43,6 +45,20 @@ const STABLE_MS = 10_000;
43
45
  * freshly-created socket to the same room opens instantly. */
44
46
  const CONNECT_TIMEOUT_MS = 10_000;
45
47
 
48
+ /** Reconnect backoff bounds. Every signaling round-trip (WS upgrade, hello,
49
+ * ping) is a billable request on the room Durable Object, so idle/broken
50
+ * clients must converge to a slow cadence: cap at 2 min, with ±25% jitter so
51
+ * a fleet of daemons dropped by one server restart doesn't thundering-herd. */
52
+ const RECONNECT_MIN_MS = 1_000;
53
+ const RECONNECT_MAX_MS = 120_000;
54
+
55
+ /** Heartbeat cadence. Paired with the room's STALE_MS (65s): the sweep
56
+ * tolerates ~2 missed beats. Each ping is a billable DO request — at 25s a
57
+ * day-long connection costs ~3.5k requests, vs ~8.6k at the old 10s. Hidden
58
+ * tabs survive too: Chrome's intensive throttling clamps timers to 1/min,
59
+ * still inside the 65s window. */
60
+ const HEARTBEAT_MS = 25_000;
61
+
46
62
  /**
47
63
  * Thin WebSocket client for the signaling room. Runs unchanged in the browser
48
64
  * and in Bun (both expose a global `WebSocket`). Auto-reconnects with backoff
@@ -52,8 +68,10 @@ export class SignalingClient {
52
68
  readonly peerId: string;
53
69
  private ws: WebSocket | null = null;
54
70
  private closed = false;
55
- private reconnectDelay = 1000;
71
+ private reconnectDelay = RECONNECT_MIN_MS;
56
72
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
73
+ /** True while a hidden tab sits out reconnection; onWake resumes it. */
74
+ private dormant = false;
57
75
  private heartbeat: ReturnType<typeof setInterval> | null = null;
58
76
  /** Fires STABLE_MS after a socket opens; only then is the backoff reset. */
59
77
  private stableTimer: ReturnType<typeof setTimeout> | null = null;
@@ -90,13 +108,24 @@ export class SignalingClient {
90
108
  }
91
109
  return;
92
110
  }
93
- // Closed and waiting out the (throttled) backoff: skip the wait.
94
- if (this.reconnectTimer != null) {
111
+ // Dormant (hidden tab sat out reconnection) or waiting out a throttled
112
+ // backoff: reconnect now.
113
+ if (this.dormant || this.reconnectTimer != null) {
114
+ this.dormant = false;
95
115
  this.clearReconnectTimer();
96
116
  this.open();
97
117
  }
98
118
  };
99
119
 
120
+ /** A hidden tab doesn't reconnect at all — abandoned tabs used to churn
121
+ * evict/reconnect cycles against the room DO all night. Existing WebRTC
122
+ * tunnels keep working without signaling; on visibility/focus/online the
123
+ * wake handler reconnects within milliseconds. */
124
+ private hidden(): boolean {
125
+ const doc = (globalThis as { document?: { visibilityState?: string } }).document;
126
+ return doc?.visibilityState === "hidden";
127
+ }
128
+
100
129
  private attachWakeListeners(): void {
101
130
  const doc = (globalThis as { document?: EventTarget }).document;
102
131
  doc?.addEventListener("visibilitychange", this.onWake);
@@ -143,7 +172,7 @@ export class SignalingClient {
143
172
  // its backoff keeps growing instead of hammering at 1s.
144
173
  this.clearStableTimer();
145
174
  this.stableTimer = setTimeout(() => {
146
- this.reconnectDelay = 1000;
175
+ this.reconnectDelay = RECONNECT_MIN_MS;
147
176
  }, STABLE_MS);
148
177
  const hello: ClientMessage = {
149
178
  type: "hello",
@@ -186,9 +215,8 @@ export class SignalingClient {
186
215
  };
187
216
  }
188
217
 
189
- // Heartbeat keeps the room's liveness sweep from evicting us. At 10s, the DO
190
- // (STALE_MS 35s) tolerates ~3 missed beats before treating us as gone — fast
191
- // enough that a crashed peer stops showing as a phantom server within ~1 sweep.
218
+ // Heartbeat keeps the room's liveness sweep from evicting us see
219
+ // HEARTBEAT_MS for the cadence/cost trade-off.
192
220
  private startHeartbeat(): void {
193
221
  this.stopHeartbeat();
194
222
  this.heartbeat = setInterval(() => {
@@ -197,7 +225,7 @@ export class SignalingClient {
197
225
  } catch {
198
226
  // socket gone; onclose will reconnect
199
227
  }
200
- }, 10000);
228
+ }, HEARTBEAT_MS);
201
229
  }
202
230
 
203
231
  private stopHeartbeat(): void {
@@ -215,12 +243,23 @@ export class SignalingClient {
215
243
  }
216
244
 
217
245
  private scheduleReconnect(): void {
218
- const delay = this.reconnectDelay;
219
- this.reconnectDelay = Math.min(delay * 2, 15000);
246
+ if (this.hidden()) {
247
+ this.dormant = true;
248
+ return;
249
+ }
250
+ // ±25% jitter so a fleet dropped together doesn't reconnect together.
251
+ const delay = Math.round(this.reconnectDelay * (0.75 + Math.random() * 0.5));
252
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
220
253
  this.clearReconnectTimer();
221
254
  this.reconnectTimer = setTimeout(() => {
222
255
  this.reconnectTimer = null;
223
- if (!this.closed) this.open();
256
+ if (this.closed) return;
257
+ if (this.hidden()) {
258
+ // Went hidden while waiting — sit out until the wake handler fires.
259
+ this.dormant = true;
260
+ return;
261
+ }
262
+ this.open();
224
263
  }, delay);
225
264
  }
226
265
 
@@ -248,6 +287,7 @@ export class SignalingClient {
248
287
 
249
288
  close(): void {
250
289
  this.closed = true;
290
+ this.dormant = false;
251
291
  this.detachWakeListeners();
252
292
  this.clearReconnectTimer();
253
293
  this.stopHeartbeat();
@@ -30,6 +30,10 @@ export interface WorkspaceInfo {
30
30
  repo?: string;
31
31
  /** Branch from the layout path, e.g. "main". */
32
32
  branch?: string;
33
+ /** This entry is the daemon's `.codehost/` config dir (setup.sh etc.), not a
34
+ * repo checkout — clients render it as a settings affordance, openable in
35
+ * the editor like any workspace. */
36
+ config?: boolean;
33
37
  }
34
38
 
35
39
  /** Metadata a `codehost serve`/`dev` daemon advertises about itself. */
@@ -1000,6 +1000,7 @@ export function Discovery() {
1000
1000
  >
1001
1001
  <span style={{ color: a.state === "active" ? "#4ec9b0" : "#777" }}>●</span> {a.tool}{" "}
1002
1002
  {a.pid}
1003
+ {a.title && <span style={styles.agentTitle}>{a.title}</span>}
1003
1004
  </a>
1004
1005
  ))}
1005
1006
  </div>
@@ -1025,11 +1026,13 @@ export function Discovery() {
1025
1026
  key={w.path}
1026
1027
  style={styles.wsLink}
1027
1028
  onClick={() => openWorkspace(s, w)}
1028
- title={w.path}
1029
+ title={w.config ? `edit this host's provisioning config\n${w.path}` : w.path}
1029
1030
  >
1030
- {w.repo
1031
- ? `${w.repo.split("/").slice(1).join("/")}${w.branch ? ` @${w.branch}` : ""}`
1032
- : w.path}
1031
+ {w.config
1032
+ ? ".codehost (setup.sh, config.yaml)"
1033
+ : w.repo
1034
+ ? `${w.repo.split("/").slice(1).join("/")}${w.branch ? ` @${w.branch}` : ""}`
1035
+ : w.path}
1033
1036
  </button>
1034
1037
  ))}
1035
1038
  </div>
@@ -1116,6 +1119,13 @@ const styles: Record<string, React.CSSProperties> = {
1116
1119
  agentChip: {
1117
1120
  fontFamily: "monospace", fontSize: 11.5, padding: "2px 8px", borderRadius: 999,
1118
1121
  border: "1px solid #3d3d3d", color: "#9aa4af", textDecoration: "none", cursor: "pointer",
1122
+ display: "inline-flex", alignItems: "baseline", gap: 4, maxWidth: 360,
1123
+ },
1124
+ // Live self-set agent title (daemon re-reads it from the PTY log and pushes
1125
+ // a meta update, so this re-renders as the agent renames itself).
1126
+ agentTitle: {
1127
+ color: "#6e7681", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
1128
+ minWidth: 0, flex: "0 1 auto",
1119
1129
  },
1120
1130
  card: { display: "flex", alignItems: "center", gap: 12, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "12px 14px" },
1121
1131
  cardMain: { flex: 1, minWidth: 0 },
@@ -25,12 +25,19 @@ export interface RoomOptions {
25
25
  onStatus?: (open: boolean) => void;
26
26
  }
27
27
 
28
+ /** After a failed dial, refuse redials to that peer for this long. Pollers
29
+ * (the agent-yes console asks every host for /api/ls every ~3s) would
30
+ * otherwise spin up a fresh RTCPeerConnection per poll, and every ICE
31
+ * candidate of every attempt is a billable signaling-DO request. */
32
+ const DIAL_FAIL_COOLDOWN_MS = 10_000;
33
+
28
34
  export class CodehostRoom {
29
35
  /** Server peers currently in the room (viewers filtered out). */
30
36
  peers: PeerInfo[] = [];
31
37
  private signaling: SignalingClient;
32
38
  private rtcs = new Map<string, RtcClient>();
33
39
  private tunnels = new Map<string, Promise<TunnelClient>>();
40
+ private dialFailedAt = new Map<string, number>();
34
41
  private closed = false;
35
42
 
36
43
  constructor(opts: RoomOptions) {
@@ -65,6 +72,10 @@ export class CodehostRoom {
65
72
  private dial(peerId: string): Promise<TunnelClient> {
66
73
  const existing = this.tunnels.get(peerId);
67
74
  if (existing) return existing;
75
+ const failedAt = this.dialFailedAt.get(peerId);
76
+ if (failedAt != null && Date.now() - failedAt < DIAL_FAIL_COOLDOWN_MS) {
77
+ return Promise.reject(new Error("dial failed recently; cooling down"));
78
+ }
68
79
  const drop = () => {
69
80
  this.tunnels.delete(peerId);
70
81
  this.rtcs.get(peerId)?.close();
@@ -79,6 +90,7 @@ export class CodehostRoom {
79
90
  sendSignal: (data: RtcSignal) => this.signaling.sendSignal(peerId, data),
80
91
  onOpen: (channel) => {
81
92
  clearTimeout(timer);
93
+ this.dialFailedAt.delete(peerId);
82
94
  resolve(new TunnelClient(channel));
83
95
  },
84
96
  onClose: drop,
@@ -94,7 +106,10 @@ export class CodehostRoom {
94
106
  });
95
107
  });
96
108
  this.tunnels.set(peerId, dialing);
97
- dialing.catch(() => this.tunnels.delete(peerId));
109
+ dialing.catch(() => {
110
+ this.dialFailedAt.set(peerId, Date.now());
111
+ this.tunnels.delete(peerId);
112
+ });
98
113
  return dialing;
99
114
  }
100
115
 
package/worker/room.ts CHANGED
@@ -15,10 +15,14 @@ interface Attachment {
15
15
  }
16
16
 
17
17
  /** How often the room scans for dead sockets, and how long a socket may go
18
- * silent before eviction. Clients heartbeat every ~10s; allow ~3 misses, so a
19
- * crashed peer drops out within ~35-50s instead of lingering as a phantom. */
20
- const SWEEP_MS = 15_000;
21
- const STALE_MS = 35_000;
18
+ * silent before eviction. Clients heartbeat every ~25s (HEARTBEAT_MS in
19
+ * signaling-client.ts); allow ~2 misses, so a crashed peer drops out within
20
+ * ~65-85s. Every sweep alarm and every ping is a billable DO request, so both
21
+ * cadences are deliberately slow — a hidden Chrome tab's throttled timers
22
+ * (1/min) must still beat STALE_MS, or background tabs churn evict/reconnect
23
+ * cycles all day. */
24
+ const SWEEP_MS = 20_000;
25
+ const STALE_MS = 65_000;
22
26
 
23
27
  /**
24
28
  * One Durable Object instance per token-room. Holds the live WebSocket