@digital-gravy/etch-connector 0.1.4 → 0.2.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/README.md CHANGED
@@ -122,18 +122,31 @@ return groups.length;
122
122
  `eval` keeps stdout clean so a harness can capture it as the result:
123
123
 
124
124
  - **stdout** — the script's return value, as pretty JSON.
125
- - **stderr** — forwarded `console.*` output, prefixed with the level.
125
+ - **stderr** — forwarded `console.*` output, prefixed with the level, plus
126
+ connector notes that carry no such prefix (the runtime banner, warnings).
126
127
  - **exit code** — `0` success · `2` the script threw · `1` operational failure
127
128
  (no such tab, ambiguous target, timeout, daemon unreachable).
128
129
 
129
130
  ## For AI agents
130
131
 
132
+ `etch-connector tabs` reports, per tab, which Etch runtime it is and which
133
+ surfaces that runtime backs (read off `etch.environment`), then prints the
134
+ authoring rules that follow from it: the dynamic-data model (data sources vs
135
+ loops), the media lookup, the namespaces that throw `NOT_AVAILABLE`, and the
136
+ block types the runtime can construct. Not every runtime backs every surface, so
137
+ read that before writing scripts — and branch on a capability, never on the
138
+ product name. `eval` names the runtime it targets on stderr as a reminder, and
139
+ `tabs --quiet` drops the prose when you are only checking that a tab is still
140
+ connected.
141
+
131
142
  The authoritative agent instructions live in **`etch-connector --help`** — read
132
143
  the entire output before interacting with Etch. It covers the backends, commands
133
144
  and flags, the stdout/stderr/exit-code contract, how to pull the current Etch
134
145
  docs (Context7 MCP or <https://docs.etchwp.com/public-api>), and the conventions
135
- for structuring pages (e.g. the section/container layout pattern). Project
136
- conventions for working on this repo live in [`AGENTS.md`](AGENTS.md).
146
+ for structuring pages (e.g. the section/container layout pattern). The public API
147
+ may also expose ready-made skills for common tasks — check the docs before
148
+ improvising. Project conventions for working on this repo live in
149
+ [`AGENTS.md`](AGENTS.md).
137
150
 
138
151
  ## CLI
139
152
 
@@ -142,7 +155,7 @@ etch-connector — drive a live Etch tab from the command line
142
155
 
143
156
  Usage:
144
157
  etch-connector serve [--ws-port 7331] [--control-port 7332] [--ws-host 127.0.0.1]
145
- etch-connector tabs [--json] [--control-port 7332] [--cdp]
158
+ etch-connector tabs [--json] [--quiet] [--control-port 7332] [--cdp]
146
159
  etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
147
160
  etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
148
161
  etch-connector html <selector> [-t name] (CDP)
@@ -192,6 +205,8 @@ the same one a future MCP server would call):
192
205
  requests, handles timeouts and disconnects. **This is the reusable piece.**
193
206
  - [`src/control-server.ts`](src/control-server.ts) — the HTTP control API over
194
207
  the core.
208
+ - [`src/guidance.ts`](src/guidance.ts) — turns a tab's `etch.environment`
209
+ descriptor into the authoring instructions for that runtime.
195
210
  - [`src/cli.ts`](src/cli.ts) — the `serve` / `tabs` / `eval` commands.
196
211
 
197
212
  ## Going to MCP later
@@ -14,7 +14,7 @@
14
14
  import { EventEmitter } from 'node:events';
15
15
  import { randomUUID } from 'node:crypto';
16
16
  import { WebSocketServer } from 'ws';
17
- import { DEFAULT_HOST, DEFAULT_WS_PORT, parsePageMessage } from './protocol.js';
17
+ import { DEFAULT_HOST, DEFAULT_WS_PORT, parseEnvironment, parsePageMessage } from './protocol.js';
18
18
  const DEFAULT_TIMEOUT_MS = 30_000;
19
19
  /**
20
20
  * Raised for operational failures (no matching tab, ambiguous target, timeout,
@@ -208,6 +208,7 @@ export class BridgeServer extends EventEmitter {
208
208
  hasEtch: hello.hasEtch,
209
209
  apiVersion: hello.apiVersion,
210
210
  version: hello.version,
211
+ environment: parseEnvironment(hello.environment),
211
212
  meta: hello.meta ?? {},
212
213
  connectedAt: Date.now()
213
214
  };
package/dist/cli.js CHANGED
@@ -7,8 +7,9 @@
7
7
  * etch-connector tabs list connected Etch tabs
8
8
  * etch-connector eval [code] run a script in a tab; value → stdout
9
9
  *
10
- * `eval` prints forwarded console output to *stderr* and the script's return
11
- * value as JSON to *stdout*, so an agent can capture stdout as the clean result.
10
+ * `eval` prints forwarded console output to *stderr* — alongside unprefixed
11
+ * connector notes such as the runtime banner — and the script's return value as
12
+ * JSON to *stdout*, so an agent can capture stdout as the clean result.
12
13
  * Exit codes: 0 ok · 2 the script threw · 1 everything else (no tab, timeout…).
13
14
  */
14
15
  import { readFileSync, writeFileSync } from 'node:fs';
@@ -16,6 +17,7 @@ import { BridgeServer } from './bridge-server.js';
16
17
  import { createControlServer, controlBaseUrl } from './control-server.js';
17
18
  import { CdpClient, CdpError, CdpEvalError } from './cdp-client.js';
18
19
  import { DEFAULT_CONTROL_PORT, DEFAULT_HOST, DEFAULT_WS_PORT } from './protocol.js';
20
+ import { backedCapabilities, environmentGuidance, runtimeBanner } from './guidance.js';
19
21
  const DEFAULT_CDP_PORT = 9222;
20
22
  /**
21
23
  * Unsafe mode (scripts get full, unconstrained access to the page) is
@@ -31,54 +33,64 @@ const BOOLEAN_FLAGS = new Set([
31
33
  'full',
32
34
  'jpeg',
33
35
  'json',
36
+ 'quiet',
34
37
  'unsafe',
35
38
  'safe',
36
39
  'help',
37
40
  'freeze'
38
41
  ]);
42
+ /** Single-letter aliases → their long flag name. */
43
+ const SHORT_FLAGS = {
44
+ t: 'tab',
45
+ f: 'file',
46
+ p: 'port',
47
+ s: 'selector',
48
+ o: 'out'
49
+ };
50
+ /** A non-flag token is a value iff it exists and isn't itself a flag. */
51
+ function isValueToken(next) {
52
+ return next !== undefined && !next.startsWith('-');
53
+ }
54
+ /** Record a `--long` flag; returns true if it consumed `next` as its value. */
55
+ function applyLongFlag(flags, arg, next) {
56
+ // Support --key=value (e.g. --freeze=false) explicitly.
57
+ const eq = arg.indexOf('=');
58
+ if (eq !== -1) {
59
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
60
+ return false;
61
+ }
62
+ const key = arg.slice(2);
63
+ if (!BOOLEAN_FLAGS.has(key) && isValueToken(next)) {
64
+ flags[key] = next;
65
+ return true;
66
+ }
67
+ flags[key] = true;
68
+ return false;
69
+ }
70
+ /** Record a `-x` short flag; returns true if it consumed `next` as its value. */
71
+ function applyShortFlag(flags, arg, next) {
72
+ const key = SHORT_FLAGS[arg.slice(1)] ?? arg.slice(1);
73
+ if (isValueToken(next)) {
74
+ flags[key] = next;
75
+ return true;
76
+ }
77
+ flags[key] = true;
78
+ return false;
79
+ }
39
80
  function parseArgs(argv) {
40
81
  const command = argv[0] ?? 'help';
41
82
  const positional = [];
42
83
  const flags = {};
43
84
  for (let i = 1; i < argv.length; i++) {
44
85
  const arg = argv[i];
86
+ const next = argv[i + 1];
45
87
  if (arg.startsWith('--')) {
46
- // Support --key=value (e.g. --freeze=false) explicitly.
47
- const eq = arg.indexOf('=');
48
- if (eq !== -1) {
49
- flags[arg.slice(2, eq)] = arg.slice(eq + 1);
50
- continue;
51
- }
52
- const key = arg.slice(2);
53
- const next = argv[i + 1];
54
- if (BOOLEAN_FLAGS.has(key)) {
55
- flags[key] = true;
56
- }
57
- else if (next !== undefined && !next.startsWith('-')) {
58
- flags[key] = next;
88
+ if (applyLongFlag(flags, arg, next))
59
89
  i++;
60
- }
61
- else {
62
- flags[key] = true;
63
- }
64
90
  }
65
91
  else if (arg.startsWith('-') && arg.length === 2) {
66
- const map = {
67
- t: 'tab',
68
- f: 'file',
69
- p: 'port',
70
- s: 'selector',
71
- o: 'out'
72
- };
73
- const key = map[arg.slice(1)] ?? arg.slice(1);
74
- const next = argv[i + 1];
75
- if (next !== undefined && !next.startsWith('-')) {
76
- flags[key] = next;
92
+ if (applyShortFlag(flags, arg, next))
77
93
  i++;
78
- }
79
- else {
80
- flags[key] = true;
81
- }
82
94
  }
83
95
  else {
84
96
  positional.push(arg);
@@ -105,6 +117,10 @@ function bool(flag, fallback) {
105
117
  return flag;
106
118
  return !/^(false|0|no|off)$/i.test(flag);
107
119
  }
120
+ /** Pretty-print a value to stdout as JSON (the clean machine-readable result). */
121
+ function writeJson(value) {
122
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
123
+ }
108
124
  function readStdin() {
109
125
  return new Promise((resolve) => {
110
126
  let data = '';
@@ -113,6 +129,15 @@ function readStdin() {
113
129
  process.stdin.on('end', () => resolve(data));
114
130
  });
115
131
  }
132
+ /** The startup banner printed once both servers are listening. */
133
+ function printServeBanner(wsHost, boundWsPort, controlHost, controlPort, defaultMode) {
134
+ console.error(`[etch-connector] bridge ws://${wsHost}:${boundWsPort}`);
135
+ console.error(`[etch-connector] control ${controlBaseUrl({ host: controlHost, port: controlPort })}`);
136
+ console.error(`[etch-connector] default mode: ${defaultMode}${defaultMode === 'unsafe' ? ' ⚠ scripts get full access to the page' : ' (scripts see only window.etch + JS built-ins)'}`);
137
+ console.error('[etch-connector] In an Etch builder tab, run:');
138
+ console.error(`[etch-connector] etch.connectAs('my-tab'${boundWsPort === DEFAULT_WS_PORT ? '' : `, { port: ${boundWsPort} }`})`);
139
+ console.error('[etch-connector] then drive it with: etch-connector eval -t my-tab "return window.etch.apiVersion"');
140
+ }
116
141
  async function runServe(flags) {
117
142
  const wsHost = str(flags['ws-host']) ?? DEFAULT_HOST;
118
143
  const wsPort = num(flags['ws-port'], DEFAULT_WS_PORT);
@@ -129,12 +154,7 @@ async function runServe(flags) {
129
154
  // Periodic liveness probe so dead tabs that never fired `close` get pruned.
130
155
  const heartbeat = setInterval(() => bridge.pingAll(), 30_000);
131
156
  heartbeat.unref();
132
- console.error(`[etch-connector] bridge ws://${wsHost}:${boundWsPort}`);
133
- console.error(`[etch-connector] control ${controlBaseUrl({ host: controlHost, port: controlPort })}`);
134
- console.error(`[etch-connector] default mode: ${defaultMode}${defaultMode === 'unsafe' ? ' ⚠ scripts get full access to the page' : ' (scripts see only window.etch + JS built-ins)'}`);
135
- console.error('[etch-connector] In an Etch builder tab, run:');
136
- console.error(`[etch-connector] etch.connectAs('my-tab'${boundWsPort === DEFAULT_WS_PORT ? '' : `, { port: ${boundWsPort} }`})`);
137
- console.error('[etch-connector] then drive it with: etch-connector eval -t my-tab "return window.etch.apiVersion"');
157
+ printServeBanner(wsHost, boundWsPort, controlHost, controlPort, defaultMode);
138
158
  const shutdown = () => {
139
159
  clearInterval(heartbeat);
140
160
  control.close();
@@ -149,6 +169,12 @@ function controlUrlFromFlags(flags) {
149
169
  port: num(flags['control-port'], DEFAULT_CONTROL_PORT)
150
170
  });
151
171
  }
172
+ /** Hit a daemon endpoint, turning a connection failure into a friendly error. */
173
+ async function controlFetch(flags, path, init) {
174
+ return fetch(`${controlUrlFromFlags(flags)}${path}`, init).catch((err) => {
175
+ throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
176
+ });
177
+ }
152
178
  /**
153
179
  * CDP (Chrome remote-debugging) config when the caller opted in via `--cdp`,
154
180
  * `--cdp-port`, `--cdp-host`, or `--remote-debugging-port`; otherwise null.
@@ -219,37 +245,85 @@ function failCdp(err, port = DEFAULT_CDP_PORT) {
219
245
  }
220
246
  process.exit(1);
221
247
  }
222
- async function runTabs(flags) {
223
- const cdp = cdpFromFlags(flags);
224
- if (cdp) {
225
- const targets = await CdpClient.listTargets(cdp).catch((e) => failCdp(e, cdp.port));
226
- if (flags['json']) {
227
- process.stdout.write(`${JSON.stringify(targets, null, 2)}\n`);
228
- return;
229
- }
230
- if (targets.length === 0) {
231
- console.error('No Chrome page targets open.');
232
- return;
233
- }
234
- for (const t of targets)
235
- process.stdout.write(`${t.title}\t${t.url}\n`);
236
- return;
248
+ /**
249
+ * Run a CDP command against the required endpoint (defaulting to localhost:9222
250
+ * for the CDP-only commands), translating any CDP error into the right exit.
251
+ */
252
+ async function withRequiredCdp(flags, fn) {
253
+ const cdp = requireCdp(flags);
254
+ try {
255
+ return await withCdp(cdp, str(flags['tab']), fn);
237
256
  }
238
- const res = await fetch(`${controlUrlFromFlags(flags)}/tabs`).catch((err) => {
239
- throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
240
- });
241
- const { tabs } = (await res.json());
242
- if (flags['json']) {
243
- process.stdout.write(`${JSON.stringify(tabs, null, 2)}\n`);
257
+ catch (err) {
258
+ failCdp(err, cdp.port);
259
+ }
260
+ }
261
+ /** Resolve a required CSS selector from `-s/--selector` or the first positional. */
262
+ function requireSelector(command, positional, flags) {
263
+ const selector = str(flags['selector']) ?? positional[0];
264
+ if (!selector) {
265
+ console.error(`${command}: pass a CSS selector (-s/--selector or positional)`);
266
+ process.exit(1);
267
+ }
268
+ return selector;
269
+ }
270
+ async function tabsViaCdp(cdp, flags) {
271
+ const targets = await CdpClient.listTargets(cdp).catch((e) => failCdp(e, cdp.port));
272
+ if (flags['json'])
273
+ return writeJson(targets);
274
+ if (targets.length === 0) {
275
+ console.error('No Chrome page targets open.');
244
276
  return;
245
277
  }
278
+ for (const t of targets)
279
+ process.stdout.write(`${t.title}\t${t.url}\n`);
280
+ }
281
+ /** The runtime column: what the tab is and which surfaces it actually backs. */
282
+ function describeRuntime(tab) {
283
+ if (!tab.hasEtch)
284
+ return 'no-etch';
285
+ if (!tab.environment)
286
+ return 'etch';
287
+ const backed = backedCapabilities(tab.environment);
288
+ return `${tab.environment.product}(${backed.join(',') || 'none'})`;
289
+ }
290
+ /**
291
+ * Instructions for the runtimes actually on the wire, to stderr so stdout stays
292
+ * the plain listing. Tabs sharing a runtime share one block — an agent needs the
293
+ * guidance once per runtime, not once per tab.
294
+ */
295
+ function printRuntimeGuidance(tabs) {
296
+ const byRuntime = new Map();
297
+ for (const tab of tabs.filter((t) => t.hasEtch)) {
298
+ const guidance = environmentGuidance(tab.environment);
299
+ const names = byRuntime.get(guidance) ?? [];
300
+ names.push(tab.name);
301
+ byRuntime.set(guidance, names);
302
+ }
303
+ for (const [guidance, names] of byRuntime) {
304
+ console.error(`\nAGENT — authoring against ${names.join(', ')}:`);
305
+ console.error(guidance);
306
+ }
307
+ }
308
+ async function tabsViaDaemon(flags) {
309
+ const res = await controlFetch(flags, '/tabs');
310
+ const { tabs } = (await res.json());
311
+ if (flags['json'])
312
+ return writeJson(tabs);
246
313
  if (tabs.length === 0) {
247
314
  console.error('No Etch tabs connected.');
248
315
  return;
249
316
  }
250
317
  for (const tab of tabs) {
251
- process.stdout.write(`${tab.name}\t${tab.hasEtch ? 'etch' : 'no-etch'}\t${tab.title}\t${tab.url}\n`);
318
+ process.stdout.write(`${tab.name}\t${describeRuntime(tab)}\t${tab.title}\t${tab.url}\n`);
252
319
  }
320
+ // `tabs` doubles as the liveness check, so let a poller opt out of the prose.
321
+ if (!flags['quiet'])
322
+ printRuntimeGuidance(tabs);
323
+ }
324
+ async function runTabs(flags) {
325
+ const cdp = cdpFromFlags(flags);
326
+ return cdp ? tabsViaCdp(cdp, flags) : tabsViaDaemon(flags);
253
327
  }
254
328
  async function gatherCode(positional, flags) {
255
329
  const file = str(flags['file']);
@@ -259,132 +333,148 @@ async function gatherCode(positional, flags) {
259
333
  return positional.join(' ');
260
334
  return (await readStdin()).trim();
261
335
  }
262
- async function runEval(positional, flags) {
263
- const code = await gatherCode(positional, flags);
264
- if (!code) {
265
- console.error('eval: no script provided (pass code as an argument, --file, or stdin)');
266
- process.exit(1);
267
- }
268
- const timeoutMs = typeof flags['timeout'] === 'string'
336
+ /** `--timeout ms` if given a numeric value, else undefined (daemon default). */
337
+ function timeoutFromFlags(flags) {
338
+ return typeof flags['timeout'] === 'string'
269
339
  ? num(flags['timeout'], 30_000)
270
340
  : undefined;
271
- // CDP mode: eval over Chrome remote debugging (full window access, no daemon).
272
- const cdp = cdpFromFlags(flags);
273
- if (cdp) {
274
- try {
275
- const value = await withCdp(cdp, str(flags['tab']), (c) => c.eval(code, { timeoutMs }));
276
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
277
- }
278
- catch (err) {
279
- failCdp(err, cdp.port);
280
- }
281
- return;
282
- }
283
- // --safe overrides the daemon default; omitting it defers to the daemon.
284
- // --unsafe is a dead path (see ALLOW_UNSAFE): it never sets unsafe mode.
285
- let mode;
341
+ }
342
+ /**
343
+ * --safe overrides the daemon default; omitting it defers to the daemon.
344
+ * --unsafe is a dead path (see ALLOW_UNSAFE): it never sets unsafe mode.
345
+ */
346
+ function resolveEvalMode(flags) {
286
347
  if (ALLOW_UNSAFE && flags['unsafe'])
287
- mode = 'unsafe';
288
- else if (flags['safe'])
289
- mode = 'safe';
348
+ return 'unsafe';
349
+ if (flags['safe'])
350
+ return 'safe';
351
+ return undefined;
352
+ }
353
+ /** Forwarded console output → stderr, so stdout stays clean for the value. */
354
+ function printForwardedLogs(logs) {
355
+ for (const log of logs) {
356
+ const text = log.args
357
+ .map((a) => (typeof a === 'string' ? a : JSON.stringify(a)))
358
+ .join(' ');
359
+ console.error(`[${log.level}] ${text}`);
360
+ }
361
+ }
362
+ // CDP mode: eval over Chrome remote debugging (full window access, no daemon).
363
+ async function evalViaCdp(cdp, flags, code, timeoutMs) {
364
+ try {
365
+ const value = await withCdp(cdp, str(flags['tab']), (c) => c.eval(code, { timeoutMs }));
366
+ writeJson(value);
367
+ }
368
+ catch (err) {
369
+ failCdp(err, cdp.port);
370
+ }
371
+ }
372
+ /**
373
+ * Which tab an eval will land on, mirroring the daemon's own resolution: a named
374
+ * tab (most recently connected wins), or the only one connected. Anything the
375
+ * daemon would reject resolves to nothing — the eval reports that itself.
376
+ */
377
+ function resolveTargetTab(tabs, name) {
378
+ if (name === undefined)
379
+ return tabs.length === 1 ? tabs[0] : undefined;
380
+ return tabs
381
+ .filter((tab) => tab.name === name)
382
+ .sort((a, b) => b.connectedAt - a.connectedAt)[0];
383
+ }
384
+ /**
385
+ * Name the runtime this eval targets, on stderr. Best-effort: a listing we
386
+ * cannot fetch or a target we cannot pin down just means no banner — never a
387
+ * failed eval.
388
+ */
389
+ async function announceTargetRuntime(flags) {
390
+ try {
391
+ const res = await controlFetch(flags, '/tabs');
392
+ if (!res.ok)
393
+ return;
394
+ const { tabs } = (await res.json());
395
+ const target = resolveTargetTab(tabs, str(flags['tab']));
396
+ if (target)
397
+ console.error(runtimeBanner(target.environment));
398
+ }
399
+ catch {
400
+ /* the eval itself reports anything that actually matters */
401
+ }
402
+ }
403
+ async function evalViaDaemon(flags, code, timeoutMs) {
404
+ const mode = resolveEvalMode(flags);
290
405
  if (mode === 'unsafe') {
291
406
  console.error('⚠ running in unsafe mode — the script has full access to the page');
292
407
  }
293
- const body = {
294
- tab: str(flags['tab']),
295
- code,
296
- timeoutMs,
297
- mode
298
- };
299
- const res = await fetch(`${controlUrlFromFlags(flags)}/eval`, {
408
+ // Started before the eval so naming the runtime costs no wall-clock.
409
+ const announced = announceTargetRuntime(flags);
410
+ const body = { tab: str(flags['tab']), code, timeoutMs, mode };
411
+ const res = await controlFetch(flags, '/eval', {
300
412
  method: 'POST',
301
413
  headers: { 'content-type': 'application/json' },
302
414
  body: JSON.stringify(body)
303
- }).catch((err) => {
304
- throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
305
415
  });
416
+ await announced;
306
417
  if (!res.ok) {
307
418
  const err = (await res.json().catch(() => ({})));
308
419
  console.error(`eval failed: ${err.error ?? res.statusText}`);
309
420
  process.exit(1);
310
421
  }
311
422
  const result = (await res.json());
312
- // Forwarded console output → stderr, so stdout stays clean for the value.
313
- for (const log of result.logs) {
314
- const text = log.args
315
- .map((a) => (typeof a === 'string' ? a : JSON.stringify(a)))
316
- .join(' ');
317
- console.error(`[${log.level}] ${text}`);
318
- }
423
+ printForwardedLogs(result.logs);
319
424
  if (!result.ok) {
320
425
  console.error(`script error: ${result.error}`);
321
426
  if (result.stack)
322
427
  console.error(result.stack);
323
428
  process.exit(2);
324
429
  }
325
- process.stdout.write(`${JSON.stringify(result.value, null, 2)}\n`);
430
+ writeJson(result.value);
431
+ }
432
+ async function runEval(positional, flags) {
433
+ const code = await gatherCode(positional, flags);
434
+ if (!code) {
435
+ console.error('eval: no script provided (pass code as an argument, --file, or stdin)');
436
+ process.exit(1);
437
+ }
438
+ const timeoutMs = timeoutFromFlags(flags);
439
+ const cdp = cdpFromFlags(flags);
440
+ return cdp
441
+ ? evalViaCdp(cdp, flags, code, timeoutMs)
442
+ : evalViaDaemon(flags, code, timeoutMs);
326
443
  }
327
444
  /** `shot` / `html` / `computed` are CDP-only; default to localhost:9222. */
328
445
  function requireCdp(flags) {
329
446
  return (cdpFromFlags(flags) ?? { host: DEFAULT_HOST, port: DEFAULT_CDP_PORT });
330
447
  }
331
448
  async function runShot(flags) {
332
- const cdp = requireCdp(flags);
333
449
  const selector = str(flags['selector']);
334
450
  const full = Boolean(flags['full']);
335
451
  const format = flags['jpeg'] ? 'jpeg' : 'png';
336
452
  const freeze = bool(flags['freeze'], true); // default on (animations → deterministic)
337
453
  const out = str(flags['out']) ?? `etch-shot.${format === 'jpeg' ? 'jpg' : 'png'}`;
338
- try {
339
- const buf = await withCdp(cdp, str(flags['tab']), (c) => c.screenshot({ selector, full, format, freeze }));
340
- writeFileSync(out, buf);
341
- const scope = selector
342
- ? ` — clipped to ${selector}`
343
- : full
344
- ? ' — full page'
345
- : ' — viewport';
346
- console.error(`wrote ${out} (${buf.length} bytes)${scope}${freeze ? ' (frozen)' : ''}`);
347
- }
348
- catch (err) {
349
- failCdp(err, cdp.port);
350
- }
454
+ const buf = await withRequiredCdp(flags, (c) => c.screenshot({ selector, full, format, freeze }));
455
+ writeFileSync(out, buf);
456
+ const scope = selector
457
+ ? ` — clipped to ${selector}`
458
+ : full
459
+ ? ' — full page'
460
+ : ' — viewport';
461
+ console.error(`wrote ${out} (${buf.length} bytes)${scope}${freeze ? ' (frozen)' : ''}`);
351
462
  }
352
463
  async function runHtml(positional, flags) {
353
- const selector = str(flags['selector']) ?? positional[0];
354
- if (!selector) {
355
- console.error('html: pass a CSS selector (-s/--selector or positional)');
356
- process.exit(1);
357
- }
358
- const cdp = requireCdp(flags);
359
- try {
360
- const html = await withCdp(cdp, str(flags['tab']), (c) => c.outerHtml(selector));
361
- process.stdout.write(`${html}\n`);
362
- }
363
- catch (err) {
364
- failCdp(err, cdp.port);
365
- }
464
+ const selector = requireSelector('html', positional, flags);
465
+ const html = await withRequiredCdp(flags, (c) => c.outerHtml(selector));
466
+ process.stdout.write(`${html}\n`);
366
467
  }
367
468
  async function runComputed(positional, flags) {
368
- const selector = str(flags['selector']) ?? positional[0];
369
- if (!selector) {
370
- console.error('computed: pass a CSS selector (-s/--selector or positional)');
371
- process.exit(1);
372
- }
469
+ const selector = requireSelector('computed', positional, flags);
373
470
  const props = str(flags['props'])
374
471
  ?.split(',')
375
472
  .map((p) => p.trim())
376
473
  .filter(Boolean);
377
- const cdp = requireCdp(flags);
378
- try {
379
- const style = await withCdp(cdp, str(flags['tab']), (c) => c.computedStyle(selector, props));
380
- process.stdout.write(`${JSON.stringify(style, null, 2)}\n`);
381
- }
382
- catch (err) {
383
- failCdp(err, cdp.port);
384
- }
474
+ const style = await withRequiredCdp(flags, (c) => c.computedStyle(selector, props));
475
+ writeJson(style);
385
476
  }
386
- function printHelp() {
387
- console.error(`etch-connector — drive a live Etch tab from the command line
477
+ const HELP_TEXT = `etch-connector — drive a live Etch tab from the command line
388
478
 
389
479
  Run without installing (requires a valid, active Etch license):
390
480
  npx @digital-gravy/etch-connector serve
@@ -396,7 +486,7 @@ Or install globally for the bare \`etch-connector\` command:
396
486
 
397
487
  Usage:
398
488
  etch-connector serve [--ws-port ${DEFAULT_WS_PORT}] [--control-port ${DEFAULT_CONTROL_PORT}] [--ws-host ${DEFAULT_HOST}]
399
- etch-connector tabs [--json] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
489
+ etch-connector tabs [--json] [--quiet] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
400
490
  etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
401
491
  etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
402
492
  etch-connector html <selector> [-t name] (CDP)
@@ -414,6 +504,15 @@ AGENT: before running anything, ask the user how they want to connect — bridge
414
504
  (API only) or cdp (DevTools privilege: full page access). cdp grants more reach;
415
505
  don't assume — let the user choose.
416
506
 
507
+ AGENT: \`etch-connector tabs\` prints, per connected tab, which runtime it is and
508
+ which surfaces that runtime backs (from \`etch.environment\`), followed by the
509
+ authoring rules for it — the dynamic-data model, the media lookup, the
510
+ namespaces that throw, and the block types it can construct. Read that before
511
+ writing scripts: not every runtime backs every surface, so the same script does
512
+ not work everywhere. Branch on a capability, never on the product name.
513
+ (\`eval\` names the runtime it targets on stderr as a reminder; \`tabs --quiet\`
514
+ drops the prose when you are only checking a tab is still connected.)
515
+
417
516
  Connecting a bridge tab (AGENT: walk the user through activating it — the AI
418
517
  button is the primary path; don't just wait for a tab to appear):
419
518
  1. Tell them to open Etch Settings and enable the AI Connector.
@@ -441,6 +540,31 @@ The eval body runs as an async function, so it may 'return' a value and use
441
540
  top-level 'await'. In bridge mode reference the API as "etch.*" ("window" is
442
541
  withheld); in cdp mode the full page is available.
443
542
 
543
+ AI presence (AGENT: drive the etch.ai overlay so the human watching the tab
544
+ sees an agent is active — this connector is exactly what etch.ai is built for.
545
+ This overlay is for the human, NOT the user you're chatting with: NEVER mention
546
+ it, narrate it, or explain these state changes — just keep it accurate):
547
+ etch.ai.setState(state) shows/updates the presence overlay in the builder.
548
+ etch.ai.getState() reads the current state.
549
+ etch.ai.hide() removes the overlay (shorthand for setState("idle")).
550
+ state is one of: "idle" (overlay hidden), "thinking" (reasoning before acting),
551
+ "working" (a script is actually running against the canvas), "waiting"
552
+ (paused, needs more input from the human). Any other value throws
553
+ EtchApiError INVALID_ARGUMENT. Calls are synchronous, in-memory, and NOT
554
+ persisted — no saveAsync, nothing survives a reload.
555
+ When to set what:
556
+ • The MOMENT a user asks you to build/change something — before reading docs,
557
+ writing scripts, or any other step — put the overlay up: setState('thinking').
558
+ • setState('thinking') while you reason/plan (no script running yet).
559
+ • setState('working') right before an eval actually runs against the canvas;
560
+ back to 'thinking' between scripts if you pause to reason.
561
+ • hide() the instant the task is finished — never leave it up on completion.
562
+ • setState('waiting') ONLY when you are NOT done and are handing back to the
563
+ human for input/a decision; resume 'thinking'/'working' when they reply.
564
+ Wrap script runs in try/finally so an error still leaves the right state:
565
+ etch.ai.setState('working');
566
+ try { /* …drive the builder… */ } finally { etch.ai.setState('thinking'); }
567
+
444
568
  Docs (AGENT: pull these BEFORE writing scripts against etch.* — the API surface
445
569
  changes and training data may be stale):
446
570
  Context7 MCP (preferred) resolve-library-id "Etch" → query-docs for the
@@ -448,6 +572,9 @@ changes and training data may be stale):
448
572
  Manual fallback https://docs.etchwp.com/public-api (window.etch.*)
449
573
  https://docs.etchwp.com (everything else)
450
574
 
575
+ Skills (AGENT: the public API may expose ready-made skills for common tasks —
576
+ check the docs before improvising).
577
+
451
578
  Building Etch pages (AGENT: conventions — follow these or the builder won't
452
579
  render what you author):
453
580
  Images. ALWAYS use the Dynamic Image element (etch/dynamic-image, <etch:img>),
@@ -488,12 +615,17 @@ render what you author):
488
615
  etch.blocks.update(instanceId, { attributes: { image: "10" } })
489
616
  • updateAsync({ blocks }) requires styles:[] on every element block
490
617
  (validation differs from blocks.create(), which omits styles entirely).
491
- • No etch.media API. Resolve a URL → media ID via the WP REST API:
618
+ • No etch.media API. Where the runtime backs the WordPress media library
619
+ (capability \`wpMedia\` — see \`tabs\`), resolve a URL → media ID via the WP
620
+ REST API:
492
621
  curl 'http://<site>/?rest_route=/wp/v2/media&per_page=100&_fields=id,source_url'
493
622
  (rest_route form works on plain permalinks; /wp-json/wp/v2/media on pretty
494
- ones.)
623
+ ones.) Without it the ids are the product's own asset store and there is
624
+ nothing to look a URL up against.
495
625
 
496
- See AGENTS.md for project conventions.`);
626
+ See AGENTS.md for project conventions.`;
627
+ function printHelp() {
628
+ console.error(HELP_TEXT);
497
629
  }
498
630
  async function main() {
499
631
  const { command, positional, flags } = parseArgs(process.argv.slice(2));
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Turns a tab's `etch.environment` descriptor into the instructions for
3
+ * authoring against *that* runtime.
4
+ *
5
+ * The two dynamic-data models are mutually exclusive and are authored
6
+ * differently, the media lookup depends on whether WordPress is underneath, and
7
+ * several namespaces are declared everywhere but backed only somewhere — so a
8
+ * script written for one product silently produces nothing on the other. This
9
+ * is what turns the descriptor from something the connector relays into
10
+ * something it acts on.
11
+ *
12
+ * Every branch keys off {@link hasCapability}, never `product`: capabilities
13
+ * move between products, and a product check rots the day one does.
14
+ */
15
+ import { hasCapability } from './protocol.js';
16
+ /** Namespaces that are declared on every runtime but backed only on some. */
17
+ const GATED_NAMESPACES = [
18
+ { capability: 'loops', namespace: 'etch.loops.*' },
19
+ { capability: 'fields', namespace: 'etch.fields.*' },
20
+ { capability: 'templates', namespace: "etch.navigation.goTo('templates')" }
21
+ ];
22
+ const UNKNOWN_RUNTIME = [
23
+ 'Runtime: not reported — this Etch predates etch.environment, so what follows',
24
+ ' is an ASSUMPTION, not a reading of the runtime: WordPress rules (loops,',
25
+ ' fields, templates, WP media), and every block type may exist. Confirm with',
26
+ ' the user before relying on it — a non-WordPress runtime throws on most of',
27
+ ' that.'
28
+ ];
29
+ function dynamicDataGuidance(environment) {
30
+ if (hasCapability(environment, 'dataSources')) {
31
+ return [
32
+ 'Dynamic data — data sources. Named project sources (json, api, js) are',
33
+ " reached through a data('key') expression. An etch/loop block carries",
34
+ ' ONLY a required `target`, and that target is any dynamic expression',
35
+ " yielding a list: data('products'), a path into one",
36
+ " (data('products').items), nested data from an enclosing loop",
37
+ ' (item.tags), or a generated list (range(1, 10)). There is no',
38
+ ' loopId/loopParams here.'
39
+ ];
40
+ }
41
+ if (hasCapability(environment, 'loops')) {
42
+ return [
43
+ 'Dynamic data — loops. etch.loops.* is a registry of named loop',
44
+ ' definitions. An etch/loop block binds one by `loopId`, passes',
45
+ ' $-prefixed `loopParams`, and `target` is an optional sub-path within',
46
+ " it. There are no data sources here — a data('key') expression",
47
+ ' resolves to nothing.'
48
+ ];
49
+ }
50
+ return [
51
+ 'Dynamic data: unavailable. This runtime backs neither loops nor data',
52
+ ' sources — do not author etch/loop blocks against it.'
53
+ ];
54
+ }
55
+ function mediaGuidance(environment) {
56
+ const binding = ' Bind an etch/dynamic-image by a numeric `mediaId` attribute, never a URL.';
57
+ if (hasCapability(environment, 'wpMedia')) {
58
+ return [
59
+ 'Media — the WordPress library. Media ids resolve against /wp/v2/media, so',
60
+ ' a URL can be looked up:',
61
+ " curl '<site>/?rest_route=/wp/v2/media&per_page=100&_fields=id,source_url'",
62
+ binding
63
+ ];
64
+ }
65
+ return [
66
+ "Media — this runtime's own asset store, NOT the WordPress library:",
67
+ ' /wp/v2/media does not apply and there is nothing to look a URL up against.',
68
+ binding
69
+ ];
70
+ }
71
+ function unavailableGuidance(environment) {
72
+ const throwing = GATED_NAMESPACES.filter((gated) => !hasCapability(environment, gated.capability)).map((gated) => gated.namespace);
73
+ if (throwing.length === 0)
74
+ return [];
75
+ return [
76
+ 'Throws EtchApiError NOT_AVAILABLE here — do not call:',
77
+ ` ${throwing.join(', ')}`
78
+ ];
79
+ }
80
+ function blockTypeGuidance(environment) {
81
+ if (environment.blockTypes.length === 0)
82
+ return [];
83
+ return [
84
+ 'Block types this runtime can construct — author nothing outside this list:',
85
+ ` ${environment.blockTypes.join(', ')}`
86
+ ];
87
+ }
88
+ /** Which capabilities a runtime actually backs, for a one-line summary. */
89
+ export function backedCapabilities(environment) {
90
+ return Object.entries(environment.capabilities)
91
+ .filter(([, backed]) => backed === true)
92
+ .map(([capability]) => capability)
93
+ .sort();
94
+ }
95
+ /**
96
+ * One stderr line naming the runtime a script is about to run against.
97
+ *
98
+ * `tabs` is where the full instructions live, but `-t` is optional with a single
99
+ * tab connected — so an agent can go straight to `eval` and never see them. This
100
+ * is the line that keeps that agent from silently authoring for the wrong
101
+ * product, and points at where the rules are.
102
+ */
103
+ export function runtimeBanner(environment) {
104
+ if (!environment) {
105
+ return '[etch] runtime reports no capabilities — assuming WordPress; confirm before relying on it';
106
+ }
107
+ const backed = backedCapabilities(environment);
108
+ return (`[${environment.product}] backs ${backed.join(', ') || 'no optional surface'}` +
109
+ " — run 'etch-connector tabs' for the authoring rules");
110
+ }
111
+ /**
112
+ * Agent-facing instructions for one runtime. An absent descriptor is a runtime
113
+ * too old to report one, which is always a WordPress build.
114
+ */
115
+ export function environmentGuidance(environment) {
116
+ if (!environment)
117
+ return UNKNOWN_RUNTIME.join('\n');
118
+ const backed = backedCapabilities(environment);
119
+ const sections = [
120
+ [
121
+ `Runtime: ${environment.product} — backs ${backed.length > 0 ? backed.join(', ') : 'no optional surface'}.`
122
+ ],
123
+ dynamicDataGuidance(environment),
124
+ mediaGuidance(environment),
125
+ unavailableGuidance(environment),
126
+ blockTypeGuidance(environment)
127
+ ].filter((section) => section.length > 0);
128
+ return sections.map((section) => section.join('\n')).join('\n');
129
+ }
package/dist/index.js CHANGED
@@ -9,3 +9,5 @@
9
9
  export { BridgeServer, BridgeError } from './bridge-server.js';
10
10
  export { createControlServer, controlBaseUrl } from './control-server.js';
11
11
  export { DEFAULT_WS_PORT, DEFAULT_CONTROL_PORT, DEFAULT_HOST } from './protocol.js';
12
+ export { hasCapability, parseEnvironment } from './protocol.js';
13
+ export { backedCapabilities, environmentGuidance, runtimeBanner } from './guidance.js';
package/dist/protocol.js CHANGED
@@ -10,6 +10,48 @@
10
10
  * server → page : EvalRequest | PingMessage
11
11
  * page → server : HelloMessage | LogMessage | ResultMessage | ErrorMessage | PongMessage
12
12
  */
13
+ /** Every optional surface a runtime may or may not back. */
14
+ const CAPABILITY_NAMES = [
15
+ 'loops',
16
+ 'dataSources',
17
+ 'fields',
18
+ 'templates',
19
+ 'wpMedia'
20
+ ];
21
+ /** Whether a runtime backs a capability. Absent or unknown counts as no. */
22
+ export function hasCapability(environment, capability) {
23
+ return environment?.capabilities[capability] === true;
24
+ }
25
+ function isRecord(value) {
26
+ return typeof value === 'object' && value !== null;
27
+ }
28
+ /**
29
+ * Narrow an `environment` off the wire. The page is untrusted input, so a
30
+ * malformed descriptor becomes `undefined` (treated as "runtime too old to
31
+ * report one") rather than propagating a broken shape.
32
+ */
33
+ export function parseEnvironment(value) {
34
+ if (!isRecord(value))
35
+ return undefined;
36
+ const { product, capabilities, blockTypes } = value;
37
+ if (typeof product !== 'string')
38
+ return undefined;
39
+ if (!isRecord(capabilities))
40
+ return undefined;
41
+ if (!Array.isArray(blockTypes))
42
+ return undefined;
43
+ const known = {};
44
+ for (const name of CAPABILITY_NAMES) {
45
+ const flag = capabilities[name];
46
+ if (typeof flag === 'boolean')
47
+ known[name] = flag;
48
+ }
49
+ return {
50
+ product,
51
+ capabilities: known,
52
+ blockTypes: blockTypes.filter((type) => typeof type === 'string')
53
+ };
54
+ }
13
55
  /** Default WebSocket port the Etch bridge dials (`ws://127.0.0.1:7331`). */
14
56
  export const DEFAULT_WS_PORT = 7331;
15
57
  /** Default HTTP control-API port the CLI / AI harness talks to. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digital-gravy/etch-connector",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/dist/manual.js DELETED
@@ -1,2 +0,0 @@
1
- // AUTO-GENERATED from README.md by scripts/build-manual.mjs — do not edit by hand.
2
- export const MANUAL = "# etch-connector\n\nA tiny local bridge that lets an AI harness — Claude Code, Cowork, or any tool\nthat can run a shell command — **drive a live Etch builder tab** by evaluating\nscripts against the [`etch-public-api`](https://www.npmjs.com/package/@digital-gravy/etch-public-api)\nsurface (`window.etch.*`).\n\nEtch's scriptable API only exists *inside the browser*. There is no server you\ncan call directly. So this connector inverts the usual direction: the browser\n**dials out** to the connector over a WebSocket (the same way Chrome remote\ndebugging works, but in reverse), and the connector relays scripts into the tab\nand streams the result back.\n\n```\nClaude Code / Cowork\n │ shell: etch-connector eval -t homepage \"return ...\"\n ▼\n etch-connector (the `serve` daemon)\n │ ▲ WebSocket (tab dials in via etch.connectTo)\n ▼ │\n Etch builder tab ──► window.etch.*\n```\n\n## Status\n\nThis is an **eval relay**: the harness sends a JavaScript snippet, the page runs\nit as an async function, and the return value + console output come back. It is\nintentionally *not* an MCP server yet, but it is built so that becoming one is a\nthin add — see [Going to MCP](#going-to-mcp-later).\n\nScripts run in one of two modes, enforced by the page:\n\n- **`safe`** (default) — the script sees only `etch` and the standard JS\n built-ins. `window`, `document`, `fetch`, storage, `eval`, … are withheld.\n- **`unsafe`** — the script runs with full access to the page. Opt in\n explicitly.\n\n## Install\n\n```bash\nbun install # or npm install\n```\n\n## Use\n\n### 1. Start the daemon (once)\n\n```bash\nbun run serve\n# bridge ws://127.0.0.1:7331\n# control http://127.0.0.1:7332\n```\n\n### 2. Connect a tab\n\nOpen an Etch builder page and, in the browser console:\n\n```js\netch.connectTo('homepage');\n```\n\n(`connectTo` is part of the public API; it opens the WebSocket back to the\ndaemon and registers this tab under the given name. Pass `{ port: <n> }` if you\nstarted the daemon on a non-default WebSocket port.)\n\n### 3. Drive it\n\n```bash\n# list connected tabs\netch-connector tabs\n\n# run a script (value → stdout, console output → stderr).\n# Default is safe mode: reference the API as `etch.*`.\netch-connector eval -t homepage \"return etch.apiVersion\"\n\n# multi-line / from a file / from stdin\netch-connector eval -t homepage -f ./script.js\necho \"return await etch.blocks.getTree()\" | etch-connector eval -t homepage\n\n# full page access (opt in)\netch-connector eval -t homepage --unsafe \"return document.title\"\n```\n\nThe script body runs as an **async function**, so it can `return` a value and\nuse top-level `await`. In safe mode write scripts against the `etch-public-api`\nsurface via the `etch` binding (`window` is not available). Examples:\n\n```js\nreturn etch.apiVersion;\nreturn await etch.blocks.getTree();\netch.blocks.select('block-id-123');\nconst groups = await etch.fields.listGroupsAsync();\nreturn groups.length;\n```\n\n### Modes\n\n| Mode | In scope | Use for |\n| --------------- | ------------------------------------- | -------------------------------- |\n| `safe` (default)| `etch` + standard JS built-ins | Driving the builder via the API |\n| `unsafe` | everything (`window`, `document`, …) | Debugging, page internals |\n\nPick per call with `--safe` / `--unsafe`, or change the daemon default with\n`serve --unsafe`. A per-call flag overrides the daemon default.\n\n### Output contract (for agents)\n\n`eval` keeps stdout clean so a harness can capture it as the result:\n\n- **stdout** — the script's return value, as pretty JSON.\n- **stderr** — forwarded `console.*` output, prefixed with the level.\n- **exit code** — `0` success · `2` the script threw · `1` operational failure\n (no such tab, ambiguous target, timeout, daemon unreachable).\n\n## CLI\n\n```\netch-connector serve [--ws-port 7331] [--control-port 7332] [--ws-host 127.0.0.1] [--unsafe]\netch-connector tabs [--json] [--control-port 7332]\netch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--safe|--unsafe]\n```\n\nWhen exactly one tab is connected, `-t` is optional. With several tabs you must\nname one.\n\n## HTTP control API\n\nThe daemon also exposes a loopback HTTP API (this is the seam the CLI uses, and\nthe same one a future MCP server would call):\n\n| Method | Path | Body | Returns |\n| ------ | --------- | ------------------------------------ | ------------ |\n| GET | `/health` | — | `{ ok }` |\n| GET | `/tabs` | — | `{ tabs }` |\n| POST | `/eval` | `{ tab?, code, timeoutMs?, mode? }` | `EvalResult` |\n\n## Architecture\n\n- [`src/protocol.ts`](src/protocol.ts) — the wire format, mirrored from the\n Etch-side `bridge.ts`. Keep the two in sync.\n- [`src/bridge-server.ts`](src/bridge-server.ts) — `BridgeServer`, the\n transport-agnostic core: owns the sockets, tracks tabs by name, correlates\n requests, handles timeouts and disconnects. **This is the reusable piece.**\n- [`src/control-server.ts`](src/control-server.ts) — the HTTP control API over\n the core.\n- [`src/cli.ts`](src/cli.ts) — the `serve` / `tabs` / `eval` commands.\n\n## Going to MCP later\n\nNothing in the transport changes. An MCP server is `new BridgeServer()` plus two\ntool definitions over the same core:\n\n```ts\nimport { BridgeServer } from 'etch-connector';\n\nconst bridge = new BridgeServer();\nawait bridge.listen();\n\n// list_etch_tabs -> bridge.listTabs()\n// eval_etch_script -> bridge.evalInTab(code, { tab })\n```\n\nWhen you want first-class per-tool MCP tools (rather than one eval tool), have\nthe Etch tab advertise a tool manifest in its `hello` and register those\ndynamically — keeping a single source of truth in the browser registry.\n\n## Security\n\nTwo layers, neither a substitute for the other:\n\n**Transport.** Everything binds to `127.0.0.1`, so any local process can reach\nthe daemon. Run it only on a trusted machine and do not expose the ports beyond\nloopback.\n\n**Execution.** `safe` mode (the default) runs the script inside a `with`-bound\nProxy that exposes only `etch` plus the standard JS built-ins and throws on\n`window`, `document`, `fetch`, storage, `eval`, `Function`, … This is a\n**guardrail, not a hardened sandbox**: because the script shares the page's JS\nrealm, a *determined* author can still reach globals through prototype /\nconstructor chains (e.g. `({}).constructor.constructor`). Safe mode reliably\nkeeps casual and AI-authored scripts on the `etch` surface and closes the\nobvious escape hatches; it does **not** contain hostile code.\n\n`unsafe` mode runs the script with full, unconstrained access to the page — only\nuse it for trusted debugging.\n\nFor a true boundary against adversarial scripts you would need to harden the\nrealm (SES `lockdown()`), which freezes all intrinsics app-wide and is out of\nscope for this relay. The enforcement lives in the Etch page (`bridge.ts`), not\nthis connector — the connector only carries the requested mode; a string filter\nhere would be theater.\n";