@wenrwa/marketplace-cli 0.4.4 → 0.4.6

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 (116) hide show
  1. package/dist/__tests__/bridge-activity-parser.test.d.ts +1 -0
  2. package/dist/__tests__/bridge-activity-parser.test.js +0 -0
  3. package/dist/__tests__/bridge-activity-tap.test.d.ts +1 -0
  4. package/dist/__tests__/bridge-activity-tap.test.js +178 -0
  5. package/dist/__tests__/bridge-api-client.test.d.ts +1 -0
  6. package/dist/__tests__/bridge-api-client.test.js +69 -0
  7. package/dist/__tests__/bridge-browser-driver.test.d.ts +1 -0
  8. package/dist/__tests__/bridge-browser-driver.test.js +379 -0
  9. package/dist/__tests__/bridge-browser-frames.test.d.ts +1 -0
  10. package/dist/__tests__/bridge-browser-frames.test.js +166 -0
  11. package/dist/__tests__/bridge-browser-idle.test.d.ts +1 -0
  12. package/dist/__tests__/bridge-browser-idle.test.js +259 -0
  13. package/dist/__tests__/bridge-browser-relay.test.d.ts +1 -0
  14. package/dist/__tests__/bridge-browser-relay.test.js +670 -0
  15. package/dist/__tests__/bridge-bundle-installer.test.d.ts +1 -0
  16. package/dist/__tests__/bridge-bundle-installer.test.js +126 -0
  17. package/dist/__tests__/bridge-cdp-client.test.d.ts +1 -0
  18. package/dist/__tests__/bridge-cdp-client.test.js +133 -0
  19. package/dist/__tests__/bridge-chromium-launcher.test.d.ts +1 -0
  20. package/dist/__tests__/bridge-chromium-launcher.test.js +202 -0
  21. package/dist/__tests__/bridge-command-browser-options.test.d.ts +1 -0
  22. package/dist/__tests__/bridge-command-browser-options.test.js +37 -0
  23. package/dist/__tests__/bridge-config-mcp.test.js +4 -0
  24. package/dist/__tests__/bridge-context-manifest-frame.test.d.ts +1 -0
  25. package/dist/__tests__/bridge-context-manifest-frame.test.js +125 -0
  26. package/dist/__tests__/bridge-context-writer.test.d.ts +1 -0
  27. package/dist/__tests__/bridge-context-writer.test.js +161 -0
  28. package/dist/__tests__/bridge-device-registration.test.d.ts +1 -0
  29. package/dist/__tests__/bridge-device-registration.test.js +33 -0
  30. package/dist/__tests__/bridge-folder-picker-runner.test.d.ts +1 -0
  31. package/dist/__tests__/bridge-folder-picker-runner.test.js +37 -0
  32. package/dist/__tests__/bridge-folder-picker.test.d.ts +1 -0
  33. package/dist/__tests__/bridge-folder-picker.test.js +66 -0
  34. package/dist/__tests__/bridge-folder-registry.test.d.ts +1 -0
  35. package/dist/__tests__/bridge-folder-registry.test.js +107 -0
  36. package/dist/__tests__/bridge-frame-pacer.test.d.ts +1 -0
  37. package/dist/__tests__/bridge-frame-pacer.test.js +102 -0
  38. package/dist/__tests__/bridge-install-bundle-frame.test.d.ts +1 -0
  39. package/dist/__tests__/bridge-install-bundle-frame.test.js +107 -0
  40. package/dist/__tests__/bridge-local-add-core.test.d.ts +1 -0
  41. package/dist/__tests__/bridge-local-add-core.test.js +48 -0
  42. package/dist/__tests__/bridge-local-path-validation-fs.test.d.ts +1 -0
  43. package/dist/__tests__/bridge-local-path-validation-fs.test.js +88 -0
  44. package/dist/__tests__/bridge-local-path-validation.test.d.ts +1 -0
  45. package/dist/__tests__/bridge-local-path-validation.test.js +45 -0
  46. package/dist/__tests__/bridge-login-status.test.d.ts +1 -0
  47. package/dist/__tests__/bridge-login-status.test.js +60 -0
  48. package/dist/__tests__/bridge-relay-client.test.d.ts +1 -0
  49. package/dist/__tests__/bridge-relay-client.test.js +283 -0
  50. package/dist/__tests__/bridge-relay-mode.test.d.ts +1 -0
  51. package/dist/__tests__/bridge-relay-mode.test.js +33 -0
  52. package/dist/__tests__/bridge-relay-pucc14.test.d.ts +1 -0
  53. package/dist/__tests__/bridge-relay-pucc14.test.js +230 -0
  54. package/dist/__tests__/bridge-server-local-endpoints.test.d.ts +1 -0
  55. package/dist/__tests__/bridge-server-local-endpoints.test.js +197 -0
  56. package/dist/__tests__/bridge-server.test.js +22 -0
  57. package/dist/__tests__/bridge-workspace-multirepo.test.d.ts +1 -0
  58. package/dist/__tests__/bridge-workspace-multirepo.test.js +317 -0
  59. package/dist/__tests__/devices.test.d.ts +1 -0
  60. package/dist/__tests__/devices.test.js +66 -0
  61. package/dist/__tests__/local.test.d.ts +1 -0
  62. package/dist/__tests__/local.test.js +147 -0
  63. package/dist/bridge/activityParser.d.ts +53 -0
  64. package/dist/bridge/activityParser.js +202 -0
  65. package/dist/bridge/apiClient.d.ts +56 -0
  66. package/dist/bridge/apiClient.js +50 -0
  67. package/dist/bridge/browserDriver.d.ts +98 -0
  68. package/dist/bridge/browserDriver.js +265 -0
  69. package/dist/bridge/browserFrames.d.ts +182 -0
  70. package/dist/bridge/browserFrames.js +179 -0
  71. package/dist/bridge/browserIdle.d.ts +75 -0
  72. package/dist/bridge/browserIdle.js +120 -0
  73. package/dist/bridge/bundleInstaller.d.ts +31 -0
  74. package/dist/bridge/bundleInstaller.js +127 -0
  75. package/dist/bridge/cdpClient.d.ts +47 -0
  76. package/dist/bridge/cdpClient.js +102 -0
  77. package/dist/bridge/chromiumLauncher.d.ts +45 -0
  78. package/dist/bridge/chromiumLauncher.js +113 -0
  79. package/dist/bridge/config.d.ts +3 -0
  80. package/dist/bridge/contextWriter.d.ts +31 -0
  81. package/dist/bridge/contextWriter.js +139 -0
  82. package/dist/bridge/deviceRegistration.d.ts +28 -0
  83. package/dist/bridge/deviceRegistration.js +19 -0
  84. package/dist/bridge/folderPicker.d.ts +34 -0
  85. package/dist/bridge/folderPicker.js +36 -0
  86. package/dist/bridge/folderPickerRunner.d.ts +3 -0
  87. package/dist/bridge/folderPickerRunner.js +25 -0
  88. package/dist/bridge/folderRegistry.d.ts +19 -0
  89. package/dist/bridge/folderRegistry.js +100 -0
  90. package/dist/bridge/framePacer.d.ts +61 -0
  91. package/dist/bridge/framePacer.js +79 -0
  92. package/dist/bridge/localAdd.d.ts +28 -0
  93. package/dist/bridge/localAdd.js +67 -0
  94. package/dist/bridge/localPathValidation.d.ts +20 -0
  95. package/dist/bridge/localPathValidation.js +58 -0
  96. package/dist/bridge/localPathValidationFs.d.ts +2 -0
  97. package/dist/bridge/localPathValidationFs.js +98 -0
  98. package/dist/bridge/loginStatus.d.ts +38 -0
  99. package/dist/bridge/loginStatus.js +56 -0
  100. package/dist/bridge/realPty.js +6 -2
  101. package/dist/bridge/relayClient.d.ts +166 -0
  102. package/dist/bridge/relayClient.js +517 -0
  103. package/dist/bridge/repoCheckout.d.ts +31 -0
  104. package/dist/bridge/repoCheckout.js +18 -0
  105. package/dist/bridge/server.d.ts +33 -0
  106. package/dist/bridge/server.js +98 -10
  107. package/dist/bridge/sessionManager.d.ts +18 -0
  108. package/dist/bridge/sessionManager.js +15 -1
  109. package/dist/commands/bridge.d.ts +21 -0
  110. package/dist/commands/bridge.js +194 -1
  111. package/dist/commands/devices.d.ts +9 -0
  112. package/dist/commands/devices.js +53 -0
  113. package/dist/commands/local.d.ts +9 -0
  114. package/dist/commands/local.js +141 -0
  115. package/dist/index.js +7 -1
  116. package/package.json +1 -1
@@ -0,0 +1,517 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assembleSessionEnv = assembleSessionEnv;
4
+ exports.startRelayClient = startRelayClient;
5
+ /**
6
+ * ht-05 — Terminal Bridge relay mode (dial-out).
7
+ *
8
+ * Instead of listening on localhost for browser connections (server.ts),
9
+ * this DIALS OUT to the platform relay over a single outbound wss
10
+ * connection, authenticates with a per-container relay token, and serves
11
+ * the same v1 frame protocol (see PROTOCOL.md) through the existing
12
+ * SessionManager. Multi-session multiplexing is unchanged: every frame
13
+ * already carries a sessionId, so one outbound socket serves every session
14
+ * exactly like the local server serves them over N inbound sockets.
15
+ *
16
+ * PTYs live in the SessionManager, not the socket — a relay drop never
17
+ * kills a session. This module owns only: dialing out, the auth frame,
18
+ * frame routing through SessionManager, and reconnect-with-backoff. It
19
+ * does no scrollback bookkeeping of its own (SessionManager already does
20
+ * that) — on re-pair the pane sends `attach` again and gets a fresh
21
+ * `replay`, exactly as it would against the local server.
22
+ */
23
+ const ws_1 = require("ws");
24
+ const activityParser_1 = require("./activityParser");
25
+ const PROTOCOL_VERSION = 1;
26
+ /**
27
+ * pucc-14 — assemble the per-session PTY env for a workspace-scoped `open`.
28
+ *
29
+ * Pure + exported so the env contract is unit-tested without a socket. Merges
30
+ * the relay-injected `env` (which already carries WENRWA_WORKSPACE_ID +
31
+ * WENRWA_CONNECT_TOKEN) and layers on:
32
+ * - WENRWA_WORKSPACE_ID — fallback if the relay didn't pre-set it in `env`.
33
+ * - WENRWA_CONNECT_TOKEN — the workspace-bound Connect Token (MCP world-wiring).
34
+ * - HT_AUTONOMOUS — per-session: `claude` vs `claude --dangerously-skip-permissions`.
35
+ * - WENRWA_GIT_TOKEN / WENRWA_GIT_REPO — the workspace git credential, read by
36
+ * git-credential-wenrwa for clone-on-demand + in-terminal git (per-workspace,
37
+ * no backend round-trip). The token rides the PTY env only; it is never
38
+ * logged here.
39
+ */
40
+ function assembleSessionEnv(frame) {
41
+ const env = { ...(frame.env ?? {}) };
42
+ if (frame.workspaceId)
43
+ env.WENRWA_WORKSPACE_ID = frame.workspaceId;
44
+ if (frame.connectToken)
45
+ env.WENRWA_CONNECT_TOKEN = frame.connectToken;
46
+ env.HT_AUTONOMOUS = frame.autonomous ? '1' : '0';
47
+ if (frame.gitCredential) {
48
+ env.WENRWA_GIT_TOKEN = frame.gitCredential.token;
49
+ env.WENRWA_GIT_REPO = frame.gitCredential.repoFullName;
50
+ }
51
+ return env;
52
+ }
53
+ function send(ws, frame) {
54
+ if (ws.readyState === ws_1.WebSocket.OPEN)
55
+ ws.send(JSON.stringify(frame));
56
+ }
57
+ function startRelayClient(opts) {
58
+ const backoff = {
59
+ initialMs: opts.backoff?.initialMs ?? 250,
60
+ maxMs: opts.backoff?.maxMs ?? 10_000,
61
+ factor: opts.backoff?.factor ?? 2,
62
+ jitter: opts.backoff?.jitter ?? Math.random,
63
+ };
64
+ const wsFactory = opts.wsFactory ?? ((url) => new ws_1.WebSocket(url));
65
+ const scheduleTimeout = opts.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
66
+ const cancelTimeout = opts.clearTimeoutFn ?? ((h) => clearTimeout(h));
67
+ const scheduleInterval = opts.setIntervalFn ?? ((cb, ms) => setInterval(cb, ms));
68
+ const cancelInterval = opts.clearIntervalFn ?? ((h) => clearInterval(h));
69
+ // pucc-int-03 activity-tap config (enabled by default; the container bridge
70
+ // runs Claude Code, so observing its tool-use is the norm).
71
+ const activityEnabled = opts.emitActivity !== false;
72
+ const activityThrottleMs = opts.activityThrottleMs ?? 1000;
73
+ const now = opts.nowFn ?? Date.now;
74
+ let stopped = false;
75
+ let ws = null;
76
+ let connected = false;
77
+ let attempt = 0;
78
+ let reconnectHandle = null;
79
+ // cbs-02: browser session drivers, keyed by sessionId. Declared OUTSIDE
80
+ // connect() — like `opts.sessions` — so a running Chromium (and its
81
+ // frame/state subscriptions, registered once below) survives a relay
82
+ // drop/reconnect exactly like a PTY does; only PTY *attachment* is
83
+ // per-connection. Frame/state forwarders close over the outer `ws`/
84
+ // `connected` (not the per-connection `socket` local) so they always
85
+ // target whichever connection is current, the same pattern reportLogin
86
+ // already uses.
87
+ const browserDrivers = new Map();
88
+ // cbs-06: one idle tracker per LIVE browser driver, same lifetime rule as
89
+ // browserDrivers above (created alongside the driver on browser-open,
90
+ // torn down the instant the driver reaches ANY terminal state — asleep OR
91
+ // closed — so a resumed session always gets a fresh tracker too).
92
+ const browserIdleTrackers = new Map();
93
+ /** cbs-06: shared by the browser-sleep frame handler AND the idle
94
+ * tracker's onIdle callback — both just mean "this driver goes to sleep
95
+ * now," differing only in the reason string forwarded to the pane. A
96
+ * safe no-op if no driver is open for this session (sleep-of-nothing, or
97
+ * a session that already went asleep/closed and was removed from the map
98
+ * by its own onState handler below). */
99
+ function sleepBrowserSession(sessionId, reason) {
100
+ browserDrivers.get(sessionId)?.sleep(reason);
101
+ }
102
+ // pucc-14 login_valid report. Container-level (not per session): the ONE
103
+ // shared login on the volume. `lastLoginValid` persists across reconnects so
104
+ // the poll only re-emits on a genuine change; each fresh connect force-emits
105
+ // so a restarted relay always relearns the current state.
106
+ let lastLoginValid = null;
107
+ let loginPollHandle = null;
108
+ function reportLogin(force) {
109
+ if (!opts.checkLoginValid)
110
+ return;
111
+ let valid;
112
+ try {
113
+ valid = opts.checkLoginValid();
114
+ }
115
+ catch {
116
+ return; // a read blip must never crash the bridge or drop a session
117
+ }
118
+ if (!force && valid === lastLoginValid)
119
+ return;
120
+ lastLoginValid = valid;
121
+ // BOOLEAN only — the credential value is never read here or sent.
122
+ if (ws && connected)
123
+ send(ws, { t: 'login-status', valid });
124
+ }
125
+ function nextDelay() {
126
+ const ceiling = Math.min(backoff.maxMs, backoff.initialMs * Math.pow(backoff.factor, attempt));
127
+ return ceiling * backoff.jitter(); // full jitter: uniform in [0, ceiling]
128
+ }
129
+ function scheduleReconnect() {
130
+ if (stopped)
131
+ return;
132
+ const delay = nextDelay();
133
+ attempt += 1;
134
+ opts.onReconnectScheduled?.(delay);
135
+ reconnectHandle = scheduleTimeout(() => connect(), delay);
136
+ }
137
+ function connect() {
138
+ if (stopped)
139
+ return;
140
+ const socket = wsFactory(opts.relayUrl);
141
+ ws = socket;
142
+ // Per-connection routing state. Recreated on every reconnect; the
143
+ // SessionManager (and its PTYs) is untouched across drops.
144
+ const handles = new Map();
145
+ // pucc-int-03: per-session activity observation state (parser + coalescing
146
+ // buffer). Keyed by sessionId, lives for the connection; a fresh connection
147
+ // starts clean. NEVER on the browser-forwarding path — see feedActivity.
148
+ const activity = new Map();
149
+ const attachTo = (sessionId) => {
150
+ const handle = opts.sessions.attach(sessionId, (frame) => {
151
+ if (frame.t === 'data') {
152
+ // Browser forwarding FIRST and unaltered (the compliance-critical
153
+ // stream). The activity tap is a strictly-additional read of the same
154
+ // bytes — wrapped so nothing it does can disturb the pane.
155
+ send(socket, { t: 'data', sessionId, data: frame.data });
156
+ feedActivity(sessionId, frame.data);
157
+ }
158
+ else if (frame.t === 'takeover') {
159
+ send(socket, { t: 'takeover', sessionId });
160
+ }
161
+ });
162
+ handles.set(sessionId, handle);
163
+ return handle;
164
+ };
165
+ // ── pucc-int-03: read-only PTY activity tap ──────────────────────────────
166
+ const flushActivity = (sessionId, st) => {
167
+ if (st.pendingPaths.size === 0 && st.pendingThinking === undefined)
168
+ return;
169
+ st.lastEmit = now();
170
+ const filePaths = [...st.pendingPaths];
171
+ const thinking = st.pendingThinking;
172
+ st.pendingPaths.clear();
173
+ st.pendingThinking = undefined;
174
+ // A separate `activity` frame — the relay intercepts it and never forwards
175
+ // it to the pane, so the browser stream is unaffected.
176
+ send(socket, {
177
+ t: 'activity',
178
+ sessionId,
179
+ ...(filePaths.length ? { filePaths } : {}),
180
+ ...(thinking !== undefined ? { thinking } : {}),
181
+ });
182
+ };
183
+ const feedActivity = (sessionId, data) => {
184
+ if (activityEnabled === false)
185
+ return;
186
+ try {
187
+ let st = activity.get(sessionId);
188
+ if (!st) {
189
+ st = { parser: new activityParser_1.ActivityParser(), pendingPaths: new Set(), pendingThinking: undefined, lastEmit: 0, timer: null };
190
+ activity.set(sessionId, st);
191
+ }
192
+ const signals = st.parser.push(data);
193
+ if (signals.length === 0)
194
+ return;
195
+ for (const s of signals) {
196
+ for (const p of s.filePaths)
197
+ st.pendingPaths.add(p);
198
+ if (s.thinking !== undefined)
199
+ st.pendingThinking = s.thinking;
200
+ }
201
+ // Coalesce/throttle: emit at most once per throttle window per session.
202
+ const elapsed = now() - st.lastEmit;
203
+ if (elapsed >= activityThrottleMs) {
204
+ flushActivity(sessionId, st);
205
+ }
206
+ else if (st.timer === null) {
207
+ st.timer = scheduleTimeout(() => {
208
+ const cur = activity.get(sessionId);
209
+ if (cur) {
210
+ cur.timer = null;
211
+ flushActivity(sessionId, cur);
212
+ }
213
+ }, activityThrottleMs - elapsed);
214
+ }
215
+ }
216
+ catch {
217
+ // The observation tap must NEVER break the terminal (ADR-0032). Swallow.
218
+ }
219
+ };
220
+ const dropActivity = (sessionId) => {
221
+ const st = activity.get(sessionId);
222
+ if (st?.timer != null)
223
+ cancelTimeout(st.timer);
224
+ activity.delete(sessionId);
225
+ };
226
+ socket.on('open', () => {
227
+ send(socket, { v: PROTOCOL_VERSION, relayToken: opts.relayToken });
228
+ connected = true;
229
+ attempt = 0;
230
+ opts.onConnect?.();
231
+ // Force-emit the current login validity on every (re)connect so the relay
232
+ // — and any restarted relay — always has a fresh boolean.
233
+ reportLogin(true);
234
+ });
235
+ socket.on('message', (raw) => {
236
+ let frame;
237
+ try {
238
+ frame = JSON.parse(String(raw));
239
+ }
240
+ catch {
241
+ send(socket, { t: 'error', message: 'invalid frame' });
242
+ return;
243
+ }
244
+ if (!frame || !('t' in frame)) {
245
+ send(socket, { t: 'error', message: 'invalid frame' });
246
+ return;
247
+ }
248
+ try {
249
+ switch (frame.t) {
250
+ case 'open': {
251
+ // Capture into locals so the async .then closures below keep the
252
+ // discriminated-union narrowing TS would otherwise drop.
253
+ const { cols, rows, workspaceId, repoUrl, autonomous, connectToken, gitCredential } = frame;
254
+ // pucc-14 multiplex: the RELAY owns the session id (== agentId) and
255
+ // stamps it on the open + every later frame. Honor it so the PTY is
256
+ // registered under exactly that id and container→pane frames demux
257
+ // to the right pane across N concurrent sessions. Absent (legacy /
258
+ // local) → SessionManager mints a random id, unchanged.
259
+ const relaySessionId = frame.sessionId;
260
+ const injectedEnv = frame.env;
261
+ const openSession = (cwd, env) => {
262
+ const sessionId = opts.sessions.open({ cols, rows, cwd, env, id: relaySessionId });
263
+ attachTo(sessionId);
264
+ send(socket, {
265
+ t: 'opened',
266
+ sessionId,
267
+ ...(workspaceId ? { workspaceId } : {}),
268
+ ...(cwd ? { cwd } : {}),
269
+ });
270
+ };
271
+ if (workspaceId) {
272
+ // pucc-04/14 contract: every workspace-scoped terminal carries its
273
+ // workspace id, the workspace-bound Connect Token (MCP), the git
274
+ // credential, and its autonomous toggle in the PTY env — so the
275
+ // wenrwa MCP tools + git creds resolve per-workspace inside the
276
+ // shared container. autolaunch.sh reads HT_AUTONOMOUS from the
277
+ // PTY's own env (claude vs claude --dangerously-skip-permissions),
278
+ // NOT a machine-wide var.
279
+ const env = assembleSessionEnv({ workspaceId, autonomous, connectToken, gitCredential, env: injectedEnv });
280
+ if (opts.resolveWorkspaceCwd) {
281
+ opts
282
+ .resolveWorkspaceCwd({ workspaceId, repoUrl })
283
+ .then((cwd) => openSession(cwd ?? undefined, env))
284
+ .catch(() => openSession(undefined, env));
285
+ }
286
+ else {
287
+ openSession(undefined, env);
288
+ }
289
+ }
290
+ else {
291
+ openSession();
292
+ }
293
+ break;
294
+ }
295
+ case 'attach': {
296
+ const handle = attachTo(frame.sessionId);
297
+ send(socket, { t: 'replay', sessionId: frame.sessionId, data: handle.replay });
298
+ break;
299
+ }
300
+ case 'input':
301
+ handles.get(frame.sessionId)?.write(frame.data);
302
+ break;
303
+ case 'resize':
304
+ handles.get(frame.sessionId)?.resize(frame.cols, frame.rows);
305
+ break;
306
+ case 'detach':
307
+ handles.get(frame.sessionId)?.detach();
308
+ handles.delete(frame.sessionId);
309
+ break;
310
+ case 'close':
311
+ handles.delete(frame.sessionId);
312
+ dropActivity(frame.sessionId);
313
+ opts.sessions.close(frame.sessionId);
314
+ break;
315
+ case 'install-bundle': {
316
+ // pucc-int-04: write the forged crew `.claude` bundle into the
317
+ // workspace checkout. No PTY, no crew engine — Claude Code reads the
318
+ // files natively next run. The relay has already overridden
319
+ // `workspaceId` with the token's authoritative value.
320
+ if (!opts.installBundle) {
321
+ send(socket, { t: 'error', message: 'install-bundle unsupported (no workspace checkouts)' });
322
+ break;
323
+ }
324
+ const result = opts.installBundle({ workspaceId: frame.workspaceId, files: frame.files ?? {} });
325
+ send(socket, {
326
+ t: 'bundle-installed',
327
+ workspaceId: frame.workspaceId,
328
+ ...(frame.sessionId ? { sessionId: frame.sessionId } : {}),
329
+ written: result?.written ?? [],
330
+ skipped: result?.skipped ?? [],
331
+ });
332
+ break;
333
+ }
334
+ case 'context-manifest': {
335
+ // pucc-int-01: write the arena-pushed file context into the
336
+ // workspace's CLAUDE.md (a fenced managed region). No PTY, no
337
+ // keystrokes — Claude Code reads the file natively (ADR-0028). The
338
+ // relay has already overridden `workspaceId` with the token's value.
339
+ if (!opts.writeContext) {
340
+ send(socket, { t: 'error', message: 'context-manifest unsupported (no workspace checkouts)' });
341
+ break;
342
+ }
343
+ const result = opts.writeContext({ workspaceId: frame.workspaceId, content: frame.content ?? '' });
344
+ send(socket, {
345
+ t: 'context-written',
346
+ workspaceId: frame.workspaceId,
347
+ ...(frame.sessionId ? { sessionId: frame.sessionId } : {}),
348
+ ...(result && typeof result === 'object' ? { action: result.action } : {}),
349
+ });
350
+ break;
351
+ }
352
+ case 'browser-open': {
353
+ // cbs-02: a headless Chromium session, driven over CDP. This
354
+ // branch NEVER touches opts.sessions / SessionManager — a
355
+ // browser session is not a PTY (PRD: "must never touch the PTY
356
+ // session manager or spawn a shell").
357
+ if (!opts.createBrowserDriver) {
358
+ send(socket, { t: 'error', message: 'browser-open unsupported (no browser driver)' });
359
+ break;
360
+ }
361
+ const sessionId = frame.sessionId;
362
+ const url = frame.url;
363
+ const existing = browserDrivers.get(sessionId);
364
+ if (existing) {
365
+ // "browser-open on an open session = navigate" (PRD decision)
366
+ // — only ever true while the driver is actually live. A
367
+ // driver that reached asleep OR closed is ALWAYS removed from
368
+ // browserDrivers by its own onState handler below (cbs-06:
369
+ // "a stopped driver must be replaced, not navigated"), so
370
+ // `existing` here can never be a stopped driver.
371
+ existing.navigate(url).catch((err) => {
372
+ send(socket, { t: 'error', message: err instanceof Error ? err.message : 'browser navigate failed' });
373
+ });
374
+ break;
375
+ }
376
+ const driver = opts.createBrowserDriver({ sessionId });
377
+ browserDrivers.set(sessionId, driver);
378
+ // cbs-06: an idle tracker rides alongside the driver for its
379
+ // whole live span. onIdle is wired to the SAME sleepBrowserSession
380
+ // helper the browser-sleep frame uses, just with a different
381
+ // reason string — the driver/pane see one uniform "went to
382
+ // sleep" path either way.
383
+ const idleTracker = opts.createBrowserIdleTracker?.({
384
+ sessionId,
385
+ onIdle: () => sleepBrowserSession(sessionId, 'idle-timeout'),
386
+ }) ?? null;
387
+ if (idleTracker) {
388
+ browserIdleTrackers.set(sessionId, idleTracker);
389
+ idleTracker.start();
390
+ }
391
+ // Registered ONCE per driver lifetime (not per connection) — see
392
+ // the browserDrivers comment above for why these close over the
393
+ // outer `ws`/`connected` rather than this connection's `socket`.
394
+ driver.onFrame((f) => {
395
+ if (ws && connected) {
396
+ send(ws, { t: 'browser-frame', sessionId, seq: f.seq, data: f.data, w: f.w, h: f.h });
397
+ }
398
+ // cbs-06: the cbs-02 interim self-ack-on-send policy is GONE.
399
+ // True end-to-end backpressure: the pacer is acked ONLY by a
400
+ // real `browser-frame-ack` from the pane (see that case
401
+ // below), or by the disconnect-time ack-flush in onDown (so a
402
+ // gone pane can never wedge the pacer forever).
403
+ });
404
+ driver.onState((s) => {
405
+ const stateFrame = { t: 'browser-state', sessionId, state: s.state };
406
+ if (s.url !== undefined)
407
+ stateFrame.url = s.url;
408
+ if (s.title !== undefined)
409
+ stateFrame.title = s.title;
410
+ if (s.reason !== undefined)
411
+ stateFrame.reason = s.reason;
412
+ if (ws && connected)
413
+ send(ws, stateFrame);
414
+ if (s.state === 'closed' || s.state === 'asleep') {
415
+ // cbs-06: ANY terminal state — sleep, idle-timeout, or a
416
+ // crash-close — retires this driver instance for good; the
417
+ // next browser-open for this sessionId always creates a
418
+ // fresh one (see the `existing` branch above).
419
+ browserDrivers.delete(sessionId);
420
+ browserIdleTrackers.get(sessionId)?.stop();
421
+ browserIdleTrackers.delete(sessionId);
422
+ }
423
+ });
424
+ driver.start(url).catch((err) => {
425
+ send(socket, { t: 'error', message: err instanceof Error ? err.message : 'browser start failed' });
426
+ browserDrivers.delete(sessionId);
427
+ browserIdleTrackers.get(sessionId)?.stop();
428
+ browserIdleTrackers.delete(sessionId);
429
+ });
430
+ break;
431
+ }
432
+ case 'browser-input':
433
+ // Never paced/dropped — dispatched straight through in wire
434
+ // order (browserFrames.ts's drop rule). A frame for a session
435
+ // with no open driver is a safe no-op.
436
+ browserDrivers.get(frame.sessionId)?.input(frame.event);
437
+ browserIdleTrackers.get(frame.sessionId)?.recordInput(); // cbs-06
438
+ break;
439
+ case 'browser-frame-ack':
440
+ // cbs-06: the real pane ack — the ONLY thing that unblocks the
441
+ // driver's keep-newest pacer for the next frame now (see
442
+ // browserDriver.ackFrame() / framePacer.ts). Also the "viewer"
443
+ // liveness signal for the idle timer. A frame for a session with
444
+ // no open driver is a safe no-op.
445
+ browserDrivers.get(frame.sessionId)?.ackFrame();
446
+ browserIdleTrackers.get(frame.sessionId)?.recordAck();
447
+ break;
448
+ case 'browser-sleep':
449
+ // cbs-06: the pane's explicit 💤 — immediate, reason 'sleep'.
450
+ // Safe no-op for an unknown/never-opened session and for a
451
+ // session already asleep (its driver was already removed from
452
+ // the map by the first sleep's onState handler above).
453
+ sleepBrowserSession(frame.sessionId, 'sleep');
454
+ break;
455
+ default:
456
+ send(socket, { t: 'error', message: 'unknown frame type' });
457
+ }
458
+ }
459
+ catch (err) {
460
+ send(socket, { t: 'error', message: err instanceof Error ? err.message : 'relay client error' });
461
+ }
462
+ });
463
+ const onDown = (info) => {
464
+ connected = false;
465
+ // Detach (not close) every live handle: the sessions/PTYs survive the
466
+ // drop in the SessionManager, scrollback keeps accumulating, and the
467
+ // next `attach` after re-pairing gets a fresh replay.
468
+ for (const handle of handles.values())
469
+ handle.detach();
470
+ handles.clear();
471
+ // Cancel any pending activity flush timers; the per-connection state is
472
+ // discarded and a fresh connection starts clean.
473
+ for (const st of activity.values())
474
+ if (st.timer != null)
475
+ cancelTimeout(st.timer);
476
+ activity.clear();
477
+ // cbs-06: a disconnect means no more `browser-frame-ack` frames can
478
+ // arrive for any in-flight `browser-frame` until the pane reconnects.
479
+ // Without this, a frame delivered right before the drop would leave
480
+ // the driver's pacer wedged (awaiting an ack that can now never come)
481
+ // for the rest of the session. Ack-flushing every open driver here
482
+ // unblocks the pacer immediately so a returning pane gets frames
483
+ // again; it's a safe no-op for any driver with nothing in flight
484
+ // (framePacer.ack() is a no-op when idle). Browser drivers themselves
485
+ // are untouched — they survive a relay drop exactly like a PTY does.
486
+ for (const driver of browserDrivers.values())
487
+ driver.ackFrame();
488
+ opts.onDisconnect?.(info);
489
+ scheduleReconnect();
490
+ };
491
+ socket.on('close', (code, reason) => onDown({ code, reason: reason ? String(reason) : undefined }));
492
+ // 'ws' always follows a connection-level 'error' with a 'close'; this
493
+ // listener exists only so an unhandled 'error' event never crashes the
494
+ // process (EventEmitter throws on unlistened 'error').
495
+ socket.on('error', () => { });
496
+ }
497
+ connect();
498
+ // pucc-14: poll the on-volume login and emit `login-status` on change. Runs
499
+ // independently of the socket lifecycle; reportLogin no-ops while disconnected
500
+ // and the next connect force-emits, so a change observed while down is still
501
+ // reported once the link is back.
502
+ if (opts.checkLoginValid) {
503
+ loginPollHandle = scheduleInterval(() => reportLogin(false), opts.loginPollMs ?? 30_000);
504
+ loginPollHandle?.unref?.();
505
+ }
506
+ return {
507
+ close() {
508
+ stopped = true;
509
+ if (reconnectHandle !== null)
510
+ cancelTimeout(reconnectHandle);
511
+ if (loginPollHandle !== null)
512
+ cancelInterval(loginPollHandle);
513
+ ws?.close();
514
+ },
515
+ isConnected: () => connected,
516
+ };
517
+ }
@@ -22,3 +22,34 @@ export interface CheckoutResult {
22
22
  }
23
23
  export declare function parseRepoFullName(repoUrl: string): string | null;
24
24
  export declare function ensureRepoCheckout(repoUrl: string, deps: RepoCheckoutDeps): Promise<CheckoutResult>;
25
+ /**
26
+ * pucc-03 — model B multi-repo layout (ADR-0031 decision 2).
27
+ *
28
+ * One container holds MANY workspaces' repos, keyed by workspaceId under
29
+ * `<reposBaseDir>/<workspaceId>` (e.g. /data/repos/<workspaceId>) rather than
30
+ * by owner/repo. This is deliberately distinct from `ensureRepoCheckout`
31
+ * (local BYO bridge, keyed by owner/repo under the user's home): here the
32
+ * workspaceId is the stable identity a terminal is opened against, and the
33
+ * same workspace always maps to the same checkout regardless of repo renames.
34
+ *
35
+ * Clone-on-demand: the FIRST terminal opened for a workspace clones its repo;
36
+ * every subsequent terminal reuses the existing checkout (idempotent — the
37
+ * `.git` presence check, not a bare-dir check, so a half-created dir doesn't
38
+ * masquerade as a clone). This is the clean seam for clone-on-demand: the
39
+ * entrypoint runs once at container boot and cannot know which workspaces will
40
+ * be opened, so the bridge (which sees each `open`) owns it — see relayClient.
41
+ */
42
+ export interface WorkspaceRepoDeps {
43
+ /** Root that holds every workspace's checkout, e.g. /data/repos. */
44
+ reposBaseDir: string;
45
+ exists: (path: string) => boolean;
46
+ mkdir: (path: string) => void;
47
+ run: (cmd: string, args: string[]) => Promise<{
48
+ stdout: string;
49
+ code: number;
50
+ }>;
51
+ }
52
+ export declare function ensureWorkspaceRepo(opts: {
53
+ workspaceId: string;
54
+ repoUrl?: string;
55
+ }, deps: WorkspaceRepoDeps): Promise<CheckoutResult>;
@@ -11,6 +11,7 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.parseRepoFullName = parseRepoFullName;
13
13
  exports.ensureRepoCheckout = ensureRepoCheckout;
14
+ exports.ensureWorkspaceRepo = ensureWorkspaceRepo;
14
15
  function parseRepoFullName(repoUrl) {
15
16
  const m = repoUrl.match(/github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[/?#].*)?$/) ||
16
17
  repoUrl.match(/^([\w.-]+)\/([\w.-]+)$/);
@@ -32,3 +33,20 @@ async function ensureRepoCheckout(repoUrl, deps) {
32
33
  return { action: 'failed' };
33
34
  }
34
35
  }
36
+ async function ensureWorkspaceRepo(opts, deps) {
37
+ const path = `${deps.reposBaseDir}/${opts.workspaceId}`;
38
+ // Reuse an existing checkout — never re-clone over a workspace already set up.
39
+ if (deps.exists(`${path}/.git`))
40
+ return { path, action: 'existing' };
41
+ // No checkout yet and no url to clone from → the terminal falls back to home.
42
+ if (!opts.repoUrl)
43
+ return { action: 'failed' };
44
+ try {
45
+ deps.mkdir(deps.reposBaseDir);
46
+ await deps.run('git', ['clone', opts.repoUrl, path]);
47
+ return { path, action: 'cloned' };
48
+ }
49
+ catch {
50
+ return { action: 'failed' };
51
+ }
52
+ }
@@ -1,4 +1,7 @@
1
1
  import { SessionManager } from './sessionManager';
2
+ import { ValidatePathResult } from './localPathValidation';
3
+ import { PickFolderResult } from './folderPicker';
4
+ import { LocalAddCoreResult } from './localAdd';
2
5
  export interface BridgeServerOpts {
3
6
  sessionToken: string;
4
7
  allowedOrigin: string;
@@ -8,6 +11,36 @@ export interface BridgeServerOpts {
8
11
  /** Workspace-scoped terminals: resolve a repo URL to a local checkout dir
9
12
  * (cloned on first use). Absent/null result → shell opens in $HOME. */
10
13
  resolveCwd?: (repoUrl: string) => Promise<string | null>;
14
+ /** lws-03: the registered local workspaces to advertise on /discovery, so
15
+ * the arena create-modal can live-detect `wen-market local add` without a
16
+ * daemon restart. A GETTER (not a static array) — server.ts calls it on
17
+ * every /discovery request, so callers should back it with a fresh
18
+ * folderRegistry.loadFolderRegistry() read (or an in-memory cache kept
19
+ * current by the `local` commands) to reflect registry changes live. */
20
+ getLocalWorkspaces?: () => Array<{
21
+ workspaceId: string;
22
+ name: string;
23
+ path: string;
24
+ }>;
25
+ /** lws-05: the device name to advertise on /discovery (arena renders "✓
26
+ * Bridge connected — <device>"). Absent/null when the device is not yet
27
+ * registered — the modal falls back to a generic connected label. */
28
+ getDeviceName?: () => string | null;
29
+ /** lws-05 — POST /local/validate-path (create-modal typed-path fallback,
30
+ * PRD D3c). No session-token gate: read-only, loopback + origin-pinned
31
+ * like /discovery. */
32
+ validatePath?: (absPath: string) => Promise<ValidatePathResult>;
33
+ /** lws-05 — POST /local/pick-folder (PRD D3b). server.ts enforces "one
34
+ * picker at a time" — a second concurrent call gets 409 without invoking
35
+ * this again. */
36
+ pickFolder?: () => Promise<PickFolderResult>;
37
+ /** lws-05 — POST /local/add (PRD D3b/D3c attach paths, mirroring `wen-market
38
+ * local add`). Session-token gated (unlike validate-path/pick-folder) since
39
+ * it spends the bridge's PAT to create a workspace. */
40
+ addLocalWorkspace?: (params: {
41
+ path: string;
42
+ name?: string;
43
+ }) => Promise<LocalAddCoreResult>;
11
44
  }
12
45
  export interface BridgeServerHandle {
13
46
  port: number;