codehost 0.20.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 +7 -0
- package/package.json +1 -1
- package/src/cli/commands/serve.ts +19 -6
- package/src/cli/plugins/agent-yes.test.ts +37 -1
- package/src/cli/plugins/agent-yes.ts +41 -2
- package/src/cli/run-server.ts +19 -7
- package/src/shared/signaling-client.ts +53 -13
- package/src/web/discovery.tsx +8 -0
- package/src/web/room-client.ts +16 -1
- package/worker/room.ts +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
# [0.20.0](https://github.com/snomiao/codehost/compare/v0.19.0...v0.20.0) (2026-06-11)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
...(
|
|
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 } : {}),
|
package/src/cli/run-server.ts
CHANGED
|
@@ -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
|
|
35
|
-
*
|
|
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
|
-
//
|
|
118
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
94
|
-
|
|
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 =
|
|
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
|
|
190
|
-
//
|
|
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
|
-
},
|
|
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
|
-
|
|
219
|
-
|
|
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 (
|
|
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();
|
package/src/web/discovery.tsx
CHANGED
|
@@ -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>
|
|
@@ -1118,6 +1119,13 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
1118
1119
|
agentChip: {
|
|
1119
1120
|
fontFamily: "monospace", fontSize: 11.5, padding: "2px 8px", borderRadius: 999,
|
|
1120
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",
|
|
1121
1129
|
},
|
|
1122
1130
|
card: { display: "flex", alignItems: "center", gap: 12, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "12px 14px" },
|
|
1123
1131
|
cardMain: { flex: 1, minWidth: 0 },
|
package/src/web/room-client.ts
CHANGED
|
@@ -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(() =>
|
|
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 ~
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
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
|