letmeknow-cli 0.6.0 → 0.7.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.
Files changed (4) hide show
  1. package/README.md +75 -44
  2. package/SKILL.md +63 -38
  3. package/bin/letmeknow.js +138 -191
  4. package/package.json +3 -2
package/README.md CHANGED
@@ -1,76 +1,93 @@
1
1
  # LetMeKnow
2
2
 
3
- LetMeKnow gives an agent a temporary public browser surface and structured human feedback. The agent edits an ordinary folder, explicitly publishes coherent revisions, and pulls form submissions as JSON. The CLI connects outbound to the hosted relay and does not listen on a network port.
3
+ LetMeKnow gives an agent a temporary public browser surface and structured human feedback. The agent authors ordinary HTML and static assets; a running CLI serves them and accepts page updates over a single ordered event stream.
4
4
 
5
5
  ## Start a session
6
6
 
7
- Node.js 22.12 or newer is required. Create a dedicated directory containing only public files, then keep the server running:
7
+ Create a directory containing the public files, including an initial `index.html`, then run:
8
8
 
9
9
  ```bash
10
10
  npx letmeknow-cli serve ./preview
11
11
  ```
12
12
 
13
- The server prints one JSON line containing the public bearer URL and initial workspace revision:
13
+ `serve` reads `index.html` once as the canonical dynamic page and serves it at `/`. Other files in the directory—such as CSS, JavaScript, images, and data—are served live as static assets. The CLI prints one JSON line containing the public bearer URL:
14
14
 
15
15
  ```json
16
- {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","workspace":"","workspace_sequence":1}
16
+ {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":""}
17
17
  ```
18
18
 
19
- Anyone with the URL can view the published workspace and submit its forms. A graceful stop closes the session; an unexpected relay disconnect can reconnect for up to ten minutes. Diagnostics go to stderr.
19
+ Give the URL to the human. Anyone with the URL can view the page and submit its forms. Canonical page state and the event stream live in memory while `serve` runs; they do not survive a stopped session. The CLI connects outbound and does not listen on a network port.
20
20
 
21
- ## Publish revisions
21
+ The agent’s files are never modified by `serve`.
22
22
 
23
- `serve` snapshots the initial folder. Later filesystem changes remain private until explicitly published:
23
+ ## Agent workflow
24
+
25
+ Pull browser events, update the page, and push the resulting page:
24
26
 
25
27
  ```bash
26
- npx letmeknow-cli pull ./preview --wait 30
27
- npx letmeknow-cli push ./preview --based-on <batch-token>
28
+ batch=$(npx letmeknow-cli pull ./preview --wait 30)
29
+ token=$(printf '%s\n' "$batch" | jq -r .token)
30
+ # inspect events, edit index.html, then:
31
+ npx letmeknow-cli push ./preview --batch "$token" --page index.html
28
32
  ```
29
33
 
30
- `pull` returns pending browser interactions, the current workspace, and an opaque batch token:
34
+ `pull` returns an opaque batch token, current-page metadata, and the browser events not yet committed by the agent. Pulling does not consume events. Events that arrive while the agent works remain for a later pull.
31
35
 
32
36
  ```json
33
- {
34
- "ok": true,
35
- "type": "batch",
36
- "token": "…",
37
- "workspace": "…",
38
- "workspace_sequence": 1,
39
- "frontier": 1,
40
- "events": [
41
- {
42
- "type": "submit",
43
- "id": "…",
44
- "form_id": "decision",
45
- "values": {"decision":"approve"},
46
- "based_on": "…",
47
- "context": {"based_on":"…","current":"…","relationship":"current"}
48
- }
49
- ]
50
- }
37
+ {"token":"…","frontier":7,"page_event":5,"page_hash":"…","events":[{"type":"submit","id":"…","event_number":7,"page_event":5,"form_id":"decision","action":"/decide","trigger":{"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}]}
38
+ ```
39
+
40
+ An event's `page_event` identifies the page the browser displayed when it submitted. Compare it with the batch's current `page_event` when deciding whether the input still applies.
41
+
42
+ A push with a page:
43
+
44
+ ```bash
45
+ npx letmeknow-cli push ./preview --batch "$token" --page index.html
46
+ ```
47
+
48
+ atomically commits the events represented by the token, replaces the canonical dynamic page with the complete HTML from `index.html`, appends one page-update event to the global event stream, and broadcasts that page to connected browsers. Browsers morph the page without navigating or reloading.
49
+
50
+ A push without `--page` only commits the pulled browser events:
51
+
52
+ ```bash
53
+ npx letmeknow-cli push ./preview --batch "$token"
54
+ ```
55
+
56
+ Use `--page -` to read the complete desired page from standard input:
57
+
58
+ ```bash
59
+ npx letmeknow-cli push ./preview --batch "$token" --page - < updated.html
51
60
  ```
52
61
 
53
- A repeated pull returns uncommitted events again. `push` atomically snapshots the folder, commits the batch, and reloads connected browsers once. Events arriving while the agent works remain for the next pull. A push can also publish independent work from an empty batch.
62
+ The update is all-or-nothing. If the token or page input is invalid, neither the browser events nor the page update is committed.
63
+
64
+ ## Inspect the current page
54
65
 
55
- If a batch requires no workspace change, commit it without publishing:
66
+ `show` writes the canonical dynamic HTML held by `serve` to standard output:
56
67
 
57
68
  ```bash
58
- npx letmeknow-cli ack ./preview --based-on <batch-token>
69
+ npx letmeknow-cli show ./preview > current.html
59
70
  ```
60
71
 
61
- `push` and `ack` are idempotent for a token. They fail if another command has moved the workspace or event cursor first.
72
+ It is read-only and does not create or commit an event. This is different from opening the public URL: `show` returns canonical HTML, while the URL shows a particular browser’s rendered DOM, including local focus, open/closed controls, unsent values, and JavaScript state.
62
73
 
63
- The commands communicate with `serve` through a private local Unix socket. `--skill` prints agent instructions without starting a session.
74
+ ## The event stream
64
75
 
65
- ## Workspace behavior
76
+ Browser submissions and CLI page updates share one ordered, in-memory event stream:
77
+
78
+ ```text
79
+ submit browser
80
+ submit browser
81
+ update_ui CLI: complete desired HTML page
82
+ ```
66
83
 
67
- A published workspace is an immutable temporary snapshot of the selected folder. It may contain HTML, CSS, JavaScript, images, data, and linked pages. The relay injects a small runtime into HTML and serves all files from the same workspace revision.
84
+ Browser submission events are delivered to the agent through `pull`. Page-update events are broadcast to all connected browsers. There is no per-browser audience or dynamic view system in the initial model.
68
85
 
69
- A successful push sends one revision notification. Browsers reload and preserve scroll position plus the values, checked state, and selected state of controls with stable unique IDs. Missing pages and connection-status pages remain live and recover on a later publication or reconnect.
86
+ The CLI assigns the event order. The number indicates acceptance order, not the physical time a person clicked. Submission IDs make retries distinguishable from new intentional submissions.
70
87
 
71
88
  ## Forms
72
89
 
73
- Use native same-origin GET or POST forms:
90
+ Use ordinary HTML forms with stable IDs and meaningful field names:
74
91
 
75
92
  ```html
76
93
  <form id="decision" action="/decide" method="post">
@@ -80,19 +97,33 @@ Use native same-origin GET or POST forms:
80
97
  </form>
81
98
  ```
82
99
 
83
- Before delivery, the runtime gives each logical submission an opaque UUID and persists the serialized request in IndexedDB. Network retries and page reloads reuse that UUID. The CLI deduplicates accepted events, so a transport retry does not become another interaction. Distinct intentional submissions receive distinct IDs.
100
+ The runtime intercepts native form submission and turns it into a durable JSON `submit` event. It assigns an opaque UUID, stores the event in the browser’s local outbox before delivery, retries after connection failures, and reuses the UUID on retry. The CLI deduplicates repeated delivery of the same event. Distinct submissions remain distinct, including rapid repeated clicks.
84
101
 
85
- The runtime displays **Sending…** or **Uploading…**, followed by **Sent. Waiting for an update…** or an error. Add `[data-letmeknow-status]` to choose the status location. Native validation runs before submission. Repeated field names become arrays.
102
+ Form values are untrusted input and should be validated by the agent. File uploads are not supported.
86
103
 
87
- POST forms may include files within the 1 MiB total request limit. `pull` events contain attachment metadata and private temporary paths. Attachments remain available until `serve` stops and are not public unless deliberately copied into the workspace and pushed.
104
+ ## Authoring the dynamic page
88
105
 
89
- Every submission records the exact workspace revision shown to the user. Its derived `context.relationship` is `current`, `stale`, or `unknown`, allowing the agent to decide whether to apply, rebase, or reject old feedback.
106
+ Each page update supplies the complete desired HTML document. The browser morphs the current document toward it, so a small change such as a counter update need not recreate the whole DOM.
90
107
 
91
- ## Security
108
+ Give elements stable unique IDs. They help the morphing runtime retain unchanged elements, including controls whose local state should survive an update:
109
+
110
+ ```html
111
+ <output id="count">0</output>
112
+ ```
113
+
114
+ Agent-authored JavaScript should be loaded by the initial page as a static asset and use delegated event listeners. Existing scripts remain active across morphs, but scripts added or changed by a pushed page are not executed in connected browsers; keep script references fixed for the session.
115
+
116
+ The CLI owns rendered page content. The browser preserves focus, scrolling, dirty controls with stable IDs, and the open state of `<details id="…">`. Mark an element with a stable ID and `data-letmeknow-local` when its `hidden` state is browser-owned. Avoid having browser JavaScript and pushed HTML otherwise mutate the same state; a later morph may replace browser-created changes.
117
+
118
+ A page update is shared with all browsers. Keep private or browser-specific behavior local unless a future requirement introduces targeted updates.
92
119
 
93
- The URL is a bearer capability. The relay receives published files and submitted values. Keep secrets and unrelated files outside the preview directory.
120
+ ## Static assets
121
+
122
+ Static assets are read live from the directory, independently of the canonical dynamic page. An agent can change CSS, JavaScript, images, and other assets without a page push. Finish an asset before pushing HTML that references it, write files atomically, and use versioned filenames or cache-busting URLs when cached assets must change with the page.
123
+
124
+ ## Security
94
125
 
95
- The CLI excludes `.env`, `.git`, SSH keys, private-key files, and database files, and prevents symlink escapes. Processes that can write the workspace and invoke `push` are trusted publishers. Browser values, filenames, media types, and attachment contents remain untrusted input.
126
+ The URL is a bearer capability. Anyone who has it can view the page and submit forms. Keep secrets and unrelated files outside the served directory. Browser values are untrusted input; escape them before placing them in HTML.
96
127
 
97
128
  ## Development
98
129
 
package/SKILL.md CHANGED
@@ -1,55 +1,87 @@
1
1
  ---
2
2
  name: letmeknow
3
- description: Publish a temporary browser workspace, pull structured human feedback, and push coherent agent revisions.
3
+ description: Serve a temporary live HTML page, collect structured human feedback, and push agent-authored page updates.
4
4
  ---
5
5
 
6
6
  # LetMeKnow
7
7
 
8
- Use LetMeKnow when a human should inspect or interact with an agent-managed page, report, dashboard, approval, quiz, table, or prototype.
8
+ Use LetMeKnow when a human should inspect or interact with an agent-authored page, report, dashboard, approval, quiz, table, or prototype.
9
9
 
10
10
  ## Start
11
11
 
12
- Create a dedicated directory containing only public files and keep the server running:
12
+ Create a directory containing the public files and an initial `index.html`:
13
13
 
14
14
  ```bash
15
15
  npx letmeknow-cli serve ./preview
16
16
  ```
17
17
 
18
- Node.js 22.12 or newer is required. The first stdout JSON line contains the bearer URL and initial workspace revision:
18
+ `serve` reads `index.html` once as the canonical dynamic document and serves it at `/`. It serves the other files in the directory live as static assets. The first stdout JSON line contains the public bearer URL:
19
19
 
20
20
  ```json
21
- {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","workspace":"","workspace_sequence":1}
21
+ {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":""}
22
22
  ```
23
23
 
24
- Give the URL to the human. Anyone with it can view the workspace and submit forms. The CLI connects outbound and opens no network port. Diagnostics go to stderr.
24
+ Give the URL to the human. Anyone with the URL can view the page and submit its forms. The CLI connects outbound and opens no network port. Canonical page state and events are temporary in-memory session state; they end when `serve` stops. `serve` never modifies agent files.
25
25
 
26
26
  ## Agent loop
27
27
 
28
- Filesystem writes are private drafts. Explicitly pull feedback and publish coherent revisions:
28
+ Pull browser events, update the desired page, and push it:
29
29
 
30
30
  ```bash
31
- npx letmeknow-cli pull ./preview --wait 30
32
- # validate feedback and edit files
33
- npx letmeknow-cli push ./preview --based-on <batch-token>
31
+ batch=$(npx letmeknow-cli pull ./preview --wait 30)
32
+ token=$(printf '%s\n' "$batch" | jq -r .token)
33
+ # inspect the events and edit index.html
34
+ npx letmeknow-cli push ./preview --batch "$token" --page index.html
34
35
  ```
35
36
 
36
- `pull` returns pending events, their derived causal context, the current workspace, and an opaque token. Pulling does not consume events; they are returned again after a crash. A successful `push` atomically snapshots the folder, commits that batch, and reloads connected browsers once. Feedback that arrives while you work remains for the next pull.
37
+ Commands:
37
38
 
38
- When a batch needs no visible workspace change:
39
+ ```text
40
+ serve <dir>
41
+ show <dir>
42
+ pull <dir> [--wait seconds]
43
+ push <dir> --batch TOKEN [--page FILE|-]
44
+ ```
45
+
46
+ `pull` returns an opaque batch token, current-page metadata, and browser events not yet committed by the agent. Pulling does not consume events. Events arriving while the agent works remain for a later pull.
47
+
48
+ ```json
49
+ {"token":"…","frontier":7,"page_event":5,"page_hash":"…","events":[{"type":"submit","id":"…","event_number":7,"page_event":5,"form_id":"decision","action":"/decide","trigger":{"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}]}
50
+ ```
51
+
52
+ An event's `page_event` identifies the page displayed when the browser submitted it. Compare it with the batch's current `page_event` before applying old input to the current page.
53
+
54
+ `push --page FILE` atomically commits the events represented by the token, makes FILE the complete desired dynamic document, appends one page-update event to the global event stream, and broadcasts it to all connected browsers. Browsers morph the page without navigation.
39
55
 
40
56
  ```bash
41
- npx letmeknow-cli ack ./preview --based-on <batch-token>
57
+ npx letmeknow-cli push ./preview --batch "$token" --page index.html
42
58
  ```
43
59
 
44
- Both commands are idempotent for a token. Do not edit while `push` is snapshotting. Do not write commands to the long-running server's stdin.
60
+ A push without `--page` only commits the pulled browser events:
61
+
62
+ ```bash
63
+ npx letmeknow-cli push ./preview --batch "$token"
64
+ ```
45
65
 
46
- A push may publish independent work from an empty batch. Use the token returned by an empty `pull`.
66
+ Use `--page -` for standard input:
47
67
 
48
- ## Build the workspace
68
+ ```bash
69
+ npx letmeknow-cli push ./preview --batch "$token" --page - < updated.html
70
+ ```
49
71
 
50
- Use ordinary HTML, CSS, JavaScript, images, and relative links. Give forms stable IDs and controls meaningful names. Give editable controls stable unique IDs so values and scroll position survive published revisions.
72
+ The page push is all-or-nothing. Invalid input or an invalid token commits nothing. The CLI assigns one global order to each browser submission and each page-update event.
51
73
 
52
- Use native same-origin GET or POST forms:
74
+ `show` retrieves the canonical dynamic HTML held by `serve` without changing the event stream:
75
+
76
+ ```bash
77
+ npx letmeknow-cli show ./preview > current.html
78
+ ```
79
+
80
+ The public URL is the visual preview. `show` returns canonical HTML, not a browser’s local DOM state such as focus, open disclosures, unsent input, scroll position, or JavaScript state.
81
+
82
+ ## Forms
83
+
84
+ Use native forms with stable IDs and meaningful names:
53
85
 
54
86
  ```html
55
87
  <form id="review" action="/review" method="post">
@@ -59,31 +91,24 @@ Use native same-origin GET or POST forms:
59
91
  </form>
60
92
  ```
61
93
 
62
- The browser persists each serialized submission before delivery and retries it with the same opaque UUID after network failures or reloads. The CLI deduplicates retries. Distinct intentional submissions remain distinct. Native validation and repeated field names work normally.
94
+ The browser runtime serializes native form submissions as JSON `submit` events. It assigns an opaque ID, stores each event in a local durable outbox before sending it, retries after connection failures, and reuses the ID on retry. The CLI deduplicates repeated delivery. Ten intentional rapid clicks should produce ten distinct events. File uploads are not supported.
63
95
 
64
- A pulled event includes the workspace the human saw:
96
+ Treat pulled values as untrusted input. Validate them and escape them before putting them into HTML.
65
97
 
66
- ```json
67
- {
68
- "type": "submit",
69
- "id": "…",
70
- "form_id": "review",
71
- "values": {"comment":"Looks good","decision":"approve"},
72
- "based_on": "…",
73
- "context": {
74
- "based_on": "…",
75
- "current": "…",
76
- "relationship": "current"
77
- }
78
- }
79
- ```
98
+ ## Dynamic page rules
99
+
100
+ Every page push supplies the complete desired dynamic document. The browser uses HTML morphing, so unchanged DOM nodes can survive while changed content is updated.
101
+
102
+ Give elements stable unique IDs. Load agent-authored JavaScript from the initial page as a static asset and use delegated listeners. Existing scripts remain active across morphs, but scripts added or changed by a pushed page are not executed in connected browsers; keep script references fixed for the session.
103
+
104
+ The CLI owns page content. The browser preserves focus, scrolling, dirty controls with stable IDs, and the open state of `<details id="…">`. Mark an element with a stable ID and `data-letmeknow-local` when its `hidden` state is browser-owned. Do not have browser JavaScript and pushed HTML otherwise mutate the same state; a later morph may replace browser-created changes.
80
105
 
81
- Treat `stale` feedback deliberately: apply its intent to current state when safe, or show that the artifact changed and ask the human to review again. Never reconstruct the workspace from stale form values.
106
+ Page updates are shared with all connected browsers. There is no dynamic view or per-browser update system. Use ordinary static links and files when the application needs more persistent pages.
82
107
 
83
- POST forms may upload files within the 1 MiB request limit. Attachment events contain private temporary paths valid until `serve` stops. Validate names, media types, sizes, contents, actions, and IDs. Copy only deliberate outputs into the public workspace.
108
+ ## Static assets
84
109
 
85
- Escape untrusted text before placing it in HTML.
110
+ CSS, JavaScript, images, and other non-`index.html` files are served live. Finish writing an asset before pushing HTML that references it. Write assets atomically, and use versioned filenames or cache-busting URLs when the browser must fetch a changed asset with the new page.
86
111
 
87
112
  ## Stop
88
113
 
89
- Send `SIGINT` or `SIGTERM` to `serve`. A graceful stop closes the public session and removes temporary snapshots, attachments, and the local control socket. `--skill` prints these instructions.
114
+ Send `SIGINT` or `SIGTERM` to `serve`. The temporary session ends when the process stops.
package/bin/letmeknow.js CHANGED
@@ -1,25 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { constants, existsSync, readFileSync, statSync, writeSync } from "node:fs";
4
- import { chmod, copyFile, lstat, mkdtemp, mkdir, open, readdir, readlink, realpath, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
4
+ import { chmod, open, readFile, realpath, stat, unlink } from "node:fs/promises";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import net from "node:net";
7
- import { dirname, extname, join, relative, resolve, sep } from "node:path";
7
+ import { dirname, join, relative, resolve, sep } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
9
  import { parseArgs } from "node:util";
10
10
  import { lookup } from "mrmime";
11
11
 
12
12
  const MAX_BODY_BYTES = 1024 * 1024;
13
+ const CONTROL_MAX_BYTES = MAX_BODY_BYTES * 2 + 16 * 1024;
13
14
  const GRACE_SECONDS = 10 * 60;
14
15
  const CONNECTION_TIMEOUT = 10_000;
15
16
  const CONTROL_TIMEOUT = 35_000;
16
- const MAX_RETRY_DELAY = 5_000;
17
17
  const CONTROL_PREFIX = "letmeknow-control-";
18
- const SNAPSHOT_PREFIX = "letmeknow-snapshot-";
19
18
  const CONTROL_URL = "https://letmeknow.dev";
20
19
  const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
21
20
  const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
22
21
  const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
22
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
23
 
24
24
  function getMimeType(filename) {
25
25
  const type = lookup(filename);
@@ -34,17 +34,6 @@ function header(packet, name) {
34
34
  return typeof entry?.[1] === "string" && entry[1] !== "" ? entry[1] : null;
35
35
  }
36
36
 
37
- function encodedHeader(packet, name) {
38
- const value = header(packet, name);
39
- if (value === null) return null;
40
- try { return decodeURIComponent(value); } catch { return null; }
41
- }
42
-
43
- function addValue(values, name, value) {
44
- if (Object.prototype.hasOwnProperty.call(values, name)) values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
45
- else values[name] = value;
46
- }
47
-
48
37
  function response(packet, status, body = Buffer.alloc(0), headers = {}) {
49
38
  if (body.byteLength > MAX_BODY_BYTES) return response(packet, 413, Buffer.from("response body is too large"), { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
50
39
  const outputHeaders = { "Cache-Control": "no-store", ...headers };
@@ -93,12 +82,15 @@ function requestUrl(packet) {
93
82
  return { pathname, encodedPathname: url.pathname, search: url.search };
94
83
  }
95
84
 
96
- async function staticResponse(root, packet, workspaceId) {
97
- const published = (status, body = Buffer.alloc(0), headers = {}) => response(packet, status, body, { ...headers, "X-LetMeKnow-Workspace": workspaceId });
85
+ async function staticResponse(root, packet, page, pageEvent) {
86
+ const published = (status, body = Buffer.alloc(0), headers = {}) => response(packet, status, body, headers);
98
87
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
99
88
  if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
100
89
  let request;
101
90
  try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
91
+ if (request.pathname === "/" || request.pathname === "/index.html") {
92
+ return published(200, Buffer.from(page), { "Content-Type": "text/html; charset=utf-8", "X-LetMeKnow-Page-Event": String(pageEvent) });
93
+ }
102
94
  if (deniedPath(request.pathname)) return errorResponse(packet, 403, "forbidden");
103
95
  const candidate = resolve(root, "." + request.pathname);
104
96
  if (!inside(root, candidate)) return errorResponse(packet, 403, "forbidden");
@@ -151,69 +143,44 @@ async function staticResponse(root, packet, workspaceId) {
151
143
  }
152
144
  }
153
145
 
154
- async function multipartSubmission(body, contentType, getAttachmentInbox) {
155
- const formData = await new Request("http://letmeknow.local", {
156
- method: "POST",
157
- headers: { "Content-Type": contentType },
158
- body
159
- }).formData();
160
- const values = Object.create(null);
161
- const attachments = [];
162
- for (const [name, value] of formData) {
163
- if (typeof value === "string") {
164
- addValue(values, name, value);
165
- continue;
166
- }
167
- if (value.name === "") continue;
168
- const bytes = Buffer.from(await value.arrayBuffer());
169
- const path = join(await getAttachmentInbox(), randomUUID());
170
- await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
171
- attachments.push({ field: name, name: value.name, type: value.type, size: bytes.byteLength, path });
172
- }
173
- return { values, attachments };
146
+ async function readInitialPage(root) {
147
+ const candidate = join(root, "index.html");
148
+ const target = await safeRealpath(root, candidate);
149
+ if (target === null || target === undefined || deniedPath("/" + relative(root, target).split(sep).join("/"))) throw new Error("index.html is required");
150
+ const info = await stat(target);
151
+ if (!info.isFile()) throw new Error("index.html must be a file");
152
+ if (info.size > MAX_BODY_BYTES) throw new Error("index.html is too large");
153
+ return (await readFile(target)).toString("utf8");
174
154
  }
175
155
 
176
- async function submission(packet, getAttachmentInbox, recordInteraction) {
177
- const url = requestUrl(packet);
156
+ async function submission(packet, recordInteraction) {
157
+ const request = requestUrl(packet);
178
158
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
179
- const values = Object.create(null);
180
- let attachments;
181
- if (method === "GET") {
182
- for (const [name, value] of new URLSearchParams(url.search)) addValue(values, name, value);
183
- } else if (method === "POST") {
184
- const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
185
- if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
186
- const contentTypeHeader = header(packet, "content-type");
187
- const contentType = contentTypeHeader?.split(";", 1)[0].trim().toLowerCase();
188
- if (contentType === "application/x-www-form-urlencoded") {
189
- for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
190
- } else if (contentType === "multipart/form-data" && contentTypeHeader) {
191
- const parsed = await multipartSubmission(body, contentTypeHeader, getAttachmentInbox);
192
- Object.assign(values, parsed.values);
193
- attachments = parsed.attachments;
194
- } else throw new Error("unsupported submission encoding");
195
- } else throw new Error("unsupported submission method");
196
- const event = {
197
- type: "submit",
198
- id: encodedHeader(packet, "x-letmeknow-id") || randomUUID(),
199
- method,
200
- action: encodedHeader(packet, "x-letmeknow-action") || url.pathname,
201
- form_id: encodedHeader(packet, "x-letmeknow-form-id"),
202
- trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
203
- values
204
- };
205
- const basedOn = encodedHeader(packet, "x-letmeknow-based-on");
206
- if (basedOn !== null) event.based_on = basedOn;
207
- if (attachments?.length) event.attachments = attachments;
208
- await recordInteraction(event);
159
+ if (method !== "POST" || request.pathname !== "/_letmeknow/submit") throw new Error("invalid submission endpoint");
160
+ const contentType = header(packet, "content-type")?.split(";", 1)[0].trim().toLowerCase();
161
+ if (contentType !== "application/json") throw new Error("JSON submission is required");
162
+ const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
163
+ if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
164
+ let value;
165
+ try { value = JSON.parse(body.toString("utf8")); } catch { throw new Error("invalid submission JSON"); }
166
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("submission must be an object");
167
+ if (typeof value.id !== "string" || !uuidPattern.test(value.id)) throw new Error("submission id must be a UUID");
168
+ if (!Number.isSafeInteger(value.page_event) || value.page_event < 0) throw new Error("page_event must be a non-negative integer");
169
+ if (value.form_id !== null && typeof value.form_id !== "string") throw new Error("form_id must be text or null");
170
+ if (typeof value.action !== "string") throw new Error("action is required");
171
+ if (value.trigger !== null && (typeof value.trigger !== "object" || Array.isArray(value.trigger))) throw new Error("trigger must be an object or null");
172
+ if (!value.values || typeof value.values !== "object" || Array.isArray(value.values)) throw new Error("values are required");
173
+ await recordInteraction({ type: "submit", id: value.id, page_event: value.page_event, form_id: value.form_id, action: value.action, trigger: value.trigger, values: value.values });
209
174
  return response(packet, 202);
210
175
  }
211
176
 
212
- async function handleRequest(root, workspaceId, packet, getAttachmentInbox, recordInteraction) {
213
- if (header(packet, "x-letmeknow-submission") === "1") {
214
- try { return await submission(packet, getAttachmentInbox, recordInteraction); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
177
+ async function handleRequest(root, page, pageEvent, packet, recordInteraction) {
178
+ let request;
179
+ try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
180
+ if (request.pathname === "/_letmeknow/submit") {
181
+ try { return await submission(packet, recordInteraction); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
215
182
  }
216
- return staticResponse(root, packet, workspaceId);
183
+ return staticResponse(root, packet, page, pageEvent);
217
184
  }
218
185
 
219
186
  function options(directory) {
@@ -227,40 +194,6 @@ function controlPath(root) {
227
194
  return join(tmpdir(), `${CONTROL_PREFIX}${key}.sock`);
228
195
  }
229
196
 
230
- async function copyDirectory(source, target, root, visited = new Set()) {
231
- const sourceReal = await realpath(source);
232
- if (visited.has(sourceReal)) return;
233
- visited.add(sourceReal);
234
- await mkdir(target, { recursive: true });
235
- for (const entry of await readdir(sourceReal, { withFileTypes: true })) {
236
- const candidate = join(sourceReal, entry.name);
237
- const pathname = "/" + relative(root, candidate).split(sep).join("/");
238
- if (deniedPath(pathname)) continue;
239
- const targetPath = join(target, entry.name);
240
- const targetReal = await safeRealpath(root, candidate);
241
- if (targetReal === null) {
242
- if ((await lstat(candidate)).isSymbolicLink()) await symlink(await readlink(candidate), targetPath);
243
- continue;
244
- }
245
- if (targetReal === undefined) continue;
246
- if (deniedPath("/" + relative(root, targetReal).split(sep).join("/"))) continue;
247
- const info = await stat(targetReal);
248
- if (info.isDirectory()) await copyDirectory(targetReal, targetPath, root, visited);
249
- else if (info.isFile()) await copyFile(targetReal, targetPath);
250
- }
251
- }
252
-
253
- async function snapshotDirectory(root) {
254
- const snapshot = await mkdtemp(join(tmpdir(), SNAPSHOT_PREFIX));
255
- try {
256
- await copyDirectory(root, snapshot, root);
257
- return snapshot;
258
- } catch (cause) {
259
- await rm(snapshot, { recursive: true, force: true });
260
- throw cause;
261
- }
262
- }
263
-
264
197
  function mutateQueue() {
265
198
  let chain = Promise.resolve();
266
199
  return operation => {
@@ -302,20 +235,19 @@ function connectControl(root, packet) {
302
235
  });
303
236
  }
304
237
 
238
+ function pageHash(page) {
239
+ return createHash("sha256").update(page).digest("hex");
240
+ }
241
+
305
242
  async function start(directory) {
306
243
  const { root } = await options(directory);
307
- let attachmentInboxPromise;
308
- const getAttachmentInbox = () => {
309
- attachmentInboxPromise ??= mkdtemp(join(tmpdir(), "letmeknow-attachments-"));
310
- return attachmentInboxPromise;
311
- };
312
- const socketPath = controlPath(root);
313
- let publishedRoot = await snapshotDirectory(root);
314
- let workspaceId = randomUUID();
315
- let workspaceSequence = 1;
316
- const workspaceIds = new Set([workspaceId]);
244
+ let page = await readInitialPage(root);
245
+ let pageEvent = 0;
246
+ let eventNumber = 0;
247
+ const currentPageHash = () => pageHash(page);
317
248
  const eventLog = [];
318
- let committedCursor = 0;
249
+ const browserEvents = [];
250
+ let committedBrowserCursor = 0;
319
251
  const seenEvents = new Set();
320
252
  const tokens = new Map();
321
253
  const pendingTokens = new Map();
@@ -332,32 +264,24 @@ async function start(directory) {
332
264
  let retryUntil = 0;
333
265
  let stopped = false;
334
266
  let ready = false;
335
- let initialPublished = false;
336
267
 
337
268
  const batch = () => {
338
- const start = committedCursor;
339
- const end = eventLog.length;
340
- const key = `${workspaceId}:${start}:${end}`;
269
+ const start = committedBrowserCursor;
270
+ const end = browserEvents.length;
271
+ const key = `${pageEvent}:${start}:${end}`;
341
272
  const existing = pendingTokens.get(key);
342
273
  if (existing) return existing;
343
274
  const token = randomUUID();
344
- const events = eventLog.slice(start, end).map(event => ({
345
- ...event,
346
- context: {
347
- based_on: event.based_on ?? null,
348
- current: workspaceId,
349
- relationship: event.based_on === workspaceId ? "current" : workspaceIds.has(event.based_on) ? "stale" : "unknown"
350
- }
351
- }));
352
- tokens.set(token, { start, end, parent: workspaceId, status: "pending", key });
353
- const result = { ok: true, type: "batch", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: end, events };
275
+ const events = browserEvents.slice(start, end);
276
+ tokens.set(token, { start, end, page_event: pageEvent, status: "pending", key, page_hash: currentPageHash(), has_page: null, requested_page_hash: null });
277
+ const result = { ok: true, type: "batch", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events };
354
278
  pendingTokens.set(key, result);
355
279
  return result;
356
280
  };
357
281
 
358
282
  const notifyPullWaiters = () => {
359
283
  for (const waiter of [...pullWaiters]) {
360
- if (eventLog.length === committedCursor) continue;
284
+ if (browserEvents.length === committedBrowserCursor) continue;
361
285
  pullWaiters.delete(waiter);
362
286
  clearTimeout(waiter.timer);
363
287
  waiter.resolve(batch());
@@ -365,53 +289,57 @@ async function start(directory) {
365
289
  };
366
290
 
367
291
  const pull = waitSeconds => {
368
- if (eventLog.length > committedCursor || waitSeconds <= 0) return Promise.resolve(batch());
292
+ if (browserEvents.length > committedBrowserCursor || waitSeconds <= 0) return Promise.resolve(batch());
369
293
  return new Promise(resolve => {
370
294
  const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
371
295
  pullWaiters.add(waiter);
372
296
  });
373
297
  };
374
298
 
375
- const commit = async (token, publish) => {
299
+ const commit = async (token, requestedPage) => {
376
300
  const record = tokens.get(token);
377
301
  if (!record) return { ok: false, error: "unknown batch token" };
378
- if (record.status !== "pending") return record.result;
379
- if (record.start < committedCursor && record.end <= committedCursor) {
380
- pendingTokens.delete(record.key);
381
- record.status = "committed";
382
- record.result = { ok: true, type: "already_committed", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: committedCursor };
383
- return record.result;
302
+ const hasPage = requestedPage !== undefined;
303
+ const requestedPageHash = hasPage ? pageHash(requestedPage) : null;
304
+ if (record.status !== "pending") {
305
+ if (record.has_page === hasPage && record.requested_page_hash === requestedPageHash) return record.result;
306
+ return { ok: false, error: "batch was already committed with a different page payload" };
384
307
  }
385
- if (record.parent !== workspaceId || record.start !== committedCursor) {
308
+ record.has_page = hasPage;
309
+ record.requested_page_hash = requestedPageHash;
310
+ if (record.page_event !== pageEvent || record.start !== committedBrowserCursor) {
386
311
  pendingTokens.delete(record.key);
387
312
  record.status = "failed";
388
- record.result = { ok: false, error: "batch is based on an old workspace or cursor", current_workspace: workspaceId, frontier: committedCursor };
313
+ record.result = { ok: false, error: "batch is based on an old page or browser cursor", frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash() };
389
314
  return record.result;
390
315
  }
391
- if (publish) {
392
- const nextRoot = await snapshotDirectory(root);
393
- const previousRoot = publishedRoot;
394
- publishedRoot = nextRoot;
395
- workspaceId = randomUUID();
396
- workspaceIds.add(workspaceId);
397
- workspaceSequence += 1;
398
- record.result = { ok: true, type: "published", token, workspace: workspaceId, workspace_sequence: workspaceSequence, parent: record.parent, frontier: record.end, events: eventLog.slice(record.start, record.end).map(event => event.id) };
399
- void rm(previousRoot, { recursive: true, force: true }).catch(() => {});
400
- } else {
401
- record.result = { ok: true, type: "acknowledged", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: record.end, events: eventLog.slice(record.start, record.end).map(event => event.id) };
316
+ if (hasPage && Buffer.byteLength(requestedPage, "utf8") > MAX_BODY_BYTES) return { ok: false, error: "page is too large" };
317
+ const committedEvents = browserEvents.slice(record.start, record.end).map(event => event.id);
318
+ let update;
319
+ if (hasPage) {
320
+ page = requestedPage;
321
+ eventNumber += 1;
322
+ pageEvent = eventNumber;
323
+ update = { type: "update_ui", event_number: pageEvent, html: page };
324
+ eventLog.push(update);
402
325
  }
403
- committedCursor = record.end;
326
+ committedBrowserCursor = record.end;
404
327
  pendingTokens.delete(record.key);
405
328
  record.status = "committed";
406
- if (publish) send({ type: "revision" });
329
+ record.result = { ok: true, type: "committed", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events: committedEvents };
330
+ if (update) send(update);
407
331
  return record.result;
408
332
  };
409
333
 
410
334
  const dispatchControl = async request => {
411
335
  if (!request || typeof request !== "object") return { ok: false, error: "invalid control request" };
412
336
  if (request.type === "pull") return pull(Number.isFinite(request.wait_seconds) ? Math.max(0, request.wait_seconds) : 0);
413
- if (request.type === "push") return commit(typeof request.token === "string" ? request.token : "", true);
414
- if (request.type === "ack") return commit(typeof request.token === "string" ? request.token : "", false);
337
+ if (request.type === "show") return { ok: true, type: "page", page_event: pageEvent, page_hash: currentPageHash(), html: page };
338
+ if (request.type === "push") {
339
+ if (typeof request.token !== "string") return { ok: false, error: "batch token is required" };
340
+ if (request.page !== undefined && typeof request.page !== "string") return { ok: false, error: "page must be text" };
341
+ return commit(request.token, request.page);
342
+ }
415
343
  return { ok: false, error: "unknown control request" };
416
344
  };
417
345
 
@@ -423,7 +351,7 @@ async function start(directory) {
423
351
  let handled = false;
424
352
  connection.on("data", async chunk => {
425
353
  input += chunk;
426
- if (input.length > MAX_BODY_BYTES || handled) return;
354
+ if (input.length > CONTROL_MAX_BYTES || handled) return;
427
355
  const newline = input.indexOf("\n");
428
356
  if (newline < 0) return;
429
357
  handled = true;
@@ -439,20 +367,20 @@ async function start(directory) {
439
367
  });
440
368
  await new Promise((resolveListen, reject) => {
441
369
  controlServer.once("error", reject);
442
- controlServer.listen(socketPath, async () => {
443
- try { await chmod(socketPath, 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
370
+ controlServer.listen(controlPath(root), async () => {
371
+ try { await chmod(controlPath(root), 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
444
372
  controlServer.off("error", reject);
445
373
  resolveListen();
446
374
  });
447
- }).catch(async cause => {
448
- await rm(publishedRoot, { recursive: true, force: true });
449
- throw new Error(`cannot start local control channel: ${cause.message}`);
450
- });
375
+ }).catch(cause => { throw new Error(`cannot start local control channel: ${cause.message}`); });
451
376
 
452
377
  const recordInteraction = event => mutate(async () => {
453
378
  if (seenEvents.has(event.id)) return;
454
379
  seenEvents.add(event.id);
455
- eventLog.push(event);
380
+ eventNumber += 1;
381
+ const numbered = { ...event, event_number: eventNumber };
382
+ eventLog.push(numbered);
383
+ browserEvents.push(numbered);
456
384
  notifyPullWaiters();
457
385
  });
458
386
 
@@ -465,11 +393,7 @@ async function start(directory) {
465
393
  try { socket?.close(); } catch {}
466
394
  for (const connection of controlConnections) connection.destroy();
467
395
  await new Promise(resolveClose => controlServer.close(() => resolveClose()));
468
- await unlink(socketPath).catch(() => {});
469
- await rm(publishedRoot, { recursive: true, force: true });
470
- if (attachmentInboxPromise) {
471
- try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
472
- }
396
+ await unlink(controlPath(root)).catch(() => {});
473
397
  process.exit(code);
474
398
  };
475
399
  process.once("SIGINT", () => void stop(0));
@@ -478,7 +402,7 @@ async function start(directory) {
478
402
  const retry = () => {
479
403
  if (stopped || Date.now() >= retryUntil) return void stop(1);
480
404
  retryTimer = setTimeout(() => { retryTimer = undefined; connect(); }, retryDelay);
481
- retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
405
+ retryDelay = Math.min(retryDelay * 2, 5_000);
482
406
  };
483
407
 
484
408
  const connect = () => {
@@ -511,15 +435,11 @@ async function start(directory) {
511
435
  } else if (packet.type === "session") {
512
436
  if (!validSessionUrl(packet.url)) return void stop(1);
513
437
  sessionUrl = packet.url;
514
- if (!initialPublished) {
515
- initialPublished = true;
516
- send({ type: "revision" });
517
- }
518
- if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, workspace: workspaceId, workspace_sequence: workspaceSequence })}\n`); }
438
+ if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, page_event: pageEvent, page_hash: currentPageHash() })}\n`); }
519
439
  } else if (packet.type === "http_request") {
520
- const requestRoot = publishedRoot;
521
- const requestWorkspace = workspaceId;
522
- void handleRequest(requestRoot, requestWorkspace, packet, getAttachmentInbox, recordInteraction).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
440
+ const requestPage = page;
441
+ const requestPageEvent = pageEvent;
442
+ void handleRequest(root, requestPage, requestPageEvent, packet, recordInteraction).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
523
443
  } else if (packet.type === "closed") {
524
444
  void stop(0);
525
445
  } else if (packet.type === "error") {
@@ -563,7 +483,7 @@ function validSessionUrl(value) {
563
483
  }
564
484
 
565
485
  function usage() {
566
- return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --based-on <token>\n npx letmeknow-cli ack <directory> --based-on <token>\n";
486
+ return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli show <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --batch <token> [--page <file|->]\n";
567
487
  }
568
488
 
569
489
  function commandArgs() {
@@ -575,7 +495,8 @@ function commandArgs() {
575
495
  skill: { type: "boolean" },
576
496
  help: { type: "boolean", short: "h" },
577
497
  wait: { type: "string" },
578
- "based-on": { type: "string" }
498
+ batch: { type: "string" },
499
+ page: { type: "string" }
579
500
  },
580
501
  allowPositionals: true,
581
502
  strict: true
@@ -584,22 +505,40 @@ function commandArgs() {
584
505
  throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
585
506
  }
586
507
  if (parsed.values.skill || parsed.values.help) {
587
- if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values["based-on"] !== undefined) throw new Error(usage());
508
+ if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.page !== undefined) throw new Error(usage());
588
509
  return { command: parsed.values.skill ? "skill" : "help" };
589
510
  }
590
511
  const [command, directory, ...extra] = parsed.positionals;
591
512
  if (!command || !directory || extra.length) throw new Error(usage());
592
- if (command === "serve" && (parsed.values.wait !== undefined || parsed.values["based-on"] !== undefined)) throw new Error(usage());
593
- if (command === "pull" && parsed.values["based-on"] !== undefined) throw new Error(usage());
594
- if ((command === "push" || command === "ack") && parsed.values.wait !== undefined) throw new Error(usage());
595
- if (!["serve", "pull", "push", "ack"].includes(command)) throw new Error(usage());
513
+ if (!["serve", "show", "pull", "push"].includes(command)) throw new Error(usage());
514
+ if ((command === "serve" || command === "show") && (parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.page !== undefined)) throw new Error(usage());
515
+ if (command === "pull" && (parsed.values.batch !== undefined || parsed.values.page !== undefined)) throw new Error(usage());
516
+ if (command === "push" && parsed.values.wait !== undefined) throw new Error(usage());
596
517
  let wait = 0;
597
518
  if (parsed.values.wait !== undefined) {
598
519
  wait = Number(parsed.values.wait);
599
520
  if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
600
521
  }
601
- if ((command === "push" || command === "ack") && typeof parsed.values["based-on"] !== "string") throw new Error("--based-on is required");
602
- return { command, directory, wait, token: parsed.values["based-on"] };
522
+ if (command === "push" && typeof parsed.values.batch !== "string") throw new Error("--batch is required");
523
+ return { command, directory, wait, token: parsed.values.batch, page: parsed.values.page };
524
+ }
525
+
526
+ async function readPageInput(filename) {
527
+ const chunks = [];
528
+ let length = 0;
529
+ if (filename === "-") {
530
+ for await (const chunk of process.stdin) {
531
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
532
+ length += value.byteLength;
533
+ if (length > MAX_BODY_BYTES) throw new Error("page is too large");
534
+ chunks.push(value);
535
+ }
536
+ } else {
537
+ const body = await readFile(resolve(filename));
538
+ if (body.byteLength > MAX_BODY_BYTES) throw new Error("page is too large");
539
+ chunks.push(body);
540
+ }
541
+ return Buffer.concat(chunks).toString("utf8");
603
542
  }
604
543
 
605
544
  let command;
@@ -610,8 +549,16 @@ try {
610
549
  else if (command.command === "serve") await start(command.directory);
611
550
  else {
612
551
  const { root } = await options(command.directory);
613
- const result = await connectControl(root, command.command === "pull" ? { type: "pull", wait_seconds: command.wait } : { type: command.command, token: command.token });
614
- process.stdout.write(`${JSON.stringify(result)}\n`);
552
+ let packet;
553
+ if (command.command === "pull") packet = { type: "pull", wait_seconds: command.wait };
554
+ else if (command.command === "show") packet = { type: "show" };
555
+ else {
556
+ packet = { type: "push", token: command.token };
557
+ if (command.page !== undefined) packet.page = await readPageInput(command.page);
558
+ }
559
+ const result = await connectControl(root, packet);
560
+ if (command.command === "show" && result.ok) process.stdout.write(result.html);
561
+ else process.stdout.write(`${JSON.stringify(result)}\n`);
615
562
  if (!result.ok) process.exitCode = 1;
616
563
  }
617
564
  } catch (cause) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.6.0",
4
- "description": "A live static preview with agent-readable form submissions.",
3
+ "version": "0.7.0",
4
+ "description": "A live agent-authored page with structured browser feedback.",
5
5
  "files": [
6
6
  "bin",
7
7
  "SKILL.md"
@@ -24,6 +24,7 @@
24
24
  "@cloudflare/vitest-plugin": "^1.1.0",
25
25
  "@cloudflare/workers-types": "^5.20260825.1",
26
26
  "@types/node": "^22.15.17",
27
+ "idiomorph": "0.7.4",
27
28
  "typescript": "^5.9.2",
28
29
  "vitest": "^4.1.11",
29
30
  "wrangler": "^4.126.0",