@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.
- package/dist/__tests__/bridge-activity-parser.test.d.ts +1 -0
- package/dist/__tests__/bridge-activity-parser.test.js +0 -0
- package/dist/__tests__/bridge-activity-tap.test.d.ts +1 -0
- package/dist/__tests__/bridge-activity-tap.test.js +178 -0
- package/dist/__tests__/bridge-api-client.test.d.ts +1 -0
- package/dist/__tests__/bridge-api-client.test.js +69 -0
- package/dist/__tests__/bridge-browser-driver.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-driver.test.js +379 -0
- package/dist/__tests__/bridge-browser-frames.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-frames.test.js +166 -0
- package/dist/__tests__/bridge-browser-idle.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-idle.test.js +259 -0
- package/dist/__tests__/bridge-browser-relay.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-relay.test.js +670 -0
- package/dist/__tests__/bridge-bundle-installer.test.d.ts +1 -0
- package/dist/__tests__/bridge-bundle-installer.test.js +126 -0
- package/dist/__tests__/bridge-cdp-client.test.d.ts +1 -0
- package/dist/__tests__/bridge-cdp-client.test.js +133 -0
- package/dist/__tests__/bridge-chromium-launcher.test.d.ts +1 -0
- package/dist/__tests__/bridge-chromium-launcher.test.js +202 -0
- package/dist/__tests__/bridge-command-browser-options.test.d.ts +1 -0
- package/dist/__tests__/bridge-command-browser-options.test.js +37 -0
- package/dist/__tests__/bridge-config-mcp.test.js +4 -0
- package/dist/__tests__/bridge-context-manifest-frame.test.d.ts +1 -0
- package/dist/__tests__/bridge-context-manifest-frame.test.js +125 -0
- package/dist/__tests__/bridge-context-writer.test.d.ts +1 -0
- package/dist/__tests__/bridge-context-writer.test.js +161 -0
- package/dist/__tests__/bridge-device-registration.test.d.ts +1 -0
- package/dist/__tests__/bridge-device-registration.test.js +33 -0
- package/dist/__tests__/bridge-folder-picker-runner.test.d.ts +1 -0
- package/dist/__tests__/bridge-folder-picker-runner.test.js +37 -0
- package/dist/__tests__/bridge-folder-picker.test.d.ts +1 -0
- package/dist/__tests__/bridge-folder-picker.test.js +66 -0
- package/dist/__tests__/bridge-folder-registry.test.d.ts +1 -0
- package/dist/__tests__/bridge-folder-registry.test.js +107 -0
- package/dist/__tests__/bridge-frame-pacer.test.d.ts +1 -0
- package/dist/__tests__/bridge-frame-pacer.test.js +102 -0
- package/dist/__tests__/bridge-install-bundle-frame.test.d.ts +1 -0
- package/dist/__tests__/bridge-install-bundle-frame.test.js +107 -0
- package/dist/__tests__/bridge-local-add-core.test.d.ts +1 -0
- package/dist/__tests__/bridge-local-add-core.test.js +48 -0
- package/dist/__tests__/bridge-local-path-validation-fs.test.d.ts +1 -0
- package/dist/__tests__/bridge-local-path-validation-fs.test.js +88 -0
- package/dist/__tests__/bridge-local-path-validation.test.d.ts +1 -0
- package/dist/__tests__/bridge-local-path-validation.test.js +45 -0
- package/dist/__tests__/bridge-login-status.test.d.ts +1 -0
- package/dist/__tests__/bridge-login-status.test.js +60 -0
- package/dist/__tests__/bridge-relay-client.test.d.ts +1 -0
- package/dist/__tests__/bridge-relay-client.test.js +283 -0
- package/dist/__tests__/bridge-relay-mode.test.d.ts +1 -0
- package/dist/__tests__/bridge-relay-mode.test.js +33 -0
- package/dist/__tests__/bridge-relay-pucc14.test.d.ts +1 -0
- package/dist/__tests__/bridge-relay-pucc14.test.js +230 -0
- package/dist/__tests__/bridge-server-local-endpoints.test.d.ts +1 -0
- package/dist/__tests__/bridge-server-local-endpoints.test.js +197 -0
- package/dist/__tests__/bridge-server.test.js +22 -0
- package/dist/__tests__/bridge-workspace-multirepo.test.d.ts +1 -0
- package/dist/__tests__/bridge-workspace-multirepo.test.js +317 -0
- package/dist/__tests__/devices.test.d.ts +1 -0
- package/dist/__tests__/devices.test.js +66 -0
- package/dist/__tests__/local.test.d.ts +1 -0
- package/dist/__tests__/local.test.js +147 -0
- package/dist/bridge/activityParser.d.ts +53 -0
- package/dist/bridge/activityParser.js +202 -0
- package/dist/bridge/apiClient.d.ts +56 -0
- package/dist/bridge/apiClient.js +50 -0
- package/dist/bridge/browserDriver.d.ts +98 -0
- package/dist/bridge/browserDriver.js +265 -0
- package/dist/bridge/browserFrames.d.ts +182 -0
- package/dist/bridge/browserFrames.js +179 -0
- package/dist/bridge/browserIdle.d.ts +75 -0
- package/dist/bridge/browserIdle.js +120 -0
- package/dist/bridge/bundleInstaller.d.ts +31 -0
- package/dist/bridge/bundleInstaller.js +127 -0
- package/dist/bridge/cdpClient.d.ts +47 -0
- package/dist/bridge/cdpClient.js +102 -0
- package/dist/bridge/chromiumLauncher.d.ts +45 -0
- package/dist/bridge/chromiumLauncher.js +113 -0
- package/dist/bridge/config.d.ts +3 -0
- package/dist/bridge/contextWriter.d.ts +31 -0
- package/dist/bridge/contextWriter.js +139 -0
- package/dist/bridge/deviceRegistration.d.ts +28 -0
- package/dist/bridge/deviceRegistration.js +19 -0
- package/dist/bridge/folderPicker.d.ts +34 -0
- package/dist/bridge/folderPicker.js +36 -0
- package/dist/bridge/folderPickerRunner.d.ts +3 -0
- package/dist/bridge/folderPickerRunner.js +25 -0
- package/dist/bridge/folderRegistry.d.ts +19 -0
- package/dist/bridge/folderRegistry.js +100 -0
- package/dist/bridge/framePacer.d.ts +61 -0
- package/dist/bridge/framePacer.js +79 -0
- package/dist/bridge/localAdd.d.ts +28 -0
- package/dist/bridge/localAdd.js +67 -0
- package/dist/bridge/localPathValidation.d.ts +20 -0
- package/dist/bridge/localPathValidation.js +58 -0
- package/dist/bridge/localPathValidationFs.d.ts +2 -0
- package/dist/bridge/localPathValidationFs.js +98 -0
- package/dist/bridge/loginStatus.d.ts +38 -0
- package/dist/bridge/loginStatus.js +56 -0
- package/dist/bridge/realPty.js +6 -2
- package/dist/bridge/relayClient.d.ts +166 -0
- package/dist/bridge/relayClient.js +517 -0
- package/dist/bridge/repoCheckout.d.ts +31 -0
- package/dist/bridge/repoCheckout.js +18 -0
- package/dist/bridge/server.d.ts +33 -0
- package/dist/bridge/server.js +98 -10
- package/dist/bridge/sessionManager.d.ts +18 -0
- package/dist/bridge/sessionManager.js +15 -1
- package/dist/commands/bridge.d.ts +21 -0
- package/dist/commands/bridge.js +194 -1
- package/dist/commands/devices.d.ts +9 -0
- package/dist/commands/devices.js +53 -0
- package/dist/commands/local.d.ts +9 -0
- package/dist/commands/local.js +141 -0
- package/dist/index.js +7 -1
- 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
|
+
}
|
package/dist/bridge/server.d.ts
CHANGED
|
@@ -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;
|