codehost 0.20.0 → 0.20.2

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.2](https://github.com/snomiao/codehost/compare/v0.20.1...v0.20.2) (2026-06-11)
2
+
3
+
4
+ ### Performance Improvements
5
+
6
+ * **tunnel:** 64KB frames, gzip passthrough, immutable-asset SW cache, event-driven backpressure; ICE path badge ([da3647c](https://github.com/snomiao/codehost/commit/da3647c96ac1e323237e4743e7f0e4b1a83f741d))
7
+
8
+ ## [0.20.1](https://github.com/snomiao/codehost/compare/v0.20.0...v0.20.1) (2026-06-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **signaling:** tame the room-DO request storm; live agent titles on the sidepanel ([d39dceb](https://github.com/snomiao/codehost/commit/d39dceb2c7e3cd47dcb17f099aa8e0d37d3a7e13))
14
+
1
15
  # [0.20.0](https://github.com/snomiao/codehost/compare/v0.19.0...v0.20.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.20.0",
3
+ "version": "0.20.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -146,16 +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);
153
- // The config dir itself is editable from the site (rendered as ⚙, opens
154
- // in the editor) advertised so its /host/<host>/<path> link resolves.
155
- const configDir = join(dir, ".codehost");
156
- if (existsSync(configDir)) {
157
- workspaces.push({ path: toPosixPath(configDir), config: true });
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 };
158
167
  }
168
+ const workspaces = [...wsWalk.list];
159
169
  for (const w of readRegisteredWorkspaces()) {
160
170
  const path = toPosixPath(w.path);
161
171
  if (workspaces.some((x) => x.path === path)) continue;
@@ -193,6 +203,9 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
193
203
  signal: argv.signal,
194
204
  meta: buildMeta(),
195
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,
196
209
  watchFiles: [workspacesFile()],
197
210
  plugins,
198
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
package/src/cli/tunnel.ts CHANGED
@@ -13,6 +13,13 @@ import {
13
13
 
14
14
  const textDecoder = new TextDecoder();
15
15
 
16
+ // Send-queue water marks. HIGH bounds how much data can sit ahead of an
17
+ // interactive message on the (single, ordered) channel — at 20 Mbps, 4 MB is
18
+ // already ~1.6 s of head-of-line latency, so resist raising it; LOW is where
19
+ // the bufferedAmountLow event resumes a paused sender.
20
+ const HIGH_WATER = 4 * 1024 * 1024;
21
+ const LOW_WATER = 1 * 1024 * 1024;
22
+
16
23
  // Hop-by-hop headers that must not be forwarded across the tunnel.
17
24
  const HOP_BY_HOP = new Set([
18
25
  "connection",
@@ -72,6 +79,12 @@ export class Tunnel {
72
79
  ) {
73
80
  this.origin = `http://127.0.0.1:${vscodePort}`;
74
81
  this.wsOrigin = `ws://127.0.0.1:${vscodePort}`;
82
+ try {
83
+ this.channel.setBufferedAmountLowThreshold(LOW_WATER);
84
+ this.channel.onBufferedAmountLow(() => this.drainWaiter?.());
85
+ } catch {
86
+ // older node-datachannel: the poll in waitForDrain still covers it
87
+ }
75
88
  this.channel.onMessage((msg) => {
76
89
  if (typeof msg === "string") return; // all frames are binary
77
90
  const buf = msg as Buffer;
@@ -148,6 +161,14 @@ export class Tunnel {
148
161
  const hasBody = method !== "GET" && method !== "HEAD" && stream.body.length > 0;
149
162
  const body = hasBody ? concat(stream.body) : undefined;
150
163
 
164
+ // A client that can inflate (TunnelClient sends this marker) gets the
165
+ // upstream's gzip bytes passed through UNTOUCHED — 3-4x fewer bytes over
166
+ // the channel for VS Code's JS/CSS. gzip only: the browser inflates with
167
+ // DecompressionStream, which has no brotli.
168
+ const wantsGzip = reqHeaders.get("x-codehost-accept-gzip") === "1";
169
+ reqHeaders.delete("x-codehost-accept-gzip");
170
+ if (wantsGzip) reqHeaders.set("accept-encoding", "gzip");
171
+
151
172
  try {
152
173
  const local = this.onLocal?.({ method, path, headers: reqHeaders, body });
153
174
  const res = local
@@ -157,6 +178,8 @@ export class Tunnel {
157
178
  headers: reqHeaders,
158
179
  body: body as BodyInit | undefined,
159
180
  redirect: "manual",
181
+ // Bun extension: don't auto-inflate — keep the wire bytes compressed.
182
+ ...(wantsGzip ? ({ decompress: false } as RequestInit) : {}),
160
183
  });
161
184
 
162
185
  const resHeaders: Record<string, string> = {};
@@ -240,15 +263,26 @@ export class Tunnel {
240
263
  return p;
241
264
  }
242
265
 
266
+ // Fires from onBufferedAmountLow so a paused sender resumes the moment the
267
+ // queue drains past LOW_WATER instead of on the next poll tick.
268
+ private drainWaiter: (() => void) | null = null;
269
+
243
270
  private waitForDrain(): Promise<void> {
244
- const HIGH = 8 * 1024 * 1024; // 8 MB
245
- if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH) return Promise.resolve();
271
+ if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH_WATER) return Promise.resolve();
246
272
  return new Promise((resolve) => {
247
- const tick = () => {
248
- if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH) resolve();
249
- else setTimeout(tick, 10);
273
+ let settled = false;
274
+ const finish = () => {
275
+ if (settled) return;
276
+ settled = true;
277
+ clearInterval(timer);
278
+ this.drainWaiter = null;
279
+ resolve();
250
280
  };
251
- tick();
281
+ this.drainWaiter = finish;
282
+ // Safety poll in case the low event raced or isn't available.
283
+ const timer = setInterval(() => {
284
+ if (!this.channel.isOpen() || this.channel.bufferedAmount() < HIGH_WATER) finish();
285
+ }, 100);
252
286
  });
253
287
  }
254
288
 
@@ -31,12 +31,15 @@ export enum Op {
31
31
  WsCont = 13,
32
32
  }
33
33
 
34
- // WebRTC data-channel messages must stay small to be portable: 16 KiB is the
35
- // largest size every WebRTC stack (libdatachannel, Chrome, Firefox) reliably
36
- // accepts. A frame is [op:1][streamId:4][payload], so the payload budget is
37
- // 16 KiB minus the 5-byte header.
34
+ // Frame size vs JS overhead: every frame costs a promise hop + two copies on
35
+ // its way through the tunnel, so bigger frames = faster bulk transfer. Both
36
+ // stacks we pair (Chrome/Firefox <-> libdatachannel) negotiate an SCTP
37
+ // max-message-size of 256 KiB, so 64 KiB rides well inside every negotiated
38
+ // limit while cutting per-MB frame count 4x vs the old 16 KiB. Receivers
39
+ // accept any frame size (decodeFrame is length-agnostic), so mixed old/new
40
+ // peers interoperate. A frame is [op:1][streamId:4][payload].
38
41
  export const FRAME_HEADER = 5;
39
- export const MAX_FRAME = 16 * 1024;
42
+ export const MAX_FRAME = 64 * 1024;
40
43
  /** Max payload bytes per frame; larger bodies/messages are split across frames. */
41
44
  export const MAX_CHUNK = MAX_FRAME - FRAME_HEADER;
42
45
 
@@ -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();
@@ -193,6 +193,9 @@ export function Discovery() {
193
193
  // client carries the peer's signaling and it's the token Share/history record.
194
194
  const [activePeerId, setActivePeerId] = useState<string | null>(null);
195
195
  const [connState, setConnState] = useState<ConnState>("idle");
196
+ // ICE path of the live session ("lan" | "p2p"); null when unknown or when
197
+ // this tab rides another tab's connection via the broker.
198
+ const [connPath, setConnPath] = useState<"lan" | "p2p" | null>(null);
196
199
  const [iframeSrc, setIframeSrc] = useState<string | null>(null);
197
200
  // Streamed setup.sh output shown while connState === "provisioning".
198
201
  const [provisionLog, setProvisionLog] = useState("");
@@ -455,6 +458,22 @@ export function Discovery() {
455
458
 
456
459
  await connBroker.connect(server.peerId, establish);
457
460
 
461
+ // Show which ICE path got nominated (owner tab only — a proxied tab has
462
+ // no RTCPeerConnection of its own). ICE may re-nominate just after the
463
+ // channel opens, so sample again shortly.
464
+ setConnPath(null);
465
+ // (assertion: TS narrows the ref to null from the reset above and can't
466
+ // see that `establish` re-assigned it)
467
+ const rtcForPath = rtcRef.current as RtcClient | null;
468
+ if (rtcForPath) {
469
+ const sample = () =>
470
+ void rtcForPath.selectedPath().then((p) => {
471
+ if (rtcRef.current === rtcForPath && p) setConnPath(p);
472
+ });
473
+ sample();
474
+ setTimeout(sample, 3000);
475
+ }
476
+
458
477
  // For a repo deep link, ask the daemon to provision (run .codehost/setup.sh
459
478
  // and hand back the authoritative workspace path) before opening. Streams
460
479
  // the log under the "provisioning" state. Daemons without the route (older
@@ -624,6 +643,7 @@ export function Discovery() {
624
643
  rtcRef.current = null;
625
644
  if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
626
645
  setIframeSrc(null);
646
+ setConnPath(null);
627
647
  setActivePeerId(null);
628
648
  activePeerRef.current = null;
629
649
  activeRoomRef.current = null;
@@ -795,6 +815,18 @@ export function Discovery() {
795
815
  activeServer?.meta?.name ??
796
816
  activePeerId?.slice(0, 8)}
797
817
  </span>
818
+ {connPath && (
819
+ <span
820
+ style={styles.dim}
821
+ title={
822
+ connPath === "lan"
823
+ ? "direct LAN path — both ends on the same network"
824
+ : "direct peer-to-peer path (NAT traversed)"
825
+ }
826
+ >
827
+ {connPath === "lan" ? "⚡LAN" : "🌐p2p"}
828
+ </span>
829
+ )}
798
830
  <span style={{ flex: 1 }} />
799
831
  <button
800
832
  style={styles.shareBtn}
@@ -1000,6 +1032,7 @@ export function Discovery() {
1000
1032
  >
1001
1033
  <span style={{ color: a.state === "active" ? "#4ec9b0" : "#777" }}>●</span> {a.tool}{" "}
1002
1034
  {a.pid}
1035
+ {a.title && <span style={styles.agentTitle}>{a.title}</span>}
1003
1036
  </a>
1004
1037
  ))}
1005
1038
  </div>
@@ -1118,6 +1151,13 @@ const styles: Record<string, React.CSSProperties> = {
1118
1151
  agentChip: {
1119
1152
  fontFamily: "monospace", fontSize: 11.5, padding: "2px 8px", borderRadius: 999,
1120
1153
  border: "1px solid #3d3d3d", color: "#9aa4af", textDecoration: "none", cursor: "pointer",
1154
+ display: "inline-flex", alignItems: "baseline", gap: 4, maxWidth: 360,
1155
+ },
1156
+ // Live self-set agent title (daemon re-reads it from the PTY log and pushes
1157
+ // a meta update, so this re-renders as the agent renames itself).
1158
+ agentTitle: {
1159
+ color: "#6e7681", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
1160
+ minWidth: 0, flex: "0 1 auto",
1121
1161
  },
1122
1162
  card: { display: "flex", alignItems: "center", gap: 12, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "12px 14px" },
1123
1163
  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
 
@@ -69,6 +69,42 @@ export class RtcClient {
69
69
  return this.channel;
70
70
  }
71
71
 
72
+ /**
73
+ * Which ICE path the nominated candidate pair uses: "lan" when both ends
74
+ * are host candidates (same network — traffic never leaves it), "p2p" for a
75
+ * NAT-traversed direct path, null while undetermined. Surfaced in the UI so
76
+ * "it feels slow" reports come with the path attached.
77
+ */
78
+ async selectedPath(): Promise<"lan" | "p2p" | null> {
79
+ try {
80
+ const stats = await this.pc.getStats();
81
+ let pairId: string | null = null;
82
+ stats.forEach((s) => {
83
+ if (s.type === "transport" && s.selectedCandidatePairId) pairId = s.selectedCandidatePairId;
84
+ });
85
+ let pair: RTCIceCandidatePairStats | null = null;
86
+ stats.forEach((s) => {
87
+ if (pairId ? s.id === pairId : s.type === "candidate-pair" && s.state === "succeeded" && s.nominated) {
88
+ pair = s as RTCIceCandidatePairStats;
89
+ }
90
+ });
91
+ if (!pair) return null;
92
+ const { localCandidateId, remoteCandidateId } = pair as RTCIceCandidatePairStats;
93
+ let lan = true;
94
+ let found = 0;
95
+ stats.forEach((s) => {
96
+ if (s.id === localCandidateId || s.id === remoteCandidateId) {
97
+ found++;
98
+ if ((s as { candidateType?: string }).candidateType !== "host") lan = false;
99
+ }
100
+ });
101
+ if (found < 2) return null;
102
+ return lan ? "lan" : "p2p";
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
72
108
  close(): void {
73
109
  try {
74
110
  this.channel?.close();
package/src/web/sw.ts CHANGED
@@ -10,6 +10,12 @@ const sw = self as unknown as ServiceWorkerGlobalScope;
10
10
 
11
11
  const VS_PREFIX = /^\/vs\/([^/]+)(\/.*)?$/;
12
12
  const CDN_CACHE = "codehost-cdn-v1";
13
+ const VS_STATIC_CACHE = "codehost-vs-static-v1";
14
+ // VS Code's own immutable assets: /stable-<commit>/static/** is content-
15
+ // addressed by the commit hash and identical across daemons, so it's cached
16
+ // once per browser instead of crossing the WebRTC tunnel on every load. The
17
+ // cache key strips the per-process /vs/<peerId> prefix.
18
+ const VS_STATIC = /^\/(stable-[0-9a-f]{40})\/static\//;
13
19
 
14
20
  sw.addEventListener("install", () => sw.skipWaiting());
15
21
  sw.addEventListener("activate", (e) => e.waitUntil(sw.clients.claim()));
@@ -35,10 +41,39 @@ sw.addEventListener("fetch", (event: FetchEvent) => {
35
41
  const m = url.pathname.match(VS_PREFIX);
36
42
  if (!m) return; // let the network/Pages handle the discovery app itself
37
43
  const peerId = m[1];
44
+ const rest = (m[2] ?? "/") + url.search;
45
+
46
+ if (event.request.method === "GET" && !event.request.headers.has("range") && VS_STATIC.test(rest)) {
47
+ event.respondWith(cachedStatic(event.request, peerId, rest));
48
+ return;
49
+ }
38
50
 
39
51
  event.respondWith(proxyOverTunnel(event.request, peerId));
40
52
  });
41
53
 
54
+ /** Cache-first for the immutable VS Code static assets; on a cache miss the
55
+ * tunnel fills it, and assets of other (older) commits are evicted. */
56
+ async function cachedStatic(request: Request, peerId: string, rest: string): Promise<Response> {
57
+ const key = `${sw.location.origin}/__codehost/vs-static${rest}`;
58
+ const cache = await caches.open(VS_STATIC_CACHE);
59
+ const hit = await cache.match(key);
60
+ if (hit) return hit;
61
+ const res = await proxyOverTunnel(request, peerId);
62
+ if (res.status === 200) {
63
+ void cache.put(key, res.clone()).catch(() => {});
64
+ void evictOtherCommits(cache, rest).catch(() => {});
65
+ }
66
+ return res;
67
+ }
68
+
69
+ async function evictOtherCommits(cache: Cache, rest: string): Promise<void> {
70
+ const commit = rest.match(VS_STATIC)?.[1];
71
+ if (!commit) return;
72
+ for (const req of await cache.keys()) {
73
+ if (!new URL(req.url).pathname.includes(`/${commit}/`)) void cache.delete(req);
74
+ }
75
+ }
76
+
42
77
  /**
43
78
  * Fetch an allow-listed VS Code CDN asset through the signaling Worker's /cdn
44
79
  * route (which adds CORS), caching the result so each asset crosses to the
@@ -118,14 +118,31 @@ export class TunnelClient {
118
118
  },
119
119
  });
120
120
 
121
+ // Tell the daemon we can inflate: it then passes the upstream's gzip
122
+ // bytes through untouched (3-4x fewer bytes over the channel) and we
123
+ // decompress here, once, for every consumer.
124
+ const reqHeaders =
125
+ typeof DecompressionStream !== "undefined"
126
+ ? { ...headers, "x-codehost-accept-gzip": "1" }
127
+ : headers;
128
+
121
129
  this.https.set(streamId, {
122
130
  onHead: (h) => {
123
131
  head = h;
132
+ const resHeaders = new Headers(h.headers);
133
+ let bodyStream: ReadableStream<Uint8Array> = stream;
134
+ if (resHeaders.get("content-encoding") === "gzip") {
135
+ bodyStream = stream.pipeThrough(
136
+ new DecompressionStream("gzip") as unknown as ReadableWritablePair<Uint8Array, Uint8Array>,
137
+ );
138
+ resHeaders.delete("content-encoding");
139
+ resHeaders.delete("content-length");
140
+ }
124
141
  resolve(
125
- new Response(stream, {
142
+ new Response(bodyStream, {
126
143
  status: h.status === 204 || h.status === 304 ? h.status : h.status,
127
144
  statusText: h.statusText,
128
- headers: h.headers,
145
+ headers: resHeaders,
129
146
  }),
130
147
  );
131
148
  },
@@ -154,7 +171,7 @@ export class TunnelClient {
154
171
  },
155
172
  });
156
173
 
157
- this.send(encodeJson(Op.HttpReq, streamId, { method, path, headers }));
174
+ this.send(encodeJson(Op.HttpReq, streamId, { method, path, headers: reqHeaders }));
158
175
  if (body && body.byteLength) {
159
176
  for (const part of chunk(body)) this.send(encodeFrame(Op.HttpReqBody, streamId, part));
160
177
  }
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