@webstew/bridge 0.1.0

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/cli.js ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // @webstew/bridge CLI entry. Registered as `bin` so users run
4
+ //
5
+ // Shebang note: we point to `tsx` (TypeScript loader for Node) so the
6
+ // raw .ts files in src/ can be executed without a build step during
7
+ // monorepo dev + initial install. When we cut a real npm release we'll
8
+ // compile to dist/ and the published shebang will go back to plain
9
+ // `#!/usr/bin/env node`. `env -S` is needed on macOS so env passes the
10
+ // multi-arg command intact.
11
+ // `npx @webstew/bridge <subcommand>` (or `webstew-bridge` if installed
12
+ // globally) without a node-modules dance.
13
+ //
14
+ // Subcommands:
15
+ // connect <code> pair with a Webstew workspace, then start the bridge
16
+ // status print connection state from local config
17
+ // logout forget the saved pairing token
18
+ // --help, -h print usage
19
+ // --version, -v print bridge + protocol versions
20
+ //
21
+ // Persistent auth lives at ~/.webstew/bridge.json (0600 perms). See
22
+ // auth.ts. The CLI does NOT manage Claude Code's OAuth — that's owned
23
+ // by Claude Code itself; the bridge just spawns `claude --print …` and
24
+ // inherits whatever auth Claude Code is using.
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ const node_os_1 = __importDefault(require("node:os"));
30
+ const protocol_1 = require("./protocol");
31
+ const auth_1 = require("./auth");
32
+ const runtime_1 = require("./runtime");
33
+ const BRIDGE_VERSION = (() => {
34
+ try {
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ return require('../package.json').version;
37
+ }
38
+ catch {
39
+ return '0.0.0-dev';
40
+ }
41
+ })();
42
+ const DEFAULT_SERVER_URL = process.env.WEBSTEW_SERVER_URL || 'https://webstew.net';
43
+ const HELP = `webstew-bridge — your kitchen line to Webstew. Cooks every order on
44
+ your installed Claude Code so your Pro/Max subscription picks up the tab.
45
+
46
+ Usage:
47
+ webstew-bridge connect Resume using saved session (after restarts).
48
+ webstew-bridge connect <code> Hire the chef. Get the code from
49
+ /integrations → "Connect Local Bridge".
50
+ webstew-bridge status Peek at the pass.
51
+ webstew-bridge logout Hang up the apron.
52
+ webstew-bridge --help This menu.
53
+ webstew-bridge --version Bridge + protocol versions.
54
+
55
+ Environment:
56
+ WEBSTEW_SERVER_URL Override the server URL (defaults to https://webstew.net).
57
+ Set to http://localhost:3000 to pair with a local dev workspace.
58
+
59
+ Keep this terminal open — that's the kitchen. Send orders from your
60
+ Webstew workspace and the chef cooks them on your subscription.
61
+ `;
62
+ function log(msg) {
63
+ const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
64
+ process.stdout.write(`[${ts}] ${msg}\n`);
65
+ }
66
+ async function cmdConnect(code) {
67
+ // No code supplied — try to resume using stored token (survives dev-server restarts).
68
+ if (!code) {
69
+ const existing = (0, auth_1.loadAuth)();
70
+ if (!existing) {
71
+ process.stderr.write('error: no pairing code and no stored session.\n usage: webstew-bridge connect <code>\n Get a code from /integrations → "Connect Local Bridge".\n');
72
+ return 2;
73
+ }
74
+ log(`resuming session bridgeId=${existing.bridgeId} — no new code needed`);
75
+ await (0, runtime_1.startBridge)({ ctx: { serverUrl: existing.serverUrl, pairingToken: existing.pairingToken }, log });
76
+ return 0;
77
+ }
78
+ // Exchange the code for a long-lived pairingToken via /api/bridge/pair.
79
+ log(`pairing with ${DEFAULT_SERVER_URL}…`);
80
+ const body = {
81
+ code,
82
+ hostname: node_os_1.default.hostname() || 'unknown',
83
+ bridgeVersion: BRIDGE_VERSION,
84
+ protocolVersion: protocol_1.PROTOCOL_VERSION,
85
+ };
86
+ let res;
87
+ try {
88
+ res = await fetch(DEFAULT_SERVER_URL.replace(/\/$/, '') + protocol_1.BRIDGE_ROUTES.pairExchange, {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify(body),
92
+ });
93
+ }
94
+ catch (e) {
95
+ process.stderr.write(`error: cannot reach ${DEFAULT_SERVER_URL}: ${e.message}\n`);
96
+ return 1;
97
+ }
98
+ if (!res.ok) {
99
+ let detail = '';
100
+ try {
101
+ detail = (await res.json())?.error || '';
102
+ }
103
+ catch { }
104
+ process.stderr.write(`error: pairing failed (HTTP ${res.status}): ${detail || 'no details'}\n`);
105
+ return 1;
106
+ }
107
+ const out = (await res.json());
108
+ (0, auth_1.saveAuth)({
109
+ serverUrl: DEFAULT_SERVER_URL,
110
+ bridgeId: out.bridgeId,
111
+ pairingToken: out.pairingToken,
112
+ pairedAt: new Date().toISOString(),
113
+ });
114
+ log(`paired ✓ bridgeId=${out.bridgeId}`);
115
+ log(`chef hired — keep this terminal open, the kitchen needs the line`);
116
+ // Start the long-poll loop. Never returns.
117
+ await (0, runtime_1.startBridge)({
118
+ ctx: { serverUrl: DEFAULT_SERVER_URL, pairingToken: out.pairingToken },
119
+ log,
120
+ });
121
+ return 0;
122
+ }
123
+ async function cmdStatus() {
124
+ const auth = (0, auth_1.loadAuth)();
125
+ if (!auth) {
126
+ process.stdout.write('not paired. Run: webstew-bridge connect <code>\n');
127
+ return 0;
128
+ }
129
+ process.stdout.write(`paired with ${auth.serverUrl}\n` +
130
+ ` bridgeId: ${auth.bridgeId}\n` +
131
+ ` paired at: ${auth.pairedAt}\n` +
132
+ `\nrun \`webstew-bridge connect\` to resume (no code needed).\n`);
133
+ return 0;
134
+ }
135
+ function cmdLogout() {
136
+ const removed = (0, auth_1.clearAuth)();
137
+ process.stdout.write(removed ? 'pairing token removed\n' : 'no pairing token to remove\n');
138
+ return 0;
139
+ }
140
+ async function main(argv) {
141
+ const [, , sub, ...rest] = argv;
142
+ if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
143
+ process.stdout.write(HELP);
144
+ return 0;
145
+ }
146
+ if (sub === '--version' || sub === '-v' || sub === 'version') {
147
+ process.stdout.write(`@webstew/bridge ${BRIDGE_VERSION} (protocol ${protocol_1.PROTOCOL_VERSION})\n`);
148
+ return 0;
149
+ }
150
+ if (sub === 'connect')
151
+ return cmdConnect(rest[0] || '');
152
+ if (sub === 'status')
153
+ return cmdStatus();
154
+ if (sub === 'logout')
155
+ return cmdLogout();
156
+ process.stderr.write(`error: unknown subcommand "${sub}"\n\n${HELP}`);
157
+ return 2;
158
+ }
159
+ main(process.argv).then((code) => process.exit(code), (err) => {
160
+ process.stderr.write(`bridge fatal: ${err?.stack || err}\n`);
161
+ process.exit(1);
162
+ });
package/dist/http.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export declare class HttpError extends Error {
2
+ status: number;
3
+ body?: unknown | undefined;
4
+ constructor(status: number, message: string, body?: unknown | undefined);
5
+ }
6
+ export interface HttpContext {
7
+ serverUrl: string;
8
+ pairingToken: string;
9
+ }
10
+ export declare function pollOnce<T>(ctx: HttpContext, signal?: AbortSignal): Promise<T>;
11
+ export declare function postResponse(ctx: HttpContext, body: unknown): Promise<{
12
+ accepted: boolean;
13
+ }>;
14
+ export declare function postHeartbeat(ctx: HttpContext): Promise<void>;
package/dist/http.js ADDED
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ // Thin fetch wrapper. Just enough to: (a) attach the bearer token, (b)
3
+ // retry transient network errors on poll/heartbeat without giving up,
4
+ // (c) surface server errors with readable messages. Uses Node 18+
5
+ // built-in fetch — no node-fetch dep.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.HttpError = void 0;
8
+ exports.pollOnce = pollOnce;
9
+ exports.postResponse = postResponse;
10
+ exports.postHeartbeat = postHeartbeat;
11
+ const protocol_1 = require("./protocol");
12
+ class HttpError extends Error {
13
+ status;
14
+ body;
15
+ constructor(status, message, body) {
16
+ super(message);
17
+ this.status = status;
18
+ this.body = body;
19
+ }
20
+ }
21
+ exports.HttpError = HttpError;
22
+ async function call(ctx, method, pathStr, body, init) {
23
+ const url = ctx.serverUrl.replace(/\/$/, '') + pathStr;
24
+ const headers = {
25
+ Authorization: `Bearer ${ctx.pairingToken}`,
26
+ 'x-webstew-bridge-protocol': protocol_1.PROTOCOL_VERSION,
27
+ };
28
+ if (body !== undefined)
29
+ headers['Content-Type'] = 'application/json';
30
+ const res = await fetch(url, {
31
+ method,
32
+ headers,
33
+ body: body === undefined ? undefined : JSON.stringify(body),
34
+ ...init,
35
+ });
36
+ if (!res.ok) {
37
+ let parsed;
38
+ try {
39
+ parsed = await res.json();
40
+ }
41
+ catch { }
42
+ throw new HttpError(res.status, parsed?.error || `${method} ${pathStr} → HTTP ${res.status}`, parsed);
43
+ }
44
+ // Some endpoints (poll) may return null body via {empty:true}; parse
45
+ // JSON unconditionally — server always returns JSON.
46
+ return (await res.json());
47
+ }
48
+ async function pollOnce(ctx, signal) {
49
+ return call(ctx, 'GET', protocol_1.BRIDGE_ROUTES.poll, undefined, { signal });
50
+ }
51
+ async function postResponse(ctx, body) {
52
+ return call(ctx, 'POST', protocol_1.BRIDGE_ROUTES.respond, body);
53
+ }
54
+ async function postHeartbeat(ctx) {
55
+ await call(ctx, 'POST', protocol_1.BRIDGE_ROUTES.heartbeat, {});
56
+ }
@@ -0,0 +1 @@
1
+ export * from './protocol';
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ // Public surface of @webstew/bridge. Webstew's app imports from here to
3
+ // get the protocol types; standalone bridge users get the same types if
4
+ // they want to script around the CLI.
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
17
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
18
+ };
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ __exportStar(require("./protocol"), exports);
@@ -0,0 +1,151 @@
1
+ export declare const PROTOCOL_VERSION = "1.0.0";
2
+ export declare const POLL_TIMEOUT_MS = 25000;
3
+ export declare const HEARTBEAT_INTERVAL_MS = 15000;
4
+ export declare const BRIDGE_OFFLINE_AFTER_MS = 60000;
5
+ export interface PairingCodeResponse {
6
+ /** Short human-friendly code the user pastes into the CLI. */
7
+ code: string;
8
+ /** Seconds until the code expires (typically 600). */
9
+ expiresInSec: number;
10
+ /** Origin the bridge should connect to (e.g. https://webstew.net). */
11
+ serverUrl: string;
12
+ }
13
+ export interface PairingExchangeRequest {
14
+ code: string;
15
+ /** Free-form label so users can tell bridges apart in the UI. */
16
+ hostname: string;
17
+ bridgeVersion: string;
18
+ protocolVersion: string;
19
+ }
20
+ export interface PairingExchangeResponse {
21
+ /** Long-lived auth token. Bridge sends as `Authorization: Bearer …`. */
22
+ pairingToken: string;
23
+ /** Server-assigned ID for this bridge — surfaces in UI + logs. */
24
+ bridgeId: string;
25
+ }
26
+ export interface BridgeStatus {
27
+ connected: boolean;
28
+ /** ISO timestamp of the bridge's last poll/heartbeat. */
29
+ lastSeenAt?: string;
30
+ bridgeId?: string;
31
+ hostname?: string;
32
+ bridgeVersion?: string;
33
+ /** Number of in-flight agent requests currently queued or running. */
34
+ inFlightRequests: number;
35
+ }
36
+ export type BridgeRequest = {
37
+ kind: 'agent.run';
38
+ requestId: string;
39
+ payload: AgentRunRequest;
40
+ };
41
+ export interface AgentRunRequest {
42
+ prompt: string;
43
+ /** Project VFS snapshot — same shape the existing /api/builder/agent
44
+ * route accepts. Bridge replays edits against this map. */
45
+ files: Record<string, string>;
46
+ history?: Array<{
47
+ role: 'user' | 'assistant';
48
+ content: any;
49
+ }>;
50
+ /** 'claude-opus-4-7' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-…' —
51
+ * bridge maps to the local CLI's --model flag. Empty = bridge picks
52
+ * its default. */
53
+ model?: string;
54
+ target?: 'website' | 'nextjs' | 'react' | 'astro' | 'expo';
55
+ /** Webstew project id — bridge echoes back on file_update events so
56
+ * the server can persist the same way it does for direct Anthropic. */
57
+ projectId?: string;
58
+ /** Cap on tool-use iterations. Server passes the agent route's
59
+ * configured limit so bridge enforcement matches direct flow. */
60
+ maxIterations?: number;
61
+ }
62
+ export type BridgeResponse = {
63
+ requestId: string;
64
+ kind: 'text';
65
+ data: {
66
+ text: string;
67
+ };
68
+ } | {
69
+ requestId: string;
70
+ kind: 'tool_use';
71
+ data: {
72
+ id: string;
73
+ name: string;
74
+ input: any;
75
+ };
76
+ } | {
77
+ requestId: string;
78
+ kind: 'tool_result';
79
+ data: {
80
+ tool_use_id: string;
81
+ ok: boolean;
82
+ content: string;
83
+ };
84
+ } | {
85
+ requestId: string;
86
+ kind: 'file_update';
87
+ data: {
88
+ path: string;
89
+ contents: string;
90
+ };
91
+ } | {
92
+ requestId: string;
93
+ kind: 'file_delete';
94
+ data: {
95
+ path: string;
96
+ };
97
+ } | {
98
+ requestId: string;
99
+ kind: 'done';
100
+ data: {
101
+ summary: string;
102
+ iterations: number;
103
+ };
104
+ } | {
105
+ requestId: string;
106
+ kind: 'error';
107
+ data: {
108
+ message: string;
109
+ };
110
+ } | {
111
+ requestId: string;
112
+ kind: 'workspace.switch_target';
113
+ data: {
114
+ target: string;
115
+ reason: string;
116
+ };
117
+ } | {
118
+ requestId: string;
119
+ kind: 'workspace.open_panel';
120
+ data: {
121
+ panel: string;
122
+ reason: string;
123
+ };
124
+ } | {
125
+ requestId: string;
126
+ kind: 'permission_request';
127
+ data: {
128
+ permissionId: string;
129
+ action: string;
130
+ title: string;
131
+ description: string;
132
+ approveLabel?: string;
133
+ denyLabel?: string;
134
+ meta?: Record<string, unknown>;
135
+ };
136
+ };
137
+ export declare const BRIDGE_ROUTES: {
138
+ readonly pairInit: "/api/bridge/pair-init";
139
+ readonly pairExchange: "/api/bridge/pair";
140
+ readonly poll: "/api/bridge/poll";
141
+ readonly respond: "/api/bridge/respond";
142
+ readonly heartbeat: "/api/bridge/heartbeat";
143
+ readonly status: "/api/bridge/status";
144
+ readonly disconnect: "/api/bridge/disconnect";
145
+ };
146
+ /** Polled when the queue is empty. Distinct from `null` so the bridge
147
+ * can re-poll immediately without ambiguity around timeouts. */
148
+ export interface EmptyPoll {
149
+ empty: true;
150
+ }
151
+ export type PollResponse = BridgeRequest | EmptyPoll;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ // Wire protocol shared by the Webstew server and the @webstew/bridge CLI.
3
+ // Both sides import these types so any change to the contract is a
4
+ // compile-time break. Keep this file deps-free (pure types + tiny consts)
5
+ // so the bridge CLI bundle stays under ~50KB.
6
+ //
7
+ // Lifecycle of one agent turn:
8
+ // 1. User submits chat in /workspace → POST /api/builder/agent on Webstew.
9
+ // 2. Webstew detects the user has an active bridge (per BridgeStatus) and
10
+ // enqueues a BridgeRequest in the per-user queue instead of calling
11
+ // Anthropic directly. The HTTP handler holds the response open (SSE).
12
+ // 3. The local bridge process is long-polling GET /api/bridge/poll; it
13
+ // receives the BridgeRequest within a few hundred ms.
14
+ // 4. Bridge spawns Claude Agent SDK (or `claude -p`) using the user's
15
+ // already-authenticated local OAuth — this is the whole point: their
16
+ // Pro/Max subscription, not API-rate billing.
17
+ // 5. As the agent streams, bridge POSTs each chunk to
18
+ // /api/bridge/respond as a BridgeResponse. Webstew forwards each
19
+ // chunk straight to the waiting SSE consumer (the workspace chat).
20
+ // 6. Bridge sends a `done` (or `error`) BridgeResponse to close.
21
+ //
22
+ // Versioning: this is v1. Breaking changes bump PROTOCOL_VERSION; the
23
+ // server rejects bridges on an older major version with a clear error
24
+ // telling the user to `npm i -g @webstew/bridge@latest`.
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.BRIDGE_ROUTES = exports.BRIDGE_OFFLINE_AFTER_MS = exports.HEARTBEAT_INTERVAL_MS = exports.POLL_TIMEOUT_MS = exports.PROTOCOL_VERSION = void 0;
27
+ exports.PROTOCOL_VERSION = '1.0.0';
28
+ // How long the bridge holds a poll open before returning empty so it can
29
+ // re-issue. Tuned for: (a) Render's default 30s edge timeout, (b) fast
30
+ // reconnect after server restarts. 25s gives 5s headroom under Render's
31
+ // limit. Bridge should re-poll immediately on empty return.
32
+ exports.POLL_TIMEOUT_MS = 25_000;
33
+ // How often the bridge sends a tiny heartbeat ping while idle, so the
34
+ // server knows the connection is live. Drives BridgeStatus.lastSeen.
35
+ exports.HEARTBEAT_INTERVAL_MS = 15_000;
36
+ // Server treats a bridge as offline if no poll/heartbeat in this long.
37
+ // Generous so brief network blips don't flap the UI status.
38
+ exports.BRIDGE_OFFLINE_AFTER_MS = 60_000;
39
+ // ── HTTP shapes ───────────────────────────────────────────────────────
40
+ // Concrete endpoint paths. Centralized so both sides import the same
41
+ // strings; typos become impossible.
42
+ exports.BRIDGE_ROUTES = {
43
+ pairInit: '/api/bridge/pair-init', // user → server, requires session
44
+ pairExchange: '/api/bridge/pair', // bridge → server, body: PairingExchangeRequest
45
+ poll: '/api/bridge/poll', // bridge → server, long-poll, returns BridgeRequest | null
46
+ respond: '/api/bridge/respond', // bridge → server, body: BridgeResponse
47
+ heartbeat: '/api/bridge/heartbeat', // bridge → server, idle keepalive
48
+ status: '/api/bridge/status', // UI → server, requires session
49
+ disconnect: '/api/bridge/disconnect', // UI → server, revokes token
50
+ };
@@ -0,0 +1,7 @@
1
+ import { HttpContext } from './http';
2
+ interface RunOpts {
3
+ ctx: HttpContext;
4
+ log: (msg: string) => void;
5
+ }
6
+ export declare function startBridge(opts: RunOpts): Promise<never>;
7
+ export {};
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ // Bridge runtime — the main loop after `connect` succeeds.
3
+ //
4
+ // Two concurrent activities:
5
+ // 1. Long-poll for work: hit /api/bridge/poll, on receiving a request
6
+ // dispatch it to the claude runner, stream events back via
7
+ // /api/bridge/respond. On empty, re-poll immediately.
8
+ // 2. Heartbeat: every HEARTBEAT_INTERVAL_MS while we're not in the
9
+ // middle of an active long-poll, POST /api/bridge/heartbeat to
10
+ // keep BridgeStatus.lastSeenAt fresh on the server.
11
+ //
12
+ // Concurrency: we process ONE request at a time per bridge (sequential
13
+ // poll → run → respond → poll). A Claude run can take 30-120s; running
14
+ // two in parallel would race the local Claude Code session. If a user
15
+ // needs parallel runs they can run multiple bridges.
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.startBridge = startBridge;
18
+ const http_1 = require("./http");
19
+ const protocol_1 = require("./protocol");
20
+ const claude_runner_1 = require("./claude-runner");
21
+ async function startBridge(opts) {
22
+ const { ctx, log } = opts;
23
+ log('kitchen open — waiting for orders 🥘');
24
+ // Heartbeat in a separate tick so it doesn't compete with polls.
25
+ // Errors are non-fatal — server's authenticate* already touches
26
+ // lastSeenAt on every poll, so missed heartbeats are cosmetic.
27
+ let lastHeartbeatAt = Date.now();
28
+ const heartbeatTick = setInterval(() => {
29
+ if (Date.now() - lastHeartbeatAt < protocol_1.HEARTBEAT_INTERVAL_MS)
30
+ return;
31
+ (0, http_1.postHeartbeat)(ctx).catch(() => { }).finally(() => {
32
+ lastHeartbeatAt = Date.now();
33
+ });
34
+ }, 5_000);
35
+ // Main long-poll loop. Never returns.
36
+ for (;;) {
37
+ let work;
38
+ try {
39
+ work = await (0, http_1.pollOnce)(ctx);
40
+ lastHeartbeatAt = Date.now(); // poll counts as activity
41
+ }
42
+ catch (e) {
43
+ if (e instanceof http_1.HttpError && e.status === 401) {
44
+ clearInterval(heartbeatTick);
45
+ log(`apron revoked (${e.message}). The kitchen disconnected this chef from the floor. Re-pair to get back on the line.`);
46
+ process.exit(2);
47
+ }
48
+ // Transient network blip — back off briefly and retry.
49
+ log(`waiter call dropped: ${e.message} — reaching back in 3s`);
50
+ await sleep(3_000);
51
+ continue;
52
+ }
53
+ if ('empty' in work && work.empty) {
54
+ // Server timed out the long-poll with no work. Re-poll
55
+ // immediately — this is the steady-state idle path.
56
+ continue;
57
+ }
58
+ // We have an agent.run request. Send to claude.
59
+ const req = work;
60
+ log(`👨‍🍳 new order ${req.requestId.slice(0, 10)}… table=${req.payload.projectId || 'walk-in'}`);
61
+ const t0 = Date.now();
62
+ // Per-request AbortController — flips the moment the server-side
63
+ // response stream is gone (we get a 410 on POST /respond). The
64
+ // signal is passed into runClaudeOnce so it SIGTERMs the spawned
65
+ // claude child, stopping subscription token burn the moment the
66
+ // user clicks Stop in the workspace UI.
67
+ const runAbort = new AbortController();
68
+ try {
69
+ await (0, claude_runner_1.runClaudeOnce)({
70
+ request: req.payload,
71
+ requestId: req.requestId,
72
+ signal: runAbort.signal,
73
+ onEvent: (chunk) => (0, http_1.postResponse)(ctx, chunk).then(() => { }).catch((e) => {
74
+ // 410 = server has no open request anymore (timeout / user
75
+ // navigated away). Abort the running claude child so we
76
+ // stop burning subscription tokens, then reject with
77
+ // BridgeCancelled so the runtime loop logs it and moves on.
78
+ if (e instanceof http_1.HttpError && e.status === 410) {
79
+ try {
80
+ runAbort.abort();
81
+ }
82
+ catch { }
83
+ return Promise.reject(new BridgeCancelled());
84
+ }
85
+ log(`respond failed: ${e.message}`);
86
+ return Promise.reject(e);
87
+ }),
88
+ });
89
+ log(`🍽️ served ${req.requestId.slice(0, 10)}… ${((Date.now() - t0) / 1000).toFixed(1)}s`);
90
+ }
91
+ catch (e) {
92
+ if (e instanceof BridgeCancelled) {
93
+ log(`🗑️ order tossed ${req.requestId.slice(0, 10)}… diner walked out`);
94
+ continue;
95
+ }
96
+ const msg = e.message || String(e);
97
+ log(`🔥 burnt ${req.requestId.slice(0, 10)}… ${msg}`);
98
+ // Try to surface the error in the chat — but tolerate the case
99
+ // where the server already gave up.
100
+ await (0, http_1.postResponse)(ctx, {
101
+ requestId: req.requestId,
102
+ kind: 'error',
103
+ data: { message: `Bridge error: ${msg}` },
104
+ }).catch(() => { });
105
+ }
106
+ }
107
+ }
108
+ class BridgeCancelled extends Error {
109
+ constructor() { super('cancelled'); }
110
+ }
111
+ function sleep(ms) {
112
+ return new Promise((r) => setTimeout(r, ms));
113
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@webstew/bridge",
3
+ "version": "0.1.0",
4
+ "description": "Local bridge — run Webstew workspace AI against your Claude Code Pro/Max subscription instead of API credits.",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "bin": {
8
+ "webstew-bridge": "./bin/webstew-bridge"
9
+ },
10
+ "files": ["dist/", "bin/", "README.md"],
11
+ "scripts": {
12
+ "build": "tsc && node -e \"const fs=require('fs'),f='dist/cli.js';let c=fs.readFileSync(f,'utf8');c=c.replace(/^#!.*\\n/,'');fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);fs.chmodSync(f,0o755)\"",
13
+ "prepack": "npm run build",
14
+ "dev": "tsx src/cli.ts"
15
+ },
16
+ "dependencies": {
17
+ "tsx": "^4.7.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^20.11.16",
21
+ "typescript": "^5.3.3"
22
+ },
23
+ "engines": { "node": ">=18" },
24
+ "repository": { "type": "git", "url": "https://github.com/SGK112/ai-website-builder" },
25
+ "keywords": ["webstew", "claude", "bridge", "ai", "website-builder"],
26
+ "license": "MIT"
27
+ }